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
+18 -3
View File
@@ -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 (
<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 (
<div className="min-h-screen grid place-items-center bg-muted">
<div className="space-y-4 text-center">
<h1 className="text-3xl font-semibold">TRM</h1>
<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 className="text-xs text-muted-foreground">
Auth status: <code className="font-mono">{status}</code>
{user && <> · {user.first_name ?? user.email ?? user.id}</>}
Signed in as <code className="font-mono">{user?.email ?? user?.id}</code>
</p>
</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>
);
}