From 9adf9b977a7a4996f99e3a6ffd16f35ed30945c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 29 Mar 2026 09:54:37 +0200 Subject: [PATCH 1/3] Passkey browser compatibility: detect support, magic link fallback - Login: hide passkey button when browser lacks WebAuthn, default to magic link mode, hide "back to passkey" link - Register: detect passkey support and offer magic link registration as fallback. New server-side registerWithMagicLink() creates account without credential and sends verification email. - Home: show passkey count for logged-in users, show amber unsupported message instead of hiding the add-passkey prompt entirely - i18n: add passkeyNotSupported, passkeyNotSupportedRegister, registerWithMagicLink, passkeyStatus keys (en + de) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/auth.server.ts | 30 +++++++ apps/journal/app/routes/api.auth.register.ts | 19 ++++- apps/journal/app/routes/auth.login.tsx | 33 ++++--- apps/journal/app/routes/auth.register.tsx | 90 +++++++++++++++++--- apps/journal/app/routes/home.tsx | 47 +++++++++- packages/i18n/src/locales/de.ts | 5 ++ packages/i18n/src/locales/en.ts | 5 ++ 7 files changed, 199 insertions(+), 30 deletions(-) diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index a85a55e..6d93276 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -144,6 +144,36 @@ export async function addPasskeyFinish( }); } +// --- Registration via Magic Link (no passkey) --- + +export async function registerWithMagicLink(email: string, username: string): Promise { + const db = getDb(); + + const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); + if (existingEmail) throw new Error("Email already in use"); + + const [existingUsername] = await db.select().from(users).where(eq(users.username, username)); + if (existingUsername) throw new Error("Username already taken"); + + const userId = randomUUID(); + const domain = process.env.DOMAIN ?? "localhost"; + + await db.insert(users).values({ id: userId, email, username, domain }); + + // Create magic token for verification + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + await db.insert(magicTokens).values({ + id: randomUUID(), + email, + token, + expiresAt, + }); + + return token; +} + // --- Passkey Login --- export async function startAuthentication() { diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index b99c252..debd278 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -1,12 +1,13 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.register"; -import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server"; -import { sendWelcome } from "~/lib/email.server"; +import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server"; +import { sendWelcome, sendMagicLink } from "~/lib/email.server"; import { logger } from "~/lib/logger.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); const { step, email, username, response, challenge, userId } = body; + const origin = process.env.ORIGIN ?? `http://localhost:3000`; try { if (step === "start") { @@ -17,13 +18,25 @@ export async function action({ request }: Route.ActionArgs) { if (step === "finish") { const newUserId = await finishRegistration(userId, email, username, response, challenge); const cookie = await createSession(newUserId, request); - // Send welcome email (fire-and-forget — don't block registration on email) sendWelcome(email, username).catch((err) => logger.error({ err }, "Failed to send welcome email"), ); return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); } + if (step === "register-magic-link") { + const token = await registerWithMagicLink(email, username); + const link = `${origin}/auth/verify?token=${token}`; + if (process.env.NODE_ENV !== "production") { + return data({ step: "magic-link-sent", devLink: link }); + } + await sendMagicLink(email, link); + sendWelcome(email, username).catch((err) => + logger.error({ err }, "Failed to send welcome email"), + ); + return data({ step: "magic-link-sent" }); + } + if (step === "add-passkey") { const options = await addPasskeyStart(userId); return data({ step: "challenge", options }); diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx index eaa0ed1..28a9ced 100644 --- a/apps/journal/app/routes/auth.login.tsx +++ b/apps/journal/app/routes/auth.login.tsx @@ -1,9 +1,18 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; export default function LoginPage() { const { t } = useTranslation("journal"); + const [supportsPasskey, setSupportsPasskey] = useState(true); const [mode, setMode] = useState<"passkey" | "magic-link">("passkey"); + + useEffect(() => { + import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { + const supported = browserSupportsWebAuthn(); + setSupportsPasskey(supported); + if (!supported) setMode("magic-link"); + }); + }, []); const [email, setEmail] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -90,7 +99,7 @@ export default function LoginPage() {

{t("auth.login")}

