Prima Get an API key

Module kit / Identity and auth

Profile and passkeys

identity-auth/ui/profile-and-passkeyslive

Kind frontend
Surface SPA
Maturity prod
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1, simplewebauthn-browser-13
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders identity-auth/user-profile-and-account, identity-auth/webauthn-passkeys-multi-rp, identity-auth/session-login-refresh-logout, identity-auth/password-reset
Entry alchemy_labs/frontend/src/pages/settings/ProfileWithUsage.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/settings/ProfileWithUsage.tsx
  • alchemy_labs/frontend/src/contexts/AuthContext.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/services/authSession.ts
  • alchemy_labs/frontend/src/onboarding/storage.ts
  • alchemy_labs/backend/api/routers/user_router.py
  • alchemy_labs/backend/api/routers/webauthn_router.py
  • alchemy_labs/backend/api/routers/workspace_router.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

Signed in with two passkeys, the password card before a code is requested default server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Passwort ändern

Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

MacBook Touch ID

Hinzugefügt 3. Aug. 2026 · Zuletzt verwendet 11. Sept. 2026

iPhone

Hinzugefügt 20. Aug. 2026

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

A signed-in person opens /settings/profile and, on one card, changes their display name, sets a new password after a mailed code, enrols, renames and removes passkeys, stores the workspace's Telegram chat id and resets the onboarding tours. The invariant that makes it trustworthy is that the page never holds or shows a credential: the e-mail field is read-only, the password fields are type="password" and are cleared on success, the passkey ceremony runs in the browser's own prompt and only the attestation JSON leaves, and every write goes to a route keyed on the session's own user (ActiveUser or CurrentUser), so nothing on the page can address another account.

It makes visible identity-auth/user-profile-and-account (B17: PUT /api/user/profile, the code-based password change), identity-auth/webauthn-passkeys-multi-rp (B7: the four credential routes and the register ceremony), identity-auth/password-reset (B6: the second flow this page uses, and the first it does not), and identity-auth/session-login-refresh-logout (B2: the user object the page starts from and the sessions it never lists).

When to use

Trigger phrases: "change my name", "add a passkey", "rename the passkey", "the profile page", "Passwort ändern", "the six-digit code", "Telegram chat id", "reset the tours", "why does the e-mail field not change". Surface: SPA, because the page belongs to the signed-in Labs account, renders inside the shell's Settings domain (nav.tsx 258 to 272: Profile is the first item, to: '/settings/profile', end: true) and needs @simplewebauthn/browser and the session's Bearer token for the ceremony.

