diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index fce99dd..f3358e9 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -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(), }, diff --git a/apps/api-server/src/procedures/orgs/helpers.ts b/apps/api-server/src/procedures/orgs/helpers.ts index d791907..e3f71cd 100644 --- a/apps/api-server/src/procedures/orgs/helpers.ts +++ b/apps/api-server/src/procedures/orgs/helpers.ts @@ -120,5 +120,5 @@ export async function countOwners( .where("role", "=", "owner") .executeTakeFirstOrThrow(); - return Number(result.count); + return result.count; } diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 0406a26..bbfd1b1 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -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, }; }); diff --git a/apps/api-server/src/utils/webauthn.ts b/apps/api-server/src/utils/webauthn.ts index 40575b4..28a1255 100644 --- a/apps/api-server/src/utils/webauthn.ts +++ b/apps/api-server/src/utils/webauthn.ts @@ -157,7 +157,7 @@ export const verifyRegistration = async ( userId: number, challengeId: number, response: RegistrationResponseJSON, -): Promise => { +): 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) }; }; /** diff --git a/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte b/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte new file mode 100644 index 0000000..9d8bfb7 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte @@ -0,0 +1,52 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/account/add-passkey-dialog.svelte b/apps/publisher-dashboard/src/lib/components/account/add-passkey-dialog.svelte new file mode 100644 index 0000000..577e43c --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/add-passkey-dialog.svelte @@ -0,0 +1,164 @@ + + + + + +
+ +
+ Add a passkey + + {#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} + +
+ + {#if supportsPasskey} +
+
+ + +

+ Choose a name to help you identify this passkey later. +

+
+ + + + + + Create passkey + + + + + {:else} + + + + {/if} +
+
diff --git a/apps/publisher-dashboard/src/lib/components/account/change-password-dialog.svelte b/apps/publisher-dashboard/src/lib/components/account/change-password-dialog.svelte new file mode 100644 index 0000000..1d557e0 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/change-password-dialog.svelte @@ -0,0 +1,142 @@ + + + + + + + {hasExistingPassword ? "Change password" : "Set password"} + + + {#if hasExistingPassword} + Enter your current password and choose a new one. + {:else} + Create a password to enable password-based login. + {/if} + + + +
+ {#if hasExistingPassword} + + {/if} + + + + + + + + + + + {hasExistingPassword ? "Change password" : "Set password"} + + + +
+
diff --git a/apps/publisher-dashboard/src/lib/components/account/confirm-dialog.svelte b/apps/publisher-dashboard/src/lib/components/account/confirm-dialog.svelte new file mode 100644 index 0000000..6f97257 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/confirm-dialog.svelte @@ -0,0 +1,88 @@ + + + + + + {#if variant === "destructive"} +
+ +
+ {/if} + + {title} + + + {description} + +
+ + + {#if variant === "destructive"} + + {confirmText} + + + {:else} + + + {confirmText} + + {/if} + +
+
diff --git a/apps/publisher-dashboard/src/lib/components/account/delete-account-dialog.svelte b/apps/publisher-dashboard/src/lib/components/account/delete-account-dialog.svelte new file mode 100644 index 0000000..cc22afa --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/delete-account-dialog.svelte @@ -0,0 +1,113 @@ + + + + + +
+ +
+ Delete your account? + + This action cannot be undone. All your data will be permanently deleted, + including organization memberships. + +
+ +
+
+ + +
+ + + + + + Delete my account + + + + +
+
diff --git a/apps/publisher-dashboard/src/lib/components/account/index.ts b/apps/publisher-dashboard/src/lib/components/account/index.ts new file mode 100644 index 0000000..c10a41a --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/index.ts @@ -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"; diff --git a/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte b/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte new file mode 100644 index 0000000..d017dda --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte @@ -0,0 +1,152 @@ + + +
+ {#each passkeys as passkey (passkey.id)} +
+
+
+ +
+
+

{passkey.name}

+

+ Created {formatDate(passkey.createdAt)} · Last used {formatRelativeTime(passkey.lastUsedAt)} +

+
+
+
+ + +
+
+ {/each} +
+ +{#if selectedPasskey} + + + +{/if} diff --git a/apps/publisher-dashboard/src/lib/components/account/rename-passkey-dialog.svelte b/apps/publisher-dashboard/src/lib/components/account/rename-passkey-dialog.svelte new file mode 100644 index 0000000..0ae3395 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/account/rename-passkey-dialog.svelte @@ -0,0 +1,108 @@ + + + + + + Rename passkey + + Give this passkey a name to help you identify it. + + + +
+
+ + +
+ + + + + + + Save + + + +
+
diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-close.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..1e7d8e3 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,8 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-content.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..a07cb5a --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,41 @@ + + + + + + {@render children?.()} + + + Close + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-description.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3d0c202 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-footer.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..40eebe0 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-header.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..e34c392 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f1a2fce --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-portal.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..6fb8fec --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-title.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..234538b --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..f95ac5c --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,8 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog.svelte b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog.svelte new file mode 100644 index 0000000..dcd9d95 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/dialog.svelte @@ -0,0 +1,8 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/dialog/index.ts b/apps/publisher-dashboard/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..575e267 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/dialog/index.ts @@ -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, +}; diff --git a/apps/publisher-dashboard/src/routes/account/+layout.svelte b/apps/publisher-dashboard/src/routes/account/+layout.svelte new file mode 100644 index 0000000..4720447 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/+layout.svelte @@ -0,0 +1,25 @@ + + + + Account Settings - Publisher Dashboard + + + +
+ + +
+ {@render children()} +
+
+
diff --git a/apps/publisher-dashboard/src/routes/account/+page.svelte b/apps/publisher-dashboard/src/routes/account/+page.svelte new file mode 100644 index 0000000..bf721f2 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/+page.svelte @@ -0,0 +1,285 @@ + + +{#if userQuery.isLoading} +
+ +
+{:else if userQuery.error} + + + Failed to load user data. Please try again. + +{:else} +
+ + + + Profile Settings + + Update your personal information and profile picture. + + + +
+ +
+
+ {#if avatarUrl} + Avatar { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} + {getInitials(displayName || userQuery.data?.email)} + {/if} +
+
+ + + {#if avatarError} +

{avatarError}

+ {/if} +
+
+ + + + +
+
+ + +

+ This will be shown to other team members +

+
+ +
+ + +
+ +
+ + + {#if phoneError} +

{phoneError}

+ {:else} +

+ Include country code for international numbers +

+ {/if} +
+
+ + + + + Save changes + + +
+
+ + + + + Danger Zone + + Permanently delete your account and all associated data. + + + +

+ Once you delete your account, there is no going back. This action is permanent + and will remove all your data, including organization memberships. +

+ +
+
+
+ + +{/if} diff --git a/apps/publisher-dashboard/src/routes/account/auth/+page.svelte b/apps/publisher-dashboard/src/routes/account/auth/+page.svelte new file mode 100644 index 0000000..48617ae --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/auth/+page.svelte @@ -0,0 +1,165 @@ + + +{#if userQuery.isLoading} +
+ +
+{:else if userQuery.error} + + + Failed to load user data. Please try again. + +{:else} +
+ + + + Email Address + + Your email is used for sign-in and notifications. + + + +
+ {userQuery.data?.email} + {#if userQuery.data?.emailVerified} + + + Verified + + {:else} + + Not verified + + {/if} +
+
+
+ + + + + Password + + {#if hasPassword} + Change your password to keep your account secure. + {:else} + Set a password to enable password-based login. + {/if} + + + +
+
+ + + {#if hasPassword} + Password is set + {:else} + No password set + {/if} + +
+ +
+
+
+ + + + +
+
+ Passkeys + + Passkeys let you sign in securely without a password. + +
+ +
+
+ + {#if passkeysQuery.isLoading} +
+ +
+ {:else if passkeysQuery.error} + + + Failed to load passkeys. + + {:else if passkeysQuery.data && passkeysQuery.data.length > 0} + + {:else} +
+ +

No passkeys registered yet.

+

+ Add a passkey for secure, passwordless sign-in. +

+
+ {/if} +
+
+
+ + + +{/if} diff --git a/apps/publisher-dashboard/src/routes/account/devices/+page.svelte b/apps/publisher-dashboard/src/routes/account/devices/+page.svelte new file mode 100644 index 0000000..739101e --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/devices/+page.svelte @@ -0,0 +1,244 @@ + + +{#if devicesQuery.isLoading} +
+ +
+{:else if devicesQuery.error} + + + Failed to load devices. Please try again. + +{:else} +
+ + + Trusted Devices + + Trusted devices can sign in with just a password, without requiring email verification. + + + + {#if devicesQuery.data && devicesQuery.data.length > 0} +
+ {#each devicesQuery.data as device (device.id)} + {@const DeviceIcon = getDeviceIcon(device.name)} + {@const isCurrentDevice = device.id === currentDeviceId} +
+
+
+ +
+
+
+

{device.name}

+ {#if isCurrentDevice} + + + Current + + {/if} +
+
+ + {formatLocation(device)} +
+

+ Last used {formatRelativeTime(device.lastUsedAt)} +

+
+
+ +
+ {/each} +
+ + {#if devicesQuery.data.length > 1} +
+ +
+ {/if} + {:else} +
+ +

No trusted devices.

+

+ Trust a device during sign-in to skip email verification. +

+
+ {/if} +
+
+
+ + + + +{/if} diff --git a/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte b/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte new file mode 100644 index 0000000..f714443 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte @@ -0,0 +1,312 @@ + + +{#if sessionsQuery.isLoading} +
+ +
+{:else if sessionsQuery.error} + + + Failed to load sessions. Please try again. + +{:else} +
+ + + + Active Sessions + + Devices that are currently signed in to your account. + + + + {#if activeSessions.length > 0} +
+ {#each activeSessions as session (session.id)} + {@const { browser, os, deviceType } = parseUserAgent(session.userAgent)} + {@const DeviceIcon = getDeviceIcon(deviceType)} +
+
+
+ +
+
+
+

{browser} on {os}

+ {#if session.isCurrent} + + + Current + + {/if} +
+
+
+ + {formatLocation(session)} +
+ · + {#if session.trustedMode} +
+ + via passkey +
+ {:else} + via password + {/if} +
+

+ Started {formatRelativeTime(session.createdAt)} +

+
+
+ {#if !session.isCurrent} + + {/if} +
+ {/each} +
+ + {#if activeSessions.length > 1} +
+ +
+ {/if} + {:else} +
+ +

No active sessions.

+
+ {/if} +
+
+ + + {#if pastSessions.length > 0} + + + Past Sessions + + Sessions that have been logged out or revoked. + + + +
+ {#each pastSessions.slice(0, 10) as session (session.id)} + {@const { browser, os, deviceType } = parseUserAgent(session.userAgent)} + {@const DeviceIcon = getDeviceIcon(deviceType)} +
+
+ +
+
+

{browser} on {os}

+
+ + {formatLocation(session)} +
+

+ {formatDate(session.createdAt)} - {session.revokedAt ? formatDate(session.revokedAt) : ""} +

+
+ + Logged out +
+
+
+ {/each} +
+ {#if pastSessions.length > 10} +

+ Showing 10 of {pastSessions.length} past sessions +

+ {/if} +
+
+ {/if} +
+ + + + +{/if} diff --git a/docs/initial-app.md b/docs/initial-app.md index 024837f..9768923 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -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) --- diff --git a/docs/test-plans/account.md b/docs/test-plans/account.md new file mode 100644 index 0000000..8a9baa4 --- /dev/null +++ b/docs/test-plans/account.md @@ -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 | +|--------|------|--------| +| | | | diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index a1db90a..66d6216 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -88,7 +88,7 @@ export const contract = oc.router({ response: z.custom(), }), ) - .output(z.void()), + .output(z.object({ passkeyId: z.number() })), createAuthenticationOptions: oc.output( z.object({ challengeId: z.number(), diff --git a/packages/api-contract/src/schemas/user.ts b/packages/api-contract/src/schemas/user.ts index d2dfb95..304dd67 100644 --- a/packages/api-contract/src/schemas/user.ts +++ b/packages/api-contract/src/schemas/user.ts @@ -15,6 +15,7 @@ export const userProfileSchema = z.object({ emailVerified: z.boolean(), needsSetup: z.boolean(), isSuperuser: z.boolean(), + hasPassword: z.boolean(), }); /**