ef18222edf
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.
187 lines
9.2 KiB
Markdown
187 lines
9.2 KiB
Markdown
# 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
|
|
|
|
```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<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
|
|
|
|
```tsx
|
|
<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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```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 `<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.tsx` — `LoginPage` 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`.
|