Prima Get an API key

Module kit / Identity and auth

Auth screens

identity-auth/ui/auth-screenslive

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
Renders identity-auth/registration-with-email-verification, identity-auth/session-login-refresh-logout, identity-auth/ed25519-jwt-and-jwks, identity-auth/surface-registry-and-gate, identity-auth/magic-link-login, identity-auth/password-reset, identity-auth/webauthn-passkeys-multi-rp, identity-auth/infomaniak-oidc-login, identity-auth/rate-limiting
Entry alchemy_labs/frontend/src/pages/LoginAlchemy.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/LoginAlchemy.tsx
  • alchemy_labs/frontend/src/pages/MagicLinkCallback.tsx
  • alchemy_labs/frontend/src/pages/AuthCallback.tsx
  • alchemy_labs/frontend/src/pages/VerifyCode.tsx
  • alchemy_labs/frontend/src/pages/EmailVerification.tsx
  • alchemy_labs/frontend/src/pages/SetupPassword.tsx
  • alchemy_labs/frontend/src/pages/ForgotPassword.tsx
  • alchemy_labs/frontend/src/pages/ResetPassword.tsx
  • alchemy_labs/frontend/src/components/PrivateRoute.tsx
  • alchemy_labs/frontend/src/components/AdminRoute.tsx
  • alchemy_labs/frontend/src/contexts/AuthContext.tsx
  • alchemy_labs/frontend/src/config/authConfig.ts
  • alchemy_labs/frontend/src/config/surface.ts
  • alchemy_labs/frontend/src/services/authSession.ts
  • alchemy_labs/frontend/src/services/authedFetch.ts
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/lib/deskLogin.ts
  • alchemy_labs/frontend/e2e/auth.spec.ts
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

Login, magic link first: four ways in login server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Vier Wege hinein. Alle führen zum selben Konto.

oder

Noch kein Konto? Mehr erfahren

ACME © 2026

One public login page with four ways in (magic link first, passkey and the OIDC provider as equal seconds, password quiet), plus the pages a credential round-trips through: the magic-link landing, the OIDC landing, forgot and reset password, e-mail verification with the first password. The invariant that makes it trustworthy is that the browser never learns more than the backend chose to say: every "we sent you a mail" screen renders the same whether or not the address has an account (the backend answers 202 either way), every session is a body the backend minted, and being signed in is not the same as being allowed here: PrivateRoute renders a page only when /api/auth/me lists this build's surface among the account's grants.

It makes nine backend modules visible: registration with verification (B1, the verify-email page and the verification_required body), login, refresh and logout (B2), the Ed25519 tokens the pages store (B3), the surface gate (B4, the 403 and the not-permitted screen), magic links (B5), password reset (B6), passkeys (B7), the OIDC provider (B8) and the auth rate limits (B18, the 429).

When to use

Trigger phrases: "the login page", "magic link sent", "passkey button", "sign in with Infomaniak", "forgot password", "reset link expired", "this account can't be used here", "redirect after login", "the 429 on login", "verification required". Surface: the Labs SPA, because these pages are React Router routes of the same bundle as the private pages and share its AuthContext; a page here renders outside <Navigation> (no shell, no workspace, no palette).

Not for the Prima surfaces: the chat's sign-in and the developer portal's /anmelden are identity-auth/ui/chat-signin-and-workspace-picker and identity-auth/ui/developer-portal-auth-pages (they post surface: prima_chat and prima_dev and never hold a Labs token). Not for the profile's change-password and passkey enrolment (identity-auth/ui/profile-and-passkeys). Not for the desk's domain sign-in mail (service-desk/ui/desk-client-portal, planned) beyond the acting_email line the magic-link landing shows.

Stack assumptions

S1 (tokens: paper, ink, ink-60, ink-40, line, brand-purple, danger, danger-bg, success, success-bg, warn-bg), S2 (the provider tree: AuthProvider above the router, AUTH_CHANGED_EVENT, the single-flight refresh) and S3 assumed. Pinned per S2 at fd8558f (2026-09-12): react 19.2.0, react-router-dom ^7.8.2, tailwindcss 3.4.18, axios ^1, @simplewebauthn/browser 13 (startAuthentication in LoginAlchemy.tsx line 4), framer-motion for the page entrances (dropped in the rebuild). Component library: @prima-modules/alchemy-ui 0.1.0 pinned to fd8558f (Button, Card, Heading, Input, Spinner, Text).