Not this recipe: the sign-in screens and the passkey login (identity-auth/ui/auth-screens, F1: the discoverable flow that reads preferPasskey), the API keys page (/developer, identity-auth/ui/api-keys-settings-and-developer-dashboard), the billing tab the second tab links to (/billing/manage, the billing domain), the workspace settings the Telegram section writes into (B15's neighbours), and the account-deletion contract, which B17 specifies and this page has no control for at fd8558f (Gotchas).

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned at fd8558f: react ^19.1.1 (19.2.0 in the lock), react-router-dom ^7.8.2, axios ^1.11.0, @simplewebauthn/browser ^13.2.2 (13.2.2 in the lock), tailwindcss 3.4.18, lucide-react 0.563.0 (frontend/package.json, package-lock.json). Component library: @prima-modules/alchemy-ui 0.1.0, of which the page uses Button, InputField, Heading, Text, Card, Divider (ProfileWithUsage.tsx 11 to 18) and one raw <input> for the inline rename (476 to 483). Data-fetch style: two at once. The api singleton for the profile, the code flow and the Telegram settings (api.updateProfile, api.requestPasswordChangeCode, api.updatePassword, api.get, api.patch; services/api.ts 234 to 249), and bare fetch with a hand-built Authorization: Bearer header (getAuthHeaders, 59 to 65, reading getAccessToken() from services/authSession.ts 39) for the four passkey calls, which therefore skip S2's single-flight refresh on a 401 (Gotchas). Copy language: du-Form Swiss High German throughout (de-CH); the shell's chrome around it is English. Fonts, tokens and the #alchemy-ui scope as in S1.

Data model

Client state (ProfileWithUsage.tsx 29 to 57):

formData: { name: string; email: string; verificationCode: string; newPassword: string; confirmPassword: string }   // name and email seeded from useAuth().user
loading: boolean;  success: string;  error: string;                       // one message card at a time, either colour
codeSent: boolean;  codeSending: boolean;                                 // the two phases of the password card
passkeys: PasskeyCredential[];  passkeysLoading: boolean;                 // { id, device_name, created_at, last_used_at }
renamingId: string | null;  renameValue: string;
telegramChatId: string;  telegramLoading: boolean;  telegramSaved: boolean;

PasskeyCredential (22 to 27) omits the rp_id the route returns (WebAuthnCredentialResponse, webauthn_router.py 97 to 105), so the list cannot say which relying party a passkey is bound to (Gotchas). The user object comes from useAuth().user (AuthContext.tsx User, 25 to 45: userId, email, name, emailVerified, isAdmin, clientStatus?, actingEmail?, surfaces), not from GET /api/user/profile, so has_password is never read (B17 Gotchas).

The demo's props (demo.tsx): { user: ShellUser, workspace: ShellWorkspace, passkeys: Passkey[], passwordPhase: 'idle' | 'code-sent', renamingId: string | null, telegramChatId: string, telegramSaved: boolean, busy: 'profile' | 'code' | 'password' | 'passkey' | 'telegram' | null, message: { kind: 'success' | 'error', text } | null }; Passkey carries createdLabel and lastUsedLabel already formatted, because the source formats with toLocaleDateString(undefined, ...) (290 to 295) and a server-rendered demo must not depend on the renderer's locale.

Browser storage this page writes: localStorage.preferPasskey = 'true' after a successful enrolment (163), read by LoginAlchemy.tsx 52 to start the discoverable flow on the next visit (F1); the alchemy.onboarding.* keys through resetOnboarding() (onboarding/storage.ts 11, 78 to 82: removes the off and done entries, writes the version). It reads S2's accessToken for the passkey calls. Lifetime: preferPasskey until cleared by hand (nothing removes it; deleting the last passkey leaves it set, Gotchas); the onboarding keys until the next tour completes. Nothing else persists; the password fields live in component state and are cleared on success (277 to 282) and on cancel (424).

API surface

Contracts consumed, with the codes the page turns into copy:

Call (line)Route (recipe)AuthParsedRendered
api.updateProfile(name) (231)PUT /api/user/profile {name} (B17)ActiveUser, LABS_SURFACESnothing on 200 (UserProfile discarded); on failure err.response?.data?.errorgreen "Profil erfolgreich aktualisiert!"; red the server's error sentence, else "Profil konnte nicht aktualisiert werden" (this is the one catch that reads the envelope's key)
api.requestPasswordChangeCode() (246)POST /api/user/password/request-code (B6, B17)ActiveUser200 always, whether or not a mail leftgreen "Bestätigungscode an deine E-Mail gesendet!", the card switches to the code form; red "Bestätigungscode konnte nicht gesendet werden"
api.updatePassword(code, new) (274)PUT /api/user/password {verification_code, new_password} (B6, B17)ActiveUser200; 400 "No verification code requested. Please request a code first.", "Verification code has expired. Please request a new one.", "Invalid verification code"; 422 under 8 charactersgreen "Passwort erfolgreich aktualisiert!", fields cleared, card back to idle; red "Passwort konnte nicht aktualisiert werden" (the three 400 sentences are not shown, Gotchas)
fetch GET /api/auth/webauthn/credentials (120)B7CurrentUser, ALL_SURFACES, no surface param so every RPthe JSON list on res.ok; anything else swallowed (126 to 128)the rows or the empty card; a failure looks like "no passkeys" (Gotchas)
fetch POST /register/begin (138), startRegistration(options) (149), fetch POST /register/complete {credential, device_name: 'Passkey'} (152)B7CurrentUser200 options; 400 surface conflict; 500 str(e) (B7 Gotchas); the browser's NotAllowedError on dismiss; 400 Registration challenge expired or not found. Please try again.green "Passkey erfolgreich registriert!" and preferPasskey set; red "Passkey-Registrierung wurde abgebrochen." on NotAllowedError; red err.message otherwise, which is the fallback "Registrierung konnte nicht gestartet werden" or "Passkey konnte nicht registriert werden" because err.detail is read from an envelope that has error (Gotchas)
fetch DELETE /credentials/{id} (180)B7own rows204 (or any res.ok); 404 Passkey not foundgreen "Passkey entfernt.", the row removed locally; red "Passkey konnte nicht gelöscht werden"
fetch PATCH /credentials/{id} {device_name} (199)B7own rows200; 404; 422 for empty or over 100 charactersthe list reloaded, the rename closed; red "Passkey konnte nicht umbenannt werden"
api.get('/api/workspaces/{id}/settings') (79, 97), api.patch(..., {settings: {...existing, telegram_chat_id}}) (103)workspace_router.py 564 and 607 (a workspace-settings module, not this row's renders)get_current_user; the PATCH needs workspace.manage_settings (620 to 623)settings.telegram_chat_id; the PATCH answer discarded"Gespeichert" on the button; red the server's detail else "Telegram-Einstellungen konnten nicht gespeichert werden" (a member without the permission sees the fallback)

Not called although the routes exist: POST /api/auth/change-password (B6's first flow, zero callers in frontend/src), GET /api/user/profile, GET /api/user/token-usage (the page's file name says "WithUsage"; no usage is read, Gotchas), DELETE /api/user/account (the stub), PATCH /api/auth/me, and any sessions list (none exists, B2 Frontend).

Error bodies as this page reads them: api.updateProfile's catch reads data.error (234), correct for Shape B; the other three api catches read data.detail (112, 250, 284), which Shape B does not carry (S7: create_error_response writes error, code, request_id, timestamp, details), so those refusals render as the fallback sentences; the passkey fetch paths read err.detail from the parsed body (144, 159), the same miss. Client-side refusals before any request: "Die neuen Passwörter stimmen nicht überein" (260) and "Bitte gib den 6-stelligen Bestätigungscode ein" (265). A 429 is a fallback sentence too. No WebSocket, no postMessage.

Application logic

After "Bestätigungscode senden": the six-digit code and the two password fields code-sent server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Neues Passwort festlegen

Gib den 6-stelligen Code aus deiner E-Mail und dein neues Passwort ein.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

MacBook Touch ID

Hinzugefügt 3. Aug. 2026 · Zuletzt verwendet 11. Sept. 2026

iPhone

Hinzugefügt 20. Aug. 2026

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

Bestätigungscode an deine E-Mail gesendet!

  1. /settings/profile is a PrivateRoute (App.tsx 540 to 547; the lazy import at 94 names the file); the page reads user from useAuth() and currentWorkspace from useWorkspace(), seeds formData.name and .email (36 to 42), and renders inside <Navigation> (298), so the shell shows Settings active and "Profile" highlighted.
  2. On mount loadPasskeys() fetches the credential list (68 to 70, 118 to 129); when a workspace is set, loadTelegramSettings() reads its settings (72 to 86).
  3. Profile: typing changes formData.name; submit calls updateProfile(name), then the green card. The e-mail field is disabled with the helper "Die E-Mail kann nicht geändert werden" (333 to 342); the name in AuthContext is not refreshed after the save (Gotchas).
  4. Password, phase one: "Bestätigungscode senden" posts request-code; on 200 codeSent flips and the card re-titles to "Neues Passwort festlegen" (240 to 254, 357 to 364).
  5. Password, phase two: submit checks the two passwords match and the code is six characters, else the red card without a request (256 to 267); then updatePassword(code, new); on 200 the green card, the fields cleared, codeSent back to false (273 to 282). "Code erneut senden" repeats step 4; "Abbrechen" clears the three fields and both cards (419 to 430).
  6. Passkeys, enrol: register/begin with the Bearer header, startRegistration(options) opens the browser prompt, register/complete with the attestation and device_name: 'Passkey'; on 200 the green card, preferPasskey set, the list reloaded (131 to 174). A dismissed prompt is NotAllowedError and the red "abgebrochen" card.
  7. Passkeys, rename: the pencil swaps the row's name for an input seeded with the current name; Enter or "Speichern" PATCHes and reloads; "Abbrechen" closes (195 to 214, 474 to 490). Delete: the trash button DELETEs at once, no confirmation, and filters the row out locally on res.ok || 204 (176 to 193).
  8. Telegram: the input marks telegramSaved false on change; "Speichern" re-reads the settings, spreads them, writes telegram_chat_id (trimmed, or null when empty) and flips the button to "Gespeichert" (88 to 116).
  9. Onboarding: "Touren zurücksetzen" calls resetOnboarding() and shows the green card (586 to 596); the tours reappear on the next module visit.
  10. Every green or red card is one string in success or error; a new action clears both first (the setError(''), setSuccess('') pairs), so one card shows at a time, at the bottom of the page.

Invariants

  • No mutation without an endpoint: the name, the password, the passkeys and the Telegram id each leave through the route named above; the only local-only change is the row removal after a 204 and the onboarding keys, which are the browser's own.
  • The e-mail is not editable here: the field is disabled and PUT /profile ignores email anyway (B17 Invariants); an address change is a verification flow.
  • Passwords are typed into type="password" fields, compared client-side, sent once, and cleared on success and on cancel; the code is six characters or the request does not leave.
  • The passkey ceremony never exposes a private key: startRegistration returns the attestation JSON and the page forwards it unchanged; the list shows names and dates, never a credential id in the UI (the id is only in the row key and the route path).
  • Every write is keyed on the session: no route the page calls takes a user id; the credential routes answer 404 for another user's id (B7).
  • The page shows one message at a time and never the raw request error object.

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

Renaming a passkey inline renaming server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Passwort ändern

Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

MacBook Touch ID

Hinzugefügt 3. Aug. 2026 · Zuletzt verwendet 11. Sept. 2026

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

Passkey enrolment in flight: the outline button carries its spinner while the browser prompt is open enrolling server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Passwort ändern

Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

MacBook Touch ID

Hinzugefügt 3. Aug. 2026 · Zuletzt verwendet 11. Sept. 2026

iPhone

Hinzugefügt 20. Aug. 2026

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

Green card: a passkey was registered, Telegram saved success server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Passwort ändern

Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

MacBook Touch ID

Hinzugefügt 3. Aug. 2026 · Zuletzt verwendet 11. Sept. 2026

iPhone

Hinzugefügt 20. Aug. 2026

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

Passkey erfolgreich registriert!

Red card: the browser prompt was dismissed (NotAllowedError) error server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Passwort ändern

Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

MacBook Touch ID

Hinzugefügt 3. Aug. 2026 · Zuletzt verwendet 11. Sept. 2026

iPhone

Hinzugefügt 20. Aug. 2026

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

Passkey-Registrierung wurde abgebrochen.

identity-auth/ui/profile-and-passkeys/demo.tsx
/**
 * Live demo for identity-auth/ui/profile-and-passkeys.
 *
 * Labs' pages/settings/ProfileWithUsage.tsx (fd8558f, 618 lines) with its
 * reads replaced by props: the signed-in user from AuthContext, the passkey
 * list GET /api/auth/webauthn/credentials answered, the workspace's
 * Telegram chat id, and the page's transient state (which password phase
 * is showing, which passkey is being renamed, which button is busy, the
 * green or red message card). Rendered inside the shell demo's component
 * with Settings active in the rail and "Profile" highlighted in the column,
 * because the page renders inside <Navigation> (line 298).
 *
 * What is kept from the source: the glass card with its two tabs, the five
 * sections in order (Profilinformationen, Passwort ändern, Passkeys,
 * Telegram-Benachrichtigungen, Onboarding), every InputField with its
 * label, helper text and size, the outline and ghost buttons, the passkey
 * rows with rename and delete, the message cards at the bottom. The copy is
 * the source's, du-Form German, with three edits: the two em dashes in the
 * onboarding paragraph (lines 583 to 584) are commas here (house rule), and
 * the Telegram bot handle at line 543 is a placeholder, since the real
 * handle is the agency's.
 *
 * What is different, and why: nothing is fetched or posted (every button
 * is inert; the browser prompt startRegistration would open cannot run
 * without a relying party, so "Passkey hinzufügen" only shows its busy
 * state); the dates on a passkey row arrive pre-formatted in the sample
 * data, because the source formats with toLocaleDateString(undefined),
 * whose output differs between Node and the browser and would not survive
 * hydration; and the page is wrapped in the shell rebuild instead of the
 * Labs Navigation.
 */
import React, { useState } from 'react';
import { Key, Pencil, Plus, Send, Sparkles, Trash2 } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Button, Card, Divider, Heading, InputField, Text } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';

