Add .planning/ scaffolding: - ROADMAP.md (4 phases, 8 non-negotiable design rules) - phase-1-foundation/ README + 9 task files (1.2-1.10) - phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders Task 1.2 — stack rounding-out: - Tailwind 4 via @tailwindcss/vite + src/styles/globals.css - shadcn/ui (slate, new-york) primitives in src/ui/primitives/: button, input, label, form, card, alert - TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps) - Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers - Prettier 3 + eslint-config-prettier + eslint-plugin-prettier - ESLint override disabling react-refresh/only-export-components for src/ui/primitives/** (intentional dual-exports in shadcn primitives) - Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json (TS 6 deprecates baseUrl; paths now resolve relative to config file). Pulled forward from 1.3 because shadcn add CLI needs it resolvable. - Scripts: dev, build, preview, lint, typecheck, format, format:check, test (placeholder) - App.tsx Tailwind smoke test (centred card + shadcn Button) - README.md rewritten with stack/scripts/shadcn-add docs All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
7.1 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, autocompleteusername). - Password field (type
password, autocompletecurrent-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 tosearchParams.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
redirectsearch param. - When the auth store transitions to
'authenticated'(e.g. via concurrent tab login), auto-navigate toredirectto 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/requestbut the email-template + the page that handles the reset link are out of Phase 1 scope. Add a placeholder "Forgot password?" link that'sdisabledor 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:checkclean.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
/loginis 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
Formprimitive (different from raw<form>) wires error announcements viaaria-describedbyautomatically. 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.)