PVR Tech Studio
Authentication

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.

11 min read
Updated July 15, 2026

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

RoutePageAuthLayout variant
/auth/loginLogin V1split — branded showcase panel + form
/auth/login-v2Login V2hero — floating two-pane card over a glow
/auth/registerRegister V1split
/auth/register-v2Register V2hero
/auth/forgot-passwordForgot Passwordfocus — centered card + icon medallion
/auth/reset-passwordReset Passwordfocus
/auth/lock-screenLock Screenfocus (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.

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

FileResponsibility
src/pages/auth/LoginPage.tsxLogin V1 — AuthLayout variant="split" + LoginView.
src/pages/auth/LoginV2Page.tsxLogin V2 — AuthLayout variant="hero" + LoginView bare (register link → /auth/register-v2).
src/pages/auth/RegisterPage.tsxRegister V1 — split + RegisterView.
src/pages/auth/RegisterV2Page.tsxRegister V2 — hero + RegisterView bare (login link → /auth/login-v2).
src/pages/auth/ForgotPasswordPage.tsxEmail form → AnimatePresence "check your email" success screen with a Resend button.
src/pages/auth/ResetPasswordPage.tsxNew-password form (strength meter) → "password updated" success screen → back-to-login.
src/pages/auth/LockScreenPage.tsxPassword-only unlock for currentUser (src/data/user.ts); success navigates to /.
src/components/auth/AuthLayout.tsxThe standalone full-viewport scaffold. Variants split / hero / focus; provides the backdrops, brand lockup, and top controls.
src/components/auth/AuthCard.tsxThe form container: optional brand mark / icon medallion, title/subtitle, form, footer — plus the invalid-submit shake.
src/components/auth/AuthShowcase.tsxThe branded left panel of split: HeroCanvas variant="lines", RotatingWord, feature bullets, testimonial, AnimatedNumber.
src/components/auth/AuthShowcaseAside.tsxThe compact branded pane inside the V2 hero card — a solid primary gradient that follows the skin.
src/components/auth/AuthTopControls.tsxTop-corner theme toggle + LanguageMenu (mirrors the app header controls).
src/components/auth/AuthMedallion.tsxTinted, ring-haloed icon tile for the focus screens (literal tone → token class map).
src/components/auth/LoginView.tsxThe shared Login card + form (email, password, remember-me, forgot link, social buttons, lock-screen demo link).
src/components/auth/RegisterView.tsxThe shared Register card + form (name, email, password + strength, confirm, terms checkbox, social buttons).
src/components/auth/PasswordField.tsxLabelled password input with a show/hide eye toggle and an optional strength meter.
src/components/auth/PasswordStrength.tsxFour-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.tsShared presentational submit flow: loading flag + delayed onDone (default 900 ms), cleaned up on unmount.
src/components/auth/validators.tsPure validators — errors are i18n message keys in the auth namespace.
src/routes.tsxExports the authRoutes map (path → element) — deliberately separate from appRoutes.
src/locales/en/auth.jsonThe 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

PropTypeDescription
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']).
childrenReactNodeThe page's form card.

AuthCard

PropTypeDescription
titlestringCard headline.
subtitle?ReactNodeMuted line under the title.
childrenReactNodeThe form body.
footer?ReactNodeCentered footer row.
brand?booleanShow the SplashMark brand lockup above the title.
icon?ReactNodeHeader medallion (focus screens) — centers the title/subtitle when set.
shakeNonce?numberIncrement to trigger the invalid-submit shake. No-op under reduced motion.
bare?booleanDrop the card chrome — used inside the V2 hero card, which supplies its own frame.
className?stringExtra classes on the outer wrapper.

LoginView / RegisterView

PropTypeDescription
brand?booleanForwarded to AuthCard (show the brand mark).
bare?booleanForwarded to AuthCard (frameless, for the V2 hero card).
registerTo? (Login only)stringWhere "create an account" links (default /auth/register).
loginTo? (Register only)stringWhere "sign in" links (default /auth/login).

PasswordField

PropTypeDescription
labelstringField label (already translated).
valuestringControlled value.
onChange(value) => voidReceives the string value (not the event).
onBlur?() => voidBlur hook for the touched/validate flow.
error?stringResolved (translated) error message — renders via Field error.
invalid?booleanDanger ring on the input.
strength?booleanShow 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.tokenKey is 'auth_token' — a bearer token stored under that localStorage key is attached automatically to every api.get/post/put/patch/delete request.
  • The profile menu's sign-out (src/layout/ProfileMenu.tsx) already removes auth_token and navigates to /auth/login.
  • auth_token is deliberately excluded from clearAppStorage(), 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('/')
})

Configuration & customization

Theming & skins

Everything is token-onlybg-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).

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 with t() only where the message is rendered.
  • Route auth screens through authRoutes, never appRoutes. An auth page inside appRoutes would 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 SocialButtons brand colours are the sole sanctioned hex exception.
  • When you wire a backend, keep useAuthSubmit's shape. Replace the simulated delay with the api call, write the token under API_CONFIG.tokenKey, and add route guards.

Troubleshooting

SymptomCause / 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 shellIt was added to appRoutes instead of authRoutes. Move it to the authRoutes map.
A new /auth/* menu leaf shows a PlaceholderThe leaf has no authRoutes entry — the fallback is intentional. Add the path → element mapping.
The card doesn't shake on a failed submitshakeNonce must increment each time; and the shake is disabled under prefers-reduced-motion.
Error messages show raw keys like errEmailRequiredThe 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 appearsIt renders only when strength is set on PasswordField and the value is non-empty.
Social buttons don't start OAuthThey 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.

Was this page helpful?