- {mode === "passkey" && ( + {mode === "passkey" && supportsPasskey && (
-
+ {supportsPasskey && ( +
+ +
+ )} )} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 6db6cd3..90ea6b1 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; export default function RegisterPage() { @@ -7,14 +7,21 @@ export default function RegisterPage() { const [username, setUsername] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [supportsPasskey, setSupportsPasskey] = useState(null); + const [magicLinkSent, setMagicLinkSent] = useState(false); - const handleRegister = async (e: React.FormEvent) => { + useEffect(() => { + import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { + setSupportsPasskey(browserSupportsWebAuthn()); + }); + }, []); + + const handleRegisterPasskey = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setLoading(true); try { - // Step 1: Get registration options from server const startResp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -28,11 +35,9 @@ export default function RegisterPage() { return; } - // Step 2: Create passkey via browser const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser"); const webAuthnResp = await startWebAuthn(startData.options); - // Step 3: Send response to server const finishResp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -59,6 +64,49 @@ export default function RegisterPage() { } }; + const handleRegisterMagicLink = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const resp = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "register-magic-link", email, username }), + }); + const result = await resp.json(); + + if (result.error) { + setError(result.error); + } else if (result.devLink) { + window.location.href = result.devLink; + } else { + setMagicLinkSent(true); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + if (magicLinkSent) { + return ( +
+

{t("auth.register")}

+
+

+ {t("auth.checkEmail")} {email}. +

+

+ {t("auth.linkExpires")} +

+
+
+ ); + } + return (

{t("auth.register")}

@@ -66,7 +114,7 @@ export default function RegisterPage() { {t("auth.registerDescription")}

-
+
) : (
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9293b57..aee50db 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -106,6 +106,8 @@ export default { addPasskey: "Passkey hinzufügen", settingUp: "Wird eingerichtet...", passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.", + passkeyStatus: "{{count}} Passkey registriert", + passkeyStatus_other: "{{count}} Passkeys registriert", routes: { title: "Routen", myRoutes: "Meine Routen", @@ -147,6 +149,9 @@ export default { registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.", registerWithPasskey: "Mit Passkey registrieren", creatingPasskey: "Erstelle Passkey...", + passkeyNotSupported: "Dein Browser unterstützt keine Passkeys. Bitte verwende einen anderen Browser zur Registrierung.", + passkeyNotSupportedRegister: "Dein Browser unterstützt keine Passkeys. Du kannst dich stattdessen per Magic Link registrieren und später einen Passkey hinzufügen.", + registerWithMagicLink: "Per Magic Link registrieren", }, }, } as const; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7921d09..41f246c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -106,6 +106,8 @@ export default { addPasskey: "Add Passkey", settingUp: "Setting up...", passkeyAdded: "Passkey added! You can now sign in instantly on this device.", + passkeyStatus: "{{count}} passkey registered", + passkeyStatus_other: "{{count}} passkeys registered", routes: { title: "Routes", myRoutes: "My Routes", @@ -147,6 +149,9 @@ export default { registerDescription: "Register with a passkey — no password needed.", registerWithPasskey: "Register with Passkey", creatingPasskey: "Creating passkey...", + passkeyNotSupported: "Your browser does not support passkeys. Please use a different browser to register.", + passkeyNotSupportedRegister: "Your browser doesn't support passkeys. You can register with a magic link instead and add a passkey later from a supported browser.", + registerWithMagicLink: "Register with Magic Link", }, }, } as const; From 0230d930041e385c8354342f9937f39cb64674fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 29 Mar 2026 09:56:12 +0200 Subject: [PATCH 2/3] Update journal-auth spec for browser compat changes Add scenarios for: magic link registration fallback, WebAuthn detection on login/register, unsupported browser messaging, passkey count display. Co-Authored-By: Claude Opus 4.6 (1M context) --- openspec/specs/journal-auth/spec.md | 36 ++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md index cdbfd7b..846484e 100644 --- a/openspec/specs/journal-auth/spec.md +++ b/openspec/specs/journal-auth/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: Passkey registration -The Journal SHALL allow new users to register using a passkey (WebAuthn). No password is required. +The Journal SHALL allow new users to register using a passkey (WebAuthn) when the browser supports it. No password is required. #### Scenario: Successful passkey registration - **WHEN** a user enters an email and username and creates a passkey via the browser prompt @@ -15,8 +15,23 @@ The Journal SHALL allow new users to register using a passkey (WebAuthn). No pas - **WHEN** a user submits a username that is already taken - **THEN** the system displays an error indicating the username is not available +### Requirement: Magic link registration (fallback) +The Journal SHALL allow new users to register via magic link when the browser does not support passkeys. The user can add a passkey later from a supported browser. + +#### Scenario: Register without passkey support +- **WHEN** a user's browser does not support WebAuthn +- **THEN** the registration page shows a "Register with Magic Link" button instead of the passkey button + +#### Scenario: Successful magic link registration +- **WHEN** a user submits email and username via magic link registration +- **THEN** a new user account is created without a credential, a verification email is sent, and the user is redirected to verify their email + +#### Scenario: Magic link verify redirects to add-passkey +- **WHEN** a user clicks the verification link from magic link registration +- **THEN** they are logged in and redirected to the home page with the add-passkey prompt + ### Requirement: Passkey login -The Journal SHALL allow returning users to log in using a stored passkey. +The Journal SHALL allow returning users to log in using a stored passkey when the browser supports WebAuthn. #### Scenario: Successful passkey login - **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt @@ -26,6 +41,10 @@ The Journal SHALL allow returning users to log in using a stored passkey. - **WHEN** a user has no passkey on the current device - **THEN** the system offers magic link login as a fallback +#### Scenario: Browser without WebAuthn support +- **WHEN** a user's browser does not support WebAuthn +- **THEN** the login page shows only the magic link option with no passkey button + ### Requirement: Magic link login (fallback) The Journal SHALL allow users to log in via a magic link sent to their email. This serves as a fallback for devices without passkey support or for logging in on a new device. @@ -49,9 +68,20 @@ The Journal SHALL allow users to log in via a magic link sent to their email. Th The Journal SHALL allow logged-in users to register additional passkeys for new devices. #### Scenario: Add passkey after magic link login -- **WHEN** a user logs in via magic link on a new device +- **WHEN** a user logs in via magic link on a device that supports WebAuthn - **THEN** the system prompts them to register a passkey for that device +#### Scenario: Add passkey prompt on unsupported browser +- **WHEN** a user logs in via magic link on a device that does not support WebAuthn +- **THEN** the system shows the add-passkey prompt with a message that the browser does not support passkeys + +### Requirement: Passkey status display +The Journal home page SHALL show the user how many passkeys they have registered. + +#### Scenario: User with passkeys +- **WHEN** a logged-in user visits the home page and has one or more passkeys +- **THEN** the page displays the passkey count (e.g., "2 passkeys registered") + ### Requirement: User profile page Each user SHALL have a public profile page displaying their username and routes. From 388a7d486681b0e36c8174b1f812b2b1db08538b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 29 Mar 2026 10:00:46 +0200 Subject: [PATCH 3/3] Add account-settings OpenSpec change Settings page at /settings with profile editing, passkey management (list, add, delete), email change with verification, and account deletion. Includes comprehensive E2E test plan using virtual WebAuthn. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../changes/account-settings/.openspec.yaml | 2 + openspec/changes/account-settings/design.md | 105 ++++++++++++++++++ openspec/changes/account-settings/proposal.md | 32 ++++++ .../specs/account-settings/spec.md | 83 ++++++++++++++ .../specs/journal-auth/spec.md | 29 +++++ openspec/changes/account-settings/tasks.md | 63 +++++++++++ 6 files changed, 314 insertions(+) create mode 100644 openspec/changes/account-settings/.openspec.yaml create mode 100644 openspec/changes/account-settings/design.md create mode 100644 openspec/changes/account-settings/proposal.md create mode 100644 openspec/changes/account-settings/specs/account-settings/spec.md create mode 100644 openspec/changes/account-settings/specs/journal-auth/spec.md create mode 100644 openspec/changes/account-settings/tasks.md diff --git a/openspec/changes/account-settings/.openspec.yaml b/openspec/changes/account-settings/.openspec.yaml new file mode 100644 index 0000000..5e98b74 --- /dev/null +++ b/openspec/changes/account-settings/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-29 diff --git a/openspec/changes/account-settings/design.md b/openspec/changes/account-settings/design.md new file mode 100644 index 0000000..47762d5 --- /dev/null +++ b/openspec/changes/account-settings/design.md @@ -0,0 +1,105 @@ +## Context + +Users register and log in via passkeys or magic links. After that, there's no +way to manage their account. The user profile page (`/users/:username`) is +public-facing and read-only. Account management needs a private settings page. + +The `users` table already has `displayName` and `bio` columns. The +`credentials` table stores `deviceType`, `transports`, and `createdAt` per +passkey — enough to show a meaningful list. + +## Goals / Non-Goals + +**Goals:** +- Single settings page with sections for profile, security, and account +- Passkey management: list, add, delete +- Profile editing: display name, bio +- Email change with verification +- Account deletion with confirmation + +**Non-Goals:** +- Avatar/photo upload (requires S3 — see activity-photos spec) +- Notification preferences (no notifications yet) +- Privacy settings (route visibility is in route-sharing spec) +- Two-factor authentication (passkeys already provide strong auth) +- Session management / "log out everywhere" + +## Decisions + +### D1: Single settings page with sections + +One route at `/settings` with anchor-linked sections rather than separate +sub-pages. Keeps navigation simple and avoids route proliferation. + +Sections: +- **Profile** — display name, bio +- **Security** — passkeys list, add passkey button +- **Account** — email, delete account + +### D2: Profile editing + +Simple form with display name and bio fields. Submit via POST to +`/api/settings/profile`. No real-time validation needed — just save on submit. + +Bio is plain text, max 160 characters (like a social bio). + +### D3: Passkey management + +List all credentials for the current user, showing: +- Device type (from `deviceType` column, e.g., "singleDevice" / "multiDevice") +- Transport hints (from `transports`, e.g., "internal", "usb", "ble", "nfc") +- Date registered (from `createdAt`) +- Delete button (with confirmation) + +Friendly labels derived from transports: +- `internal` → "This device" +- `usb` → "Security key" +- `ble` → "Bluetooth" +- `hybrid` → "Phone or tablet" +- fallback → "Passkey" + +Add passkey button reuses the existing `addPasskeyStart` / `addPasskeyFinish` +flow from auth.server.ts. + +Deleting the last passkey is allowed — user can still log in via magic link. +Show a warning when deleting the last one. + +### D4: Email change + +Two-step flow: +1. User enters new email → server sends magic link to the **new** email +2. User clicks link → email is updated + +This ensures ownership of the new address. The old email is not notified +(simplicity; can add later if needed). + +New server function: `initiateEmailChange(userId, newEmail)` creates a +magic token with a `newEmail` field, sends verification to the new address. +New verify handler recognizes email-change tokens and updates the user record. + +### D5: Account deletion + +- Button at bottom of settings page, styled as danger +- Confirmation modal: "This will permanently delete your account and all your + data. Type your username to confirm." +- Server-side: cascading delete via DB foreign keys (credentials, magic tokens, + routes, activities all cascade from users) +- After deletion: destroy session, redirect to home page + +### D6: Navigation + +Add "Settings" link to the user dropdown/nav when logged in. Links to +`/settings`. Must be registered in `routes.ts`. + +### D7: Auth guard + +Settings page requires authentication. Loader checks session and redirects to +`/auth/login` if not logged in. + +## Risks / Trade-offs + +- **Email change without old-email notification**: Simpler but less secure. + If an attacker has session access, they could change email silently. Mitigate: + magic link to new address still required. Acceptable for now. +- **Deleting last passkey**: User relies entirely on magic links. Acceptable + since magic links are a first-class auth method. diff --git a/openspec/changes/account-settings/proposal.md b/openspec/changes/account-settings/proposal.md new file mode 100644 index 0000000..0510e3f --- /dev/null +++ b/openspec/changes/account-settings/proposal.md @@ -0,0 +1,32 @@ +## Why + +Users have no way to manage their account after registration. There's no +settings page to edit their profile, manage passkeys, change email, or delete +their account. Passkey count currently shows on the home page as a stopgap, +but it belongs in a dedicated settings area. + +## What Changes + +- **Account settings page** (`/settings`): central place for account management +- **Profile editing**: update display name and bio +- **Email change**: update email with verification via magic link +- **Passkey management**: view registered passkeys (device type, date added), + add new passkeys, delete existing ones +- **Account deletion**: delete account and all associated data with confirmation +- Move passkey status display from home page to settings + +## Capabilities + +### New Capabilities +- `account-settings`: Settings page with profile, security, and account sections + +### Modified Capabilities +- `journal-auth`: Passkey management moves from home page prompt to settings; + add passkey delete functionality + +## Impact + +- **Files**: New route `apps/journal/app/routes/settings.tsx`, API routes for + profile update, email change, passkey delete, account delete +- **Database**: No schema changes — uses existing users and credentials tables +- **Routes**: Must register new routes in `apps/journal/app/routes.ts` diff --git a/openspec/changes/account-settings/specs/account-settings/spec.md b/openspec/changes/account-settings/specs/account-settings/spec.md new file mode 100644 index 0000000..67a3e32 --- /dev/null +++ b/openspec/changes/account-settings/specs/account-settings/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Settings page access +The Journal SHALL provide an account settings page at `/settings` accessible to authenticated users. + +#### Scenario: Authenticated access +- **WHEN** a logged-in user navigates to `/settings` +- **THEN** the settings page is displayed with profile, security, and account sections + +#### Scenario: Unauthenticated access +- **WHEN** an unauthenticated user navigates to `/settings` +- **THEN** they are redirected to `/auth/login` + +### Requirement: Profile editing +The settings page SHALL allow users to edit their display name and bio. + +#### Scenario: Update display name +- **WHEN** a user changes their display name and submits +- **THEN** the display name is updated and reflected on their public profile + +#### Scenario: Update bio +- **WHEN** a user enters a bio (max 160 characters) and submits +- **THEN** the bio is saved and visible on their public profile + +#### Scenario: Empty fields +- **WHEN** a user clears their display name +- **THEN** their username is used as the display name fallback + +### Requirement: Passkey management +The settings page SHALL list all registered passkeys and allow adding or deleting them. + +#### Scenario: View passkeys +- **WHEN** a user visits the security section +- **THEN** they see a list of their passkeys with device type, transport label, and registration date + +#### Scenario: Add passkey +- **WHEN** a user clicks "Add passkey" on a browser that supports WebAuthn +- **THEN** the browser passkey creation prompt appears and the new passkey is added to the list + +#### Scenario: Add passkey unsupported browser +- **WHEN** a user visits the security section on a browser without WebAuthn +- **THEN** the "Add passkey" button is disabled with a message explaining the browser limitation + +#### Scenario: Delete passkey +- **WHEN** a user clicks delete on a passkey and confirms +- **THEN** the passkey is removed and can no longer be used for login + +#### Scenario: Delete last passkey +- **WHEN** a user deletes their only passkey +- **THEN** a warning is shown that they will need to use magic links to sign in, and the deletion proceeds after confirmation + +### Requirement: Email change +The settings page SHALL allow users to change their email address with verification. + +#### Scenario: Initiate email change +- **WHEN** a user enters a new email and submits +- **THEN** a verification link is sent to the new email address + +#### Scenario: Verify new email +- **WHEN** the user clicks the verification link +- **THEN** their email is updated to the new address + +#### Scenario: Duplicate email +- **WHEN** a user enters an email already in use by another account +- **THEN** an error is shown indicating the email is taken + +### Requirement: Account deletion +The settings page SHALL allow users to permanently delete their account. + +#### Scenario: Delete account +- **WHEN** a user clicks "Delete account" and types their username to confirm +- **THEN** their account and all associated data are permanently deleted and they are logged out + +#### Scenario: Cancel deletion +- **WHEN** a user opens the delete confirmation but does not confirm +- **THEN** no data is deleted and they remain on the settings page + +### Requirement: Settings navigation +The Journal navigation SHALL include a link to the settings page for authenticated users. + +#### Scenario: Settings link visible +- **WHEN** a logged-in user views the navigation +- **THEN** a "Settings" link is present that navigates to `/settings` diff --git a/openspec/changes/account-settings/specs/journal-auth/spec.md b/openspec/changes/account-settings/specs/journal-auth/spec.md new file mode 100644 index 0000000..a088b87 --- /dev/null +++ b/openspec/changes/account-settings/specs/journal-auth/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Add passkey from new device +The Journal SHALL allow logged-in users to register additional passkeys from the account settings page or via the post-login prompt. + +#### Scenario: Add passkey after magic link login +- **WHEN** a user logs in via magic link on a device that supports WebAuthn +- **THEN** the system prompts them to register a passkey for that device + +#### Scenario: Add passkey from settings +- **WHEN** a user clicks "Add passkey" in the security section of account settings +- **THEN** the browser passkey creation prompt appears and the new passkey is stored + +#### Scenario: Add passkey prompt on unsupported browser +- **WHEN** a user logs in via magic link on a device that does not support WebAuthn +- **THEN** the system shows the add-passkey prompt with a message that the browser does not support passkeys + +## ADDED Requirements + +### Requirement: Delete passkey +The Journal SHALL allow users to delete individual passkeys from account settings. + +#### Scenario: Delete passkey +- **WHEN** a user deletes a passkey from account settings +- **THEN** the credential is removed from the database and can no longer be used for authentication + +#### Scenario: Delete last passkey warning +- **WHEN** a user attempts to delete their only remaining passkey +- **THEN** a warning is shown explaining they will need to use magic links, and deletion proceeds after confirmation diff --git a/openspec/changes/account-settings/tasks.md b/openspec/changes/account-settings/tasks.md new file mode 100644 index 0000000..f685b36 --- /dev/null +++ b/openspec/changes/account-settings/tasks.md @@ -0,0 +1,63 @@ +## 1. Settings Route & Layout + +- [ ] 1.1 Create `apps/journal/app/routes/settings.tsx` with loader auth guard (redirect to `/auth/login` if unauthenticated) +- [ ] 1.2 Register route in `apps/journal/app/routes.ts` +- [ ] 1.3 Layout with three sections: Profile, Security, Account (anchor links for navigation) + +## 2. Profile Section + +- [ ] 2.1 Display name and bio form fields, pre-filled from current user data +- [ ] 2.2 Create `POST /api/settings/profile` action to update display name and bio +- [ ] 2.3 Register API route in `routes.ts` +- [ ] 2.4 Success/error feedback on save + +## 3. Security Section — Passkey Management + +- [ ] 3.1 Load user's credentials in settings loader (device type, transports, createdAt) +- [ ] 3.2 Render passkey list with friendly transport labels and registration date +- [ ] 3.3 Add passkey button using existing `addPasskeyStart`/`addPasskeyFinish` flow, hidden when WebAuthn unsupported +- [ ] 3.4 Create `POST /api/settings/passkey/delete` action to remove a credential by ID +- [ ] 3.5 Register API route in `routes.ts` +- [ ] 3.6 Delete confirmation dialog, with last-passkey warning when applicable + +## 4. Account Section — Email Change + +- [ ] 4.1 Email display with "Change" button revealing input for new email +- [ ] 4.2 Create `initiateEmailChange(userId, newEmail)` in auth.server.ts — generates magic token with `newEmail` metadata +- [ ] 4.3 Create `POST /api/settings/email` action to initiate email change +- [ ] 4.4 Register API route in `routes.ts` +- [ ] 4.5 Update `verifyMagicToken` to handle email-change tokens (update user email) +- [ ] 4.6 Send verification email to new address using existing email templates + +## 5. Account Section — Deletion + +- [ ] 5.1 "Delete account" danger button at bottom of settings page +- [ ] 5.2 Confirmation modal requiring username input to proceed +- [ ] 5.3 Create `POST /api/settings/delete-account` action — cascading delete, destroy session, redirect to home +- [ ] 5.4 Register API route in `routes.ts` + +## 6. Navigation + +- [ ] 6.1 Add "Settings" link to Journal nav for authenticated users +- [ ] 6.2 Move passkey count display from home page to settings security section + +## 7. i18n + +- [ ] 7.1 Add translation keys for all settings UI text (en + de) + +## 8. Testing + +### Unit tests (Vitest) +- [ ] 8.1 Profile update: valid input saves, empty display name falls back to username +- [ ] 8.2 Passkey delete: removes credential, rejects invalid credential ID, allows deleting last passkey +- [ ] 8.3 Email change: creates token for new email, rejects duplicate email, rejects same-as-current email +- [ ] 8.4 Account deletion: cascading delete removes user + credentials + tokens, destroys session + +### E2E tests (Playwright) +- [ ] 8.5 Auth guard: unauthenticated user visiting `/settings` is redirected to `/auth/login` +- [ ] 8.6 Profile editing: log in, navigate to settings, update display name and bio, verify changes persist after reload, verify public profile reflects changes +- [ ] 8.7 Passkey management: log in, navigate to security section, add passkey (virtual WebAuthn authenticator), verify it appears in list, delete it, verify removed +- [ ] 8.8 Delete last passkey: delete only passkey, verify warning modal appears, confirm deletion, verify magic link login still works +- [ ] 8.9 Email change: initiate email change, follow verification link (dev mode), verify email updated in settings +- [ ] 8.10 Account deletion: click delete, type wrong username (verify blocked), type correct username, confirm, verify redirected to home and session destroyed +- [ ] 8.11 Navigation: verify "Settings" link appears in nav when logged in, not visible when logged out