Files
spa/.planning/phase-1-foundation/06-login-page.md
T
julian ef18222edf feat: task 1.6 login page
src/ui/pages/login.tsx — LoginPage component:
- Centred card on muted background.
- shadcn Form + FormField with react-hook-form + zodResolver.
  FormMessage renders field-level zod errors automatically.
- Email + password with proper autocomplete attrs for password managers.
  autoFocus on email.
- Submit calls useAuthStore.getState().login(); errors render in a
  destructive Alert above the form.
- In-flight via the auth store's 'authenticating' status (cross-tab safe).
- onAuthenticated callback fires on store transition to authenticated;
  1.7 wires this to a router redirect.

src/App.tsx branches on auth status:
- unknown / authenticating -> "Signing you in..." placeholder
  (avoids flashing the login page on hard refresh while the boot probe
  runs)
- anonymous -> <LoginPage />
- authenticated -> home placeholder (1.7 replaces with router shell)

Deviation: skipped src/routes/login.tsx — requires the router plugin
to have generated routeTree.gen.ts, which is 1.7's territory. The page
works standalone via App.tsx's status branch.

Search-param redirect (?redirect=...) also deferred to 1.7 — no router
yet to expose useSearch. onAuthenticated callback is the seam.
2026-05-02 18:44:57 +02:00

9.2 KiB

Task 1.6 — Login page

Phase: 1 — Foundation Status: Not started Depends on: 1.5 Wiki refs: docs/wiki/entities/react-spa.md §Auth pattern

Goal

A clean, single-screen login page. Email + password fields, validation via zod + react-hook-form, submit calls useAuthStore.login(...), error state surfaces a humanised message under the form, success redirects to the intended destination (or / if none).

The look-and-feel target is "professional, not flashy" — race operators logging in on stage day shouldn't be wondering whether the page is broken. Use shadcn/ui's Card + Input + Label + Button + Alert primitives installed in 1.2.

