# 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 `` 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 ```tsx 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; const form = useForm({ 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 ```tsx
Sign in to TRM Use your operator credentials. {submitError && ( {submitError} )}
{form.formState.errors.email && (

{form.formState.errors.email.message}

)}
{form.formState.errors.password && (

{form.formState.errors.password.message}

)}
``` ### Submit handler ```tsx const [submitError, setSubmitError] = useState(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: ```tsx 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 `
`) 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 (Filled in when the task lands.)