Files
pn-new-crm/src/app/(auth)/login/page.tsx
Matt 7476eabec6 feat(form-error-ux): adopt useFormScrollToError + FormErrorSummary across remaining 10 forms
Completes the form-error rollout the prior session shipped on the 6
highest-impact forms (client/interest/yacht/company/berth/expense). Adds
the scroll-to-first-error wrapper + the top-of-form summary banner to:

- src/app/(auth)/login/page.tsx
- src/app/(auth)/reset-password/page.tsx
- src/app/(auth)/set-password/page.tsx
- src/app/(auth)/setup/page.tsx
- src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
- src/components/berths/berth-detail-header.tsx (status-change dialog)
- src/components/companies/add-membership-dialog.tsx
- src/components/invoices/invoice-detail.tsx (record-payment form)
- src/components/reservations/berth-reserve-dialog.tsx
- src/components/yachts/yacht-transfer-dialog.tsx

Each call site: hook wraps handleSubmit, FormErrorSummary renders only
when 2+ errors fire (no visual change otherwise), and per-form `labels`
prop translates field names to human-readable strings. invoice-line-items
is a sub-form via useFormContext, so it inherits from the parent.

1471/1471 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:26:04 +02:00

172 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
// `identifier` accepts either an email address or a username (330 lowercase
// letters / digits / dot / underscore / hyphen). The server endpoint
// /api/auth/sign-in-by-identifier resolves the username server-side and
// forwards to better-auth in one round-trip - the canonical email is never
// returned to the browser, which closes the username-enumeration vector.
const loginSchema = z.object({
identifier: z.string().min(1, 'Email or username is required'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
/**
* H-02: Validate a redirect target before pushing the user to it. The
* middleware appends `?redirect=<path>` when a session check fails on a
* protected route; an unsanitized router.push of that value would let a
* crafted URL bounce the user to an external host or protocol-relative
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
* slash paths pass.
*/
function safeRedirectTarget(raw: string | null): string {
if (!raw) return '/dashboard';
// Allow only paths starting with a single `/` (rules out `//evil.com`
// protocol-relative URLs and `https://…` absolute ones).
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}
export default function LoginPage() {
const router = useRouter();
const branding = useAuthBranding();
const appName = branding?.appName?.trim() || 'CRM';
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
// owns the first-run flow. Failure of the status endpoint is silent
// (login still works for everyone else).
useEffect(() => {
let cancelled = false;
fetch('/api/v1/bootstrap/status')
.then((r) => (r.ok ? (r.json() as Promise<{ data?: { needsBootstrap?: boolean } }>) : null))
.then((payload) => {
if (cancelled || !payload) return;
if (payload.data?.needsBootstrap) router.replace('/setup');
})
.catch(() => {
/* silent - login UX must still work even if status check fails */
});
return () => {
cancelled = true;
};
}, [router]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
async function onSubmit(data: LoginFormData) {
setIsLoading(true);
try {
const res = await fetch('/api/auth/sign-in-by-identifier', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: data.identifier.trim(),
password: data.password,
}),
});
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as {
error?: { message?: string };
};
toast.error(payload.error?.message ?? 'Invalid credentials');
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">{appName}</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
</div>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
<FormErrorSummary
errors={errors}
labels={{ identifier: 'Email or username', password: 'Password' }}
/>
<div className="space-y-1.5">
<Label htmlFor="identifier">Email or username</Label>
<Input
id="identifier"
type="text"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
disabled={isLoading}
className={cn(errors.identifier && 'border-destructive focus-visible:ring-destructive')}
{...register('identifier')}
/>
{errors.identifier && (
<p className="text-sm text-destructive">{errors.identifier.message}</p>
)}
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/reset-password"
className="text-xs text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
disabled={isLoading}
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
{...register('password')}
/>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</BrandedAuthShell>
);
}