/* ---- props ----------------------------------------------------------- */

/** One row of GET /api/auth/webauthn/credentials (WebAuthnCredentialResponse, webauthn_router.py 97 to 105), with the two dates already formatted. */
export interface Passkey {
  id: string;
  device_name: string;
  rp_id: string;
  createdLabel: string;
  lastUsedLabel: string | null;
}

export interface Props {
  user: ShellUser;
  workspace: ShellWorkspace;
  passkeys: Passkey[];
  /** The password card: before or after "Bestaetigungscode senden". */
  passwordPhase: 'idle' | 'code-sent';
  /** Which passkey row shows the inline rename input, if any. */
  renamingId: string | null;
  telegramChatId: string;
  telegramSaved: boolean;
  /** Which submit is in flight: the atoms' `loading` prop on that one button. */
  busy: 'profile' | 'code' | 'password' | 'passkey' | 'telegram' | null;
  /** The card under the sections: setSuccess or setError in the source. */
  message: { kind: 'success' | 'error'; text: string } | null;
}

/* ---- sample data ------------------------------------------------------ */

const member: ShellUser = { name: 'Mira Keller', email: '[email protected]', isAdmin: false };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' };

const passkeys: Passkey[] = [
  { id: 'pk_1', device_name: 'MacBook Touch ID', rp_id: 'labs.example', createdLabel: '3. Aug. 2026', lastUsedLabel: '11. Sept. 2026' },
  { id: 'pk_2', device_name: 'iPhone', rp_id: 'labs.example', createdLabel: '20. Aug. 2026', lastUsedLabel: null },
];