Data-fetch style is mixed in the source and the recipe fixes it: LoginAlchemy and MagicLinkCallback call fetch on VITE_API_URL directly (lines 58, 106, 151; MagicLinkCallback.tsx 48), EmailVerification, ForgotPassword and ResetPassword go through services/api.ts (api.post, api.requestPasswordReset, api.resetPassword), the password login goes through AuthContext.login (axios). None of these carries a token, so authedFetch is not involved; a new app uses one bare client for the anonymous auth routes and never the intercepted one (the 401 interceptor must not retry /auth/*, authSession.ts isAuthPath lines 78 to 83).

Build-time flags read by these pages: VITE_INFOMANIAK_LOGIN (true shows the second button, LoginAlchemy.tsx 327 to 345), VITE_SURFACE (surface.ts 17 to 28, the value hasSurface is checked against), VITE_USE_COOKIE_AUTH (authConfig.ts 39: tokens in memory instead of localStorage), VITE_USE_MOCK_AUTH (dead in a production build, authConfig.ts 31 to 59), VITE_API_URL. Copy: de-CH, du-Form; the source writes ß in five strings across four pages (EmailVerification.tsx 72 and 198, ResetPassword.tsx 243, SetupPassword.tsx 207 and 269) and the rebuild writes ss.

Data model

Client state per page, as props of the demo (Props in demo.tsx): screen, mode (magic-link default, LoginAlchemy.tsx line 27), email, password, showPassword, submitting, failure (a Failure: status, code, message, retryAfter), oidcError, callback (status, next, desk_only, acting_email, error), surfaces and buildSurface (the not-permitted screen), resendCooldown, message, code[6]. Nothing here is persisted except what the table below names.

Types the pages read from AuthContext (AuthContext.tsx 25 to 59): User { userId, email, name, emailVerified, isAdmin, clientStatus?, actingEmail?, surfaces: string[] } and AuthContextType { user, loading, surface, hasSurface, login, register, logout, refreshToken, loadUser }. surfaces defaults to [] when /me omits it, which is deny (145).

Browser storage written or read by these pages, token mode (VITE_USE_COOKIE_AUTH off, the deployed mode per S2):

KeyStoreWritten byCleared byLifetime
accessToken, refreshTokenlocalStoragestoreTokens (authConfig.ts 113 to 121) via setTokens; LoginAlchemy.tsx 79 to 80 and 124 to 125 write them directly after a passkey; SetupPassword.tsx 63 to 64 likewiseclearAuthStorage (authSession.ts 95 to 108)until logout or a failed refresh
preferPasskeylocalStorage"true" after any passkey success (LoginAlchemy.tsx 81, 126)never (not in clearAuthStorage)permanent: the next visit to /login auto-triggers the passkey prompt (48 to 89)
alchemy_consentlocalStorageConsentProvider (S2)neverpermanent

The two tokens are Ed25519 JWS (B3) and are opaque to the page: nothing here decodes a claim. In cookie mode nothing auth-related is written to localStorage and the backend's HttpOnly cookies carry the session (authConfig.ts 61 to 83). The OIDC landing reads the tokens from the URL fragment, never from the query, and strips them with history.replaceState before navigating (AuthCallback.tsx 22 to 30). No page holds a password after submit, no page holds an API key.

API surface

Every route below is anonymous and rate-limited by auth_rate_limit (30 per minute per IP per path, api/dependencies/rate_limit.py 26 to 34, 38 to 59), so every page must render a 429. The envelope is S7's (app_fastapi.py 725 to 786): {error: <detail string>, code, request_id, timestamp, details}; the strings below are the error value and are never shown as they are (failureCopy in helpers.ts turns status and code into de-CH copy).

RoutePageBodyOutcomes rendered
POST /api/auth/request-magic-linkLoginAlchemy magic-link mode{email, next?}; next only when it is a relative path (LoginAlchemy.tsx 145 to 156)202 {message} for every address, existing or not, granted or not (auth_router.py 1007, 1122 to 1125) renders magic-link-sent; 429 (per IP, and per address 10 per minute through _magic_link_email_throttle 1000 to 1004) renders login-rate-limited; 422 on an absolute next
POST /api/auth/verify-magic-linkMagicLinkCallback{token} from ?token=200 {access_token, refresh_token, user, next?, desk_only?, acting_email?} renders magic-link-success and navigates to signInDestination(body) after 1200 ms (92 to 101); 200 {status: "verification_required", ...} with no tokens (1186; body at 88 to 114) renders verification-required; 400 "Invalid or expired magic link" (1160 to 1170) renders magic-link-invalid
POST /api/auth/loginLoginAlchemy password mode, via AuthContext.login{email, password}200 {access_token, refresh_token, user}: tokens stored, user set, navigate; 200 verification_required (502) renders verification-required; 401 "Invalid email or password" (28 to 30) renders login-error; 403 "This account is not permitted to sign in to ..." (140 to 152, the missing labs grant) or "Account is disabled" (34 to 36) renders login-not-permitted; 400 "Please complete your account setup by setting a password first" (41 to 43) renders the set-password sentence; 429 renders login-rate-limited
POST /api/auth/webauthn/login/begin-discoverable, .../complete-discoverableLoginAlchemy passkey button and the preferPasskey auto-triggernone, then {credential, challenge_id} (webauthn_router.py 232, 274 to 289)200 tokens stored and location.replace(redirect or /dashboard); a cancelled prompt (NotAllowedError) shows nothing (129 to 131); 400 renders the passkey sentence; 429 as above
GET /api/auth/infomaniak/start?surface=labsLoginAlchemy second buttonfull-page navigation (339)the provider, then GET /api/auth/infomaniak/callback redirects to /auth/callback#access_token=...&refresh_token=... (auth_infomaniak_router.py 122 to 131) or to /login?oidc_error=<code> with one of signin_failed, no_account, no_grant, disabled, missing_tokens, access_denied (65 to 117; the map at LoginAlchemy.tsx 36 to 43) rendering login-oidc-error
POST /api/auth/forgot-passwordForgotPassword{email}202 {message} for every address (879 to 898) renders forgot-password-sent; 429
POST /api/auth/reset-passwordResetPassword{token, new_password} (api.ts 162 to 168)204 renders reset-password-done then /login after 3 s; 400 "Invalid or expired reset token" (901 to 917) renders the expired sentence; 422 from the validator renders as a field error
POST /api/auth/verify-emailEmailVerification with ?token={token}204 renders verify-email-set-password; 400 "Invalid or expired verification token" (955 to 976) renders the error card; 404 when verification is off
POST /api/auth/set-passwordEmailVerification set_password{token, password}204 renders the success card then /login after 2 s; 400 same string
POST /api/auth/resend-verificationEmailVerification pending and error{email}202 for every address (921 onward); 404 when verification is off; a 60 s client cooldown (113)
GET /api/auth/meAuthContext.verifyToken on mount and after a magic linkBearer200 UserResponse with surfaces (B2); 401 clears storage (150)
POST /api/auth/refreshthe interceptor, single-flight{refresh_token}200 a new pair; 401 logs out and sends to /login unless already on an auth path (AuthContext.tsx 302 to 309)

The two 429 shapes both carry Retry-After (B18): the middleware's raw {error: "rate_limit_exceeded", message} and the dependency's envelope {error: "Too many authentication attempts. Please try again later.", code: "RATE_LIMIT_EXCEEDED"}; readFailure reads both and the page shows "In N Sekunden erneut versuchen" with the button disabled for that long. No postMessage or WebSocket contract on these pages.

Application logic

Numbered as the user moves; screen, action, state, outcome.

  1. /login mounts. AuthContext checks getAccessToken(); with a token it calls /me (loading true, the page is a spinner, LoginAlchemy.tsx 190 to 196); with a user it leaves at once, to ?redirect= when present, else /dashboard (91 to 100). Without a user and with localStorage.preferPasskey === "true", the passkey prompt opens by itself (48 to 89); a cancel falls through to the form.
  2. Magic-link mode (default). Typing clears the error (288). Submit posts {email, next?} and, on 202, renders magic-link-sent with the typed address; "Andere E-Mail verwenden" returns to the form with the field emptied (230). The page cannot tell an unknown address from a known one and must not try.
  3. The user opens the mail and lands on /auth/magic-link?token=. MagicLinkCallback posts once (a useRef guards StrictMode's double effect, 33 to 36), stores the body tokens, computes destination and actingEmail from the body (69 to 76), calls loadUser(), shows the success card for 1200 ms and navigates. next wins when it is a relative path; a desk-only account goes to /desk; everyone else to /dashboard (deskLogin.ts 78 to 82).
  4. Password mode ("Mit Passwort anmelden"). Submit calls login(email, password); on success AuthContext stores the tokens, sets the axios header, sets the user (156 to 211) and the page navigates (178 to 182). A failure is error.response.data.error (213) rendered under the fields.
  5. Passkey. Click posts begin, runs startAuthentication, posts complete, stores the tokens and hard-navigates with location.replace (102 to 137). The provider button is a full-page navigation to the backend's /start; the round trip ends on /auth/callback, which reads the fragment, stores, strips and navigates to / (AuthCallback.tsx 18 to 34), or on /login?oidc_error= which the page renders as one of six sentences.
  6. Forgot password: submit posts the address; 202 renders the sent card with "6 Stunden gültig". Reset: /reset-password?token= renders the two fields and the five-part strength meter; the button is enabled at 5 of 5 with a matching confirmation; 204 renders the done card and /login follows after 3 s (ResetPassword.tsx 104 to 111).
  7. Verify e-mail: with ?token=, post at mount (28 to 32); 204 flips to the set-password form; 204 on set-password renders success and /login after 2 s. Without a token, the pending card with the resend form and a 60 s cooldown.
  8. A private route: PrivateRoute waits for loading, sends a signed-out visitor to /login?redirect=<path><search> (46 to 54), renders "This account can't be used here" with links to the surfaces the account does hold (56 to 83) when hasSurface is false, else the page. AdminRoute sends a non-admin to / (32 to 35).

Invariants

  • No mutation without an endpoint: every state change that outlives the page (a session, a password, a verified address) is a response body; the page holds only what the backend returned.
  • The "sent" screens never reveal whether the address exists: magic-link-sent and forgot-password-sent render from the 202 alone, with no branch on the address, so the frame is identical for a known and an unknown one. At fd8558f the copy still asserts that a mail went out ("Ein magischer Login-Link ist unterwegs an", LoginAlchemy.tsx 223; "Ein Link zum Zurücksetzen wurde an … gesendet.", ForgotPassword.tsx 96 to 102); the rebuild makes the sentence conditional ("Falls zu dieser Adresse ein Konto besteht", demo.tsx 302 and 456) so the screen claims no more than the backend knows. Both satisfy the invariant; the conditional wording is the rebuild's, not the reference's.
  • A 200 is a session only when it carries access_token: isVerificationRequired(body) is checked before storeTokens, so the verification-required body renders its screen and stores nothing (the Labs defect in Gotchas is the reason this is written down).
  • A redirect target is relative or it is dropped: safeNext (helpers.ts) is applied to ?redirect= before it is forwarded as next and before it is navigated to, on every path (magic link, password, passkey, already-signed-in), or the login page is an open redirect (Gotchas).
  • Tokens are stored through one function (setTokens) so the same-tab AUTH_CHANGED_EVENT fires and WorkspaceContext reloads its list (authSession.ts 51 to 67); the three direct localStorage.setItem sites in LoginAlchemy.tsx and SetupPassword.tsx are deviations.
  • Signed in is not permitted: hasSurface is surfaces.includes(BUILD_SURFACE) and nothing else (surface.ts 35 to 37); a /me without surfaces opens nothing.
  • Every failure renders as de-CH copy chosen by status and code, never as the backend's English string, and a 429 renders its Retry-After.
  • The OIDC tokens travel in the fragment and are removed from the address bar before any navigation (AuthCallback.tsx 28 to 30).

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.

Password mode, filled login-password server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Melde dich mit deinem Passwort an.

ACME © 2026

Password mode, request in flight login-submitting server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Melde dich mit deinem Passwort an.

ACME © 2026

401 from POST /api/auth/login login-error server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Melde dich mit deinem Passwort an.

ACME © 2026

403: the account holds no grant for this surface login-not-permitted server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Melde dich mit deinem Passwort an.

ACME © 2026

429 with Retry-After 42 on the magic-link request login-rate-limited server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Vier Wege hinein. Alle führen zum selben Konto.

oder

Noch kein Konto? Mehr erfahren

ACME © 2026

Back from the OIDC provider with ?oidc_error=no_account login-oidc-error server rendered, hydrated in view
Acme

Kundenportal

Willkommen zurück.

Vier Wege hinein. Alle führen zum selben Konto.

oder

Noch kein Konto? Mehr erfahren

ACME © 2026

202 from /request-magic-link: the same screen whether or not the address exists magic-link-sent server rendered, hydrated in view
Acme

Link gesendet.

Falls zu dieser Adresse ein Konto besteht, ist ein Login-Link unterwegs an

[email protected]

Der Link ist 15 Minuten gültig. Prüfe auch deinen Spam-Ordner.

ACME © 2026

/auth/magic-link while POST /verify-magic-link runs magic-link-verifying server rendered, hydrated in view
Acme

Authentifizierung

Das Portal öffnet sich …

Dein Schlüssel wird verifiziert

Einen Moment bitte …

ACME © 2026

Redeemed under a shared account, bound for the desk magic-link-success server rendered, hydrated in view
Acme

Authentifizierung

Willkommen zurück

Du wirst weitergeleitet …

Erfolgreich angemeldet

Du bist als [email protected] angemeldet.

Du wirst zum Service Desk weitergeleitet …

ACME © 2026

400 "Invalid or expired magic link" magic-link-invalid server rendered, hydrated in view
Acme

Authentifizierung

Authentifizierung fehlgeschlagen

Der Login-Link konnte nicht verifiziert werden

Etwas ist schiefgelaufen

ACME © 2026

/auth/callback: the fragment is read, stored and stripped oidc-callback server rendered, hydrated in view

Anmeldung wird abgeschlossen …

Forgot password forgot-password server rendered, hydrated in view
Acme

Passwort

Passwort vergessen?

Kein Problem. Gib deine E-Mail ein und wir senden dir einen Link zum Zurücksetzen.

ACME © 2026

202 from /forgot-password: always this screen forgot-password-sent server rendered, hydrated in view
Acme

E-Mail unterwegs.

Falls zu dieser Adresse ein Konto besteht, geht ein Link zum Zurücksetzen an

[email protected]

Der Link ist 6 Stunden gültig. Prüfe auch deinen Spam-Ordner.

ACME © 2026

Reset with ?token=, four of five requirements met reset-password server rendered, hydrated in view
Acme

Passwort zurücksetzen

Gib unten dein neues Passwort ein

Passwortstärke

4 von 5

✓ Mindestens 8 Zeichen✓ Ein Grossbuchstabe✓ Ein Kleinbuchstabe✓ Eine Zahl• Ein Sonderzeichen

ACME © 2026

204 from /reset-password reset-password-done server rendered, hydrated in view
Acme

Passwort zurückgesetzt.

Alle bisherigen Sitzungen wurden beendet. Melde dich mit dem neuen Passwort an.

ACME © 2026

/verify-email without a token: resend with a cooldown verify-email server rendered, hydrated in view
Acme

Prüfe deine E-Mails

Wir haben dir eine Bestätigungs-E-Mail gesendet. Bitte prüfe dein Postfach und klicke auf den Bestätigungslink.

E-Mail nicht erhalten? Prüfe deinen Spam-Ordner oder fordere unten eine neue Bestätigungs-E-Mail an.
Bestätigungs-E-Mail gesendet! Bitte prüfe dein Postfach.
Zurück zum Login

ACME © 2026

Token verified: set the first password verify-email-set-password server rendered, hydrated in view
Acme

E-Mail bestätigt!

Lege jetzt dein Passwort fest, um dein Konto zu sichern.

ACME © 2026

Six-digit code, three typed (dead at fd8558f: its backend routes are commented out) verify-code server rendered, hydrated in view
Acme

Prüfe deine E-Mails

Gib den 6-stelligen Code ein, den wir gesendet haben an

[email protected]

Code läuft in 15 Minuten ab

Code nicht erhalten?

ACME © 2026

identity-auth/ui/auth-screens/demo.tsx
/**
 * Live demo for identity-auth/ui/auth-screens.
 *
 * The public screens of the Labs SPA (alchemy_labs, branch d1a-token-source,
 * fd8558f) rebuilt as one presentational component with the design system:
 * LoginAlchemy.tsx (460 lines), MagicLinkCallback.tsx (286), AuthCallback.tsx
 * (44), PrivateRoute.tsx (89), ForgotPassword.tsx (282), ResetPassword.tsx
 * (316), EmailVerification.tsx (370), VerifyCode.tsx (278) and
 * SetupPassword.tsx (286). The real pages read useAuth, React Router's
 * search params and fetch; this rebuild takes the screen and its state as
 * props, and the network outcome as a `Failure` already read through
 * helpers.ts, so every state the pages can be in renders from a literal.
 *
 * What differs from Labs, and why (recipe Frontend and Gotchas):
 *   - the ground is bg-paper and the accent is the brand-purple token on every
 *     screen; LoginAlchemy paints bg-white with seven #6642C1 literals (the
 *     prima.li purple, not the Labs one), AuthCallback bg-[#f5f1ea], the rest
 *     bg-stone-100. D1a makes the token the rule, so the rebuild uses it;
 *   - the controls are the atoms (Button, Input, Card, Heading, Text, Spinner)
 *     where LoginAlchemy hand-writes its input and button class strings;
 *   - the root is min-h-[560px] instead of min-h-screen, the framer-motion
 *     entrances are gone (the frame has no first paint to animate), and the
 *     agency logo files are an inline mark plus a brand word;
 *   - failures are rendered through failureCopy: the 401, 403, 429 and
 *     verification_required outcomes get a sentence each, which the Labs
 *     pages do not do at fd8558f (they show one fixed string or the backend's
 *     English detail);
 *   - the two split-layout pages (VerifyCode, SetupPassword) render their form
 *     column only; the lg: branding column is described in the recipe;
 *   - copy is de-CH: "ss" where the source has ß.
 *
 * Sample data only: addresses under example.com, no token, no key.
 */
import React from 'react';
import { AlertCircle, ArrowLeft, ArrowRight, CheckCircle, Eye, EyeOff, Key, Lock, Mail } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Button, Card, Heading, Input, Spinner, Text } from '@prima-modules/alchemy-ui';
import { type Failure, failureCopy, oidcErrorCopy, passwordStrength, signInDestination } from './helpers';

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