Deliverables

  • src/ui/pages/login.tsx:
    • A centred card on a plain background.
    • "TRM" or "Time Racing Management" header (whatever brand text is agreed; can be a placeholder for now).
    • Email field (type email, autocomplete username).
    • Password field (type password, autocomplete current-password).
    • "Sign in" button.
    • Validation: email must be valid format; password must be ≥ 1 character (Directus enforces real password rules; we don't duplicate them client-side).
    • Submit handler calls useAuthStore.getState().login(email, password). While the call is in flight, the button shows "Signing in…" and is disabled.
    • On { ok: false, error }, render an <Alert variant="destructive"> above the form with the error message.
    • On { ok: true }, navigate to searchParams.get('redirect') ?? '/'.
  • src/routes/login.tsx (TanStack Router file-based route — final wiring lands in 1.7, but the page component itself is built here):
    • Exports the page component.
    • Accepts a redirect search param.
    • When the auth store transitions to 'authenticated' (e.g. via concurrent tab login), auto-navigate to redirect to avoid a stale login page.
  • src/ui/pages/__tests__/login.test.tsx (optional for Phase 1 — Vitest infra lands in Phase 3; for now a manual smoke is acceptance enough).

Specification

Form library + validation

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const LoginFormSchema = z.object({
  email: z.string().email({ message: 'Enter a valid email address.' }),
  password: z.string().min(1, { message: 'Password is required.' }),
});
type LoginForm = z.infer<typeof LoginFormSchema>;

const form = useForm<LoginForm>({
  resolver: zodResolver(LoginFormSchema),
  defaultValues: { email: '', password: '' },
});

Field-level errors render under each input. Submit-level errors (the "wrong credentials" message) render in the alert above the form.

Layout

<div className="min-h-screen grid place-items-center bg-muted">
  <Card className="w-full max-w-sm">
    <CardHeader>
      <CardTitle>Sign in to TRM</CardTitle>
      <CardDescription>Use your operator credentials.</CardDescription>
    </CardHeader>
    <CardContent>
      {submitError && (
        <Alert variant="destructive" className="mb-4">
          <AlertDescription>{submitError}</AlertDescription>
        </Alert>
      )}
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <div>
          <Label htmlFor="email">Email</Label>
          <Input id="email" type="email" autoComplete="username" {...form.register('email')} />
          {form.formState.errors.email && (
            <p className="text-sm text-destructive mt-1">{form.formState.errors.email.message}</p>
          )}
        </div>
        <div>
          <Label htmlFor="password">Password</Label>
          <Input
            id="password"
            type="password"
            autoComplete="current-password"
            {...form.register('password')}
          />
          {form.formState.errors.password && (
            <p className="text-sm text-destructive mt-1">
              {form.formState.errors.password.message}
            </p>
          )}
        </div>
        <Button type="submit" disabled={isSubmitting} className="w-full">
          {isSubmitting ? 'Signing in…' : 'Sign in'}
        </Button>
      </form>
    </CardContent>
  </Card>
</div>

Submit handler

const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const navigate = useNavigate();
const { redirect } = useSearch({ from: '/login' });

async function onSubmit(values: LoginForm) {
  setSubmitError(null);
  setIsSubmitting(true);
  const result = await useAuthStore.getState().login(values.email, values.password);
  setIsSubmitting(false);
  if (result.ok) {
    navigate({ to: redirect ?? '/', replace: true });
  } else {
    setSubmitError(result.error);
  }
}

replace: true on success — don't pile login pages in the back-button history.

Auto-navigate if already authenticated

If the user is already authenticated (e.g. they bookmarked /login and came back), redirect them away. Watch the auth store:

const status = useAuthStore((s) => s.status);
useEffect(() => {
  if (status === 'authenticated') {
    navigate({ to: redirect ?? '/', replace: true });
  }
}, [status, navigate, redirect]);

What this task does NOT include

  • Forgot-password / password-reset link. Directus has /auth/password/request but the email-template + the page that handles the reset link are out of Phase 1 scope. Add a placeholder "Forgot password?" link that's disabled or routes to a "Not yet available" page; document as deferred.
  • OAuth / SSO buttons. Directus supports OAuth, but Phase 1's dogfood doesn't need it. Skip entirely.
  • 2FA / OTP. Same — not needed for the dogfood. If a Directus user has 2FA enabled, login will fail with a specific error code; add a TODO comment, document, and ignore for now.
  • "Remember me" checkbox. The refresh cookie is the persistence layer; no separate "remember me" toggle.

Acceptance criteria

  • pnpm typecheck, pnpm lint, pnpm format:check clean.
  • pnpm dev → navigate to /login (after 1.7 wires routing) → renders the form.
  • Submitting with empty fields shows field-level validation errors; no network call made.
  • Submitting with valid format but wrong credentials shows the alert with a humanised error.
  • Submitting with correct credentials redirects to / (or to the ?redirect=... param if present).
  • Already-authenticated user landing on /login is redirected away, not stuck on the page.
  • Form is keyboard-navigable (tab order: email → password → submit); pressing Enter in either field submits.
  • Browser autofill works (the autocomplete attributes let password managers populate correctly).

Risks / open questions

  • Form a11y. The shadcn Form primitive (different from raw <form>) wires error announcements via aria-describedby automatically. Use it if 1.2 added it; otherwise the manual error rendering above is acceptable for v1.
  • Visual design. This is functional, not branded. If the user wants a real visual identity before dogfood, that's a Phase 3 task ("apply visual brand"). For now, plain card on muted background.
  • Error message localisation. English-only; i18n is Phase 4.

Done

src/ui/pages/login.tsxLoginPage component:

  • Centred card on muted background, max-width sm.
  • shadcn Form + FormField primitives with react-hook-form + zodResolver. FormMessage renders field-level zod errors automatically.
  • Email + password inputs with autoComplete="username" / current-password" so password managers populate correctly. autoFocus on email.
  • Submit handler calls useAuthStore.getState().login(); on { ok: false } populates a top-of-card destructive Alert with the humanised error.
  • In-flight indication via the auth store's 'authenticating' status (cross-tab safe — no separate local state needed).
  • onAuthenticated callback prop fires when the store transitions to 'authenticated' (covers both successful login and concurrent-tab login). The actual redirect lands in 1.7 alongside the router.

src/App.tsx updated to branch on auth status:

  • 'unknown' / 'authenticating' → "Signing you in…" placeholder (avoids flashing the login page on hard refresh while the boot probe runs).
  • 'anonymous'<LoginPage />.
  • 'authenticated' → home placeholder (1.7 replaces this branch with the TanStack Router shell).

Deviation from spec:

The spec called for src/routes/login.tsx (TanStack Router file-based route). Skipped that here — the route file requires the router plugin to have generated routeTree.gen.ts, which is 1.7's territory. For 1.6 the page works standalone via App.tsx's status branch; 1.7 will carve out routes/login.tsx and rewire App.tsx to render the router instead.

Search-param redirect (?redirect=...) also deferred to 1.7 — there's no router yet to expose useSearch. The onAuthenticated callback is the seam that 1.7 will use to navigate to the redirect target.

Smoke check: pnpm typecheck, pnpm lint, pnpm format:check, pnpm build all green. Bundle: 330KB / 102KB gzipped (up from 261KB / 80KB; react-hook-form + radix-ui from the shadcn Form primitive are now in the runtime path). Browser-side login against a stage Directus is the operational gate — pending until 1.10 deploys the SPA there.

Landed in PENDING_SHA.