Patterns
Authentication
How people get in — and what the door does while it figures out who you are. The Portal layout owns the room (centered card, nothing extra); this recipe owns the manners: Google goes first, email and password back it up, failures are honest without giving secrets away, and while identity is unresolved you get exactly one of three things — a redirect, a loading state, or a logout. Never the empty state.
One door, five states
One card, five moods. Sign in is home base — Google up top, email and password underneath, one main action. Error keeps the form and stays honest about the pair. Locked takes the password path away and leaves the rest alone. Authenticating swaps the form out — once you’ve hit go, there’s nothing left to type. Expired is the trip back: the door again, with a friendly note saying why.
SSO first
Two ways in, and the order is on purpose. Google goes first — the colorful mark, a little lift, first in the code — because it’s the path with no password to forget, leak, or lock yourself out of. Email and password sit below the divider as the quieter backup. One screen, one main action: the backup button never shouts over the lead.
- google sso
- The lead. The vendor mark keeps its required four colors — never recolored, the one deliberate exception to the currentColor icon rule (ADR-0010) — and the wording follows Google’s guidelines. First in source order, so it leads for keyboard and screen-reader users too.
- email + password
- The fallback, visually quieter (an outlined neutral submit, below the divider). The forgot-password link sits at the field — where the failure happens — not in a footer.
- one primary action
- No competing CTAs. Recovery and any sign-up affordance are links, not buttons — the screen does one job per the auth-screen anatomy.
Errors and lockout
- The pair failed, not a field. A credential error never says which half was wrong — “that email and password don’t match” — because “wrong password” confirms the account exists. Both fields take the invalid frame; the message is form-level, adjacent to the action it blocks.
- Action-oriented, per forms. The message names the way out — try again, or reset your password — following the forms pattern: adjacent to the field group, specific, never a toast.
- Failures count. Repeated password failures lock the password path for a fixed window. The lockout state says how long and is announced (role=status) — silent disablement is a dead end.
- Lockout gates one path, not the door. Google sign-in and password reset stay live; the lockout withdraws only the credential form it protects.
The AuthContext contract
A page that needs to know who you are has exactly three in-between states, and each one gets exactly one look. The contract matters because every slip looks like a bug: a flash of empty UI tells a compliance officer their work is gone.
- unauthenticated
- Redirect to the Portal — immediately, preserving the return path. Never render the page shell “empty” while deciding.
- authenticating
- The loading state. The session check is in flight; render loading chrome — not a flash of the sign-in form, and not a skeleton of data the user may not own.
- 401 mid-session
- Logout — clear the client session and land at the door with the expired notice. A 401 is the server saying the session is over; treating it as a data error retries forever.
- never
- The empty-state UI. An empty state means “you have no data yet”; an unresolved identity means “we don’t know who you are.” Conflating them shows real users a false zero.
function RequireAuth({ children }: { children: ReactNode }) {
const { status } = useAuthContext()
if (status === 'unauthenticated') {
// preserve the return path; the door sends them back after
return <Navigate to="/sign-in" search={{ from: location.pathname }} />
}
if (status === 'authenticating') {
return <PageLoading /> // loading chrome — never the empty state
}
return children // authenticated — the page renders
}
// …and in the API client: a 401 is a logout, not a data error
onResponse(401, () => auth.logout({ reason: 'expired' }))Session expiry
- Land at the door with a reason. An expired session returns the user to the Portal with an info notice naming what happened — “Session expired” — so the sign-in form doesn’t read as an error or a crash.
- Preserve the return path. Carry the pre-expiry location through the round trip; after re-auth the user lands where they were, not at a dashboard.
- Staged work is the casualty to design for. Under the commit doctrine, edits stage locally until an intent commits them — an expiry-triggered logout must not silently discard a staged set. Warn before the session dies, or persist the stage across the round trip; the recipe for which is still a gap, named below.
Accessibility
- One labelled form. The door card is a form named by its visible heading (aria-labelledby); fields use real label elements and the right autocomplete tokens (email, current-password), so password managers and assistive tech both work.
- Errors are announced where they live. The credential error is a role=alert adjacent to the action it blocks, referenced via aria-describedby from both fields; aria-invalid marks the pair.
- State changes speak. Authenticating and locked are role=status live regions — a screen-reader user hears the door change state without hunting for it.
- Severity is never color alone. The credential error pairs its tone with the circle-x glyph, the lockout pairs with the lock glyph, and the expiry notice carries the info glyph (ADR-0017).
- Motion respects the preference. The authenticating bar stops sweeping under prefers-reduced-motion and holds still; the visible text carries the state on its own.
Gaps
- No MFA recipe. Second-factor entry, recovery codes, and device trust wait until the product grows the capability — this page won’t describe a flow that doesn’t exist.
- No sign-up or recovery screens. The forgot-password link opens a recovery flow this recipe doesn’t cover yet. The anatomy (one task, one primary action) will hold when it lands.
- No staged-work preservation across expiry. The expiry section names the requirement; the mechanism — a pre-expiry warning versus a persisted stage — is undecided.
- Static mock, not the wired pattern. The demo shows the states, not the state machine. The AuthContext is documented here as a contract; the client portal is the reference implementation.
Anti-patterns
- “Wrong password.” Confirms the account exists — an enumeration gift. The error names the pair, never the half.
- The empty-state flash. Rendering the app shell with empty data while the session check runs teaches users their work vanished. Unresolved identity renders loading, nothing else.
- A spinner with no exit. An authenticating state that can hang must resolve to an error with a way out — retry, or back to the door — never spin forever.
- Blocking paste in password fields. It defeats password managers — the single best credential hygiene a user can have.
- The surprise logout. Expiry that drops the user at a bare sign-in form with no notice reads as a crash. Name what happened, and bring them back to where they were.
Related
- /portal — the door’s layout: the geography, the splash, the ambient corners. This page is what happens inside it.
- /form — error placement and action-oriented validation language; the credential error follows it.
- /field-patterns — EmailField and PasswordField encode the right types, autocomplete tokens, and the reveal affordance for the wired version.
- /error-states — the fallback patterns for everything that isn’t an identity question.
- /loading — the chrome-level loading channel the authenticating state feeds once you’re through the door.