export type Screen =
  | 'login'
  | 'magic-link-sent'
  | 'verification-required'
  | 'magic-link-callback'
  | 'oidc-callback'
  | 'not-permitted'
  | 'forgot-password'
  | 'forgot-password-sent'
  | 'reset-password'
  | 'reset-password-done'
  | 'verify-email'
  | 'verify-email-set-password'
  | 'verify-code';

export interface CallbackState {
  status: 'loading' | 'success' | 'error';
  /** The verify body's `next`, `desk_only` and `acting_email` (MagicLinkCallback.tsx lines 60 to 76). */
  next?: string;
  desk_only?: boolean;
  acting_email?: string | null;
  error?: string;
}

export interface Props {
  screen: Screen;
  /** The word beside the mark; a customer build puts its own name here. */
  brandWord?: string;
  /** Login: magic link is the default mode, password the quiet second (LoginAlchemy.tsx line 27). */
  mode?: 'magic-link' | 'password';
  /** Whether the build has VITE_INFOMANIAK_LOGIN=true (LoginAlchemy.tsx lines 327 to 345). */
  infomaniak?: boolean;
  email?: string;
  password?: string;
  confirmPassword?: string;
  showPassword?: boolean;
  submitting?: boolean;
  /** A failed request, already read through readFailure. */
  failure?: Failure | null;
  /** The `?oidc_error=` code the OIDC callback redirected with. */
  oidcError?: string | null;
  callback?: CallbackState;
  /** PrivateRoute: the account's grants and this build's surface (PrivateRoute.tsx lines 56 to 83). */
  surfaces?: string[];
  buildSurface?: string;
  /** Verify e-mail: the resend cooldown in seconds and the last message. */
  resendCooldown?: number;
  message?: { tone: 'success' | 'error'; text: string } | null;
  /** Verify code: the six digits typed so far. */
  code?: string[];
}

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

const EMAIL = '[email protected]';

const base: Props = { screen: 'login', brandWord: 'Acme', mode: 'magic-link', infomaniak: true, email: '' };

