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
407 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|