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:
@@ -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) | ⬜ | — |
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user