feat: task 1.6 login page

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.
This commit is contained in:
2026-05-02 18:02:43 +02:00
parent a65ad428e6
commit ef18222edf
4 changed files with 162 additions and 5 deletions
+1 -1
View File
@@ -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.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.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.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.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.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) | ⬜ | — | | 1.9 | [Gitea CI + Dockerfile + nginx static serve](./phase-1-foundation/09-gitea-ci-and-dockerfile.md) | ⬜ | — |
+24 -1
View File
@@ -160,4 +160,27 @@ useEffect(() => {
## Done ## 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'``<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`.
+18 -3
View File
@@ -1,19 +1,34 @@
import { useAuthStore } from '@/auth'; import { useAuthStore } from '@/auth';
import { LoginPage } from '@/ui/pages/login';
function App() { function App() {
const status = useAuthStore((s) => s.status); const status = useAuthStore((s) => s.status);
const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); 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 (
<div className="min-h-screen grid place-items-center bg-muted text-sm text-muted-foreground">
Signing you in
</div>
);
}
if (status === 'anonymous') {
return <LoginPage />;
}
// Phase 1.7 replaces this branch with the TanStack Router shell.
return ( return (
<div className="min-h-screen grid place-items-center bg-muted"> <div className="min-h-screen grid place-items-center bg-muted">
<div className="space-y-4 text-center"> <div className="space-y-4 text-center">
<h1 className="text-3xl font-semibold">TRM</h1> <h1 className="text-3xl font-semibold">TRM</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Phase 1 scaffold. Routing + login UI land in 1.6 / 1.7. Phase 1 scaffold. Routing + protected pages land in 1.7.
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Auth status: <code className="font-mono">{status}</code> Signed in as <code className="font-mono">{user?.email ?? user?.id}</code>
{user && <> · {user.first_name ?? user.email ?? user.id}</>}
</p> </p>
</div> </div>
</div> </div>
+119
View File
@@ -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<typeof LoginFormSchema>;
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<string | null>(null);
const form = useForm<LoginForm>({
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 (
<div className="min-h-screen grid place-items-center bg-muted p-4">
<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 {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
autoComplete="username"
autoFocus
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="current-password"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}