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 |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Version | 1 |
Purpose
login server rendered, hydrated in view Kundenportal
Willkommen zurück.
Vier Wege hinein. Alle führen zum selben Konto.
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):
| Key | Store | Written by | Cleared by | Lifetime |
|---|---|---|---|---|
accessToken, refreshToken | localStorage | storeTokens (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 likewise | clearAuthStorage (authSession.ts 95 to 108) | until logout or a failed refresh |
preferPasskey | localStorage | "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_consent | localStorage | ConsentProvider (S2) | never | permanent |
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).
| Route | Page | Body | Outcomes rendered |
|---|---|---|---|
POST /api/auth/request-magic-link | LoginAlchemy 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-link | MagicLinkCallback | {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/login | LoginAlchemy 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-discoverable | LoginAlchemy passkey button and the preferPasskey auto-trigger | none, 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=labs | LoginAlchemy second button | full-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-password | ForgotPassword | {email} | 202 {message} for every address (879 to 898) renders forgot-password-sent; 429 |
POST /api/auth/reset-password | ResetPassword | {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-email | EmailVerification 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-password | EmailVerification set_password | {token, password} | 204 renders the success card then /login after 2 s; 400 same string |
POST /api/auth/resend-verification | EmailVerification pending and error | {email} | 202 for every address (921 onward); 404 when verification is off; a 60 s client cooldown (113) |
GET /api/auth/me | AuthContext.verifyToken on mount and after a magic link | Bearer | 200 UserResponse with surfaces (B2); 401 clears storage (150) |
POST /api/auth/refresh | the 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.
/loginmounts.AuthContextchecksgetAccessToken(); with a token it calls/me(loadingtrue, the page is a spinner,LoginAlchemy.tsx190 to 196); with a user it leaves at once, to?redirect=when present, else/dashboard(91 to 100). Without a user and withlocalStorage.preferPasskey === "true", the passkey prompt opens by itself (48 to 89); a cancel falls through to the form.- Magic-link mode (default). Typing clears the error (288). Submit posts
{email, next?}and, on 202, rendersmagic-link-sentwith 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. - The user opens the mail and lands on
/auth/magic-link?token=.MagicLinkCallbackposts once (auseRefguards StrictMode's double effect, 33 to 36), stores the body tokens, computesdestinationandactingEmailfrom the body (69 to 76), callsloadUser(), shows the success card for 1200 ms and navigates.nextwins when it is a relative path; a desk-only account goes to/desk; everyone else to/dashboard(deskLogin.ts78 to 82). - Password mode ("Mit Passwort anmelden"). Submit calls
login(email, password); on successAuthContextstores the tokens, sets the axios header, sets the user (156 to 211) and the page navigates (178 to 182). A failure iserror.response.data.error(213) rendered under the fields. - Passkey. Click posts begin, runs
startAuthentication, posts complete, stores the tokens and hard-navigates withlocation.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.tsx18 to 34), or on/login?oidc_error=which the page renders as one of six sentences. - 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/loginfollows after 3 s (ResetPassword.tsx104 to 111). - 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/loginafter 2 s. Without a token, the pending card with the resend form and a 60 s cooldown. - A private route:
PrivateRoutewaits forloading, 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) whenhasSurfaceis false, else the page.AdminRoutesends 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-sentandforgot-password-sentrender 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.tsx223; "Ein Link zum Zurücksetzen wurde an … gesendet.",ForgotPassword.tsx96 to 102); the rebuild makes the sentence conditional ("Falls zu dieser Adresse ein Konto besteht",demo.tsx302 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 beforestoreTokens, 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 asnextand 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-tabAUTH_CHANGED_EVENTfires andWorkspaceContextreloads its list (authSession.ts51 to 67); the three directlocalStorage.setItemsites inLoginAlchemy.tsxandSetupPassword.tsxare deviations. - Signed in is not permitted:
hasSurfaceissurfaces.includes(BUILD_SURFACE)and nothing else (surface.ts35 to 37); a/mewithoutsurfacesopens 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.tsx28 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.
login-password server rendered, hydrated in view Kundenportal
Willkommen zurück.
Melde dich mit deinem Passwort an.
Noch kein Konto? Mehr erfahren
ACME © 2026
login-submitting server rendered, hydrated in view Kundenportal
Willkommen zurück.
Melde dich mit deinem Passwort an.
Noch kein Konto? Mehr erfahren
ACME © 2026
login-error server rendered, hydrated in view Kundenportal
Willkommen zurück.
Melde dich mit deinem Passwort an.
Noch kein Konto? Mehr erfahren
ACME © 2026
login-not-permitted server rendered, hydrated in view Kundenportal
Willkommen zurück.
Melde dich mit deinem Passwort an.
Noch kein Konto? Mehr erfahren
ACME © 2026
login-rate-limited server rendered, hydrated in view Kundenportal
Willkommen zurück.
Vier Wege hinein. Alle führen zum selben Konto.
Noch kein Konto? Mehr erfahren
ACME © 2026
login-oidc-error server rendered, hydrated in view Kundenportal
Willkommen zurück.
Vier Wege hinein. Alle führen zum selben Konto.
Noch kein Konto? Mehr erfahren
ACME © 2026
magic-link-sent server rendered, hydrated in view Link gesendet.
Falls zu dieser Adresse ein Konto besteht, ist ein Login-Link unterwegs an
Der Link ist 15 Minuten gültig. Prüfe auch deinen Spam-Ordner.
ACME © 2026
magic-link-verifying server rendered, hydrated in view Authentifizierung
Das Portal öffnet sich …
Dein Schlüssel wird verifiziert
Einen Moment bitte …
ACME © 2026
magic-link-success server rendered, hydrated in view Authentifizierung
Willkommen zurück
Du wirst weitergeleitet …
Erfolgreich angemeldet
Du bist als [email protected] angemeldet.
Du wirst zum Service Desk weitergeleitet …
ACME © 2026
magic-link-invalid server rendered, hydrated in view Authentifizierung
Authentifizierung fehlgeschlagen
Der Login-Link konnte nicht verifiziert werden
Etwas ist schiefgelaufen
ACME © 2026
oidc-callback server rendered, hydrated in view Anmeldung wird abgeschlossen …
forgot-password server rendered, hydrated in view Passwort
Passwort vergessen?
Kein Problem. Gib deine E-Mail ein und wir senden dir einen Link zum Zurücksetzen.
ACME © 2026
forgot-password-sent server rendered, hydrated in view E-Mail unterwegs.
Falls zu dieser Adresse ein Konto besteht, geht ein Link zum Zurücksetzen an
Der Link ist 6 Stunden gültig. Prüfe auch deinen Spam-Ordner.
ACME © 2026
reset-password server rendered, hydrated in view reset-password-done server rendered, hydrated in view Passwort zurückgesetzt.
Alle bisherigen Sitzungen wurden beendet. Melde dich mit dem neuen Passwort an.
ACME © 2026
verify-email server rendered, hydrated in view 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.
ACME © 2026
verify-email-set-password server rendered, hydrated in view E-Mail bestätigt!
Lege jetzt dein Passwort fest, um dein Konto zu sichern.
ACME © 2026
verify-code server rendered, hydrated in view 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 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
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 theRetry-Afterfallback, 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). Runnpx vitest run --config modules/identity-auth/ui/auth-screens/vitest.config.mjsfrom the repo root:16 passed(2026-09-12). The rootvitest.config.mjs(1350915) includespackages/*/test/**andtest/**only, so the folder is named on the command line; addingmodules/**/ui/*/*.test.{ts,tsx}to that include is the one-line change that folds it intonpm test. - The jsdom render check with fetch stubbed:
node tooling/verify/render-smoke.mjs identity-auth/ui/auth-screensrenders the 21 states withrenderToString(allhydrate: visible); it printedokfor every state and0 failureson 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 fillsinput[name="email"](17, 58) and no auth page setsnameon an input; it visits/register, which redirects to/login(App.tsx244); its assertions are English regexes over German copy (37, 49, 79, 122). A new app's gate walks:/loginmagic-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-passwordwithout 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 --testfor 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
verification-required server rendered, hydrated in view Bitte zuerst bestätigen.
Dieses Konto ist noch nicht bestätigt. Wir haben einen neuen Bestätigungslink geschickt an
Ö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_requiredbody is not handled anywhere infrontend/src(grep -rn verification_required srcprints nothing).AuthContext.login(187 to 209) destructuresaccess_tokenoff it, getsundefined, andstoreTokenswrites the string"undefined"intolocalStorage.accessToken; the user object is set from the body'suser, the app renders as signed in, the next request is 401, the refresh with"undefined"fails, and the user is sent back to/loginwith no sentence. Fix: checkisVerificationRequired(body)before storing and render theverification-requiredscreen (the demo's state); the same onMagicLinkCallback(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.tsx178 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:safeNexton 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.ts23 to 29 selects the index origin; nothing undersrc/index-site/setsnoindexor 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 oncesafeNexthas landed, or once the index build is access-controlled. - 2026-09-12 (measured at fd8558f): no auth page reads
Retry-Afteror the 429 body (grep -rn "Retry-After\|retry_after" srcprints nothing). The magic-link path throws one fixed string on any non-2xx (159 to 160), the passkey path readserr.detailoff a body that carrieserrorand falls back to "Passkey authentication failed" (119 to 121), the password path shows the envelope's Englisherrorstring (AuthContext.tsx213 to 219). Fix:readFailureandfailureCopy. - 2026-09-12 (measured at fd8558f, rendering trap):
LoginAlchemy.tsxuses none of the atoms and carries seven#6642C1literals (193, 203, 219, 220, 309, 412, 443) that survived commit fd8558f's D1a sweep;#6642C1is prima.li's purple, not the Labsbrand-purple#6844C4(S1, the open purple question), so the login page and the pages after it render two purples.AuthCallback.tsx37 hasbg-[#f5f1ea],VerifyCode.tsx131 andSetupPassword.tsx102 a#5a4db8gradient stop,EmailVerification.tsx292 and 359 andResetPassword.tsx304 a#6359c7hover. Fix: the tokens (brand-purple,paper); the rebuild uses them. - 2026-09-12 (measured at fd8558f):
EmailVerification.tsxdecides the notice's colour bymessage.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'smessageprop). - 2026-09-12 (measured at fd8558f):
/verify-codeand/setup-passwordare mounted (App.tsx247 to 248) and dead:POST /api/auth/request-2fa-codeandPOST /api/auth/setup-passwordare commented out inauth_router.py(1270 onward, "2FA ENDPOINTS - COMMENTED OUT"), nothing insrc/links to either page, and VerifyCode's "Zurück zur Registrierung" goes to/register, which isNavigate to="/login"(244). A new app ships neither; the demo keepsverify-codeas a labelled specimen. - 2026-09-12 (measured at fd8558f):
e2e/auth.spec.tspredates the current pages (see Tests) and no spec ine2e/covers/loginas 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.setItemdirectly instead ofsetTokens(LoginAlchemy.tsx79 to 80, 124 to 125;SetupPassword.tsx63 to 64), soAUTH_CHANGED_EVENTdoes not fire andWorkspaceContextdoes not reload; the passkey path then doeslocation.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):
preferPasskeyis never cleared byclearAuthStorage(authSession.ts95 to 108), so a shared browser keeps opening the passkey prompt on/loginfor 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.tsx264) anduppercase tracking-widest(MagicLinkCallback.tsx131,ForgotPassword.tsx165), 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):
AuthCallbacknavigates to/after the OIDC round trip (30), so a?redirect=that started an OIDC sign-in is lost; the provider button carries nonext(339). Fix: passnextthrough/startand 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) andsrc/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 insrc/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.
grep -c "#6642C1" src/pages/LoginAlchemy.tsxprints7at fd8558f and0on a build to this recipe;grep -rn "verification_required" src | wc -lprints0at fd8558f and at least2on a build to this recipe (the check and the screen).grep -n "redirectUrl" src/pages/LoginAlchemy.tsxshows 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 throughsafeNext.- 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 (withVITE_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/loginwhile 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-passwordwithout 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 grantedprima_webonly, open/dashboard, observe the not-permitted screen with a link to the surface it does hold. - Demo gate:
npm run checkprints no line for this recipe;node tooling/verify/render-smoke.mjs identity-auth/ui/auth-screensprintsokfor the 21 states and0 failures;npx vitest run --config modules/identity-auth/ui/auth-screens/vitest.config.mjsprints16 passed. - Docs host:
PRIMA_MODULES_DIR=<checkout> npm run build:indexin prima.li (branchw8b-content-source), servedist/, open/docs/modules/identity-auth/ui/auth-screens/: 21figure[data-demo-state]frames, each.demo-stagenon-empty after hydration, zero console errors, zero page errors (measured 2026-09-12 at prima.li 86fe9d1). - 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/loginmounts outside the shell atApp.tsx240 and no auth page renders a<main>;compare.mjs216 to 217 exits on a selector that matches nothing) writes a report withpage_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 againstmin-h-screenon the page) andcompare.mjsdiffs the overlap only. Accept amismatch_ratiounder 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.