const base: Props = {
  user: member,
  workspace,
  passkeys,
  passwordPhase: 'idle',
  renamingId: null,
  telegramChatId: '',
  telegramSaved: false,
  busy: null,
  message: null,
};

export const states = defineStates<Props>({
  default: { label: 'Signed in with two passkeys, the password card before a code is requested', props: base, height: 1240, viewport: 1440 },
  'no-passkeys': { label: 'No passkey yet: the empty card that invites the first enrolment', props: { ...base, passkeys: [] }, height: 1240, viewport: 1440 },
  'code-sent': { label: 'After "Bestätigungscode senden": the six-digit code and the two password fields', props: { ...base, passwordPhase: 'code-sent', message: { kind: 'success', text: 'Bestätigungscode an deine E-Mail gesendet!' } }, height: 1380, viewport: 1440 },
  renaming: { label: 'Renaming a passkey inline', props: { ...base, renamingId: 'pk_2' }, height: 1240, viewport: 1440 },
  enrolling: { label: 'Passkey enrolment in flight: the outline button carries its spinner while the browser prompt is open', props: { ...base, busy: 'passkey' }, height: 1240, viewport: 1440 },
  success: { label: 'Green card: a passkey was registered, Telegram saved', props: { ...base, telegramChatId: '-1001234567890', telegramSaved: true, message: { kind: 'success', text: 'Passkey erfolgreich registriert!' } }, height: 1240, viewport: 1440 },
  error: { label: 'Red card: the browser prompt was dismissed (NotAllowedError)', props: { ...base, message: { kind: 'error', text: 'Passkey-Registrierung wurde abgebrochen.' } }, height: 1240, viewport: 1440 },
});

/* ---- the page ---------------------------------------------------------- */

