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.

BetaSignInCard is Beta — the door’s anatomy is settled (brand crown · SSO lead · credential fallback · lockout · authenticating); the prop surface may still move.

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.

Authentication states
The real SignInCard — an opaque white card behind a warm hairline under the official brand. The Sign in tab is wired: hit go (or take the Google path) and the door slides to authenticating, then comes back honest — this demo door doesn’t take anyone’s credentials.

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 credential group — 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.

API

SignInCard owns the door’s conduct by construction — SSO-first source order, the pair error, lockout, the authenticating meter — and composes the canon for everything else: the surface is Card, the fields are Form + EmailField/PasswordField, the error is HelperText.

brand
The crown — brand artwork rendered above the title. Required: the door is the most stripped-back surface in the system, and the brand carries its identity. Pass a proposed/official <img> pair with the .brand-artwork-* helpers for brand-pack gating.
sso? · <SignInCard.GoogleButton>
The lead slot, first in source order; the “or” divider renders only when it’s present. GoogleButton is the shipped lead — the vendor mark in its required colors, the chrome wording per Google’s guidelines, an onClick that hands off to the provider.
state? — 'signin' | 'locked' | 'authenticating'
Which face the door shows. locked replaces only the credential form with an announced caution plaque; authenticating replaces the form with the role=status meter — credentials in flight mean nothing is left to edit. Typed values survive the round trip back to signin.
errorMessage?
The form-level credential-pair error. Renders a role=alert HelperText adjacent to the submit, referenced from the form via aria-describedby; both fields take the critical frame and aria-invalid. The copy is yours — name the pair, never the half.
lockTitle? · lockMessage?
The lockout plaque’s heading (chrome default “Too many attempts”) and body. The body should name the window and the ways out — a reset link inside it stays live, as does the SSO lead above.
forgot?
The forgot-password affordance — pass a Link; it renders at the credential group, not in a footer.
onSubmit?({ email, password })
Receives the credential pair; awaited, with the submit disabled while in flight. Move state to authenticating once credentials are accepted into flight; return the pair error via errorMessage when the server says no.
chrome strings
Title, field labels, placeholders, the divider, the submit, the reveal toggle, the lockout title, and the authenticating line ship as chrome strings (EN/ES) registered by the component — overridable via title, lockTitle, and authenticatingLabel where product voice differs.

Usage

A wired doortsx
import { SignInCard, Link } from '@halo-compliance/ui'

function PortalDoor() {
  const auth = useAuth() // your AuthContext — the contract below

  return (
    <SignInCard
      titleAs="h1" // the door IS the page
      brand={<img src="/brand/lockup.svg" alt="Halo Compliance" />}
      sso={<SignInCard.GoogleButton onClick={auth.signInWithGoogle} />}
      forgot={<Link href="/reset-password">Forgot?</Link>}
      state={
        auth.status === 'authenticating' ? 'authenticating'
          : auth.lockedOut ? 'locked' : 'signin'
      }
      errorMessage={auth.credentialError /* the PAIR failed — never which half */}
      lockMessage={
        <>Password sign-in is locked for 15 minutes. Google sign-in still
        works, or you can <Link href="/reset-password">reset your password</Link>.</>
      }
      onSubmit={({ email, password }) => auth.signIn(email, password)}
    />
  )
}

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.
The contract, in a route guardtsx
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); the fields are the canonical EmailField and PasswordField — real label elements, the right autocomplete tokens (email, current-password), and a reveal toggle (aria-pressed) — 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 from the form via aria-describedby; aria-invalid marks both fields of 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.
  • No AuthContext provider shipped. SignInCard is the door’s face; the route-guard state machine documented above is still a contract the consumer implements. 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.
  • /portal — the door’s layout: the geography, the splash, the ambient corners. Its card is this same SignInCard.
  • /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; SignInCard composes them.
  • /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.