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:
+18
-3
@@ -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>
|
||||
|
||||
@@ -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