function Profile({ user, passkeys: passkeysInitial, passwordPhase, renamingId: renamingInitial, telegramChatId: telegramInitial, telegramSaved: savedInitial, busy, message }: Omit<Props, 'workspace'>) {
  const [name, setName] = useState(user.name);
  const [code, setCode] = useState('');
  const [newPassword, setNewPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [renamingId, setRenamingId] = useState<string | null>(renamingInitial);
  const [renameValue, setRenameValue] = useState(renamingInitial ? passkeysInitial.find((p) => p.id === renamingInitial)?.device_name ?? '' : '');
  const [telegramChatId, setTelegramChatId] = useState(telegramInitial);
  const [telegramSaved, setTelegramSaved] = useState(savedInitial);
  const codeSent = passwordPhase === 'code-sent';

  return (
    <div className="max-w-7xl mx-auto px-4 py-6">
      <Card variant="glass" padding="none">
        <div className="border-b border-stone-200">
          <nav className="flex -mb-px">
            <Button variant="ghost" className="!rounded-none border-b-2 !border-brand-purple !text-brand-purple">
              Profil
            </Button>
            <Button variant="ghost" className="!rounded-none border-b-2 !border-transparent">
              Kiosk &amp; Abo
            </Button>
          </nav>
        </div>

        <div className="p-6">
          <div className="space-y-8">
            <div data-gallery-focus="profile-info">
              <Heading level={2} className="mb-6">Profilinformationen</Heading>
              <form onSubmit={(e) => e.preventDefault()} className="space-y-4 max-w-md">
                <InputField label="Name" type="text" name="name" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
                <InputField
                  label="E-Mail"
                  type="email"
                  name="email"
                  value={user.email}
                  onChange={() => undefined}
                  disabled
                  size="sm"
                  helperText="Die E-Mail kann nicht geändert werden"
                />
                <Button type="submit" variant="primary" loading={busy === 'profile'} disabled={busy === 'profile'}>
                  Profil aktualisieren
                </Button>
              </form>
            </div>

            <Divider />

            <div data-gallery-focus="password-reset">
              <Heading level={3} className="mb-2">
                {codeSent ? 'Neues Passwort festlegen' : 'Passwort ändern'}
              </Heading>
              <Text size="sm" muted className="mb-4">
                {codeSent
                  ? 'Gib den 6-stelligen Code aus deiner E-Mail und dein neues Passwort ein.'
                  : 'Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.'}
              </Text>

              {!codeSent ? (
                <Button variant="primary" loading={busy === 'code'} disabled={busy === 'code'}>
                  Bestätigungscode senden
                </Button>
              ) : (
                <form onSubmit={(e) => e.preventDefault()} className="space-y-4 max-w-md">
                  <InputField
                    label="Bestätigungscode"
                    type="text"
                    name="verificationCode"
                    value={code}
                    onChange={(e) => setCode(e.target.value)}
                    size="sm"
                    placeholder="6-stelliger Code aus deiner E-Mail"
                    inputMode="numeric"
                    autoComplete="one-time-code"
                  />
                  <InputField label="Neues Passwort" type="password" name="newPassword" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} size="sm" autoComplete="new-password" />
                  <InputField label="Neues Passwort bestätigen" type="password" name="confirmPassword" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} size="sm" autoComplete="new-password" />
                  <div className="flex items-center gap-3">
                    <Button type="submit" variant="primary" loading={busy === 'password'} disabled={busy === 'password'}>
                      Passwort festlegen
                    </Button>
                    <Button type="button" variant="ghost">
                      Code erneut senden
                    </Button>
                    <Button type="button" variant="ghost">
                      Abbrechen
                    </Button>
                  </div>
                </form>
              )}
            </div>

            <Divider />

            <div data-gallery-focus="passkeys">
              <div className="flex items-center justify-between mb-4">
                <div className="flex items-center gap-2">
                  <Key className="w-5 h-5 text-brand-purple" />
                  <Heading level={3}>Passkeys</Heading>
                </div>
                <Button variant="outline" size="sm" className="shrink-0" loading={busy === 'passkey'} disabled={busy === 'passkey'}>
                  <Plus className="w-4 h-4" />
                  Passkey hinzufügen
                </Button>
              </div>
              <Text size="sm" muted className="mb-4">
                Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.
              </Text>

              {passkeysInitial.length === 0 ? (
                <Card variant="glass" padding="md">
                  <Text size="sm" muted className="text-center">
                    Noch keine Passkeys registriert. Füge einen hinzu, um die passwortlose Anmeldung zu aktivieren.
                  </Text>
                </Card>
              ) : (
                <div className="space-y-2">
                  {passkeysInitial.map((pk) => (
                    <Card key={pk.id} variant="glass" padding="sm">
                      <div className="flex items-center justify-between">
                        <div className="flex items-center gap-3">
                          <Key className="w-5 h-5 text-neutral-400" />
                          <div>
                            {renamingId === pk.id ? (
                              <div className="flex items-center gap-2">
                                <input
                                  type="text"
                                  value={renameValue}
                                  onChange={(e) => setRenameValue(e.target.value)}
                                  aria-label="Neuer Name"
                                  className="text-sm px-2 py-1 border border-line rounded bg-white"
                                />
                                <Button size="sm" variant="ghost" onClick={() => setRenamingId(null)}>
                                  Speichern
                                </Button>
                                <Button size="sm" variant="ghost" onClick={() => setRenamingId(null)}>
                                  Abbrechen
                                </Button>
                              </div>
                            ) : (
                              <>
                                <Text size="sm" weight="medium">{pk.device_name}</Text>
                                <Text size="sm" muted>
                                  Hinzugefügt {pk.createdLabel}
                                  {pk.lastUsedLabel && ` · Zuletzt verwendet ${pk.lastUsedLabel}`}
                                </Text>
                              </>
                            )}
                          </div>
                        </div>
                        {renamingId !== pk.id && (
                          <div className="flex items-center gap-1">
                            <Button
                              size="sm"
                              variant="ghost"
                              aria-label="Umbenennen"
                              onClick={() => {
                                setRenamingId(pk.id);
                                setRenameValue(pk.device_name);
                              }}
                            >
                              <Pencil className="w-4 h-4" />
                            </Button>
                            <Button size="sm" variant="ghost" aria-label="Entfernen" className="!text-red-500 hover:!text-red-600">
                              <Trash2 className="w-4 h-4" />
                            </Button>
                          </div>
                        )}
                      </div>
                    </Card>
                  ))}
                </div>
              )}
            </div>

            <Divider />

            <div>
              <div className="flex items-center gap-2 mb-4">
                <Send className="w-5 h-5 text-brand-purple" />
                <Heading level={3}>Telegram-Benachrichtigungen</Heading>
              </div>
              <Text size="sm" muted className="mb-2">
                Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.
              </Text>
              <ol className="text-sm text-ink-60 mb-4 list-decimal list-inside space-y-1">
                <li>Starte einen Chat mit <span className="text-brand-purple underline">@example_bot</span> auf Telegram</li>
                <li>Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)</li>
                <li>Schreibe <span className="text-brand-purple underline">@userinfobot</span>, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu</li>
                <li>Füge deine Chat-ID unten ein und speichere</li>
              </ol>
              <div className="flex items-end gap-3 max-w-md">
                <div className="flex-1">
                  <InputField
                    label="Telegram-Chat-ID"
                    type="text"
                    name="telegramChatId"
                    value={telegramChatId}
                    onChange={(e) => {
                      setTelegramChatId(e.target.value);
                      setTelegramSaved(false);
                    }}
                    size="sm"
                    placeholder="-100123456789"
                    helperText="Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten"
                  />
                </div>
                <Button variant="primary" size="sm" className="mb-5 shrink-0" loading={busy === 'telegram'} disabled={busy === 'telegram'}>
                  {telegramSaved ? 'Gespeichert' : 'Speichern'}
                </Button>
              </div>
            </div>

            <div>
              <Divider className="my-2" />
              <div className="flex items-center gap-2 mb-2">
                <Sparkles className="w-5 h-5 text-brand-purple" />
                <Heading level={3}>Onboarding</Heading>
              </div>
              <Text size="sm" muted className="mb-4 max-w-md">
                Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück,
                um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in
                der Icon-Leiste.
              </Text>
              <Button variant="outline" size="sm">
                <Sparkles className="w-4 h-4 mr-2" />
                Touren zurücksetzen
              </Button>
            </div>
          </div>

          {message?.kind === 'success' && (
            <Card variant="glass" padding="md" className="mt-4 !bg-green-50 !outline-green-400">
              <Text className="!text-green-700">{message.text}</Text>
            </Card>
          )}
          {message?.kind === 'error' && (
            <Card variant="glass" padding="md" className="mt-4 !bg-red-50 !outline-red-400">
              <Text className="!text-red-700">{message.text}</Text>
            </Card>
          )}
        </div>
      </Card>
    </div>
  );
}

