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}
+
+ )}
+
+
+
+
+
+ );
+}