Implement Workstream I: Account pages with code review fixes
Add account management UI with profile settings, authentication options, device/passkey management, and session management pages. Key changes: - Add account pages: profile, auth, devices, sessions - Add dialog components: confirm, add-passkey, change-password, rename-passkey - Return passkeyId from verifyRegistration to fix race condition - Add hasPassword field to user schema - Add aria-label to dialog close button for accessibility - Add avatar URL validation and fix phone input styling - Add comprehensive test plan documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,8 @@ function createAPIContext(): APIContext {
|
||||
origin: TEST_RP.origin,
|
||||
allowedOrigins: [...TEST_RP.allowedOrigins],
|
||||
rpName: TEST_RP.rpName,
|
||||
reqHeaders: new Headers(),
|
||||
resHeaders: new Headers(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ function createAuthenticatedContext(
|
||||
isSuperuser: false,
|
||||
},
|
||||
session: {
|
||||
id: 1,
|
||||
id: "1",
|
||||
trustedMode: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
|
||||
@@ -120,5 +120,5 @@ export async function countOwners(
|
||||
.where("role", "=", "owner")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Number(result.count);
|
||||
return result.count;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,13 @@ const verifyRegistration = os.auth.webauthn.verifyRegistration
|
||||
context.allowedOrigins,
|
||||
context.rpName,
|
||||
);
|
||||
await verifyReg(context.db, rpInfo, context.user.id, challengeId, response);
|
||||
return verifyReg(
|
||||
context.db,
|
||||
rpInfo,
|
||||
context.user.id,
|
||||
challengeId,
|
||||
response,
|
||||
);
|
||||
});
|
||||
|
||||
const createAuthenticationOptions = os.auth.webauthn.createAuthenticationOptions
|
||||
@@ -161,6 +167,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
||||
"avatar_url",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
"password_hash",
|
||||
])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
@@ -175,6 +182,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
hasPassword: user.password_hash !== null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export const verifyRegistration = async (
|
||||
userId: number,
|
||||
challengeId: number,
|
||||
response: RegistrationResponseJSON,
|
||||
): Promise<void> => {
|
||||
): Promise<{ passkeyId: number }> => {
|
||||
// Fetch the challenge
|
||||
const challengeRow = await db
|
||||
.selectFrom("webauthn_challenges")
|
||||
@@ -207,7 +207,7 @@ export const verifyRegistration = async (
|
||||
guidName ?? `Key registered at ${formatPasskeyDate(new Date())}`;
|
||||
|
||||
// Store the passkey
|
||||
await db
|
||||
const { id: passkeyId } = await db
|
||||
.insertInto("passkeys")
|
||||
.values({
|
||||
user_id: userId,
|
||||
@@ -222,7 +222,10 @@ export const verifyRegistration = async (
|
||||
rpid: rpInfo.rpID,
|
||||
name: passKeyName,
|
||||
})
|
||||
.execute();
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { passkeyId: Number(passkeyId) };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import ClockIcon from "@lucide/svelte/icons/clock";
|
||||
import MonitorIcon from "@lucide/svelte/icons/monitor";
|
||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||
import UserIcon from "@lucide/svelte/icons/user";
|
||||
import { page } from "$app/stores";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: "/account", label: "Profile", icon: UserIcon },
|
||||
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
|
||||
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
|
||||
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
|
||||
];
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
if (href === "/account") {
|
||||
return pathname === "/account";
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class={cn(
|
||||
"bg-muted inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href={item.href}
|
||||
class={cn(
|
||||
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<item.icon class="size-4" />
|
||||
<span class="hidden sm:inline">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { Key } from "@lucide/svelte";
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), email }: Props = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let passkeyName = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
// Check passkey support
|
||||
const supportsPasskey = $derived(browserSupportsWebAuthn());
|
||||
|
||||
// Reset state when dialog closes
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
passkeyName = "";
|
||||
error = "";
|
||||
isSubmitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
const isValid = $derived(passkeyName.trim().length > 0);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!isValid || isSubmitting || !supportsPasskey) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
// Step 1: Get registration options
|
||||
const { challengeId, options } =
|
||||
await api.auth.webauthn.createRegistrationOptions({ email });
|
||||
|
||||
// Step 2: Start WebAuthn registration ceremony
|
||||
const registrationResponse = await startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
|
||||
// Step 3: Verify registration (authenticated endpoint) - returns the new passkey ID
|
||||
const { passkeyId } = await api.auth.webauthn.verifyRegistration({
|
||||
challengeId,
|
||||
response: registrationResponse,
|
||||
});
|
||||
|
||||
// Step 4: Rename the passkey to the user's chosen name
|
||||
await api.me.passkeys.rename({
|
||||
passkeyId,
|
||||
name: passkeyName.trim(),
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
toast.success("Passkey added successfully!");
|
||||
open = false;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
// Handle WebAuthn cancellation
|
||||
if (err.name === "NotAllowedError") {
|
||||
error = "Passkey creation was cancelled. Please try again.";
|
||||
} else {
|
||||
error = err.message;
|
||||
}
|
||||
} else {
|
||||
error = "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Key class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<Dialog.Title class="text-center">Add a passkey</Dialog.Title>
|
||||
<Dialog.Description class="text-center">
|
||||
{#if supportsPasskey}
|
||||
Give your passkey a name, then follow your browser's prompts to create it.
|
||||
{:else}
|
||||
Your browser doesn't support passkeys. Please use a modern browser.
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if supportsPasskey}
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="passkey-name">Passkey name</Label>
|
||||
<Input
|
||||
id="passkey-name"
|
||||
type="text"
|
||||
placeholder="e.g., MacBook Pro, iPhone"
|
||||
bind:value={passkeyName}
|
||||
maxlength={100}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Choose a name to help you identify this passkey later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ErrorAlert message={error} />
|
||||
|
||||
<Dialog.Footer class="flex-col gap-2 sm:flex-col">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
class="w-full"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
loadingText="Creating passkey..."
|
||||
>
|
||||
Create passkey
|
||||
</LoadingButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => (open = false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{:else}
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ErrorAlert, PasswordFormField } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
hasExistingPassword: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), hasExistingPassword }: Props = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let currentPassword = $state("");
|
||||
let newPassword = $state("");
|
||||
let confirmPassword = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
// Reset state when dialog closes
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmPassword = "";
|
||||
error = "";
|
||||
isSubmitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
const passwordsMatch = $derived(newPassword === confirmPassword);
|
||||
const isValid = $derived(
|
||||
newPassword.length >= 8 &&
|
||||
passwordsMatch &&
|
||||
(!hasExistingPassword || currentPassword.length > 0),
|
||||
);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!isValid || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
await api.me.setPassword({
|
||||
currentPassword: hasExistingPassword ? currentPassword : undefined,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
toast.success(
|
||||
hasExistingPassword
|
||||
? "Password changed successfully!"
|
||||
: "Password set successfully!",
|
||||
);
|
||||
open = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to set password";
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{hasExistingPassword ? "Change password" : "Set password"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if hasExistingPassword}
|
||||
Enter your current password and choose a new one.
|
||||
{:else}
|
||||
Create a password to enable password-based login.
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if hasExistingPassword}
|
||||
<PasswordFormField
|
||||
id="currentPassword"
|
||||
label="Current password"
|
||||
bind:value={currentPassword}
|
||||
placeholder="Enter current password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PasswordFormField
|
||||
id="newPassword"
|
||||
label="New password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Enter new password"
|
||||
autocomplete="new-password"
|
||||
showStrength
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordFormField
|
||||
id="confirmPassword"
|
||||
label="Confirm new password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
error={confirmPassword && !passwordsMatch ? "Passwords do not match" : ""}
|
||||
required
|
||||
/>
|
||||
|
||||
<ErrorAlert message={error} />
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => (open = false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
{hasExistingPassword ? "Change password" : "Set password"}
|
||||
</LoadingButton>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle } from "@lucide/svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
loadingText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
loading?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
description,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
loadingText = "Processing...",
|
||||
variant = "default",
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: Props = $props();
|
||||
|
||||
async function handleConfirm() {
|
||||
await onConfirm();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
{#if variant === "destructive"}
|
||||
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle class="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
{/if}
|
||||
<Dialog.Title class={variant === "destructive" ? "text-center" : ""}>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class={variant === "destructive" ? "text-center" : ""}>
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Footer class={variant === "destructive" ? "flex-col gap-2 sm:flex-col" : ""}>
|
||||
{#if variant === "destructive"}
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
class="w-full"
|
||||
loading={loading}
|
||||
loadingText={loadingText}
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</LoadingButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => (open = false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (open = false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</LoadingButton>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle } from "@lucide/svelte";
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let password = $state("");
|
||||
let isDeleting = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
// Reset state when dialog closes
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
password = "";
|
||||
error = "";
|
||||
isDeleting = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleDelete(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!password || isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
await api.me.delete({ password });
|
||||
|
||||
// Clear all cached data
|
||||
queryClient.clear();
|
||||
|
||||
toast.success("Your account has been deleted.");
|
||||
open = false;
|
||||
|
||||
// Redirect to login
|
||||
goto("/auth/login");
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to delete account";
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle class="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<Dialog.Title class="text-center">Delete your account?</Dialog.Title>
|
||||
<Dialog.Description class="text-center">
|
||||
This action cannot be undone. All your data will be permanently deleted,
|
||||
including organization memberships.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={handleDelete} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="delete-password">Enter your password to confirm</Label>
|
||||
<Input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
bind:value={password}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ErrorAlert message={error} />
|
||||
|
||||
<Dialog.Footer class="flex-col gap-2 sm:flex-col">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
class="w-full"
|
||||
disabled={!password}
|
||||
loading={isDeleting}
|
||||
loadingText="Deleting..."
|
||||
>
|
||||
Delete my account
|
||||
</LoadingButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => (open = false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as AccountNav } from "./account-nav.svelte";
|
||||
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
|
||||
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
|
||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
|
||||
export { default as PasskeyList } from "./passkey-list.svelte";
|
||||
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { Key, Pencil, Trash2 } from "@lucide/svelte";
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import ConfirmDialog from "./confirm-dialog.svelte";
|
||||
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
|
||||
|
||||
interface Passkey {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
passkeys: Passkey[];
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
let { passkeys, hasPassword }: Props = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let renameDialogOpen = $state(false);
|
||||
let deleteDialogOpen = $state(false);
|
||||
let selectedPasskey = $state<Passkey | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string | null): string {
|
||||
if (!date) {
|
||||
return "Never";
|
||||
}
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
}
|
||||
return formatDate(d);
|
||||
}
|
||||
|
||||
function openRename(passkey: Passkey) {
|
||||
selectedPasskey = passkey;
|
||||
renameDialogOpen = true;
|
||||
}
|
||||
|
||||
function openDelete(passkey: Passkey) {
|
||||
selectedPasskey = passkey;
|
||||
deleteDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedPasskey || isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
await api.me.passkeys.delete({ passkeyId: selectedPasskey.id });
|
||||
await queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
toast.success("Passkey deleted successfully!");
|
||||
deleteDialogOpen = false;
|
||||
selectedPasskey = null;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete passkey");
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canDeletePasskey = $derived(hasPassword || passkeys.length > 1);
|
||||
</script>
|
||||
|
||||
<div class="divide-y">
|
||||
{#each passkeys as passkey (passkey.id)}
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Key class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{passkey.name}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Created {formatDate(passkey.createdAt)} · Last used {formatRelativeTime(passkey.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => openRename(passkey)}
|
||||
aria-label="Rename passkey"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onclick={() => openDelete(passkey)}
|
||||
disabled={!canDeletePasskey}
|
||||
aria-label="Delete passkey"
|
||||
title={!canDeletePasskey ? "Cannot delete last passkey without a password" : "Delete passkey"}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedPasskey}
|
||||
<RenamePasskeyDialog
|
||||
bind:open={renameDialogOpen}
|
||||
passkeyId={selectedPasskey.id}
|
||||
currentName={selectedPasskey.name}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteDialogOpen}
|
||||
title="Delete passkey?"
|
||||
description="This will remove the passkey '{selectedPasskey.name}' from your account. You can add it again later."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
passkeyId: number;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), passkeyId, currentName }: Props = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let name = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
name = currentName;
|
||||
error = "";
|
||||
} else {
|
||||
isSubmitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
const isValid = $derived(name.trim().length > 0 && name.trim() !== currentName);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!isValid || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
await api.me.passkeys.rename({
|
||||
passkeyId,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
toast.success("Passkey renamed successfully!");
|
||||
open = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to rename passkey";
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Rename passkey</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Give this passkey a name to help you identify it.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="passkey-name">Name</Label>
|
||||
<Input
|
||||
id="passkey-name"
|
||||
type="text"
|
||||
placeholder="e.g., MacBook Pro"
|
||||
bind:value={name}
|
||||
maxlength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ErrorAlert message={error} />
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => (open = false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps, Snippet } from "svelte";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import DialogOverlay from "./dialog-overlay.svelte";
|
||||
import DialogPortal from "./dialog-portal.svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DialogPortal {...portalProps}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 flex w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close dialog"
|
||||
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-1.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-foreground text-lg font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Root bind:open {...restProps} />
|
||||
@@ -0,0 +1,34 @@
|
||||
import Root from "./dialog.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Portal from "./dialog-portal.svelte";
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Close,
|
||||
Trigger,
|
||||
Portal,
|
||||
Overlay,
|
||||
Content,
|
||||
Header,
|
||||
Footer,
|
||||
Title,
|
||||
Description,
|
||||
//
|
||||
Root as Dialog,
|
||||
Close as DialogClose,
|
||||
Trigger as DialogTrigger,
|
||||
Portal as DialogPortal,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Header as DialogHeader,
|
||||
Footer as DialogFooter,
|
||||
Title as DialogTitle,
|
||||
Description as DialogDescription,
|
||||
};
|
||||
25
apps/publisher-dashboard/src/routes/account/+layout.svelte
Normal file
25
apps/publisher-dashboard/src/routes/account/+layout.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { AccountNav } from "$lib/components/account";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Account Settings - Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Account Settings">
|
||||
<div class="space-y-6">
|
||||
<AccountNav />
|
||||
|
||||
<div class="max-w-2xl">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
285
apps/publisher-dashboard/src/routes/account/+page.svelte
Normal file
285
apps/publisher-dashboard/src/routes/account/+page.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, Loader2, Trash2 } from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import DeleteAccountDialog from "$lib/components/account/delete-account-dialog.svelte";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { cn } from "$lib/utils";
|
||||
import { validatePhone } from "$lib/utils/validation";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
}));
|
||||
|
||||
// Form state initialized from query data
|
||||
let displayName = $state("");
|
||||
let fullName = $state("");
|
||||
let phoneNumber = $state("");
|
||||
let avatarUrl = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state("");
|
||||
let phoneError = $state("");
|
||||
let avatarError = $state("");
|
||||
let deleteDialogOpen = $state(false);
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAvatarBlur() {
|
||||
if (avatarUrl && !isValidUrl(avatarUrl)) {
|
||||
avatarError = "Please enter a valid URL (http:// or https://)";
|
||||
} else {
|
||||
avatarError = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize form when data loads
|
||||
$effect(() => {
|
||||
if (userQuery.data) {
|
||||
displayName = userQuery.data.displayName ?? "";
|
||||
fullName = userQuery.data.fullName ?? "";
|
||||
phoneNumber = userQuery.data.phoneNumber ?? "";
|
||||
avatarUrl = userQuery.data.avatarUrl ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
function handlePhoneBlur() {
|
||||
const result = validatePhone(phoneNumber);
|
||||
if (phoneNumber && !result.valid) {
|
||||
phoneError = "Please enter a valid phone number (e.g., +1 555 123 4567)";
|
||||
} else {
|
||||
phoneError = "";
|
||||
if (phoneNumber && result.formatted) {
|
||||
phoneNumber = result.formatted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = $derived(
|
||||
displayName.trim().length >= 1 &&
|
||||
displayName.trim().length <= 100 &&
|
||||
(!phoneNumber || validatePhone(phoneNumber).valid) &&
|
||||
isValidUrl(avatarUrl),
|
||||
);
|
||||
|
||||
const hasChanges = $derived(
|
||||
userQuery.data &&
|
||||
(displayName !== (userQuery.data.displayName ?? "") ||
|
||||
fullName !== (userQuery.data.fullName ?? "") ||
|
||||
phoneNumber !== (userQuery.data.phoneNumber ?? "") ||
|
||||
avatarUrl !== (userQuery.data.avatarUrl ?? "")),
|
||||
);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!isValid || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
await api.me.updateProfile({
|
||||
displayName: displayName.trim(),
|
||||
fullName: fullName.trim() || undefined,
|
||||
phoneNumber: phoneNumber.trim() || undefined,
|
||||
avatarUrl: avatarUrl.trim() || undefined,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
toast.success("Profile updated successfully!");
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to save profile";
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(name: string | null | undefined): string {
|
||||
if (!name) {
|
||||
return "?";
|
||||
}
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if userQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if userQuery.error}
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>Failed to load user data. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Profile Settings Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Update your personal information and profile picture.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-amber-500 to-orange-600 text-lg font-semibold text-white"
|
||||
>
|
||||
{#if avatarUrl}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
class="h-full w-full object-cover"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(displayName || userQuery.data?.email)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 space-y-1">
|
||||
<Label for="avatarUrl">Avatar URL</Label>
|
||||
<Input
|
||||
id="avatarUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
bind:value={avatarUrl}
|
||||
onblur={handleAvatarBlur}
|
||||
class={cn("h-9", avatarError && "border-destructive")}
|
||||
/>
|
||||
{#if avatarError}
|
||||
<p class="text-xs text-destructive">{avatarError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Profile Fields -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="displayName">Display name <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
placeholder="How should we call you?"
|
||||
bind:value={displayName}
|
||||
required
|
||||
maxlength={100}
|
||||
class="h-10"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
This will be shown to other team members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="fullName">Full name</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="Your full name (for invoices)"
|
||||
bind:value={fullName}
|
||||
maxlength={200}
|
||||
class="h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="phoneNumber">Phone number</Label>
|
||||
<Input
|
||||
id="phoneNumber"
|
||||
type="tel"
|
||||
placeholder="+1 555 123 4567"
|
||||
bind:value={phoneNumber}
|
||||
onblur={handlePhoneBlur}
|
||||
class={cn("h-10", phoneError && "border-destructive")}
|
||||
/>
|
||||
{#if phoneError}
|
||||
<p class="text-xs text-destructive">{phoneError}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Include country code for international numbers
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorAlert message={error} />
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
class="h-10"
|
||||
disabled={!isValid || !hasChanges}
|
||||
loading={isSubmitting}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
Save changes
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Danger Zone Card -->
|
||||
<Card class="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete your account and all associated data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Once you delete your account, there is no going back. This action is permanent
|
||||
and will remove all your data, including organization memberships.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={() => (deleteDialogOpen = true)}
|
||||
>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Delete account
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DeleteAccountDialog bind:open={deleteDialogOpen} />
|
||||
{/if}
|
||||
165
apps/publisher-dashboard/src/routes/account/auth/+page.svelte
Normal file
165
apps/publisher-dashboard/src/routes/account/auth/+page.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, CheckCircle, Key, Loader2, Plus } from "@lucide/svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "$lib/api/client";
|
||||
import AddPasskeyDialog from "$lib/components/account/add-passkey-dialog.svelte";
|
||||
import ChangePasswordDialog from "$lib/components/account/change-password-dialog.svelte";
|
||||
import PasskeyList from "$lib/components/account/passkey-list.svelte";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
}));
|
||||
|
||||
const passkeysQuery = createQuery(() => ({
|
||||
queryKey: ["passkeys"],
|
||||
queryFn: () => api.me.passkeys.list(),
|
||||
}));
|
||||
|
||||
let passwordDialogOpen = $state(false);
|
||||
let addPasskeyDialogOpen = $state(false);
|
||||
|
||||
const hasPassword = $derived(userQuery.data?.hasPassword ?? false);
|
||||
</script>
|
||||
|
||||
{#if userQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if userQuery.error}
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>Failed to load user data. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Email Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Address</CardTitle>
|
||||
<CardDescription>
|
||||
Your email is used for sign-in and notifications.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{userQuery.data?.email}</span>
|
||||
{#if userQuery.data?.emailVerified}
|
||||
<Badge variant="outline" class="text-green-600 border-green-600/30 bg-green-50">
|
||||
<CheckCircle class="mr-1 h-3 w-3" />
|
||||
Verified
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline" class="text-amber-600 border-amber-600/30 bg-amber-50">
|
||||
Not verified
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Password Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>
|
||||
{#if hasPassword}
|
||||
Change your password to keep your account secure.
|
||||
{:else}
|
||||
Set a password to enable password-based login.
|
||||
{/if}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Key class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm">
|
||||
{#if hasPassword}
|
||||
Password is set
|
||||
{:else}
|
||||
No password set
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (passwordDialogOpen = true)}
|
||||
>
|
||||
{#if hasPassword}
|
||||
Change password
|
||||
{:else}
|
||||
Set password
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Passkeys Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
<CardDescription>
|
||||
Passkeys let you sign in securely without a password.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (addPasskeyDialogOpen = true)}
|
||||
>
|
||||
<Plus class="mr-1 h-4 w-4" />
|
||||
Add passkey
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if passkeysQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if passkeysQuery.error}
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>Failed to load passkeys.</AlertDescription>
|
||||
</Alert>
|
||||
{:else if passkeysQuery.data && passkeysQuery.data.length > 0}
|
||||
<PasskeyList
|
||||
passkeys={passkeysQuery.data}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Key class="mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<p class="text-sm text-muted-foreground">No passkeys registered yet.</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Add a passkey for secure, passwordless sign-in.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ChangePasswordDialog
|
||||
bind:open={passwordDialogOpen}
|
||||
hasExistingPassword={hasPassword}
|
||||
/>
|
||||
<AddPasskeyDialog
|
||||
bind:open={addPasskeyDialogOpen}
|
||||
email={userQuery.data?.email ?? ""}
|
||||
/>
|
||||
{/if}
|
||||
244
apps/publisher-dashboard/src/routes/account/devices/+page.svelte
Normal file
244
apps/publisher-dashboard/src/routes/account/devices/+page.svelte
Normal file
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Star,
|
||||
Tablet,
|
||||
} from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ConfirmDialog } from "$lib/components/account";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const devicesQuery = createQuery(() => ({
|
||||
queryKey: ["trustedDevices"],
|
||||
queryFn: () => api.me.listTrustedDevices(),
|
||||
}));
|
||||
|
||||
const currentDeviceQuery = createQuery(() => ({
|
||||
queryKey: ["deviceInfo"],
|
||||
queryFn: () => api.me.getDeviceInfo(),
|
||||
}));
|
||||
|
||||
// Get current device fingerprint from comparison
|
||||
const currentDeviceId = $derived(currentDeviceQuery.data?.id);
|
||||
|
||||
let confirmDialogOpen = $state(false);
|
||||
let confirmAllDialogOpen = $state(false);
|
||||
let selectedDeviceId = $state<number | null>(null);
|
||||
let isRemoving = $state(false);
|
||||
let isRemovingAll = $state(false);
|
||||
|
||||
function formatLocation(device: {
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
}): string {
|
||||
const parts = [device.city, device.region, device.country].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
}
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function getDeviceIcon(name: string) {
|
||||
const nameLower = name.toLowerCase();
|
||||
if (
|
||||
nameLower.includes("iphone") ||
|
||||
nameLower.includes("android") ||
|
||||
nameLower.includes("mobile")
|
||||
) {
|
||||
return Smartphone;
|
||||
}
|
||||
if (nameLower.includes("ipad") || nameLower.includes("tablet")) {
|
||||
return Tablet;
|
||||
}
|
||||
return Monitor;
|
||||
}
|
||||
|
||||
function openRemoveDialog(deviceId: number) {
|
||||
selectedDeviceId = deviceId;
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleRemoveTrust() {
|
||||
if (!selectedDeviceId || isRemoving) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRemoving = true;
|
||||
try {
|
||||
await api.me.untrustDevice({ deviceId: selectedDeviceId });
|
||||
await queryClient.invalidateQueries({ queryKey: ["trustedDevices"] });
|
||||
toast.success("Device trust removed");
|
||||
confirmDialogOpen = false;
|
||||
selectedDeviceId = null;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove trust");
|
||||
} finally {
|
||||
isRemoving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAllTrust() {
|
||||
if (isRemovingAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRemovingAll = true;
|
||||
try {
|
||||
await api.me.revokeAllTrustedDevices();
|
||||
await queryClient.invalidateQueries({ queryKey: ["trustedDevices"] });
|
||||
toast.success("All trusted devices removed");
|
||||
confirmAllDialogOpen = false;
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to remove trusted devices",
|
||||
);
|
||||
} finally {
|
||||
isRemovingAll = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if devicesQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if devicesQuery.error}
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>Failed to load devices. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trusted Devices</CardTitle>
|
||||
<CardDescription>
|
||||
Trusted devices can sign in with just a password, without requiring email verification.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if devicesQuery.data && devicesQuery.data.length > 0}
|
||||
<div class="divide-y">
|
||||
{#each devicesQuery.data as device (device.id)}
|
||||
{@const DeviceIcon = getDeviceIcon(device.name)}
|
||||
{@const isCurrentDevice = device.id === currentDeviceId}
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">{device.name}</p>
|
||||
{#if isCurrentDevice}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
<Star class="mr-1 h-3 w-3" />
|
||||
Current
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin class="h-3 w-3" />
|
||||
<span>{formatLocation(device)}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Last used {formatRelativeTime(device.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openRemoveDialog(device.id)}
|
||||
>
|
||||
Remove trust
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if devicesQuery.data.length > 1}
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full text-destructive hover:text-destructive"
|
||||
onclick={() => (confirmAllDialogOpen = true)}
|
||||
>
|
||||
Remove all trusted devices
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Monitor class="mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<p class="text-sm text-muted-foreground">No trusted devices.</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Trust a device during sign-in to skip email verification.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmDialogOpen}
|
||||
title="Remove device trust?"
|
||||
description="This device will need email verification for future password sign-ins."
|
||||
confirmText="Remove trust"
|
||||
variant="destructive"
|
||||
loading={isRemoving}
|
||||
onConfirm={handleRemoveTrust}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmAllDialogOpen}
|
||||
title="Remove all trusted devices?"
|
||||
description="All devices will require email verification for future password sign-ins."
|
||||
confirmText="Remove all"
|
||||
variant="destructive"
|
||||
loading={isRemovingAll}
|
||||
onConfirm={handleRemoveAllTrust}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
Key,
|
||||
Loader2,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Star,
|
||||
Tablet,
|
||||
} from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ConfirmDialog } from "$lib/components/account";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const sessionsQuery = createQuery(() => ({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: () => api.me.listSessions(),
|
||||
}));
|
||||
|
||||
let confirmDialogOpen = $state(false);
|
||||
let confirmAllDialogOpen = $state(false);
|
||||
let selectedSessionId = $state<number | null>(null);
|
||||
let isRevoking = $state(false);
|
||||
let isRevokingAll = $state(false);
|
||||
|
||||
// Split sessions into active and past
|
||||
const activeSessions = $derived(
|
||||
sessionsQuery.data?.filter((s) => s.revokedAt === null) ?? [],
|
||||
);
|
||||
const pastSessions = $derived(
|
||||
sessionsQuery.data?.filter((s) => s.revokedAt !== null) ?? [],
|
||||
);
|
||||
|
||||
function formatLocation(session: {
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
}): string {
|
||||
const parts = [session.city, session.region, session.country].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
}
|
||||
return formatDate(d);
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgent: string): {
|
||||
browser: string;
|
||||
os: string;
|
||||
deviceType: string;
|
||||
} {
|
||||
const parser = new UAParser(userAgent);
|
||||
const browser = parser.getBrowser().name || "Unknown browser";
|
||||
const os = parser.getOS().name || "Unknown OS";
|
||||
const deviceType = parser.getDevice().type || "desktop";
|
||||
return { browser, os, deviceType };
|
||||
}
|
||||
|
||||
function getDeviceIcon(deviceType: string) {
|
||||
switch (deviceType) {
|
||||
case "mobile":
|
||||
return Smartphone;
|
||||
case "tablet":
|
||||
return Tablet;
|
||||
default:
|
||||
return Monitor;
|
||||
}
|
||||
}
|
||||
|
||||
function openRevokeDialog(sessionId: number) {
|
||||
selectedSessionId = sessionId;
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!selectedSessionId || isRevoking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRevoking = true;
|
||||
try {
|
||||
await api.me.revokeSession({ sessionId: selectedSessionId });
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
toast.success("Session revoked");
|
||||
confirmDialogOpen = false;
|
||||
selectedSessionId = null;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to revoke session");
|
||||
} finally {
|
||||
isRevoking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeAll() {
|
||||
if (isRevokingAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRevokingAll = true;
|
||||
try {
|
||||
await api.me.revokeAllSessions();
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
toast.success("All other sessions revoked");
|
||||
confirmAllDialogOpen = false;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to revoke sessions");
|
||||
} finally {
|
||||
isRevokingAll = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if sessionsQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if sessionsQuery.error}
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>Failed to load sessions. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Active Sessions -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Devices that are currently signed in to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if activeSessions.length > 0}
|
||||
<div class="divide-y">
|
||||
{#each activeSessions as session (session.id)}
|
||||
{@const { browser, os, deviceType } = parseUserAgent(session.userAgent)}
|
||||
{@const DeviceIcon = getDeviceIcon(deviceType)}
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">{browser} on {os}</p>
|
||||
{#if session.isCurrent}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
<Star class="mr-1 h-3 w-3" />
|
||||
Current
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<MapPin class="h-3 w-3" />
|
||||
<span>{formatLocation(session)}</span>
|
||||
</div>
|
||||
<span>·</span>
|
||||
{#if session.trustedMode}
|
||||
<div class="flex items-center gap-1">
|
||||
<Key class="h-3 w-3" />
|
||||
<span>via passkey</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span>via password</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Started {formatRelativeTime(session.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !session.isCurrent}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openRevokeDialog(session.id)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeSessions.length > 1}
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full text-destructive hover:text-destructive"
|
||||
onclick={() => (confirmAllDialogOpen = true)}
|
||||
>
|
||||
Revoke all other sessions
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Monitor class="mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<p class="text-sm text-muted-foreground">No active sessions.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Past Sessions -->
|
||||
{#if pastSessions.length > 0}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Past Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Sessions that have been logged out or revoked.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="divide-y">
|
||||
{#each pastSessions.slice(0, 10) as session (session.id)}
|
||||
{@const { browser, os, deviceType } = parseUserAgent(session.userAgent)}
|
||||
{@const DeviceIcon = getDeviceIcon(deviceType)}
|
||||
<div class="flex items-center gap-3 py-3 first:pt-0 last:pb-0 opacity-60">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{browser} on {os}</p>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin class="h-3 w-3" />
|
||||
<span>{formatLocation(session)}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{formatDate(session.createdAt)} - {session.revokedAt ? formatDate(session.revokedAt) : ""}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<LogOut class="h-3 w-3" />
|
||||
<span>Logged out</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if pastSessions.length > 10}
|
||||
<p class="mt-4 text-center text-xs text-muted-foreground">
|
||||
Showing 10 of {pastSessions.length} past sessions
|
||||
</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmDialogOpen}
|
||||
title="Revoke this session?"
|
||||
description="This will sign out the device. You'll need to sign in again to use that device."
|
||||
confirmText="Revoke session"
|
||||
variant="destructive"
|
||||
loading={isRevoking}
|
||||
onConfirm={handleRevoke}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmAllDialogOpen}
|
||||
title="Revoke all other sessions?"
|
||||
description="This will sign out all devices except your current one. You'll need to sign in again on those devices."
|
||||
confirmText="Revoke all"
|
||||
variant="destructive"
|
||||
loading={isRevokingAll}
|
||||
onConfirm={handleRevokeAll}
|
||||
/>
|
||||
{/if}
|
||||
@@ -2293,10 +2293,10 @@ _Depends on: D1-D9, E1-E4, C3_
|
||||
_Depends on: F1-F7, C3_
|
||||
_Can run parallel to H after F1 is done_
|
||||
|
||||
- [ ] **I1**: Create `/account` page (profile settings, avatar upload)
|
||||
- [ ] **I2**: Create `/account/auth` page (password, passkeys management)
|
||||
- [ ] **I3**: Create `/account/devices` page (trusted devices)
|
||||
- [ ] **I4**: Create `/account/sessions` page (session history)
|
||||
- [x] **I1**: Create `/account` page (profile settings, avatar upload)
|
||||
- [x] **I2**: Create `/account/auth` page (password, passkeys management)
|
||||
- [x] **I3**: Create `/account/devices` page (trusted devices)
|
||||
- [x] **I4**: Create `/account/sessions` page (session history)
|
||||
|
||||
---
|
||||
|
||||
|
||||
287
docs/test-plans/account.md
Normal file
287
docs/test-plans/account.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Test Plan: Account Pages (Workstream I)
|
||||
|
||||
## Overview
|
||||
Manual test plan for the account management pages: Profile, Authentication, Devices, and Sessions.
|
||||
|
||||
## Prerequisites
|
||||
- Dev server running: `bun run --cwd apps/publisher-dashboard dev`
|
||||
- Logged-in user account
|
||||
- Access to multiple devices/browsers for session testing
|
||||
|
||||
---
|
||||
|
||||
## 1. Account Navigation
|
||||
|
||||
### 1.1 Navigation Tabs
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Navigate to `/account` | Profile tab is highlighted |
|
||||
| 2 | Click "Authentication" tab | Navigate to `/account/auth`, tab highlighted |
|
||||
| 3 | Click "Devices" tab | Navigate to `/account/devices`, tab highlighted |
|
||||
| 4 | Click "Sessions" tab | Navigate to `/account/sessions`, tab highlighted |
|
||||
| 5 | Resize to mobile width | Tab labels hidden, only icons visible |
|
||||
|
||||
---
|
||||
|
||||
## 2. Profile Page (`/account`)
|
||||
|
||||
### 2.1 Profile Loading
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Navigate to `/account` | Loading spinner shown briefly |
|
||||
| 2 | Wait for data | Form populated with current user data |
|
||||
| 3 | Refresh page | Data persists correctly |
|
||||
|
||||
### 2.2 Avatar URL
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Enter valid image URL | Avatar preview updates |
|
||||
| 2 | Enter invalid URL (e.g., "not-a-url") | Error: "Please enter a valid URL" on blur |
|
||||
| 3 | Enter URL without http/https | Error shown |
|
||||
| 4 | Clear URL | Avatar shows initials fallback |
|
||||
| 5 | Enter URL to non-image | Preview attempts to load, falls back gracefully |
|
||||
|
||||
### 2.3 Display Name (Required)
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Clear display name | Save button disabled |
|
||||
| 2 | Enter valid name (1-100 chars) | Save button enabled |
|
||||
| 3 | Enter name > 100 chars | Input enforces maxlength |
|
||||
|
||||
### 2.4 Full Name (Optional)
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Leave empty | Form valid |
|
||||
| 2 | Enter value | Form valid |
|
||||
| 3 | Save with value | Value persists after refresh |
|
||||
|
||||
### 2.5 Phone Number
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Enter valid phone: "+1 555 123 4567" | Auto-formats to E.164 on blur |
|
||||
| 2 | Enter invalid phone: "abc123" | Error: "Please enter a valid phone number" |
|
||||
| 3 | Leave empty | Form valid (optional field) |
|
||||
| 4 | Enter international number | Formats correctly |
|
||||
|
||||
### 2.6 Save Profile
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Make no changes | Save button disabled |
|
||||
| 2 | Change any field | Save button enabled |
|
||||
| 3 | Click Save | Loading state, success toast |
|
||||
| 4 | Refresh page | Changes persisted |
|
||||
| 5 | Save with validation error | Button disabled, cannot submit |
|
||||
|
||||
### 2.7 Delete Account
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Click "Delete account" button | Confirmation dialog opens |
|
||||
| 2 | Dialog shows warning icon | Red/destructive styling |
|
||||
| 3 | Enter wrong password | Error message shown |
|
||||
| 4 | Enter correct password | Account deleted, redirected to login |
|
||||
| 5 | Click Cancel | Dialog closes, no action |
|
||||
|
||||
---
|
||||
|
||||
## 3. Authentication Page (`/account/auth`)
|
||||
|
||||
### 3.1 Email Display
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | View email section | Email displayed (read-only) |
|
||||
| 2 | Verified user | "Verified" badge shown |
|
||||
| 3 | Unverified user | No badge (or "Unverified" indicator) |
|
||||
|
||||
### 3.2 Password - No Existing Password
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | User without password | Shows "No password set" |
|
||||
| 2 | Click "Set password" | Dialog opens |
|
||||
| 3 | No "Current password" field | Only new password fields shown |
|
||||
| 4 | Enter weak password (<8 chars) | Submit disabled |
|
||||
| 5 | Enter mismatched passwords | Submit disabled |
|
||||
| 6 | Enter valid matching passwords | Submit enabled |
|
||||
| 7 | Submit | Success toast, status updates to "Password is set" |
|
||||
|
||||
### 3.3 Password - Existing Password
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | User with password | Shows "Password is set" |
|
||||
| 2 | Click "Change password" | Dialog opens with current password field |
|
||||
| 3 | Leave current password empty | Submit disabled |
|
||||
| 4 | Enter wrong current password | Error message on submit |
|
||||
| 5 | Enter correct current + valid new | Success toast |
|
||||
|
||||
### 3.4 Passkeys List
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | No passkeys | Empty state message |
|
||||
| 2 | Has passkeys | List with name, created date, last used |
|
||||
| 3 | Each passkey | Rename and Delete buttons visible |
|
||||
|
||||
### 3.5 Add Passkey
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Click "+ Add passkey" | Dialog opens |
|
||||
| 2 | Browser doesn't support WebAuthn | Message shown, form disabled |
|
||||
| 3 | Enter passkey name | Create button enabled |
|
||||
| 4 | Click Create | Browser WebAuthn prompt appears |
|
||||
| 5 | Complete WebAuthn ceremony | Success toast, passkey appears in list |
|
||||
| 6 | Cancel WebAuthn prompt | Error: "Passkey creation was cancelled" |
|
||||
| 7 | Verify passkey name | Name matches what user entered |
|
||||
|
||||
### 3.6 Rename Passkey
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Click rename icon | Dialog opens with current name |
|
||||
| 2 | Enter same name | Save disabled |
|
||||
| 3 | Enter new name | Save enabled |
|
||||
| 4 | Save | Success toast, list updates |
|
||||
|
||||
### 3.7 Delete Passkey
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Click delete icon | Confirmation dialog |
|
||||
| 2 | Confirm deletion | Success toast, passkey removed |
|
||||
| 3 | Only 1 passkey, no password | Delete button disabled with tooltip |
|
||||
| 4 | Has password or 2+ passkeys | Delete enabled |
|
||||
|
||||
---
|
||||
|
||||
## 4. Devices Page (`/account/devices`)
|
||||
|
||||
### 4.1 Trusted Devices List
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Navigate to page | List of trusted devices shown |
|
||||
| 2 | Current device | Marked with "Current" badge |
|
||||
| 3 | Each device shows | Name, location, last used date |
|
||||
| 4 | No trusted devices | Empty state message |
|
||||
|
||||
### 4.2 Remove Trust
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Click "Remove trust" on any device | Confirmation dialog |
|
||||
| 2 | Confirm | Success toast, device removed from list |
|
||||
| 3 | Cancel | Dialog closes, no change |
|
||||
|
||||
### 4.3 Remove All Trusted Devices
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Only 1 device | "Remove all" button not shown |
|
||||
| 2 | 2+ devices | "Remove all trusted devices" button shown |
|
||||
| 3 | Click remove all | Confirmation dialog |
|
||||
| 4 | Confirm | All devices removed, empty state shown |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sessions Page (`/account/sessions`)
|
||||
|
||||
### 5.1 Active Sessions
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Navigate to page | Active sessions listed |
|
||||
| 2 | Current session | Marked with "Current" badge |
|
||||
| 3 | Each session shows | Browser/OS, location, auth method, start date |
|
||||
| 4 | Passkey session | Shows "via passkey" with key icon |
|
||||
| 5 | Password session | Shows "via password" |
|
||||
|
||||
### 5.2 Revoke Session
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Current session | No revoke button |
|
||||
| 2 | Other session | Revoke button visible |
|
||||
| 3 | Click Revoke | Confirmation dialog |
|
||||
| 4 | Confirm | Session moved to past sessions |
|
||||
|
||||
### 5.3 Revoke All Sessions
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | Only current session | "Revoke all" button not shown |
|
||||
| 2 | Multiple sessions | "Revoke all other sessions" shown |
|
||||
| 3 | Confirm revoke all | All other sessions revoked |
|
||||
|
||||
### 5.4 Past Sessions
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1 | No past sessions | Section not shown |
|
||||
| 2 | Has past sessions | Section shows with grayed styling |
|
||||
| 3 | Past session shows | Browser/OS, location, date range, "Logged out" |
|
||||
| 4 | More than 10 | Shows "Showing 10 of X" message |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Cutting Concerns
|
||||
|
||||
### 6.1 Loading States
|
||||
| Page | Expected |
|
||||
|------|----------|
|
||||
| All pages | Spinner while loading data |
|
||||
| Form submissions | Button shows loading state |
|
||||
| Dialogs | Buttons disabled during submission |
|
||||
|
||||
### 6.2 Error Handling
|
||||
| Scenario | Expected |
|
||||
|----------|----------|
|
||||
| Network error | Error alert shown |
|
||||
| API error | Error message from server displayed |
|
||||
| Validation error | Inline error message |
|
||||
|
||||
### 6.3 Accessibility
|
||||
| Test | Expected |
|
||||
|------|----------|
|
||||
| Keyboard navigation | All interactive elements focusable |
|
||||
| Screen reader | ARIA labels present on icons/buttons |
|
||||
| Dialog close button | Has aria-label="Close dialog" |
|
||||
| Navigation tabs | Has aria-current="page" on active |
|
||||
|
||||
### 6.4 Mobile Responsiveness
|
||||
| Breakpoint | Expected |
|
||||
|------------|----------|
|
||||
| Desktop (>640px) | Full navigation labels |
|
||||
| Mobile (<640px) | Icon-only navigation |
|
||||
| All sizes | Forms remain usable |
|
||||
|
||||
---
|
||||
|
||||
## 7. Integration Tests
|
||||
|
||||
### 7.1 Full Passkey Flow
|
||||
1. Navigate to `/account/auth`
|
||||
2. Add new passkey with custom name
|
||||
3. Verify passkey appears in list with correct name
|
||||
4. Rename passkey
|
||||
5. Delete passkey (if not last auth method)
|
||||
|
||||
### 7.2 Password Change Flow
|
||||
1. Set initial password (if none)
|
||||
2. Change password with correct current password
|
||||
3. Verify can log in with new password
|
||||
|
||||
### 7.3 Multi-Device Session Test
|
||||
1. Log in on Device A
|
||||
2. Log in on Device B
|
||||
3. On Device A, view sessions - see both
|
||||
4. Revoke Device B session
|
||||
5. Verify Device B is logged out
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge Cases
|
||||
|
||||
| Scenario | Expected Behavior |
|
||||
|----------|-------------------|
|
||||
| Last auth method | Cannot delete (button disabled) |
|
||||
| Very long passkey name | Truncated in UI, full in tooltip |
|
||||
| Many sessions (>10 past) | Pagination/truncation message |
|
||||
| Slow network | Loading states visible |
|
||||
| Session expired mid-action | Redirected to login |
|
||||
| Concurrent passkey creation | Correct passkey gets renamed (race condition fixed) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
| Tester | Date | Status |
|
||||
|--------|------|--------|
|
||||
| | | |
|
||||
@@ -88,7 +88,7 @@ export const contract = oc.router({
|
||||
response: z.custom<RegistrationResponseJSON>(),
|
||||
}),
|
||||
)
|
||||
.output(z.void()),
|
||||
.output(z.object({ passkeyId: z.number() })),
|
||||
createAuthenticationOptions: oc.output(
|
||||
z.object({
|
||||
challengeId: z.number(),
|
||||
|
||||
@@ -15,6 +15,7 @@ export const userProfileSchema = z.object({
|
||||
emailVerified: z.boolean(),
|
||||
needsSetup: z.boolean(),
|
||||
isSuperuser: z.boolean(),
|
||||
hasPassword: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user