Continuous Integration / config (pull_request) Successful in 11s
Continuous Integration / changes (pull_request) Successful in 21s
Continuous Integration / backend-build (pull_request) Skipped
Continuous Integration / backend-test (pull_request) Skipped
Continuous Integration / vulnerability-scan (pull_request) Skipped
Continuous Integration / frontend-prepare (pull_request) Successful in 1m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m12s
Continuous Integration / frontend-test (pull_request) Successful in 4m42s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 6m51s
Continuous Integration / deploy-test (pull_request) Skipped
Groups auth, setup, invitation, profile, users, cms, availability and system code (services/hooks, components, schemas, mocks, pages) under src/features/<name> instead of splitting by technical layer (api/, components/, lib/schemas/, mocks/, pages/). Renames the old connection-oriented `api` layer to `services` per feature, and splits the monolithic api/types.ts into per-feature types.ts files (with ProblemDetails/ApiResult merged into lib/api-client.ts as shared infra). Layout-agnostic code (ui primitives, app shell, i18n, test utils, lib) stays at the top level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
5.3 KiB
TypeScript
122 lines
5.3 KiB
TypeScript
import { useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useAuth } from '@/features/auth/context/auth-context';
|
|
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
|
import { loginFormSchema, type LoginFormData } from '@/features/auth/schemas/auth';
|
|
|
|
export function LoginPage() {
|
|
const { t } = useTranslation();
|
|
const { login } = useAuth();
|
|
const navigate = useNavigate();
|
|
const search = useSearch({ strict: false }) as { redirect?: string };
|
|
const [serverError, setServerError] = useState<string | null>(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<LoginFormData>({
|
|
resolver: zodResolver(loginFormSchema),
|
|
mode: 'onTouched',
|
|
defaultValues: { email: '', password: '' },
|
|
});
|
|
|
|
const onSubmit = handleSubmit(async (values) => {
|
|
setServerError(null);
|
|
try {
|
|
await login(values.email, values.password);
|
|
await navigate({ to: (search.redirect as string | undefined) ?? '/dashboard' });
|
|
} catch (err) {
|
|
if (err instanceof ProblemDetailsError && err.status === 401) {
|
|
setServerError(t('login.errors.invalidCredentials'));
|
|
} else if (err instanceof NetworkError) {
|
|
setServerError(t('errors.network'));
|
|
} else {
|
|
setServerError(t('login.errors.generic'));
|
|
}
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
|
<Card className="w-full max-w-sm">
|
|
<CardHeader>
|
|
<CardTitle>{t('login.title')}</CardTitle>
|
|
<CardDescription>{t('login.subtitle')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
|
{serverError !== null && (
|
|
<div
|
|
role="alert"
|
|
data-testid="login-error"
|
|
className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
>
|
|
{serverError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t('login.email')}</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
placeholder={t('login.emailPlaceholder')}
|
|
data-testid="login-email-input"
|
|
aria-invalid={errors.email !== undefined}
|
|
{...register('email')}
|
|
/>
|
|
{errors.email && (
|
|
<p
|
|
className="text-sm text-destructive"
|
|
data-testid="login-email-error"
|
|
>
|
|
{errors.email.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">{t('login.password')}</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="••••••••"
|
|
autoComplete="current-password"
|
|
data-testid="login-password-input"
|
|
aria-invalid={errors.password !== undefined}
|
|
{...register('password')}
|
|
/>
|
|
{errors.password && (
|
|
<p
|
|
className="text-sm text-destructive"
|
|
data-testid="login-password-error"
|
|
>
|
|
{errors.password.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={isSubmitting}
|
|
data-testid="login-form-submit-button"
|
|
>
|
|
{isSubmitting ? t('login.submitting') : t('login.submit')}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|