export const states = defineStates<Props>({
  login: { label: 'Login, magic link first: four ways in', props: base, height: 720 },
  'login-password': { label: 'Password mode, filled', props: { ...base, mode: 'password', email: EMAIL, password: 'correct-horse-battery', showPassword: false }, height: 720 },
  'login-submitting': { label: 'Password mode, request in flight', props: { ...base, mode: 'password', email: EMAIL, password: 'correct-horse-battery', submitting: true }, height: 720 },
  'login-error': { label: '401 from POST /api/auth/login', props: { ...base, mode: 'password', email: EMAIL, password: 'wrong', failure: { status: 401, code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' } }, height: 720 },
  'login-not-permitted': { label: '403: the account holds no grant for this surface', props: { ...base, mode: 'password', email: EMAIL, password: 'correct-horse-battery', failure: { status: 403, code: 'FORBIDDEN', message: 'This account is not permitted to sign in here.' } }, height: 720 },
  'login-rate-limited': { label: '429 with Retry-After 42 on the magic-link request', props: { ...base, email: EMAIL, failure: { status: 429, code: 'RATE_LIMIT_EXCEEDED', message: 'Too many authentication attempts. Please try again later.', retryAfter: 42 } }, height: 720 },
  'login-oidc-error': { label: 'Back from the OIDC provider with ?oidc_error=no_account', props: { ...base, oidcError: 'no_account' }, height: 760 },
  'magic-link-sent': { label: '202 from /request-magic-link: the same screen whether or not the address exists', props: { ...base, screen: 'magic-link-sent', email: EMAIL }, height: 560 },
  'verification-required': { label: '200 verification_required: a body with no session', props: { ...base, screen: 'verification-required', email: EMAIL }, height: 560 },
  'magic-link-verifying': { label: '/auth/magic-link while POST /verify-magic-link runs', props: { ...base, screen: 'magic-link-callback', callback: { status: 'loading' } }, height: 600 },
  'magic-link-success': { label: 'Redeemed under a shared account, bound for the desk', props: { ...base, screen: 'magic-link-callback', callback: { status: 'success', desk_only: true, acting_email: '[email protected]' } }, height: 600 },
  'magic-link-invalid': { label: '400 "Invalid or expired magic link"', props: { ...base, screen: 'magic-link-callback', callback: { status: 'error', error: 'Dieser Link ist ungültig oder abgelaufen.' } }, height: 600 },
  'oidc-callback': { label: '/auth/callback: the fragment is read, stored and stripped', props: { ...base, screen: 'oidc-callback' }, height: 360 },
  'not-permitted': { label: 'PrivateRoute: signed in, no grant for this build', props: { ...base, screen: 'not-permitted', surfaces: ['prima_web', 'prima_chat'], buildSurface: 'labs' }, height: 360 },
  'forgot-password': { label: 'Forgot password', props: { ...base, screen: 'forgot-password', email: '' }, height: 720 },
  'forgot-password-sent': { label: '202 from /forgot-password: always this screen', props: { ...base, screen: 'forgot-password-sent', email: EMAIL }, height: 600 },
  'reset-password': { label: 'Reset with ?token=, four of five requirements met', props: { ...base, screen: 'reset-password', password: 'Abcdefg1', confirmPassword: 'Abcdefg1' }, height: 800 },
  'reset-password-done': { label: '204 from /reset-password', props: { ...base, screen: 'reset-password-done' }, height: 480 },
  'verify-email': { label: '/verify-email without a token: resend with a cooldown', props: { ...base, screen: 'verify-email', email: EMAIL, resendCooldown: 47, message: { tone: 'success', text: 'Bestätigungs-E-Mail gesendet! Bitte prüfe dein Postfach.' } }, height: 760 },
  'verify-email-set-password': { label: 'Token verified: set the first password', props: { ...base, screen: 'verify-email-set-password', password: '', confirmPassword: '' }, height: 800 },
  'verify-code': { label: 'Six-digit code, three typed (dead at fd8558f: its backend routes are commented out)', props: { ...base, screen: 'verify-code', email: EMAIL, code: ['4', '8', '1', '', '', ''] }, height: 720 },
});

/* ---- chrome ------------------------------------------------------------- */

function Mark({ className }: { className?: string }) {
  return (
    <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
      <path d="M12 3l1.8 4.9L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.1L12 3z" />
    </svg>
  );
}

/** The centred column every public page uses: mark, content, footer. */
function Page({ brandWord = 'Acme', children }: { brandWord?: string; children: React.ReactNode }) {
  return (
    <div className="min-h-[560px] flex flex-col items-center justify-center relative overflow-hidden p-6 bg-paper text-ink font-sans">
      <div className="relative z-10 w-full max-w-md">
        <div className="text-center mb-8 flex items-center justify-center gap-2 text-brand-purple">
          <Mark />
          <span className="text-xl font-semibold text-ink">{brandWord}</span>
        </div>
        {children}
        <div className="text-center mt-8">
          <Text size="xs" muted>{brandWord.toUpperCase()} © 2026</Text>
        </div>
      </div>
    </div>
  );
}

function Notice({ tone, children }: { tone: 'error' | 'success' | 'info'; children: React.ReactNode }) {
  const cls = {
    error: 'bg-danger-bg border-danger/30 text-danger',
    success: 'bg-success-bg border-success/30 text-success-dark',
    info: 'bg-brand-purple/10 border-brand-purple/30 text-brand-purple',
  }[tone];
  return (
    <div role={tone === 'error' ? 'alert' : 'status'} className={`rounded-2xl px-4 py-3 mb-5 border text-sm leading-relaxed ${cls}`}>
      {children}
    </div>
  );
}

function Field({
  label,
  icon,
  type,
  value,
  placeholder,
  autoComplete,
  trailing,
  disabled,
}: {
  label: string;
  icon: React.ReactNode;
  type: string;
  value: string;
  placeholder: string;
  autoComplete?: string;
  trailing?: React.ReactNode;
  disabled?: boolean;
}) {
  return (
    <div className="mb-5">
      <Text as="label" size="sm" weight="medium" className="block mb-2 px-4">
        {label}
      </Text>
      <div className="relative">
        <div className="absolute inset-y-0 left-0 pl-6 flex items-center pointer-events-none z-10 text-ink-40">{icon}</div>
        <Input type={type} defaultValue={value} placeholder={placeholder} autoComplete={autoComplete} inputSize="md" className={`pl-14 ${trailing ? 'pr-14' : ''}`} disabled={disabled} />
        {trailing && <div className="absolute inset-y-0 right-0 pr-4 flex items-center z-10">{trailing}</div>}
      </div>
    </div>
  );
}

function Divider({ word }: { word: string }) {
  return (
    <div className="flex items-center gap-3 my-6">
      <span className="h-px flex-1 bg-line" />
      <span className="text-[11px] uppercase tracking-[0.22em] text-ink-40">{word}</span>
      <span className="h-px flex-1 bg-line" />
    </div>
  );
}

function IconDisc({ tone, children }: { tone: 'purple' | 'success' | 'error'; children: React.ReactNode }) {
  const cls = { purple: 'bg-brand-purple/10 text-brand-purple', success: 'bg-success-bg text-success', error: 'bg-danger-bg text-danger' }[tone];
  return <div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6 ${cls}`}>{children}</div>;
}

/* ---- /login ------------------------------------------------------------- */

function Login(p: Props) {
  const mode = p.mode ?? 'magic-link';
  const failure = failureCopy(p.failure, mode === 'password' ? 'password' : 'magic-link') ?? oidcErrorCopy(p.oidcError);
  const waiting = p.failure?.status === 429;
  const busy = !!p.submitting || waiting;
  return (
    <Page brandWord={p.brandWord}>
      <div className="text-center mb-8">
        <p className="uppercase tracking-[0.28em] text-[12px] font-semibold text-ink-40 mb-3">Kundenportal</p>
        <Heading level={1} className="mb-2">Willkommen zurück.</Heading>
        <Text muted>{mode === 'magic-link' ? 'Vier Wege hinein. Alle führen zum selben Konto.' : 'Melde dich mit deinem Passwort an.'}</Text>
      </div>

      <Card variant="glass" padding="lg" data-gallery-focus="true">
        <form onSubmit={(e) => e.preventDefault()}>
          <Field label="E-Mail" icon={<Mail className="w-5 h-5" />} type="email" value={p.email ?? ''} placeholder="[email protected]" autoComplete="username" disabled={busy} />

          {mode === 'password' && (
            <Field
              label="Passwort"
              icon={<Lock className="w-5 h-5" />}
              type={p.showPassword ? 'text' : 'password'}
              value={p.password ?? ''}
              placeholder="••••••••"
              autoComplete="current-password"
              disabled={busy}
              trailing={
                <button type="button" aria-label={p.showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} className="text-ink-40 hover:text-ink transition-colors">
                  {p.showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
                </button>
              }
            />
          )}

          {failure && <Notice tone="error">{failure}</Notice>}

          <Button type="submit" variant="primary" size="lg" fullWidth loading={!!p.submitting} disabled={busy}>
            {p.submitting ? (mode === 'magic-link' ? 'Wird gesendet …' : 'Einen Moment …') : waiting ? `Warten (${p.failure?.retryAfter ?? 60} s)` : mode === 'magic-link' ? 'Login-Link senden' : 'Anmelden'}
            {!p.submitting && !waiting && <ArrowRight className="w-5 h-5" />}
          </Button>

          {mode === 'magic-link' ? (
            <>
              <Divider word="oder" />
              <div className={`grid gap-2.5 ${p.infomaniak ? 'grid-cols-2' : 'grid-cols-1'}`}>
                <Button type="button" variant="outline" size="md" disabled={busy}>
                  <Key className="w-4 h-4" />
                  Passkey
                </Button>
                {p.infomaniak && (
                  <Button type="button" variant="outline" size="md" disabled={busy}>
                    <span aria-hidden className="w-4 h-4 rounded bg-ink/80 inline-block" />
                    Infomaniak
                  </Button>
                )}
              </div>
              <div className="text-center mt-5">
                <button type="button" className="text-sm text-ink-60 hover:text-ink transition-colors">Mit Passwort anmelden</button>
              </div>
            </>
          ) : (
            <div className="text-center mt-5">
              <button type="button" className="text-sm text-ink-60 hover:text-ink transition-colors">Zurück zum Login-Link</button>
            </div>
          )}
        </form>

        <div className="mt-6 pt-6 border-t border-line text-center">
          {mode === 'password' && (
            <a href="#forgot-password" className="block mb-3 text-sm text-ink-60 hover:text-ink transition-colors">Passwort vergessen?</a>
          )}
          <Text size="sm" muted>
            Noch kein Konto? <a href="#" className="text-ink font-medium hover:text-brand-purple transition-colors">Mehr erfahren</a>
          </Text>
        </div>
      </Card>
    </Page>
  );
}

/** The 202 screen. The copy names the typed address and nothing about the account: the backend answers 202 for every address (auth_router.py line 1125). */
function MagicLinkSent(p: Props) {
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="lg" className="text-center">
        <IconDisc tone="purple"><CheckCircle className="w-8 h-8" /></IconDisc>
        <Heading level={2} className="mb-3">Link gesendet.</Heading>
        <Text muted className="mb-1">Falls zu dieser Adresse ein Konto besteht, ist ein Login-Link unterwegs an</Text>
        <Text weight="semibold" className="mb-4 break-all">{p.email}</Text>
        <div className="rounded-2xl p-4 mb-6 bg-white border border-line">
          <Text size="sm" muted>Der Link ist 15 Minuten gültig. Prüfe auch deinen Spam-Ordner.</Text>
        </div>
        <button type="button" className="text-sm font-medium text-ink-60 hover:text-ink transition-colors">Andere E-Mail verwenden</button>
      </Card>
    </Page>
  );
}

/** The 200 body with status verification_required and no tokens (auth_router.py lines 88 to 114), not rendered by Labs at fd8558f. */
function VerificationRequired(p: Props) {
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="lg" className="text-center">
        <IconDisc tone="purple"><Mail className="w-8 h-8" /></IconDisc>
        <Heading level={2} className="mb-3">Bitte zuerst bestätigen.</Heading>
        <Text muted className="mb-1">Dieses Konto ist noch nicht bestätigt. Wir haben einen neuen Bestätigungslink geschickt an</Text>
        <Text weight="semibold" className="mb-4 break-all">{p.email}</Text>
        <div className="rounded-2xl p-4 mb-6 bg-white border border-line">
          <Text size="sm" muted>Öffne den Link in der E-Mail, lege ein Passwort fest und melde dich dann an.</Text>
        </div>
        <Button variant="outline" size="md">Zurück zum Login</Button>
      </Card>
    </Page>
  );
}

/* ---- /auth/magic-link --------------------------------------------------- */

function MagicLinkCallback(p: Props) {
  const c = p.callback ?? { status: 'loading' as const };
  const destination = signInDestination(c);
  return (
    <Page brandWord={p.brandWord}>
      <div className="text-center mb-8">
        <Text size="xs" className="uppercase mb-2 text-brand-purple tracking-widest">Authentifizierung</Text>
        <Heading level={1} className="mb-2">
          {c.status === 'loading' && 'Das Portal öffnet sich …'}
          {c.status === 'success' && 'Willkommen zurück'}
          {c.status === 'error' && 'Authentifizierung fehlgeschlagen'}
        </Heading>
        <Text muted>
          {c.status === 'loading' && 'Dein Schlüssel wird verifiziert'}
          {c.status === 'success' && 'Du wirst weitergeleitet …'}
          {c.status === 'error' && 'Der Login-Link konnte nicht verifiziert werden'}
        </Text>
      </div>
      <Card variant="glass" padding="lg" className="text-center">
        {c.status === 'loading' && (
          <>
            <div className="w-20 h-20 mx-auto mb-6 grid place-items-center"><Spinner size="xl" /></div>
            <Text muted className="italic">Einen Moment bitte …</Text>
          </>
        )}
        {c.status === 'success' && (
          <>
            <IconDisc tone="success"><CheckCircle className="w-8 h-8" /></IconDisc>
            <Heading level={3} className="mb-3">Erfolgreich angemeldet</Heading>
            {c.acting_email && <Text size="sm" muted className="mb-2">Du bist als {c.acting_email} angemeldet.</Text>}
            <Text muted className="mb-6">{destination === '/desk' ? 'Du wirst zum Service Desk weitergeleitet …' : 'Du wirst weitergeleitet …'}</Text>
            <div className="h-1 rounded-full bg-brand-purple mx-auto max-w-[200px]" />
          </>
        )}
        {c.status === 'error' && (
          <>
            <IconDisc tone="error"><AlertCircle className="w-8 h-8" /></IconDisc>
            <Heading level={3} className="mb-3">Etwas ist schiefgelaufen</Heading>
            <Notice tone="error">{c.error ?? 'Verifizierung fehlgeschlagen'}</Notice>
            <Button variant="primary" fullWidth>Neuen Link anfordern</Button>
          </>
        )}
      </Card>
    </Page>
  );
}

/* ---- /auth/callback (OIDC) and PrivateRoute ------------------------------ */

function OidcCallback() {
  return (
    <div className="min-h-[320px] flex flex-col items-center justify-center gap-4 bg-paper text-ink font-sans p-6">
      <Spinner size="lg" />
      <Text size="sm" className="text-ink-60">Anmeldung wird abgeschlossen …</Text>
    </div>
  );
}

const SURFACE_LABEL: Record<string, string> = { labs: 'Admin', prima_web: 'Prima Web', prima_chat: 'Prima Chat', prima_dev: 'Prima Developer' };

/** PrivateRoute's "signed in, not here" screen (PrivateRoute.tsx lines 56 to 83). Labs renders it in English; the rebuild keeps the copy language of the page. */
function NotPermitted(p: Props) {
  const elsewhere = (p.surfaces ?? []).filter((s) => s !== p.buildSurface);
  return (
    <div className="min-h-[320px] flex flex-col justify-center items-center gap-4 px-6 text-center bg-paper text-ink font-sans">
      <Heading level={3}>Dieses Konto kann hier nicht verwendet werden</Heading>
      <Text size="sm" muted className="max-w-md">
        Du bist angemeldet, aber dieses Konto ist nicht für {SURFACE_LABEL[p.buildSurface ?? 'labs'] ?? p.buildSurface} freigeschaltet.
      </Text>
      {elsewhere.length > 0 && (
        <Text size="sm">
          Versuche{' '}
          {elsewhere.map((s, i) => (
            <React.Fragment key={s}>
              {i > 0 && ' oder '}
              <a className="underline" href="#">{SURFACE_LABEL[s] ?? s}</a>
            </React.Fragment>
          ))}
          .
        </Text>
      )}
    </div>
  );
}

/* ---- /forgot-password and /reset-password -------------------------------- */

function ForgotPassword(p: Props) {
  const failure = failureCopy(p.failure, 'forgot');
  return (
    <Page brandWord={p.brandWord}>
      <div className="text-center mb-8">
        <Text size="xs" className="uppercase mb-2 text-brand-purple tracking-widest">Passwort</Text>
        <Heading level={1} className="mb-2">Passwort vergessen?</Heading>
        <Text muted>Kein Problem. Gib deine E-Mail ein und wir senden dir einen Link zum Zurücksetzen.</Text>
      </div>
      <Card variant="glass" padding="lg">
        <form onSubmit={(e) => e.preventDefault()}>
          <IconDisc tone="purple"><Key className="w-8 h-8" /></IconDisc>
          <Field label="E-Mail-Adresse" icon={<Mail className="w-5 h-5" />} type="email" value={p.email ?? ''} placeholder="[email protected]" autoComplete="username" disabled={!!p.submitting} />
          {failure && <Notice tone="error">{failure}</Notice>}
          <Button type="submit" variant="primary" size="lg" fullWidth loading={!!p.submitting} className="mb-4">
            {!p.submitting && 'Link senden'}
          </Button>
          <div className="text-center">
            <Button type="button" variant="ghost" size="sm">
              <ArrowLeft className="w-4 h-4" />
              Zurück zum Login
            </Button>
          </div>
        </form>
      </Card>
    </Page>
  );
}

/** The 202 screen for /forgot-password: the backend answers 202 whether or not the address has an account (auth_router.py lines 890 to 898). */
function ForgotPasswordSent(p: Props) {
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="lg" className="text-center">
        <IconDisc tone="success"><CheckCircle className="w-8 h-8" /></IconDisc>
        <Heading level={2} className="mb-3">E-Mail unterwegs.</Heading>
        <Text muted className="mb-1">Falls zu dieser Adresse ein Konto besteht, geht ein Link zum Zurücksetzen an</Text>
        <Text weight="semibold" className="text-brand-purple mb-4 break-all">{p.email}</Text>
        <div className="rounded-2xl p-4 mb-6 bg-warn-bg border border-warn/20">
          <Text size="sm" muted>Der Link ist 6 Stunden gültig. Prüfe auch deinen Spam-Ordner.</Text>
        </div>
        <Button variant="ghost">
          <ArrowLeft className="w-4 h-4" />
          Zurück zum Login
        </Button>
      </Card>
    </Page>
  );
}

function Requirement({ ok, children }: { ok: boolean; children: React.ReactNode }) {
  return (
    <Text as="span" size="xs" className={`block ${ok ? 'text-success-dark' : 'text-ink-40'}`}>
      {ok ? '✓' : '•'} {children}
    </Text>
  );
}

/** ResetPassword.tsx lines 158 onward: new password with the five-part strength meter, confirmation, submit. */
function PasswordForm({ title, lead, submitLabel, p }: { title: string; lead: string; submitLabel: string; p: Props }) {
  const pwd = p.password ?? '';
  const s = passwordStrength(pwd);
  const bar = s.score <= 2 ? 'bg-danger' : s.score <= 4 ? 'bg-warn' : 'bg-success';
  const failure = failureCopy(p.failure, 'reset');
  const mismatch = !!p.confirmPassword && p.confirmPassword !== pwd;
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="lg">
        <div className="text-center mb-8">
          <IconDisc tone="purple"><Lock className="w-8 h-8" /></IconDisc>
          <Heading level={2} className="mb-2">{title}</Heading>
          <Text muted>{lead}</Text>
        </div>
        {failure && <Notice tone="error">{failure}</Notice>}
        {mismatch && <Notice tone="error">Die Passwörter stimmen nicht überein</Notice>}
        <form onSubmit={(e) => e.preventDefault()}>
          <Field
            label="Neues Passwort"
            icon={<Lock className="w-5 h-5" />}
            type={p.showPassword ? 'text' : 'password'}
            value={pwd}
            placeholder="Passwort eingeben"
            autoComplete="new-password"
            trailing={
              <button type="button" aria-label={p.showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} className="text-ink-40 hover:text-ink transition-colors">
                {p.showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
              </button>
            }
          />
          {pwd && (
            <div className="mb-5 px-1">
              <div className="flex items-center justify-between mb-1">
                <Text size="xs" muted>Passwortstärke</Text>
                <Text size="xs" muted>{s.score} von 5</Text>
              </div>
              <div className="h-1.5 rounded-full bg-line overflow-hidden">
                <div className={`h-full rounded-full ${bar}`} style={{ width: `${(s.score / 5) * 100}%` }} />
              </div>
              <div className="mt-2 grid grid-cols-2 gap-x-4">
                <Requirement ok={s.requirements.length}>Mindestens 8 Zeichen</Requirement>
                <Requirement ok={s.requirements.uppercase}>Ein Grossbuchstabe</Requirement>
                <Requirement ok={s.requirements.lowercase}>Ein Kleinbuchstabe</Requirement>
                <Requirement ok={s.requirements.number}>Eine Zahl</Requirement>
                <Requirement ok={s.requirements.special}>Ein Sonderzeichen</Requirement>
              </div>
            </div>
          )}
          <Field label="Passwort bestätigen" icon={<Lock className="w-5 h-5" />} type={p.showPassword ? 'text' : 'password'} value={p.confirmPassword ?? ''} placeholder="Passwort bestätigen" autoComplete="new-password" />
          <Button type="submit" variant="primary" size="lg" fullWidth loading={!!p.submitting} disabled={!!p.submitting || s.score < 5 || !p.confirmPassword || mismatch}>
            {submitLabel}
            <ArrowRight className="w-4 h-4" />
          </Button>
        </form>
        <div className="text-center mt-6">
          <a href="#login" className="text-sm text-brand-purple font-medium">Zurück zum Login</a>
        </div>
      </Card>
    </Page>
  );
}

function ResetDone(p: Props) {
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="xl" className="text-center">
        <IconDisc tone="success"><CheckCircle className="w-8 h-8" /></IconDisc>
        <Heading level={2} className="mb-2">Passwort zurückgesetzt.</Heading>
        <Text muted className="mb-6">Alle bisherigen Sitzungen wurden beendet. Melde dich mit dem neuen Passwort an.</Text>
        <Button variant="primary" size="lg">
          Zum Login
          <ArrowRight className="w-4 h-4" />
        </Button>
      </Card>
    </Page>
  );
}

/* ---- /verify-email and /verify-code -------------------------------------- */

/** EmailVerification.tsx lines 320 onward: no token in the URL, so the page offers a resend with a 60 s cooldown. */
function VerifyEmail(p: Props) {
  const cooldown = p.resendCooldown ?? 0;
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="lg" className="text-center">
        <IconDisc tone="purple"><Mail className="w-8 h-8" /></IconDisc>
        <Heading level={2} className="mb-2">Prüfe deine E-Mails</Heading>
        <Text muted className="mb-6">Wir haben dir eine Bestätigungs-E-Mail gesendet. Bitte prüfe dein Postfach und klicke auf den Bestätigungslink.</Text>
        <Notice tone="info">
          <strong>E-Mail nicht erhalten?</strong> Prüfe deinen Spam-Ordner oder fordere unten eine neue Bestätigungs-E-Mail an.
        </Notice>
        <form onSubmit={(e) => e.preventDefault()} className="text-left">
          <Field label="Deine E-Mail-Adresse" icon={<Mail className="w-5 h-5" />} type="email" value={p.email ?? ''} placeholder="[email protected]" autoComplete="username" />
          <Button type="submit" variant="primary" size="lg" fullWidth disabled={cooldown > 0 || !p.email} className="mb-4">
            {cooldown > 0 ? `Erneut senden in ${cooldown} s` : 'Bestätigungs-E-Mail erneut senden'}
          </Button>
          {p.message && <Notice tone={p.message.tone}>{p.message.text}</Notice>}
        </form>
        <a href="#login" className="inline-block text-brand-purple text-sm font-medium">Zurück zum Login</a>
      </Card>
    </Page>
  );
}

/** VerifyCode.tsx lines 170 onward: six one-character inputs, auto-advance, paste fills all six. The lg: branding column of the page is not rendered. */
function VerifyCode(p: Props) {
  const code = p.code ?? ['', '', '', '', '', ''];
  const complete = code.every((d) => d !== '');
  return (
    <Page brandWord={p.brandWord}>
      <Card variant="glass" padding="xl">
        <div className="text-center mb-8">
          <IconDisc tone="purple"><Mail className="w-8 h-8" /></IconDisc>
          <Heading level={2} className="mb-2">Prüfe deine E-Mails</Heading>
          <Text muted>Gib den 6-stelligen Code ein, den wir gesendet haben an</Text>
          <Text weight="medium" className="mt-1">{p.email}</Text>
        </div>
        <div className="mb-8">
          <div className="flex gap-3 justify-center">
            {code.map((digit, i) => (
              <input
                key={i}
                type="text"
                inputMode="numeric"
                maxLength={1}
                defaultValue={digit}
                aria-label={`Ziffer ${i + 1}`}
                className="w-12 h-14 text-center text-2xl font-bold border-2 border-line rounded-xl bg-white text-ink focus:border-brand-purple focus:ring-2 focus:ring-brand-purple/20 focus:outline-none transition-all"
              />
            ))}
          </div>
          <Text size="sm" muted className="text-center mt-4">Code läuft in 15 Minuten ab</Text>
        </div>
        <Button variant="primary" size="lg" fullWidth disabled={!complete}>
          Weiter
          <ArrowRight className="w-5 h-5" />
        </Button>
        <div className="mt-6 text-center">
          <Text size="sm" muted className="mb-2">Code nicht erhalten?</Text>
          <Button variant="ghost" size="sm">Code erneut senden</Button>
        </div>
      </Card>
    </Page>
  );
}

/* ---- the demo ------------------------------------------------------------ */

export default function Demo(props: Props) {
  switch (props.screen) {
    case 'login':
      return <Login {...props} />;
    case 'magic-link-sent':
      return <MagicLinkSent {...props} />;
    case 'verification-required':
      return <VerificationRequired {...props} />;
    case 'magic-link-callback':
      return <MagicLinkCallback {...props} />;
    case 'oidc-callback':
      return <OidcCallback />;
    case 'not-permitted':
      return <NotPermitted {...props} />;
    case 'forgot-password':
      return <ForgotPassword {...props} />;
    case 'forgot-password-sent':
      return <ForgotPasswordSent {...props} />;
    case 'reset-password':
      return <PasswordForm title="Passwort zurücksetzen" lead="Gib unten dein neues Passwort ein" submitLabel="Passwort zurücksetzen" p={props} />;
    case 'reset-password-done':
      return <ResetDone {...props} />;
    case 'verify-email':
      return <VerifyEmail {...props} />;
    case 'verify-email-set-password':
      return <PasswordForm title="E-Mail bestätigt!" lead="Lege jetzt dein Passwort fest, um dein Konto zu sichern." submitLabel="Passwort festlegen & fortfahren" p={props} />;
    case 'verify-code':
      return <VerifyCode {...props} />;
    default:
      return <Login {...props} />;
  }
}
Component tree, from the recipe
<Route /login> LoginAlchemy                     App.tsx 240; no <Navigation>
  div.min-h-screen.bg-white (watermark img, four corner marks)
    img logo
    p.eyebrow "Kundenportal" · h1 "Willkommen zurück." · p lead (per mode)
    div.card
      form[magic-link]                         default mode (line 27)
        label E-Mail · input[type=email] (Mail icon)
        div.error?                             one string, whatever failed
        button.submit "Login-Link senden"      POST /api/auth/request-magic-link {email, next?}
        divider "oder"
        div.grid-cols-2|1                      VITE_INFOMANIAK_LOGIN decides the columns
          button "Passkey"                     begin-discoverable -> startAuthentication -> complete-discoverable
          button "Infomaniak"?                 location = <API>/api/auth/infomaniak/start?surface=labs
        button.quiet "Mit Passwort anmelden"
      form[password]
        label E-Mail · input · label Passwort · input[type=password|text] + eye toggle
        div.error?
        button.submit "Anmelden"               useAuth().login -> POST /api/auth/login
        button.quiet "Zurück zum Login-Link"
      footer: Link /forgot-password (password mode) · "Noch kein Konto? Mehr erfahren" -> /
  [magicLinkSent] div.card "Link gesendet." · {email} · "15 Minuten gültig" · "Andere E-Mail verwenden"
  [authLoading || passkeyLoading] spinner only (lines 190 to 196)
<Route /auth/magic-link> MagicLinkCallback     POST /verify-magic-link {token}; loading | success | error
  Text eyebrow "Authentifizierung" · Heading · Card.glass (spinner | check + acting email + progress bar | AlertCircle + error + "Neuen Link anfordern")
<Route /auth/callback> AuthCallback            fragment -> setTokens -> replaceState -> navigate('/'); Spinner + "Anmeldung wird abgeschlossen…"
<Route /forgot-password> ForgotPassword        Key disc · Input email · Button "Link senden" · [success] "E-Mail gesendet!" + "6 Stunden gültig"
<Route /reset-password> ResetPassword          ?token= · Lock disc · Input x2 + strength meter (5 requirements) · [!token] "Ungültiger Reset-Link" · [success] "Passwort erfolgreich zurückgesetzt!"
<Route /verify-email> EmailVerification        ?token= -> POST /verify-email; pending (resend + 60 s cooldown) | verifying | set_password (POST /set-password) | success | error
<Route /verify-code> VerifyCode                six inputs; dead: POST /request-2fa-code is commented out on the backend
<Route /setup-password> SetupPassword          dead: POST /setup-password is commented out on the backend
<Route /register> Navigate -> /login           App.tsx 244
PrivateRoute                                   loading spinner | !user -> /login?redirect=<path> | !hasSurface -> "This account can't be used here" | children
AdminRoute                                     loading | !user -> /login | !isAdmin -> / | children
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 auth-screens/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 nine Labs pages read useAuth, useSearchParams, fetch and localStorage; the rebuild lifts each screen into a presentational component that takes the screen and its state as props and the network outcome as a Failure, so a state is a literal and the frame never reaches the network. helpers.ts beside it holds the pure decisions (readFailure, failureCopy, oidcErrorCopy, signInDestination, safeNext, passwordStrength, isVerificationRequired) and helpers.test.ts pins them.

States, in the order the page renders them: login (magic-link mode, the four ways in with the provider button present), login-password (the quiet mode, both fields filled, the forgot-password link visible), login-submitting (the primary button in its loading state, fields disabled), login-error (the 401 sentence), login-not-permitted (the 403 sentence for a missing grant), login-rate-limited (the 429 with "In 42 Sekunden erneut versuchen" and the button reading "Warten (42 s)"), login-oidc-error (back from the provider with no_account), magic-link-sent (the 202 card naming the typed address), verification-required (the 200 body with no session, which Labs does not render), magic-link-verifying, magic-link-success (with the acting-email line and the desk destination), magic-link-invalid (the 400), oidc-callback (the spinner and one line), not-permitted (PrivateRoute's screen, links to the two surfaces the sample account holds), forgot-password, forgot-password-sent, reset-password (four of five requirements met, button disabled), reset-password-done, verify-email (no token: the resend form with a 47 s cooldown and a success notice), verify-email-set-password (the first password), verify-code (the six inputs, three typed; dead at fd8558f, kept because the tree names it).

Component tree of the rebuild: Page (the centred max-w-md column with an inline mark, the brand word and a fixed-year footer) around Card variant="glass" padding="lg"; inside, Heading levels 1 to 3, Text for leads and labels (as="label"), Field (the Input atom at inputSize="md" with a lucide icon in the left padding and an optional trailing eye toggle), Notice (one box per tone: danger-bg/danger, success-bg/success-dark, brand-purple/10), Button primary size="lg" fullWidth for the one action, outline for the two equal seconds, ghost for the way back, Spinner for the waits, IconDisc for the round icon above a card's title. Tokens used: paper ground, ink, ink-60, ink-40, line, brand-purple, danger, danger-bg, success, success-dark, success-bg, warn-bg, warn; radii rounded-2xl and the atoms' own; no hex literal in the demo.

Responsive: the column is max-w-md and the page pads 24 px, so a 390 px frame shows the same card edge to edge; the split-layout pages (VerifyCode, SetupPassword) hide their branding column below lg, and the rebuild renders the form column only. Empty, error and loading are states above; the login page has no empty state (a blank form is the default).

What the demo deliberately does not render: the watermark image and the four corner marks (agency assets), the framer-motion entrances, the logo files (/logo/AZ_Logo_Primary.svg, /alchemy.png, /logo/infomaniak.png), the lg: branding column of VerifyCode and SetupPassword, the SetupPassword page itself (its backend route is commented out; the set-password form is shown once, on the verify-email screen), the AdminRoute spinner (identical to PrivateRoute's), and any real redirect: links are href="#". Nothing in the frame depends on the clock: the footer year is the literal 2026 and the cooldown is a prop.

Smaller rewrites the frame does show, all deliberate: the e-mail placeholder reads [email protected] where the source writes [email protected] (LoginAlchemy.tsx 290 and 371), because sample data in this kit lives under a fictional domain (AUTHORING.md, the sample-data rule); the Passkey button carries a Key icon that the source's text-only button does not (LoginAlchemy.tsx 327 to 334); the submitting label is "Wird gesendet …" with a space where the source writes "Wird gesendet…" (311); and not-permitted is rendered in de-CH with surface labels ("Dieses Konto kann hier nicht verwendet werden", "Prima Web") where PrivateRoute renders English with the surfaces' hostnames ("This account can't be used here", labs.alchemy.zuerich, PrivateRoute.tsx 27 to 32 and 60 to 62), because the page around it is de-CH and the hostnames are deployment facts rather than screen copy.

Security & tenancy

PrivateRoute: signed in, no grant for this build not-permitted server rendered, hydrated in view

Dieses Konto kann hier nicht verwendet werden

Du bist angemeldet, aber dieses Konto ist nicht für Admin freigeschaltet.

Versuche Prima Web oder Prima Chat.

What the browser may hold: the two session tokens (localStorage in token mode, memory in cookie mode), preferPasskey, the consent flag. Never a key, never a password beyond the controlled input's value, never a magic-link or reset token beyond the URL it arrived in (the pages read ?token= and post it; they do not store it). The OIDC fragment is consumed and removed before navigation.

Origin and CSRF: every route here is anonymous and takes a JSON body, so the CSRF surface is the cookie mode's SameSite=Lax cookies (B2); in token mode nothing is sent automatically. next and ?redirect= are attacker-controlled (a mailed link, a crafted URL) and are constrained to relative paths by safeNext; the backend refuses an absolute next with 422 (LoginAlchemy.tsx 145 to 150). ?oidc_error= is attacker-controlled and is looked up in a fixed map (an unknown code reads as signin_failed). ?token= is passed through to the backend, which is the only party that can judge it.

Surface gating from /me.surfaces: PrivateRoute is a UX gate, not the boundary (its own comment, PrivateRoute.tsx 10 to 26); the backend refuses the same request at the router mount (B4). hasSurface decides between the page and the not-permitted screen; nothing is hidden by CSS. AdminRoute likewise: a non-admin is bounced, the admin routers answer 403 regardless.

Rate limits the pages must render: 30 per minute per IP per auth path and 10 per minute per address on the magic-link request (B18). Enumeration: the sent screens are identical for every address; the login page's 401 and 403 differ by design (a known account that is disabled or ungranted says so), which is the backend's choice and the page follows it.

Tests

  • Colocated: auth-screens/helpers.test.ts (vitest, 16 cases: both 429 shapes and the Retry-After fallback, the de-CH copy per status, the six OIDC codes, the destination rules incl. the protocol-relative refusal, safeNext, the strength score, the verification-required body). Run npx vitest run --config modules/identity-auth/ui/auth-screens/vitest.config.mjs from the repo root: 16 passed (2026-09-12). The root vitest.config.mjs (1350915) includes packages/*/test/** and test/** only, so the folder is named on the command line; adding modules/**/ui/*/*.test.{ts,tsx} to that include is the one-line change that folds it into npm test.
  • The jsdom render check with fetch stubbed: node tooling/verify/render-smoke.mjs identity-auth/ui/auth-screens renders the 21 states with renderToString (all hydrate: visible); it printed ok for every state and 0 failures on 2026-09-12.
  • Labs' own tests at fd8558f: frontend/e2e/auth.spec.ts (125 lines) is the Playwright gate the inventory names, and it cannot pass against this commit (Gotchas): it fills input[name="email"] (17, 58) and no auth page sets name on an input; it visits /register, which redirects to /login (App.tsx 244); its assertions are English regexes over German copy (37, 49, 79, 122). A new app's gate walks: /login magic-link submit shows the sent card; password submit with a wrong password shows the 401 sentence; a 31st submit within a minute shows the wait; /reset-password without a token shows the invalid-link card; a password login with an absolute off-site URL in ?redirect= lands on /dashboard, never on that host.
  • node --test for pure helpers: none needed beyond the vitest file; the helpers import nothing.

Deploy & ops

Ships in the Labs bundle (S2 Deploy & ops): Vite static build, VITE_API_URL, VITE_SURFACE, VITE_USE_MOCK_AUTH=false, VITE_USE_COOKIE_AUTH=false baked at build, plus VITE_INFOMANIAK_LOGIN for the second button. The SPA host answers index.html for every path, so /auth/magic-link, /auth/callback and /reset-password resolve on a cold load (S2). CSP and CORS: the API origin in connect-src (the deployed Caddyfile sets no CSP on purpose, deploy/infomaniak/Caddyfile.staging 251 to 266 at b1089b6 per S2), the SPA origin in the API's CORS allow-list, and the OIDC provider's redirect URI registered as the API's /api/auth/infomaniak/callback (B8). The backend must be deployed with FRONTEND_URL pointing at this bundle in development so a magic link lands on the right origin (auth_router.py 1093 to 1095; in production the surface's own origin is used). Rollback is the bundle's; no migration, no scheduler job.

Gotchas

200 verification_required: a body with no session verification-required server rendered, hydrated in view
Acme

Bitte zuerst bestätigen.

Dieses Konto ist noch nicht bestätigt. Wir haben einen neuen Bestätigungslink geschickt an

[email protected]

Öffne den Link in der E-Mail, lege ein Passwort fest und melde dich dann an.

ACME © 2026

  • 2026-09-12 (measured at fd8558f): the 200 verification_required body is not handled anywhere in frontend/src (grep -rn verification_required src prints nothing). AuthContext.login (187 to 209) destructures access_token off it, gets undefined, and storeTokens writes the string "undefined" into localStorage.accessToken; the user object is set from the body's user, the app renders as signed in, the next request is 401, the refresh with "undefined" fails, and the user is sent back to /login with no sentence. Fix: check isVerificationRequired(body) before storing and render the verification-required screen (the demo's state); the same on MagicLinkCallback (60 to 64 store first, too).
  • 2026-09-12 (measured at fd8558f): ?redirect= is navigated to unvalidated on three paths: after a password login (LoginAlchemy.tsx 178 to 179, window.location.href = redirectUrl), after a passkey login (82, 127, location.replace), and for an already signed-in visitor (94 to 95). Only the magic-link path constrains it (150), so an absolute value in that parameter is followed off-site: the deployed login page is an open redirect for a signed-in visitor. Fix: safeNext on every read of the parameter. Publication condition, dated 2026-09-12: this row names an unpatched weakness of a surface that is live, and the docs build puts every recipe on a public origin with no gate (prima.li 86fe9d1, src/site.ts 23 to 29 selects the index origin; nothing under src/index-site/ sets noindex or an access check), so it is written as a description of the three call sites and carries no crafted URL to paste. A worked example goes back in once safeNext has landed, or once the index build is access-controlled.
  • 2026-09-12 (measured at fd8558f): no auth page reads Retry-After or the 429 body (grep -rn "Retry-After\|retry_after" src prints nothing). The magic-link path throws one fixed string on any non-2xx (159 to 160), the passkey path reads err.detail off a body that carries error and falls back to "Passkey authentication failed" (119 to 121), the password path shows the envelope's English error string (AuthContext.tsx 213 to 219). Fix: readFailure and failureCopy.
  • 2026-09-12 (measured at fd8558f, rendering trap): LoginAlchemy.tsx uses none of the atoms and carries seven #6642C1 literals (193, 203, 219, 220, 309, 412, 443) that survived commit fd8558f's D1a sweep; #6642C1 is prima.li's purple, not the Labs brand-purple #6844C4 (S1, the open purple question), so the login page and the pages after it render two purples. AuthCallback.tsx 37 has bg-[#f5f1ea], VerifyCode.tsx 131 and SetupPassword.tsx 102 a #5a4db8 gradient stop, EmailVerification.tsx 292 and 359 and ResetPassword.tsx 304 a #6359c7 hover. Fix: the tokens (brand-purple, paper); the rebuild uses them.
  • 2026-09-12 (measured at fd8558f): EmailVerification.tsx decides the notice's colour by message.includes('sent') (337 to 345) while the message is German ("Bestätigungs-E-Mail gesendet!"), so a successful resend renders in the red box; and the set-password branch hides any message containing "bestätigt" (200), which is the success message itself. Fix: a {tone, text} message in state (the demo's message prop).
  • 2026-09-12 (measured at fd8558f): /verify-code and /setup-password are mounted (App.tsx 247 to 248) and dead: POST /api/auth/request-2fa-code and POST /api/auth/setup-password are commented out in auth_router.py (1270 onward, "2FA ENDPOINTS - COMMENTED OUT"), nothing in src/ links to either page, and VerifyCode's "Zurück zur Registrierung" goes to /register, which is Navigate to="/login" (244). A new app ships neither; the demo keeps verify-code as a labelled specimen.
  • 2026-09-12 (measured at fd8558f): e2e/auth.spec.ts predates the current pages (see Tests) and no spec in e2e/ covers /login as built; the inventory's "Playwright gate" for this row does not exist. Fix: the walk-through under Tests, as a spec.
  • 2026-09-12 (measured at fd8558f): three places write the tokens with localStorage.setItem directly instead of setTokens (LoginAlchemy.tsx 79 to 80, 124 to 125; SetupPassword.tsx 63 to 64), so AUTH_CHANGED_EVENT does not fire and WorkspaceContext does not reload; the passkey path then does location.replace, a full reload, which hides it. In cookie mode those three writes are also wrong (the tokens belong in memory). Fix: setTokens.
  • 2026-09-12 (measured at fd8558f): preferPasskey is never cleared by clearAuthStorage (authSession.ts 95 to 108), so a shared browser keeps opening the passkey prompt on /login for whoever comes next, and while the prompt is up the page is a spinner with no form (190 to 196). Fix: clear it at logout, and render the form under the prompt.
  • 2026-09-12 (measured at fd8558f, rendering trap): the magic-link landing and the reset page navigate on timers (1200 ms, 3000 ms, 2000 ms); a verifier screenshot taken after the timer sees the next page. The demo renders the pre-navigation frame.
  • 2026-09-12 (measured at fd8558f, rendering trap): the eyebrows are uppercase tracking-[0.28em] (LoginAlchemy.tsx 264) and uppercase tracking-widest (MagicLinkCallback.tsx 131, ForgotPassword.tsx 165), against S1 rule 14; the rebuild keeps them because it shows the page as it ships. A new app writes sentence case.
  • 2026-09-12 (measured at fd8558f): AuthCallback navigates to / after the OIDC round trip (30), so a ?redirect= that started an OIDC sign-in is lost; the provider button carries no next (339). Fix: pass next through /start and back.

Reference implementation

Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-12), forked from feat/service-desk b1089b6, the deployed line plus the token cleanup. Paths relative to frontend/:

  • src/pages/LoginAlchemy.tsx (460 lines): state (19 to 30), OIDC error map (32 to 45), passkey auto-trigger (47 to 89), the signed-in redirect (91 to 100), handlePasskeyLogin (102 to 137), handleMagicLinkSubmit (139 to 169), handlePasswordSubmit (171 to 188), the spinner (190 to 196), class strings (202 to 204), the sent card (206 to 240), the page (242 to 457: header 262 to 273, magic-link form 278 to 358, password form 359 to 432, links 434 to 447).
  • src/pages/MagicLinkCallback.tsx (286): verify effect (33 to 90), navigate effect (92 to 101), the three cards (152 to 267).
  • src/pages/AuthCallback.tsx (44): the fragment read (18 to 34).
  • src/pages/ForgotPassword.tsx (282): submit (23 to 50), sent card (52 to 135), form (137 to 279).
  • src/pages/ResetPassword.tsx (316): strength (55 to 67), validate (75 to 92), submit (94 to 120), invalid-link card (132 to 148), success card (150 to 172), form (174 onward).
  • src/pages/EmailVerification.tsx (370): verify (43 to 54), set password (56 to 104), resend (106 to 119), the five branches (121 to 368).
  • src/pages/VerifyCode.tsx (278) and src/pages/SetupPassword.tsx (286): dead routes, see Gotchas.
  • src/components/PrivateRoute.tsx (89): SURFACE_HOME (27 to 32), loading (38 to 44), redirect (46 to 54), not permitted (56 to 83). src/components/AdminRoute.tsx (41).
  • src/contexts/AuthContext.tsx (346): User (25 to 45), mount (78 to 116), verifyToken (118 to 154), login (156 to 221), register (223 to 246, no caller in src/pages/), logout (248 to 254), refreshToken (256 to 266), the interceptor (279 to 324), hasSurface (334).
  • src/config/authConfig.ts (131): the two flags (31 to 39), assertAuthConfig (49 to 59), the memory store and accessors (67 to 131). src/config/surface.ts (37).
  • src/services/authSession.ts (153): setTokens (51 to 54), AUTH_CHANGED_EVENT (60 to 67), isAuthPath (78 to 83), clearAuthStorage (95 to 108), single-flight refresh (115 to 153). src/services/authedFetch.ts (44). src/services/api.ts: requestPasswordReset (155 to 160), resetPassword (162 to 168).
  • src/lib/deskLogin.ts: signInDestination (78 to 82).
  • src/App.tsx: the routes (240 to 252).
  • e2e/auth.spec.ts (125): stale, see Tests.

Backend anchors cited above are backend/api/routers/auth_router.py (login 436 onward, _verification_required_response 88 to 114, _require_labs_grant 140 to 152, forgot 879, reset 901, resend 921, verify-email 955, set-password 978, request-magic-link 1007, verify-magic-link 1126), backend/api/routers/webauthn_router.py (232, 274), backend/api/routers/auth_infomaniak_router.py (38, 53, 65 to 131), backend/api/dependencies/rate_limit.py (26 to 34, 38 to 59, 87 to 89), backend/app_fastapi.py (725 to 786).

Deviations of the reference from this recipe, all dated 2026-09-12: the twelve Gotchas above; LoginAlchemy built without the atoms; the eyebrow uppercase; three direct token writes; no de-CH failure copy; no Playwright spec. Deviations of the rebuild from the reference, same date: the not-permitted screen is translated into de-CH and names surface labels where Labs names hostnames; the two "sent" screens are reworded to the conditional form, and forgot-password-sent's heading reads "E-Mail unterwegs." where the source reads "E-Mail gesendet!" (ForgotPassword.tsx 91); plus the placeholder, the Passkey icon and the ellipsis spacing named under Frontend. For a customer build: "Kundenportal" and the brand word are the two strings to replace.

Acceptance checks

From alchemy_labs/frontend at fd8558f, or a new app built to this recipe; expected observations are exact.

  1. grep -c "#6642C1" src/pages/LoginAlchemy.tsx prints 7 at fd8558f and 0 on a build to this recipe; grep -rn "verification_required" src | wc -l prints 0 at fd8558f and at least 2 on a build to this recipe (the check and the screen).
  2. grep -n "redirectUrl" src/pages/LoginAlchemy.tsx shows the parameter navigated to at lines 82, 95, 127 and 179 without a relative-path test; on a build to this recipe every navigation to it goes through safeNext.
  3. Browser walk-through, signed out, tour closed: (a) open /login, observe the eyebrow, the heading "Willkommen zurück.", the e-mail field, "Login-Link senden", the "oder" divider, "Passkey" and (with VITE_INFOMANIAK_LOGIN=true) "Infomaniak", and "Mit Passwort anmelden"; (b) submit an address that has no account, observe "Link gesendet." naming that address, then submit a seeded address, observe the identical screen; (c) click "Mit Passwort anmelden", submit the seeded owner with a wrong password, observe the 401 sentence under the fields; submit 31 times within a minute, observe the wait sentence naming the seconds and the button disabled; (d) submit the right password, observe /dashboard; (e) open /login while signed in with an absolute off-site URL in ?redirect=, observe /dashboard, not that host; (f) open /forgot-password, submit, observe the sent card with "6 Stunden gültig"; open /reset-password without a token, observe the invalid-link card; (g) open /auth/magic-link?token=x, observe the error card and "Neuen Link anfordern"; (h) sign in as an account granted prima_web only, open /dashboard, observe the not-permitted screen with a link to the surface it does hold.
  4. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs identity-auth/ui/auth-screens prints ok for the 21 states and 0 failures; npx vitest run --config modules/identity-auth/ui/auth-screens/vitest.config.mjs prints 16 passed.
  5. Docs host: PRIMA_MODULES_DIR=<checkout> npm run build:index in prima.li (branch w8b-content-source), serve dist/, open /docs/modules/identity-auth/ui/auth-screens/: 21 figure[data-demo-state] frames, each .demo-stage non-empty after hydration, zero console errors, zero page errors (measured 2026-09-12 at prima.li 86fe9d1).
  6. Verifier: node tooling/verify/compare.mjs identity-auth/ui/auth-screens --state login --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/anon.json (the Labs harness up, verify.route: /login, session: anon, selector: body, because /login mounts outside the shell at App.tsx 240 and no auth page renders a <main>; compare.mjs 216 to 217 exits on a selector that matches nothing) writes a report with page_errors: []. The judged region is therefore the whole page, and the reader excludes by name the watermark image, the four corner marks, the agency logo above the header (the rebuild draws an inline mark and a brand word in its place) and the footer year line, then judges what is left: the eyebrow, the heading, the lead, the card and its controls. The two captures differ in height (min-h-[560px] in the demo against min-h-screen on the page) and compare.mjs diffs the overlap only. Accept a mismatch_ratio under 0.25, because the ground, the purple and the input radii differ by design (the rebuild uses the tokens and the atoms; the source does not).

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.