trails/openspec/changes/account-settings/design.md
Ullrich Schäfer 388a7d4866
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) <noreply@anthropic.com>
2026-03-29 10:00:46 +02:00

3.7 KiB

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.