redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -0,0 +1,96 @@
'use client'
import { localizedPath, type Locale } from '@/lib/localization/config'
import Image from 'next/image'
import type { ReactNode } from 'react'
interface AuthShellProps {
locale: Locale
title: string
subtitle?: string
children: ReactNode
}
export function AuthShell({ locale, title, subtitle, children }: AuthShellProps) {
return (
<main
id="main-content"
tabIndex={-1}
style={{
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
minHeight: 'calc(100vh - 80px)',
}}
>
<div style={{ width: '100%', maxWidth: '28rem' }}>
<div style={{ marginBottom: '2rem', textAlign: 'center' }}>
<a href={localizedPath('home', locale)}>
<Image
src="/rentaldrivego.jpeg"
alt="RentalDriveGo"
width={96}
height={96}
priority
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
padding: '0.375rem',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
}}
/>
</a>
<p
style={{
marginTop: '1rem',
fontSize: '0.75rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: 'var(--color-accent)',
}}
>
RentalDriveGo
</p>
<h1
style={{
marginTop: '0.5rem',
fontSize: '1.75rem',
fontWeight: 800,
letterSpacing: '-0.03em',
color: 'var(--color-heading)',
}}
>
{title}
</h1>
{subtitle ? (
<p
style={{
marginTop: '0.5rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{subtitle}
</p>
) : null}
</div>
<section
style={{
borderRadius: '2rem',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
padding: '2rem',
boxShadow: '0 30px 80px rgba(28,25,23,0.06)',
}}
>
{children}
</section>
</div>
</main>
)
}
@@ -0,0 +1,189 @@
'use client'
import { Button } from '@/components/actions/Button'
import { TextInput } from '@/components/forms/TextInput'
import { FormField } from '@/components/forms/FormField'
import { AuthShell } from '@/components/auth/AuthShell'
import { API_BASE } from '@/lib/api'
import { localizedPath, type Locale } from '@/lib/localization/config'
import { useState } from 'react'
interface Dict {
title: string
subtitle: string
email: string
emailPlaceholder: string
submit: string
submitting: string
backToLogin: string
successTitle: string
successBody: string
error: string
}
const dicts: Record<string, Dict> = {
en: {
title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Send reset link',
submitting: 'Sending…',
backToLogin: 'Back to sign in',
successTitle: 'Check your email',
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
error: 'Something went wrong. Please try again.',
},
fr: {
title: 'Mot de passe oublié ?',
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Envoyer le lien',
submitting: 'Envoi…',
backToLogin: 'Retour à la connexion',
successTitle: 'Vérifiez votre email',
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
error: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'نسيت كلمة المرور؟',
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
submit: 'إرسال رابط إعادة التعيين',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تحقق من بريدك الإلكتروني',
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}
export function ForgotPasswordForm({ locale }: { locale: Locale }) {
const dict = (dicts[locale] ?? dicts.en) as Dict
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const [empRes, adminRes] = await Promise.all([
fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
fetch(`${API_BASE}/admin/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
])
if (!empRes.ok && !adminRes.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
} finally {
setLoading(false)
}
}
const signInHref = localizedPath('sign-in', locale)
return (
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
{sent ? (
<div style={{ textAlign: 'center' }}>
<div
style={{
margin: '0 auto 1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
background: 'var(--color-success-bg, #ecfdf5)',
}}
>
<svg
style={{ width: '1.75rem', height: '1.75rem', color: 'var(--color-success-text, #059669)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
{dict.successTitle}
</h2>
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
{dict.successBody}
</p>
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{error ? (
<div
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-error-border, #fecaca)',
background: 'var(--color-error-bg, #fef2f2)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-error-text, #b91c1c)',
}}
>
{error}
</div>
) : null}
<FormField id="forgot-email" label={dict.email} required>
<TextInput
id="forgot-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
autoComplete="email"
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.submitting : dict.submit}
</Button>
</form>
)}
<div
style={{
marginTop: '1.5rem',
borderTop: '1px solid var(--color-border)',
paddingTop: '1.5rem',
textAlign: 'center',
fontSize: '0.875rem',
}}
>
<a
href={signInHref}
style={{
fontWeight: 600,
color: 'var(--color-heading)',
textDecoration: 'underline',
textUnderlineOffset: '4px',
}}
>
{dict.backToLogin}
</a>
</div>
</AuthShell>
)
}
@@ -0,0 +1,320 @@
'use client';
import { Button } from '@/components/actions/Button';
import { FormField } from '@/components/forms/FormField';
import { AuthShell } from '@/components/auth/AuthShell';
import { API_BASE } from '@/lib/api';
import { localizedPath, type Locale } from '@/lib/localization/config';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState } from 'react';
interface Dict {
title: string;
subtitle: string;
newPassword: string;
confirmPassword: string;
submit: string;
submitting: string;
backToLogin: string;
successTitle: string;
successBody: string;
signIn: string;
errorMismatch: string;
errorShort: string;
errorInvalidToken: string;
errorGeneric: string;
noToken: string;
requestNew: string;
}
const dicts: Record<string, Dict> = {
en: {
title: 'Set new password',
subtitle: 'Choose a strong password for your account.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
backToLogin: 'Back to sign in',
successTitle: 'Password updated',
successBody: 'Your password has been reset. You can now sign in with your new password.',
signIn: 'Sign in',
errorMismatch: 'Passwords do not match.',
errorShort: 'Password must be at least 8 characters.',
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
errorGeneric: 'Something went wrong. Please try again.',
noToken: 'No reset token found. Please request a new password reset.',
requestNew: 'Request new reset link',
},
fr: {
title: 'Nouveau mot de passe',
subtitle: 'Choisissez un mot de passe fort pour votre compte.',
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
submit: 'Réinitialiser',
submitting: 'Traitement…',
backToLogin: 'Retour à la connexion',
successTitle: 'Mot de passe mis à jour',
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
signIn: 'Se connecter',
errorMismatch: 'Les mots de passe ne correspondent pas.',
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
noToken: 'Aucun jeton de réinitialisation trouvé. Veuillez demander un nouveau lien.',
requestNew: 'Demander un nouveau lien',
},
ar: {
title: 'تعيين كلمة مرور جديدة',
subtitle: 'اختر كلمة مرور قوية لحسابك.',
newPassword: 'كلمة المرور الجديدة',
confirmPassword: 'تأكيد كلمة المرور',
submit: 'إعادة تعيين',
submitting: 'جارٍ المعالجة…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تم تحديث كلمة المرور',
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
signIn: 'تسجيل الدخول',
errorMismatch: 'كلمتا المرور غير متطابقتين.',
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
noToken: 'لم يتم العثور على رمز إعادة التعيين. يرجى طلب رابط جديد.',
requestNew: 'طلب رابط جديد',
},
};
function ResetPasswordContent({ locale }: { locale: Locale }) {
const dict = (dicts[locale] ?? dicts.en) as Dict;
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (password.length < 8) {
setError(dict.errorShort);
return;
}
if (password !== confirm) {
setError(dict.errorMismatch);
return;
}
setLoading(true);
setError(null);
try {
// Try employee endpoint first, fall back to admin
let res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
}
const json = await res.json();
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken);
throw new Error(dict.errorGeneric);
}
setDone(true);
} catch (error: unknown) {
setError(error instanceof Error ? error.message : dict.errorGeneric);
} finally {
setLoading(false);
}
}
const signInHref = localizedPath('sign-in', locale);
const forgotHref = localizedPath('forgot-password', locale);
if (!token) {
return (
<AuthShell locale={locale} title={dict.title}>
<p
style={{
textAlign: 'center',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dict.noToken}
</p>
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
<a href={forgotHref}>
<Button intent="primary">{dict.requestNew}</Button>
</a>
</div>
</AuthShell>
);
}
if (done) {
return (
<AuthShell locale={locale} title={dict.title}>
<div style={{ textAlign: 'center' }}>
<div
style={{
margin: '0 auto 1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
background: 'var(--color-success-bg, #ecfdf5)',
}}
>
<svg
style={{
width: '1.75rem',
height: '1.75rem',
color: 'var(--color-success-text, #059669)',
}}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
{dict.successTitle}
</h2>
<p
style={{
marginTop: '0.5rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dict.successBody}
</p>
<div style={{ marginTop: '1rem' }}>
<a href={signInHref}>
<Button intent="primary" fullWidth>
{dict.signIn}
</Button>
</a>
</div>
</div>
</AuthShell>
);
}
return (
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
{error ? (
<div
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-error-border, #fecaca)',
background: 'var(--color-error-bg, #fef2f2)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-error-text, #b91c1c)',
}}
>
{error}
</div>
) : null}
<FormField id="reset-password" label={dict.newPassword} required>
<input
id="reset-password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) var(--space-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-primary)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
/>
</FormField>
<FormField id="reset-confirm" label={dict.confirmPassword} required>
<input
id="reset-confirm"
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) var(--space-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-primary)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.submitting : dict.submit}
</Button>
</form>
<div
style={{
marginTop: '1.5rem',
borderTop: '1px solid var(--color-border)',
paddingTop: '1.5rem',
textAlign: 'center',
fontSize: '0.875rem',
}}
>
<a
href={signInHref}
style={{
fontWeight: 600,
color: 'var(--color-heading)',
textDecoration: 'underline',
textUnderlineOffset: '4px',
}}
>
{dict.backToLogin}
</a>
</div>
</AuthShell>
);
}
export function ResetPasswordForm({ locale }: { locale: Locale }) {
return (
<Suspense>
<ResetPasswordContent locale={locale} />
</Suspense>
);
}
@@ -0,0 +1,374 @@
'use client';
import { Button } from '@/components/actions/Button';
import { TextInput } from '@/components/forms/TextInput';
import { FormField } from '@/components/forms/FormField';
import { AuthShell } from '@/components/auth/AuthShell';
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api';
import { localizedPath, type Locale } from '@/lib/localization/config';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
interface Dict {
title: string;
subtitle: string;
email: string;
emailPlaceholder: string;
password: string;
signIn: string;
signingIn: string;
verify: string;
verifying: string;
authCode: string;
enterCode: string;
totpPlaceholder: string;
back: string;
forgotPassword: string;
invalidCredentials: string;
passwordNotSet: string;
tooManyRequests: string;
emailNotVerified: string;
unexpectedError: string;
}
const dicts: Record<string, Dict> = {
en: {
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
verify: 'Verify code',
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
invalidCredentials: 'Invalid email or password.',
passwordNotSet:
'No password set yet. Check your invitation email or use "Forgot your password?"',
tooManyRequests: 'Too many attempts. Please try again later.',
emailNotVerified: 'Please verify your email first. Check your inbox for the verification link.',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
passwordNotSet:
"Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »",
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.',
emailNotVerified: 'Veuillez vérifier votre adresse email. Consultez votre boîte de réception.',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
};
export function SignInForm({ locale }: { locale: Locale }) {
const dict = (dicts[locale] ?? dicts.en) as Dict;
const searchParams = useSearchParams();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [step, setStep] = useState<'credentials' | 'totp'>('credentials');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestedPortal = searchParams.get('portal');
const employeeRedirect = searchParams.get('redirect') || '/dashboard';
const preferAdminAuth = requestedPortal === 'admin';
async function handleCredentials(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const tryAdminLogin = async () => {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const adminJson = await adminRes.json();
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${window.location.origin}/admin/dashboard`;
return true;
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp');
return true;
}
if (adminRes.status === 429) {
setError(dict.tooManyRequests);
return true;
}
return false;
};
const tryEmployeeLogin = async () => {
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const empJson = await empRes.json();
if (empRes.ok && empJson?.data?.employee) {
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee));
const prefLang = empJson.data.employee?.preferredLanguage;
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`;
}
}
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'));
window.location.replace(employeeRedirect);
return true;
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet);
return true;
}
if (empJson?.error === 'email_not_verified') {
setError(dict.emailNotVerified);
return true;
}
if (empRes.status === 429) {
setError(dict.tooManyRequests);
return true;
}
return false;
};
if (preferAdminAuth) {
if (await tryAdminLogin()) return;
setError(dict.invalidCredentials);
return;
}
if (await tryEmployeeLogin()) return;
setError(dict.invalidCredentials);
} catch {
setError(dict.unexpectedError);
} finally {
setLoading(false);
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const normalizedCode = totpCode.trim().toUpperCase();
const secondFactor = /^\d{6}$/.test(normalizedCode)
? { totpCode: normalizedCode }
: { recoveryCode: normalizedCode };
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password, ...secondFactor }),
});
const adminJson = await adminRes.json();
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${window.location.origin}/admin/dashboard`;
return;
}
setError(dict.invalidCredentials);
} catch {
setError(dict.unexpectedError);
} finally {
setLoading(false);
}
}
const hrefBase = localizedPath('forgot-password', locale);
return (
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
{error ? (
<div
style={{
marginBottom: '1.25rem',
borderRadius: '1.5rem',
border: '1px solid var(--color-error-border, #fecaca)',
background: 'var(--color-error-bg, #fef2f2)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-error-text, #b91c1c)',
}}
>
{error}
</div>
) : null}
{step === 'credentials' ? (
<form
onSubmit={handleCredentials}
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
<FormField id="signin-email" label={dict.email} required>
<TextInput
id="signin-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
autoComplete="email"
/>
</FormField>
<FormField id="signin-password" label={dict.password} required>
<input
id="signin-password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) var(--space-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-primary)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.signingIn : dict.signIn}
</Button>
<div style={{ textAlign: 'center' }}>
<a
href={hrefBase}
style={{
fontSize: '0.875rem',
color: 'var(--color-text-tertiary)',
textDecoration: 'underline',
textUnderlineOffset: '4px',
}}
>
{dict.forgotPassword}
</a>
</div>
</form>
) : (
<form
onSubmit={handleTotp}
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
<div
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-border)',
background: 'var(--color-surface-secondary)',
padding: '1rem',
textAlign: 'center',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dict.enterCode}
</div>
<FormField id="signin-totp" label={dict.authCode} required>
<TextInput
id="signin-totp"
type="text"
inputMode="text"
required
maxLength={14}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())
}
placeholder={dict.totpPlaceholder}
style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.45em' }}
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.verifying : dict.verify}
</Button>
<button
type="button"
onClick={() => {
setStep('credentials');
setTotpCode('');
setError(null);
}}
style={{
width: '100%',
fontSize: '0.875rem',
color: 'var(--color-text-tertiary)',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
{dict.back}
</button>
</form>
)}
</AuthShell>
);
}