export default function Demo({ workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
  return (
    <Shell domains={DOMAINS} user={page.user} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/settings/profile" hasTour={false} hideChrome={galleryMode}>
      <Profile {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /settings/profile> PrivateRoute > ProfileWithUsage
  Navigation = AppShell                                     (Settings active in the rail, "Profile" in the column)
    div.max-w-7xl > Card glass padding none
      tabs        Button ghost "Profil" (active, purple underline) | Button ghost "Kiosk & Abo" (-> /billing/manage)
      section 1   Heading 2 "Profilinformationen"; form: InputField sm "Name", InputField sm "E-Mail" disabled + helper; Button primary "Profil aktualisieren" (loading)
      Divider
      section 2   Heading 3 "Passwort ändern" | "Neues Passwort festlegen"; Text muted
                    [idle]       Button primary "Bestätigungscode senden" (loading)
                    [code-sent]  form: InputField "Bestätigungscode", "Neues Passwort", "Neues Passwort bestätigen"; Button primary "Passwort festlegen", ghost "Code erneut senden", ghost "Abbrechen"
      Divider
      section 3   Key icon + Heading 3 "Passkeys"; Button outline sm "Passkey hinzufügen" (loading while the browser prompt is open); Text muted
                    [none]  Card glass: "Noch keine Passkeys registriert. ..."
                    [rows]  Card glass sm per credential: Key icon, device_name, "Hinzugefügt <date> · Zuletzt verwendet <date>"; ghost Pencil, ghost Trash2 red
                            [renaming] input + ghost "Speichern", ghost "Abbrechen"
      Divider
      section 4   Send icon + Heading 3 "Telegram-Benachrichtigungen"; Text muted; ol (4 steps); InputField sm "Telegram-Chat-ID" + helper; Button primary sm "Speichern" | "Gespeichert"
      section 5   Divider; Sparkles + Heading 3 "Onboarding"; Text muted; Button outline sm "Touren zurücksetzen"
      [success]   Card glass !bg-green-50 !outline-green-400 > Text green
      [error]     Card glass !bg-red-50 !outline-red-400 > Text red
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild profile-and-passkeys/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md section 3). The reads are props (user, passkeys, telegramChatId, telegramSaved), the transient state is props (passwordPhase, renamingId, busy, message), and the page is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx) with a member viewer and /settings/profile active, so Settings is filled in the rail and "Profile" highlighted in the column. The tree is the frontmatter's. Sample data: a fictional member at example.com, two passkeys with device names "MacBook Touch ID" and "iPhone" and pre-formatted Swiss dates, a fictional Telegram chat id in the success state, and a placeholder bot handle in the Telegram instructions (the source's handle at 543 is the agency's).

States (rendering.states, in order): default (two passkeys, the password card idle, Telegram empty), no-passkeys (the empty card with the invitation), code-sent (the password card in phase two with the code and the two password fields, the green "Bestätigungscode ... gesendet!" card at the bottom, height 1380), renaming (the second passkey row in its inline rename), enrolling (the outline "Passkey hinzufügen" button carrying its spinner while the browser prompt is open), success (the green card "Passkey erfolgreich registriert!" and the Telegram button on "Gespeichert"), error (the red card "Passkey-Registrierung wurde abgebrochen."). All seven are shell-wrapped and declare viewport: 1440; the page is long, so the stage height is 1240 by default.

Tokens and classes as the source uses them: Card glass with padding="none" as the page card; the tabs as ghost Buttons with !rounded-none border-b-2 and !border-brand-purple !text-brand-purple on the active one (304 to 317); border-stone-200 under the tab row; text-brand-purple on the three section icons and the Telegram links; text-neutral-400 on the row key icon; !text-red-500 hover:!text-red-600 on the delete button; the message cards as Card glass with !bg-green-50 !outline-green-400 !text-green-700 and !bg-red-50 !outline-red-400 !text-red-700 (601 to 610); text-ink-60 on the ordered list; max-w-md on every form.

Responsive: the card fills the shell's main column; every form is max-w-md, the Telegram row is flex items-end and does not wrap below about 420 px (the button sits beside the input); the shell hides its sidebar below sm. Empty state: the passkey card. Error and success states: the two cards. Loading states: per button through the atoms' loading prop (profile, code, password, passkey, Telegram); there is no page-level skeleton, and the passkey list is empty until its fetch returns, which reads as "no passkeys" for a moment (Gotchas).

What the demo deliberately does not render: the browser's passkey prompt (a WebAuthn ceremony needs a relying party the docs host is not), the mailed code, the network refetches, the /billing/manage navigation of the second tab, the tour that resetOnboarding() would re-open, and the shell's toasts (the page uses none).

Security & tenancy

What the browser may hold: S2's accessToken and refreshToken (localStorage, or memory under the cookie flag), which this page reads directly for the four passkey calls; preferPasskey; the onboarding keys; and, transiently, the typed name, the six-digit code and the two password fields in component state until submit or cancel. Never a key, a passkey private key (it stays in the authenticator), or a session list. The attestation JSON that startRegistration returns contains the new credential's public key and id and is forwarded once.

Origin and CSRF: the api singleton and the fetch calls both send a Bearer header, so a cross-site form post carries nothing; under VITE_USE_COOKIE_AUTH the HttpOnly cookies of S5 and the same-site rule apply to the api calls, while the bare fetch calls send no credentials and would then fail with 401 (Gotchas). The relying party for the ceremony is the surface's (labs), resolved server-side from Origin (B7 API surface); the page sends no surface.

Surface gating: /api/user/* is LABS_SURFACES and the webauthn router ALL_SURFACES (B17, B7); the SPA's PrivateRoute already refuses a session without the labs grant (S2). The workspace settings PATCH is behind workspace.manage_settings, so a member sees a red fallback card where an owner sees "Gespeichert" (the button is not hidden by role; nothing here is hidden by CSS, and the permission is enforced by the route).

Attacker-controlled inputs the page sends: name (split on a space server-side, bounded by the columns), verification_code (six characters, compared with !=, never rate-limited beyond B18's global ceiling: B17 Security), new_password (length-checked by Pydantic), device_name (up to 100 characters), telegram_chat_id (free text into the workspace's JSONB settings; the Telegram sender is another module's concern). What the page renders from the server unescaped: nothing; React escapes device_name and the sentences.

Tests

  • Colocated with the demo: profile-and-passkeys/demo.test.tsx (vitest, jsdom, @testing-library/react): the e-mail field is disabled and the name field editable; one rename and one delete control per passkey row and none in the empty state; the code and password fields appear only in the code-sent state, the code input carries autocomplete="one-time-code" and both password inputs are type="password"; the message card carries the green or red text class of its kind; no state's text matches a key or JWT shape. Passed 5 of 5 on 2026-09-12 with npx vitest run --config <a config whose include is modules/identity-auth/ui/*/demo.test.tsx>; the root vitest.config.mjs include covers packages/*/test and test/ only (Gotchas). The file registers afterEach(cleanup) because the root runs with globals: false.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs identity-auth/ui/profile-and-passkeys renders the seven states with renderToString and fetch stubbed.
  • Labs at fd8558f has no unit test of ProfileWithUsage.tsx and no Playwright spec that opens /settings/profile (grep -rl "settings/profile" frontend/e2e is empty); the backend's test_api_endpoints.py::TestUserEndpoints has the red test_update_user_settings against the password route (B17 Tests), and test_webauthn_multi_rp.py covers the credential routes (B7 Tests).
  • What a new app adds: a jsdom test with fetch and api mocked that (a) sends register/complete with the object startRegistration returned and nothing else, (b) renders the server's sentence when data.error is set (once the reads are fixed), (c) refuses to submit a five-character code and a mismatched pair without a request, (d) clears preferPasskey when the last passkey is deleted (once that is built); and a Playwright spec with a virtual authenticator (CDPSession WebAuthn.addVirtualAuthenticator) that enrols, renames and deletes a passkey against the harness.

Deploy & ops

Build target: the Labs Vite bundle (S2 Deploy & ops); the page is a lazy chunk (App.tsx 94). Env baked at build: VITE_API_URL (read twice: by the api singleton and by this page's own API_URL at line 20 for the fetch calls, with different defaults, empty here and http://localhost:8000 in api.ts; Gotchas) and VITE_USE_COOKIE_AUTH. CSP and CORS: the passkey fetch calls go to the same API origin as the singleton and need no extra entry; WebAuthn needs the page served over HTTPS (or localhost) and an RP id that is a registrable suffix of the origin, which is the deploy's job through the surface registry (B7 Deploy & ops). Deploy path: S13's frontend rsync; rollback is the bundle's. No migration; the routes are B7's and B17's.

Gotchas

No passkey yet: the empty card that invites the first enrolment no-passkeys server rendered, hydrated in view

Profilinformationen

Die E-Mail kann nicht geändert werden

Passwort ändern

Wir senden dir einen Bestätigungscode an deine E-Mail, um sicherzugehen, dass du es bist.

Passkeys

Nutze Fingerabdruck, Face ID oder einen Hardware-Schlüssel, um dich ohne Passwort anzumelden.

Noch keine Passkeys registriert. Füge einen hinzu, um die passwortlose Anmeldung zu aktivieren.

Telegram-Benachrichtigungen

Erhalte eine Telegram-Nachricht, sobald jemand eines deiner Formulare absendet.

  1. Starte einen Chat mit @example_bot auf Telegram
  2. Sende dem Bot eine beliebige Nachricht (damit er dir antworten kann)
  3. Schreibe @userinfobot, um deine Chat-ID zu erhalten, oder füge den Bot zu einer Gruppe hinzu
  4. Füge deine Chat-ID unten ein und speichere

Schreibe @userinfobot auf Telegram, um deine Chat-ID zu erhalten

Onboarding

Die geführten Touren erklären jedes Modul beim ersten Besuch. Setz sie hier zurück, um sie erneut zu sehen, oder nutz jederzeit den „Zeig mir die App“-Button unten in der Icon-Leiste.

  • 2026-09-12 (measured at fd8558f): four of the five refusals the page can show are fallback sentences. handleSaveTelegram, handleRequestCode and handlePasswordUpdate read err.response?.data?.detail (112, 250, 284) and the two passkey fetch paths read err.detail from the body (144, 159); the Labs envelope carries error, not detail (S7, schemas/error_schemas.py 130 to 136). Only handleProfileUpdate reads data.error (234). So "Invalid verification code", "Verification code has expired ...", the 422 for a short password and a 500 from register/begin all read as "... konnte nicht ...". Fix: one readError(body) that returns error ?? detail ?? message, used in all six catches.
  • 2026-09-12 (measured at fd8558f): the passkey calls use bare fetch with a hand-built Bearer header (59 to 65, 120, 138, 152, 180, 199) instead of the api singleton, so a 401 from an expired access token is not retried through S2's single-flight refresh; the list silently stays empty (126 to 128) and an enrolment fails with the fallback sentence until the next api call refreshes the token. Under VITE_USE_COOKIE_AUTH the same calls send no cookie. Fix: route the four calls through api (or authedFetch, S2) and delete getAuthHeaders and the page's own API_URL.
  • 2026-09-12 (measured at fd8558f): loadPasskeys swallows every failure (126 to 128), so a 401, a 503 from the surface gate or a network fault renders as "Noch keine Passkeys registriert", which invites a person to enrol a second time. Fix: an error state for the list, distinct from the empty card.
  • 2026-09-12 (measured at fd8558f): the page uses B6's second password flow (request-code then PUT /api/user/password, 246 and 274), whose code lives in a per-worker dict (B17 Gotchas: with two workers the second call lands on the other worker about half the time and answers "No verification code requested"), is compared with !=, and revokes no session; POST /api/auth/change-password (asks for the current password, revokes every session) has zero callers. D8 asks to retire the code flow; the fix on this page is to swap the card for a current-password form on /api/auth/change-password and to sign the person out afterwards, as B2's Frontend line already describes it.
  • 2026-09-12 (measured at fd8558f): the B2 and B7 Frontend sections describe this page as it should be, not as it is: B2 names POST /change-password, PATCH /me and a sessions list; B7 names the list "grouped by rp_id" and an origin-bound warning. At fd8558f the page calls PUT /api/user/profile, the code flow, and lists passkeys flat without rp_id (PasskeyCredential 22 to 27 omits the field the route returns). The frontmatter tree is the measured one; the three additions are the rebuild's.
  • 2026-09-12 (measured at fd8558f): a successful name change does not refresh AuthContext (224 to 238 call updateProfile and show the card), so the shell's account row and every other reader of user.name show the old name until a reload or the next /me. Fix: refreshUser() after the 200, or set the context from the response.
  • 2026-09-12 (measured at fd8558f): deleting a passkey has no confirmation (514 to 521 DELETE on click) and never clears preferPasskey (set at 163, removed nowhere), so after the last passkey is removed the login page still starts the discoverable ceremony on every visit and the person sees the browser's empty picker (F1 reads the flag at LoginAlchemy.tsx 52). Fix: confirm before delete; clear the flag when the list becomes empty.
  • 2026-09-12 (measured at fd8558f): the file is ProfileWithUsage.tsx and reads no usage; GET /api/user/token-usage has no caller on this page (B17 Frontend). D2 (2026-09-11) makes the token tanks legacy anyway; a rebuild names the file Profile.tsx and shows the prepaid balance from the ledger, if anything.
  • 2026-09-12 (measured at fd8558f): formatDate uses toLocaleDateString(undefined, {...}) (290 to 295), so the passkey dates render in the browser's locale, and a server-rendered demo would not hydrate cleanly; the demo passes formatted strings. A rebuild formats with de-CH explicitly.
  • 2026-09-12 (measured at fd8558f): the Telegram section names the agency's bot handle in its instructions (543) and links to a third-party bot (545); the demo carries a placeholder. A client build takes the handle from configuration, not from the page.
  • 2026-09-12 (measured at fd8558f): the onboarding paragraph carries two em dashes (583 to 584); the demo's copy replaces them with commas (house rule). Rendering trap: the passkey button's spinner state (loading on the outline Button) is the only visible sign that the browser prompt is open; if the prompt is behind another window the page looks stuck.
  • 2026-09-12 (kit): the root vitest.config.mjs include is packages/*/test/** and test/**, so the colocated demo.test.tsx does not run under npm test and needs a config that names it (Tests). Fix: add modules/**/ui/*/*.test.tsx to the root include.

Reference implementation

Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-12). Paths relative to the repo root:

  • frontend/src/pages/settings/ProfileWithUsage.tsx (618 lines): imports and API_URL (1 to 20), PasskeyCredential (22 to 27), state (29 to 57), getAuthHeaders (59 to 65), the mount effects (67 to 74), Telegram (76 to 116), loadPasskeys (118 to 129), handleAddPasskey (131 to 174), handleDeletePasskey (176 to 193), handleRenamePasskey (195 to 214), the profile submit (224 to 238), the code flow (240 to 288), formatDate (290 to 295), the tabs (300 to 318), the five sections (320 to 598), the message cards (600 to 610).
  • frontend/src/services/api.ts: updateProfile (234 to 237), requestPasswordChangeCode (239 to 242), updatePassword (244 to 249); the singleton (16 to 80).
  • frontend/src/services/authSession.ts 39 (getAccessToken); frontend/src/config/authConfig.ts 87 to 120 (the storage modes).
  • frontend/src/contexts/AuthContext.tsx: the User shape (25 to 45).
  • frontend/src/onboarding/storage.ts: the prefix (11), resetOnboarding (78 to 82).
  • frontend/src/pages/LoginAlchemy.tsx 52, 81, 126: the readers and the other writer of preferPasskey.
  • frontend/src/components/nav.tsx 258 to 272: the Settings domain; frontend/src/App.tsx 94 and 540 to 547: the lazy import and the route.
  • backend/api/routers/user_router.py: _password_change_codes (30), request_password_change_code (212 onward), update_password (252 onward); backend/api/routers/webauthn_router.py: register/begin (167), register/complete (186), credentials (296), delete (309), patch (317); backend/api/routers/workspace_router.py: settings get (564) and patch (607).
  • The demo: modules/identity-auth/ui/profile-and-passkeys/demo.tsx and demo.test.tsx; the shell it renders in: modules/ui-design-system/ui/appshell-navigation/demo.tsx and nav.tsx.

Deviations of the reference from this recipe, all dated 2026-09-12: the detail reads; bare fetch with a hand-built header for the passkey calls; the swallowed list failure; the second password flow; no sessions list, no rp_id, no origin note; the stale context after a name change; no delete confirmation and the sticky preferPasskey; a file named for usage it does not read; locale-dependent dates; the agency's bot handle on the page; two em dashes.

Acceptance checks

From alchemy_labs/frontend at fd8558f with the harness up (tooling/verify/labs-local.sh up, the seeded owner's session), or from a new app built to this recipe:

  1. grep -c "data?.detail\|err.detail" src/pages/settings/ProfileWithUsage.tsx prints 5 at fd8558f and 0 after the fix; grep -c "await fetch(" src/pages/settings/ProfileWithUsage.tsx prints 5 at fd8558f and 0 after the passkey calls move to the singleton.
  2. grep -n "change-password" src/pages/settings/ProfileWithUsage.tsx src/services/api.ts | wc -l prints 0 at fd8558f and at least 1 once the card uses B6's first flow; grep -c "preferPasskey" src/pages/settings/ProfileWithUsage.tsx prints 1 (the set) at fd8558f and 2 once the delete path clears it.
  3. Browser walk-through, signed in as the seeded owner: (a) open /settings/profile, observe Settings filled on the rail, "Profile" highlighted, the "Profil" tab underlined in purple, the e-mail field greyed with "Die E-Mail kann nicht geändert werden"; (b) change the name and click "Profil aktualisieren", observe the green card, then GET /api/auth/me returning the new name while the shell's account row still shows the old one until reload (fd8558f); (c) click "Bestätigungscode senden", observe the green card and the card re-titled "Neues Passwort festlegen" with three fields; type two different passwords and submit, observe the red "stimmen nicht überein" card and no request; type a five-character code, observe the red "6-stelligen" card; read the code from the backend log ([DEV] line, B17) and submit, observe "Passwort erfolgreich aktualisiert!" and the card back to idle; (d) in a browser with a virtual authenticator, click "Passkey hinzufügen", observe the button's spinner, complete the prompt, observe "Passkey erfolgreich registriert!", a row named "Passkey" with today's date, and localStorage.preferPasskey === 'true'; dismiss the prompt on a second attempt, observe "Passkey-Registrierung wurde abgebrochen."; (e) click the pencil, rename, press Enter, observe the new name; click the trash, observe the row gone with no confirmation (fd8558f) and "Passkey entfernt."; (f) type a chat id and click "Speichern", observe "Gespeichert" as the owner, and as a member of the same workspace observe the red fallback card; (g) click "Touren zurücksetzen", open /forms, observe the tour dialog.
  4. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs identity-auth/ui/profile-and-passkeys prints ok for the seven states and 0 failures; the colocated test prints 5 passed.
  5. Verifier: node tooling/verify/compare.mjs identity-auth/ui/profile-and-passkeys --state no-passkeys --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (the seeded owner has no passkey, so no-passkeys is the comparable state) writes a report with page_errors: []; mismatch_ratio is read for the card above the Telegram section, with the name and address fields excluded by name (sample versus seeded values) and the Telegram instructions excluded (the demo's placeholder handle).

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.