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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user