Authentication
Seven standalone auth screens — Login and Register in two variants each, plus Forgot Password, Reset Password, and Lock Screen — built from a shared component kit.
The Authentication module ships seven screens on three layout variants, rendered standalone
outside the app shell and built from a shared kit in src/components/auth/. Validation, loading
states, and success screens are fully wired; the submit itself is presentational (no backend
call), with a clear integration point for real API auth.
Overview
| 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 is presentational
useAuthSubmit simulates a ~900 ms network round-trip, then: Login/Register fire a success toast,
Forgot/Reset swap to an AnimatePresence success screen (check-your-email / password-updated), and
Lock Screen navigates home. No token is written and no redirect happens on login/register — route
guards and live auth are a roadmap phase.
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.
The three variants
A full-height branded showcase (AuthShowcase: HeroCanvas variant="lines", a RotatingWord
headline, feature bullets, a glassy testimonial, and an AnimatedNumber trust row) beside the
form. Used by Login V1 and Register V1.
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 (register link → /auth/register-v2). |
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 backdrops, brand lockup, and top controls. |
src/components/auth/AuthCard.tsx | The form container: optional brand mark / icon medallion, title/subtitle, form, footer — plus the invalid-submit shake. |
src/components/auth/AuthShowcase.tsx | The branded left panel of split: HeroCanvas variant="lines", RotatingWord, feature bullets, testimonial, AnimatedNumber. |
src/components/auth/AuthShowcaseAside.tsx | The compact branded pane inside the V2 hero card — a solid primary gradient that follows the skin. |
src/components/auth/AuthTopControls.tsx | Top-corner theme toggle + LanguageMenu (mirrors the app header controls). |
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). |
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/components/auth/useAuthSubmit.ts | Shared presentational submit flow: loading flag + delayed onDone (default 900 ms), cleaned up on unmount. |
src/components/auth/validators.ts | Pure validators — errors are i18n message keys in the auth namespace. |
src/routes.tsx | Exports the authRoutes map (path → element) — deliberately separate from appRoutes. |
src/locales/en/auth.json | The auth i18n namespace. Japanese ships at parity. |
Routing model — standalone, outside the shell
Auth screens are not appRoutes entries. src/routes.tsx exports a plain map:
export const authRoutes: Record<string, ReactNode> = {
'/auth/login': <LoginPage />,
'/auth/login-v2': <LoginV2Page />,
'/auth/register': <RegisterPage />,
'/auth/register-v2': <RegisterV2Page />,
'/auth/forgot-password': <ForgotPasswordPage />,
'/auth/reset-password': <ResetPasswordPage />,
'/auth/lock-screen': <LockScreenPage />,
}App.tsx collects every /auth/* leaf from the sidebar menu and renders it as a sibling of the
<Route element={<AppLayout />}> branch — any /auth/* menu leaf without an authRoutes entry falls
back to a titled <Placeholder>, so menu and router can't drift:
{authLeaves.map((l) => (
<Route key={l.to} path={l.to.slice(1)} element={authRoutes[l.to] ?? <Placeholder titleKey={`nav:${l.key}`} />} />
))}Because <Toaster/> is mounted at the root in main.tsx (not inside AppLayout), toasts fired
on auth pages — and the sign-out toast that survives the redirect to /auth/login — work normally.
Validation flow
Validators are pure functions returning a per-field map of i18n keys, 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}.
Usage
The screens are already wired — open /auth/login (Pages → Authentication in the sidebar). Each
page is a thin composition:
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
its path to authRoutes in src/routes.tsx, add the menu leaf in src/data/menu.ts, and add the
nav:<key> label. Build it from the kit so it inherits the backdrop, shake, theming, and top controls
for free.
API / Props
AuthLayout
| Prop | Type | Description |
|---|---|---|
variant | 'split' | 'hero' | 'focus' | split = branded panel + form (V1). hero = floating two-pane card over a glow (V2). focus = centered card over concentric rings. |
tones? | HeroCanvasTone[] | Token keys for the backdrop canvas (default ['primary', 'info']). |
children | ReactNode | The page's form card. |
AuthCard
| Prop | Type | Description |
|---|---|---|
title | string | Card headline. |
subtitle? | ReactNode | Muted line under the title. |
children | ReactNode | The form body. |
footer? | ReactNode | Centered footer row. |
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 — 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). |
loginTo? (Register only) | string | Where "sign in" links (default /auth/login). |
PasswordField
| 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
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)
Returns {loading, run}. run(onDone) flips loading on, waits delay ms (simulated round-trip),
flips it off, then calls onDone. The timer is cleaned up on unmount. Swap the inside of run's
callback for a real API call when integrating a backend.
Validators
validateLogin, validateRegister, validateForgot, validateReset, validateLock — each returns
AuthErrors (Record<string, string> of field → i18n key). Plus isEmail(v) and
passwordStrength(pw): Strength. PasswordStrength takes only value: string; SocialButtons,
AuthShowcaseAside, and AuthTopControls take no props; AuthShowcase takes tones.
Wiring real API auth later
The demo deliberately writes no token and performs no redirect on login/register. The intended
integration point is the HTTP client in src/lib/api.ts:
API_CONFIG.tokenKeyis'auth_token'— a bearer token stored under thatlocalStoragekey is attached automatically to everyapi.get/post/put/patch/deleterequest.- The profile menu's sign-out (
src/layout/ProfileMenu.tsx) already removesauth_tokenand navigates to/auth/login. auth_tokenis deliberately excluded fromclearAppStorage(), so "Reset to defaults" keeps the user signed in.
A real login replaces the run() callback in LoginView.submit:
run(async () => {
const {token} = await api.post<{token: string}>('/auth/login', values)
localStorage.setItem(API_CONFIG.tokenKey, token)
navigate('/')
})Route guards not included
Route guards are a roadmap phase (see Architecture & Routing).
Until you add them, /auth/* and the shell are both freely reachable. A typical approach is a wrapper
around the <AppLayout> route that checks localStorage.getItem('auth_token') and redirects to
/auth/login.
Configuration & customization
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 HeroCanvas backdrops take tones as token keys, never hex. The one deliberate
exception: SocialButtons uses Google's and GitHub's official fixed brand colours and marks.
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).
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. Person/company names in the testimonial 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, useAuthSubmit} from '@/components/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: authRoutes['/auth/two-factor'] = <TwoFactorPage /> in src/routes.tsx, a menu
leaf in src/data/menu.ts, and the new auth:* / nav:* keys.
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. - Route auth screens through
authRoutes, neverappRoutes. An auth page insideappRouteswould render within the shell — the maps are separate on purpose. - 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. - When you wire a backend, keep
useAuthSubmit's shape. Replace the simulated delay with theapicall, write the token underAPI_CONFIG.tokenKey, and add route guards.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Signing in doesn't redirect or "log in" | By design — the demo submit is presentational (toast only). Wire src/lib/api.ts + auth_token. |
| An auth page renders inside the sidebar/header shell | It was added to appRoutes instead of authRoutes. Move it to the authRoutes map. |
A new /auth/* menu leaf shows a Placeholder | The leaf has no authRoutes entry — the fallback is intentional. Add the path → element mapping. |
| The card doesn't shake on a failed submit | shakeNonce must increment each time; and the shake is 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 the locale file. |
| 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. |
FAQ
Why aren't auth routes in appRoutes? appRoutes entries render inside AppLayout. Auth screens
are standalone full-viewport pages, so they live in the separate authRoutes map.
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.
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), persisted to localStorage.
Related
Architecture & Routing
appRoutes vs authRoutes, the Placeholder fallback, persistence and Reset.
Forms
The Field / Input / Checkbox primitives and the shared validation pattern.
Components
Button (gradient, loading), Avatar, and the root-mounted Toaster.
Design Skins
Why the screens re-theme automatically across every skin.
API Client & Utilities
src/lib/api.ts, ApiError, and the bearer-token convention.
Was this page helpful?
