PVR Tech Studio

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/.

14 min read
Updated August 13, 2026

Overview

The Authentication module ships seven screens on three layout variants:

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.

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

FileResponsibility
src/pages/auth/LoginPage.tsxLogin V1 — AuthLayout variant="split" + LoginView.
src/pages/auth/LoginV2Page.tsxLogin 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.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 HeroCanvas / ring backdrops, the corner brand lockup, and positions AuthTopControls.
src/components/auth/AuthCard.tsxThe 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.tsxThe 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.tsxThe compact branded pane inside the V2 hero card — a solid primary gradient that auto-follows the active skin.
src/components/auth/AuthTopControls.tsxTop-corner theme toggle + LanguageMenu (mirrors the app header controls, since the shell header isn't rendered here).
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) used by both V1 and V2.
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/platform/auth.tsShared 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.tsPure validators (validateLogin / validateRegister / validateForgot / validateReset / validateLock, isEmail, passwordStrength). Errors are i18n message keys in the auth namespace.
src/components/auth/index.tsBarrel — 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.tsThe Server Actions behind each form (sign-in, register, forgot, reset, unlock) and sign-out.
src/lib/session.tsReads and writes the httpOnly session cookie — the only place the session is touched.
src/locales/en/auth.jsonThe 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)

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

AuthCard (AuthCard.tsx)

PropTypeDescription
titlestringCard headline.
subtitle?ReactNodeMuted line under the title.
childrenReactNodeThe form body.
footer?ReactNodeCentered footer row (e.g. "Don't have an account?").
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 (border/bg/shadow/padding/max-width) — 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; V2 passes the V2 route).
loginTo? (Register only)stringWhere "sign in" links (default /auth/login; V2 passes the V2 route).

PasswordField (PasswordField.tsx)

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 (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.ts is 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=true and the app routes are protected in src/proxy.ts before 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.

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 with t() 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 SocialButtons brand 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

SymptomCause / fix
Signing in succeeds but the app still redirects to loginThe 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 unexpectedlyAUTH_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 shellIts page.tsx is in the (app) group. Move it under (auth), which has the bare layout.
Reading the session from JavaScript returns nothingCorrect — the cookie is httpOnly by design. Read it on the server via src/lib/session.ts.
The card doesn't shake on a failed submitshakeNonce must increment each time (setShake((n) => n + 1)); and the shake is intentionally 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 src/locales/<lng>/auth.json.
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.
Dark mode toggle on an auth page looks different from the appIt'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.ts and is reduced-motion safe — no per-page tuning needed.

Was this page helpful?