diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 0c3f2fc..e58e9ef 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -54,7 +54,7 @@ These rules govern every task. Any deviation must be discussed and documented as | 1.3 | [Vite dev proxy + path aliases + tsconfig hardening](./phase-1-foundation/03-vite-dev-proxy.md) | 🟩 | `39b60c9` | | 1.4 | [Runtime config endpoint](./phase-1-foundation/04-runtime-config.md) | 🟩 | `8e2151a` | | 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | 🟩 | `38fe2e3` | -| 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | ⬜ | β€” | +| 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | 🟩 | `PENDING_SHA` | | 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | ⬜ | β€” | | 1.8 | [Logout flow](./phase-1-foundation/08-logout-flow.md) | ⬜ | β€” | | 1.9 | [Gitea CI + Dockerfile + nginx static serve](./phase-1-foundation/09-gitea-ci-and-dockerfile.md) | ⬜ | β€” | diff --git a/.planning/phase-1-foundation/06-login-page.md b/.planning/phase-1-foundation/06-login-page.md index 3b8b388..76109e3 100644 --- a/.planning/phase-1-foundation/06-login-page.md +++ b/.planning/phase-1-foundation/06-login-page.md @@ -160,4 +160,27 @@ useEffect(() => { ## Done -(Filled in when the task lands.) +`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'` β†’ ``. +- `'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`. diff --git a/src/App.tsx b/src/App.tsx index a88ff4c..a9e8457 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,34 @@ import { useAuthStore } from '@/auth'; +import { LoginPage } from '@/ui/pages/login'; function App() { const status = useAuthStore((s) => s.status); const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); + // While the boot probe is in flight, render a minimal placeholder rather + // than flashing the login page for users who already have a session. + if (status === 'unknown' || status === 'authenticating') { + return ( +
+ Signing you in… +
+ ); + } + + if (status === 'anonymous') { + return ; + } + + // Phase 1.7 replaces this branch with the TanStack Router shell. return (

TRM

- Phase 1 scaffold. Routing + login UI land in 1.6 / 1.7. + Phase 1 scaffold. Routing + protected pages land in 1.7.

- Auth status: {status} - {user && <> Β· {user.first_name ?? user.email ?? user.id}} + Signed in as {user?.email ?? user?.id}

diff --git a/src/ui/pages/login.tsx b/src/ui/pages/login.tsx new file mode 100644 index 0000000..ba61b7d --- /dev/null +++ b/src/ui/pages/login.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useAuthStore } from '@/auth'; +import { Button } from '@/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/ui/primitives/card'; +import { Alert, AlertDescription } from '@/ui/primitives/alert'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form'; +import { Input } from '@/ui/primitives/input'; + +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; + +export type LoginPageProps = { + /** Called once the auth store transitions to `'authenticated'`. */ + onAuthenticated?: () => void; +}; + +export function LoginPage({ onAuthenticated }: LoginPageProps) { + const status = useAuthStore((s) => s.status); + const isSubmitting = status === 'authenticating'; + const [submitError, setSubmitError] = useState(null); + + const form = useForm({ + resolver: zodResolver(LoginFormSchema), + defaultValues: { email: '', password: '' }, + }); + + // If the auth store flips to authenticated (e.g. login succeeds, or another + // tab logs in concurrently), notify the surrounding container. + useEffect(() => { + if (status === 'authenticated') { + onAuthenticated?.(); + } + }, [status, onAuthenticated]); + + async function onSubmit(values: LoginForm) { + setSubmitError(null); + const result = await useAuthStore.getState().login(values.email, values.password); + if (!result.ok) { + setSubmitError(result.error); + } + // Success path is handled by the status effect above. + } + + return ( +
+ + + Sign in to TRM + Use your operator credentials. + + + {submitError && ( + + {submitError} + + )} +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + +
+
+
+ ); +}