Authentication
Seven polished auth screens — Login & Register (each in two visual variants), Forgot Password, Reset Password, and Lock Screen — rendered standalone outside the app shell, built from a shared component kit in src/components/auth/.
Overview
The Authentication module ships seven screens on three layout variants:
| Route | Page | AuthLayout variant |
|---|---|---|
/auth/login | Login V1 | split — branded showcase panel + form |
/auth/login-v2 | Login V2 | hero — floating two-pane card over a glow |
/auth/register | Register V1 | split |
/auth/register-v2 | Register V2 | hero |
/auth/forgot-password | Forgot Password | focus — centered card + icon medallion |
/auth/reset-password | Reset Password | focus |
/auth/lock-screen | Lock Screen | focus (avatar of currentUser as the "icon") |
Every screen has real form behavior: touched-on-blur validation with live re-validation, per-field
error messages (danger ring + message via the Field primitive), a card shake on invalid submit
(reduced-motion safe), a loading spinner on the submit button, and a success outcome. Login and
Register share one form component each (LoginView / RegisterView) between their V1 and V2 pages —
the variants differ only in the surrounding AuthLayout.
The submit runs on the server. useAuthSubmit calls a Server Action, which validates the
submitted values with the same pure validators the client uses, then writes an httpOnly session
cookie — invisible to JavaScript, so it cannot be read or forged from the browser. Login/Register
fire a success toast, Forgot/Reset swap to an AnimatePresence success screen (check-your-email /
password-updated), and Lock Screen navigates home. Signing out clears the cookie.
The route guard is env-gated and off by default (AUTH_REQUIRED), because a browsable demo has to
stay browsable. Turn it on and unauthenticated requests are redirected to the login screen before any
JavaScript loads — verifiable with scripting disabled. See
wiring real API auth.
Auth pages render outside AppLayout — no sidebar, header, footer, or chat widgets. Each screen
carries its own top-right utility cluster (AuthTopControls: light/dark toggle + language switcher)
and a corner brand lockup, so the standalone pages still feel like the product.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/auth/LoginPage.tsx | Login V1 — AuthLayout variant="split" + LoginView. |
src/pages/auth/LoginV2Page.tsx | Login V2 — AuthLayout variant="hero" + LoginView bare (its register link points at /auth/register-v2 so the V2 flow stays on V2 pages). |
src/pages/auth/RegisterPage.tsx | Register V1 — split + RegisterView. |
src/pages/auth/RegisterV2Page.tsx | Register V2 — hero + RegisterView bare (login link → /auth/login-v2). |
src/pages/auth/ForgotPasswordPage.tsx | Email form → AnimatePresence "check your email" success screen with a Resend button. |
src/pages/auth/ResetPasswordPage.tsx | New-password form (strength meter) → "password updated" success screen → back-to-login. |
src/pages/auth/LockScreenPage.tsx | Password-only unlock for currentUser (src/data/user.ts); success navigates to /. |
src/components/auth/AuthLayout.tsx | The standalone full-viewport scaffold. Variants: split / hero / focus. Provides the HeroCanvas / ring backdrops, the corner brand lockup, and positions AuthTopControls. |
src/components/auth/AuthCard.tsx | The form container for every screen: optional brand mark or icon medallion, title/subtitle, form, footer — plus the invalid-submit shake (shakeNonce, useAnimationControls, reduced-motion gated). |
src/components/auth/AuthShowcase.tsx | The branded left panel of the split variant: HeroCanvas variant="lines", RotatingWord headline, feature bullets, a glassy testimonial, and an AnimatedNumber trust row. |
src/components/auth/AuthShowcaseAside.tsx | The compact branded pane inside the V2 hero card — a solid primary gradient that auto-follows the active skin. |
src/components/auth/AuthTopControls.tsx | Top-corner theme toggle + LanguageMenu (mirrors the app header controls, since the shell header isn't rendered here). |
src/components/auth/AuthMedallion.tsx | Tinted, ring-haloed icon tile for the focus screens (literal tone → token class map). |
src/components/auth/LoginView.tsx | The shared Login card + form (email, password, remember-me, forgot link, social buttons, lock-screen demo link) used by both V1 and V2. |
src/components/auth/RegisterView.tsx | The shared Register card + form (name, email, password + strength, confirm, terms checkbox, social buttons). |
src/components/auth/PasswordField.tsx | Labelled password input with a show/hide eye toggle and an optional strength meter. |
src/components/auth/PasswordStrength.tsx | Four-segment animated strength meter + label, driven by the pure passwordStrength() helper. |
src/components/auth/SocialButtons.tsx | "Or continue with" divider + Google / GitHub buttons in each brand's official styling. Demo toast on click — no real OAuth. |
src/platform/auth.ts | Shared submit flow (useAuthSubmit) + signOut(). Lives in src/platform/ rather than here because it is the one auth piece that changes when you attach a real backend — see Architecture. |
src/components/auth/validators.ts | Pure validators (validateLogin / validateRegister / validateForgot / validateReset / validateLock, isEmail, passwordStrength). Errors are i18n message keys in the auth namespace. |
src/components/auth/index.ts | Barrel — everything above is importable from @/components/auth. |
src/app/[locale]/(auth)/ | The auth route group — its own bare layout, so these screens never mount the app shell. |
src/app/actions/auth.ts | The Server Actions behind each form (sign-in, register, forgot, reset, unlock) and sign-out. |
src/lib/session.ts | Reads and writes the httpOnly session cookie — the only place the session is touched. |
src/locales/en/auth.json | The auth i18n namespace (labels, placeholders, toasts, showcase copy, error messages). Japanese ships at parity. |
Routing model — standalone, outside the shell
Auth screens live in the (auth) route group. A group in parentheses shapes the layout without
appearing in the URL, so /auth/login is still /auth/login while getting a bare layout instead of
the app shell:
src/app/[locale]/
├── (app)/ ← sidebar, header, footer, chat widgets
│ └── …
└── (auth)/ ← bare: no shell
├── layout.tsx
├── auth/login/page.tsx
├── auth/register/page.tsx
└── …That separation is structural rather than conventional: there is no way for an auth screen to accidentally inherit the shell, because the shell lives in a sibling group's layout.
The toast host is mounted in the client providers boundary above both groups, so toasts fired on auth pages — and the sign-out toast that survives the redirect to the login screen — work normally.
Validation flow
Validators are pure functions returning a per-field map of i18n keys (the same pattern as
FormValidationPage), resolved via t() only at the display site:
export function validateLogin(v: LoginValues): AuthErrors {
const e: AuthErrors = {}
if (!v.email.trim()) e.email = 'errEmailRequired'
else if (!isEmail(v.email)) e.email = 'errEmailInvalid'
if (!v.password) e.password = 'errPasswordRequired'
return e
}The forms follow one lifecycle: a field validates on blur (marked "touched"), re-validates
live on every change after being touched, and submit validates everything — if errors remain,
the card shakes (shakeNonce increments) and nothing else happens. Password rules: required,
≥ 8 characters (register/reset), confirm must match; register also requires the terms checkbox.
passwordStrength(pw) scores length + character-class diversity into {score: 0–4, labelKey, tone}
(danger/warning/info/success), rendered by PasswordStrength as four animated segments.
Usage
The screens are already wired — open /auth/login (Pages → Authentication in the sidebar).
Each page is a thin composition, e.g.:
import {AuthLayout, LoginView} from '@/components/auth'
export function LoginPage() {
return (
<AuthLayout variant="split">
<LoginView />
</AuthLayout>
)
}To add another auth screen (e.g. a two-factor prompt): create the page under src/pages/auth/,
add a page.tsx for it inside the (auth) group, add the menu leaf in src/data/menu.ts, and
add the nav:<key> label. Build it from the kit — AuthLayout + AuthCard (+ AuthMedallion for
a focus screen) — so it inherits the backdrop, shake, theming, and top controls for free.
API / Props
AuthLayout (AuthLayout.tsx)
| Prop | Type | Description |
|---|---|---|
variant | 'split' | 'hero' | 'focus' | split = branded panel + form (V1). hero = floating two-pane card over a HeroCanvas glow (V2). focus = centered card over concentric rings (forgot / reset / lock). |
tones? | HeroCanvasTone[] | Token keys for the backdrop canvas (default ['primary', 'info']). |
children | ReactNode | The page's form card. |
AuthCard (AuthCard.tsx)
| Prop | Type | Description |
|---|---|---|
title | string | Card headline. |
subtitle? | ReactNode | Muted line under the title. |
children | ReactNode | The form body. |
footer? | ReactNode | Centered footer row (e.g. "Don't have an account?"). |
brand? | boolean | Show the SplashMark brand lockup above the title. |
icon? | ReactNode | Header medallion (focus screens) — centers the title/subtitle when set. |
shakeNonce? | number | Increment to trigger the invalid-submit shake. No-op under reduced motion. |
bare? | boolean | Drop the card chrome (border/bg/shadow/padding/max-width) — used inside the V2 hero card, which supplies its own frame. |
className? | string | Extra classes on the outer wrapper. |
LoginView / RegisterView
| Prop | Type | Description |
|---|---|---|
brand? | boolean | Forwarded to AuthCard (show the brand mark). |
bare? | boolean | Forwarded to AuthCard (frameless, for the V2 hero card). |
registerTo? (Login only) | string | Where "create an account" links (default /auth/register; V2 passes the V2 route). |
loginTo? (Register only) | string | Where "sign in" links (default /auth/login; V2 passes the V2 route). |
PasswordField (PasswordField.tsx)
| Prop | Type | Description |
|---|---|---|
label | string | Field label (already translated). |
value | string | Controlled value. |
onChange | (value) => void | Receives the string value (not the event). |
onBlur? | () => void | Blur hook for the touched/validate flow. |
error? | string | Resolved (translated) error message — renders via Field error. |
invalid? | boolean | Danger ring on the input. |
strength? | boolean | Show the PasswordStrength meter below (Register / Reset). |
placeholder? / autoComplete? / required? / id? | — | Pass-throughs to Input / Field. |
AuthMedallion (AuthMedallion.tsx)
icon: ReactNode, tone?: 'primary' | 'info' | 'success' | 'warning' | 'danger' (default
primary), className?. Tones map to literal token classes (Tailwind only emits literal strings).
useAuthSubmit(delay = 900) — src/platform/auth.ts
Returns {loading, run}. run(onDone, action?, payload?) flips loading on, waits delay ms
(simulated round-trip), flips it off, then calls onDone. action names the screen for analytics;
payload carries the submitted values and is ignored by this implementation — it is there so a
backend-connected version can act on them without touching a single view. The timer is cleaned up on
unmount.
This is the file to replace when you attach a real backend. Because every auth view calls it, and it
already receives the submitted values, you can authenticate inside run and leave all seven screens
untouched. signOut() lives beside it for the same reason.
Validators (validators.ts)
validateLogin(LoginValues), validateRegister(RegisterValues), validateForgot({email}),
validateReset(ResetValues), validateLock({password}) — each returns AuthErrors
(Record<string, string> of field → i18n key). Plus isEmail(v) and
passwordStrength(pw): Strength.
Other pieces
PasswordStrength takes only value: string (renders nothing while empty). SocialButtons,
AuthShowcaseAside, and AuthTopControls take no props; AuthShowcase takes tones.
Configuration & customization
Wiring real API auth later
The session mechanism is already real — a Server Action validates the submission and writes an httpOnly cookie, and the guard redirects on the server. What is stubbed is only the identity check: the action accepts any well-formed credentials instead of asking a user store.
So attaching a backend means editing one function, in src/app/actions/auth.ts:
'use server'
export async function signIn(values: LoginValues) {
const errors = validateLogin(values) // the same pure validators the client runs
if (Object.keys(errors).length) return {errors}
// ── replace this line with your real identity check ──
const user = await db.users.verify(values.email, values.password)
if (!user) return {errors: {password: 'errInvalidCredentials'}}
await createSession(user.email) // src/lib/session.ts — writes the httpOnly cookie
return {ok: true}
}Three properties are worth keeping when you do:
- Validate on the server too. The client validation is for feedback; it is not a control. The action re-runs the same pure validators, so a crafted request cannot skip them.
- Keep the session httpOnly.
src/lib/session.tsis the only place that touches the cookie. Reading it from JavaScript is not possible by design, which is the point. - Turn the guard on. Set
AUTH_REQUIRED=trueand the app routes are protected insrc/proxy.tsbefore rendering — not by a redirect after the page has already loaded.
Because credentials never reach the browser bundle, this edition needs no bearer token in
localStorage, and the API client attaches nothing.
Theming & skins
Everything is token-only — bg-surface, text-foreground, border-border, bg-primary, the
tone tints on AuthMedallion — so all screens follow light/dark and every design skin automatically.
The hero variant's branded aside rides a from-primary to-primary/80 gradient that re-colors with
the skin; the backdrops (HeroCanvas) take tones as token keys, never hex. The one deliberate
exception: SocialButtons uses Google's and GitHub's official fixed brand colours and marks (brand
assets, not themable surfaces). Since the app header isn't rendered, AuthTopControls supplies the
light/dark toggle (useTheme) and the LanguageMenu on every screen.
Responsive behavior
Nothing is hidden on mobile. The split layout is a single column that stacks the showcase panel
above the form (lg:grid-cols-[1fr_34rem], xl widens the form column); the hero card's two
panes stack inside the card (lg:grid-cols-2); focus is centered at every width. Cards are
max-w-md (unless bare).
Motion
The card entrance is FadeIn; success-screen swaps use AnimatePresence mode="wait" with
DURATION.base / EASE_OUT from src/lib/motion.ts — no magic numbers in the pages. The shake,
the HeroCanvas backdrops, and the strength-meter fill are all reduced-motion safe.
Copy & links
All labels, placeholders, toasts, showcase copy, and error messages live in
src/locales/<lng>/auth.json (en + ja seeded); tab titles reuse nav:* keys via
useDocumentTitle. The register form's terms link points at /faq; the login form includes a small
"Lock screen demo" link. Change the split-panel testimonial/features in AuthShowcase.tsx (person
and company names stay literal by convention).
Examples
A custom focus-variant screen (e.g. two-factor code)
import {useState} from 'react'
import {useTranslation} from '@/platform/i18n'
import {ShieldCheck} from 'lucide-react'
import {Button, Field, Input} from '@/components/ui'
import {AuthCard, AuthLayout, AuthMedallion} from '@/components/auth'
import {useAuthSubmit} from '@/platform/auth'
export function TwoFactorPage() {
const {t} = useTranslation('auth')
const {loading, run} = useAuthSubmit()
const [code, setCode] = useState('')
const [shake, setShake] = useState(0)
function submit(e: React.FormEvent) {
e.preventDefault()
if (code.length !== 6) return setShake((n) => n + 1)
run(() => {
/* verify + redirect */
})
}
return (
<AuthLayout variant="focus">
<AuthCard icon={<AuthMedallion icon={<ShieldCheck />} tone="info" />} shakeNonce={shake} title={t('twoFactorTitle')}>
<form onSubmit={submit} noValidate className="space-y-4">
<Field label={t('twoFactorCode')} htmlFor="tf-code">
<Input id="tf-code" inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} />
</Field>
<Button type="submit" variant="gradient" className="w-full" loading={loading}>
{t('verifyBtn')}
</Button>
</form>
</AuthCard>
</AuthLayout>
)
}Then register it:
add (auth)/auth/two-factor/page.tsx rendering <TwoFactorPage />, a menu
leaf in src/data/menu.ts, and the new auth:* / nav:* keys.
Reuse the login form in a different frame
import {AuthLayout, LoginView} from '@/components/auth'
// A third login variant on the focus backdrop — the form logic comes along unchanged.
export function LoginV3Page() {
return (
<AuthLayout variant="focus">
<LoginView brand />
</AuthLayout>
)
}Best practices
- Keep validators pure and key-returning. They run outside React and return
auth:i18n keys; resolve witht()only where the message is rendered. - Keep auth screens inside the
(auth)group. A page placed in(app)instead would render within the shell (sidebar + header) — the groups exist to make that mistake structurally impossible. - Never trust the client's validation. The Server Action re-runs the same pure validators; keep it that way when you attach a real user store.
- Compose from the kit. New screens should be
AuthLayout+AuthCard(+PasswordField/SocialButtons/AuthMedallion) so shake, theming, top controls, and responsive behavior stay consistent. - Tokens only. The
SocialButtonsbrand colours are the sole sanctioned hex exception; every other surface must use semantic tokens so skins and dark mode hold. - When you wire a backend, keep
useAuthSubmit's shape (src/platform/auth.ts) and edit the Server Action instead. The UI states (loading / error / success), the session cookie and the guard are already built — only the identity check is yours to supply.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Signing in succeeds but the app still redirects to login | The guard is reading no session. Confirm the action reached createSession and that the cookie is being set on the response. |
| Every page redirects to login unexpectedly | AUTH_REQUIRED=true is set. Unset it (or set false) to browse freely — it is off by default. |
| An auth page renders inside the sidebar/header shell | Its page.tsx is in the (app) group. Move it under (auth), which has the bare layout. |
| Reading the session from JavaScript returns nothing | Correct — the cookie is httpOnly by design. Read it on the server via src/lib/session.ts. |
| The card doesn't shake on a failed submit | shakeNonce must increment each time (setShake((n) => n + 1)); and the shake is intentionally disabled under prefers-reduced-motion. |
Error messages show raw keys like errEmailRequired | The key wasn't resolved — render via t(errors.field) with the auth namespace, or the key is missing from src/locales/<lng>/auth.json. |
| The strength meter never appears | It renders only when strength is set on PasswordField and the value is non-empty. |
| Social buttons don't start OAuth | They are presentational (demo toast). Real OAuth is an integration task alongside the API wiring. |
| Dark mode toggle on an auth page looks different from the app | It's the same useTheme store — but AuthTopControls is a simple light/dark toggle; the full Light/Dark/System control lives in the Customizer inside the shell. |
FAQ
Why a separate (auth) route group? Everything in (app) inherits the shell from that group's
layout — sidebar, header, footer, widgets. Auth screens are standalone full-viewport pages, so they sit
in a sibling group with a bare layout. The parentheses keep the group out of the URL, so the paths are
unchanged.
What's the difference between V1 and V2? Only the frame. V1 (split) is a full-height branded
showcase beside the form; V2 (hero) is a floating two-pane card over an animated glow. Both render
the same LoginView / RegisterView — V2 passes bare (the card supplies its own chrome) and
cross-links to the other V2 page so the flow stays consistent.
Whose avatar is on the Lock Screen? The mock currentUser from src/data/user.ts — the same
identity as the header profile menu and sidebar user card.
Is "Remember me" functional? It's local UI state only (checked by default) — persist it when you wire real auth.
Does the language/theme choice on an auth page carry into the app? Yes — both use the shared
stores (useTheme, useLanguage), so preferences survive across the auth/shell boundary.
Where is the route guard? In src/proxy.ts, composed with the locale middleware so the locale is
resolved first and preserved in the redirect. It runs before rendering, so an unauthenticated visitor
never receives the protected page at all — not even briefly. It is gated on AUTH_REQUIRED and off by
default.
Is the demo login checking a password? No. It validates the shape of the submission and then creates a session for any well-formed credentials. That is the one deliberately stubbed piece; see wiring real API auth.
Notes for designers & content editors
- All copy — titles, placeholders, buttons, error messages, toasts, and the showcase panel's
headline/features/testimonial — lives in
src/locales/<lng>/auth.json. Edit there, never in components. Person/company names in the testimonial stay literal. - Colors are semantic tokens; switching the skin re-colors the backdrops, the V2 gradient aside, medallions, and the strength meter automatically. The Google/GitHub buttons keep their official brand colours on purpose.
- The brand lockup comes from
src/config/brand.ts(nameLead/nameAccent) — change the product name there once and every auth screen follows. - Motion (card entrance, success-screen swap, shake, rotating headline word) uses the shared
tokens in
src/lib/motion.tsand is reduced-motion safe — no per-page tuning needed.
Related
Architecture & Routing
how routes are registered, how auth screens stay outside the shell, the Placeholder fallback, and persistence & Reset
Forms
the Field / Input / Checkbox primitives and the shared validation pattern the auth forms mirror
Core & Feedback
Button (gradient, loading), Avatar, and the root-mounted Toaster
Animation & Effects
HeroCanvas, FadeIn, RotatingWord, AnimatedNumber, and the motion tokens
Utilities & API Client
src/lib/api.ts and ApiError
Internationalization
namespaces and adding keys to auth.json
Was this page helpful?
