Files
carmanagement/apps/homepage/src/components/auth/SignInForm.tsx
T
root e6460de4d0
Build & Deploy / Build & Push Docker Image (push) Failing after 7s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 26s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
add eye for all password fields
2026-06-27 23:12:24 -04:00

407 lines
14 KiB
TypeScript

'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 [showPassword, setShowPassword] = useState(false);
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>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="signin-password"
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) 2.5rem 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',
}}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
style={{
position: 'absolute',
top: 0,
bottom: 0,
right: '0.5rem',
display: 'flex',
alignItems: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
padding: 0,
}}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</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>
);
}