Files
carmanagement/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx
T
2026-05-17 08:53:19 -04:00

463 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function SignInPage() {
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const requestedPortal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
const [portal, setPortal] = useState<'admin' | 'workspace'>(requestedPortal)
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const dict = {
en: {
title: 'Sign in',
workspaceTab: 'Company Workspace',
platformTab: 'Platform',
subtitle: 'Choose where you want to sign in, then enter your credentials.',
email: 'Email',
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.',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
newAccount: 'New company account?',
createWorkspace: 'Create your workspace',
invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
title: 'Connexion',
workspaceTab: 'Espace',
platformTab: 'Plateforme',
subtitle: 'Choisissez votre accès puis saisissez vos identifiants.',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: 'Code dauthentification',
enterCode: 'Entrez le code à 6 chiffres de votre application dauthentification.',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
newAccount: 'Nouveau compte entreprise ?',
createWorkspace: 'Créer votre espace',
invalidCredentials: 'Email 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é ? »',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'تسجيل الدخول',
workspaceTab: 'المساحة',
platformTab: 'المنصة',
subtitle: 'اختر جهة الدخول ثم أدخل بياناتك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
newAccount: 'حساب شركة جديد؟',
createWorkspace: 'أنشئ مساحتك',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const themeStyles = {
light: {
main: 'bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]',
logoFrame: 'border-blue-100 bg-white shadow-sm',
brand: 'text-blue-600',
title: 'text-slate-900',
subtitle: 'text-slate-500',
card: 'border-slate-200 bg-white shadow-sm',
toggleWrap: 'border-slate-200 bg-slate-50',
activeToggle: 'bg-slate-900 text-white shadow-sm',
inactiveToggle: 'text-slate-600 hover:bg-white hover:text-slate-900',
},
medium: {
main: 'bg-[radial-gradient(circle_at_top,rgba(148,163,184,0.32),transparent_35%),linear-gradient(180deg,#d7dee8,#f3f4f6)]',
logoFrame: 'border-slate-300 bg-white/90 shadow-[0_12px_30px_rgba(71,85,105,0.18)]',
brand: 'text-slate-700',
title: 'text-slate-900',
subtitle: 'text-slate-600',
card: 'border-slate-300/80 bg-white/88 shadow-[0_18px_50px_rgba(51,65,85,0.14)] backdrop-blur',
toggleWrap: 'border-slate-300 bg-slate-200/80',
activeToggle: 'bg-amber-400 text-slate-950 shadow-sm',
inactiveToggle: 'text-slate-700 hover:bg-white/85 hover:text-slate-950',
},
dark: {
main: 'bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.2),transparent_35%),linear-gradient(180deg,#020617,#0f172a)]',
logoFrame: 'border-slate-700 bg-slate-900 shadow-[0_12px_30px_rgba(2,6,23,0.45)]',
brand: 'text-amber-400',
title: 'text-slate-100',
subtitle: 'text-slate-400',
card: 'border-slate-800 bg-slate-900/92 shadow-[0_18px_50px_rgba(2,6,23,0.45)] backdrop-blur',
toggleWrap: 'border-slate-700 bg-slate-950/80',
activeToggle: 'bg-amber-400 text-slate-950 shadow-sm',
inactiveToggle: 'text-slate-300 hover:bg-slate-800 hover:text-white',
},
}[theme]
useEffect(() => {
setPortal(requestedPortal)
}, [requestedPortal])
useEffect(() => {
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
}
}, [language, requestedLanguage, setLanguage])
useEffect(() => {
if (
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
}
}, [requestedTheme, setTheme, theme])
useEffect(() => {
const currentPath = window.location.pathname + (window.location.search || '')
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
function handlePortalChange(nextPortal: 'admin' | 'workspace') {
setPortal(nextPortal)
const nextParams = new URLSearchParams(searchParams.toString())
if (nextPortal === 'admin') {
nextParams.set('portal', 'admin')
} else {
nextParams.delete('portal')
}
const nextSearch = nextParams.toString()
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, { scroll: false })
}
return (
<main className={`flex min-h-screen items-center justify-center px-4 py-12 transition-colors ${themeStyles.main}`}>
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={104}
height={104}
className={`h-24 w-24 rounded-[1.75rem] border p-1.5 transition-colors ${themeStyles.logoFrame}`}
/>
</div>
<Link href={marketplaceUrl} className={`text-xs font-semibold uppercase tracking-[0.24em] transition-colors ${themeStyles.brand}`}>
RentalDriveGo
</Link>
<h1 className={`mt-3 text-3xl font-black tracking-tight transition-colors ${themeStyles.title}`}>
{dict.title}
</h1>
<p className={`mt-2 text-sm transition-colors ${themeStyles.subtitle}`}>
{dict.subtitle}
</p>
</div>
<section className={`rounded-[2rem] border p-8 transition-colors ${themeStyles.card}`}>
<div className={`mb-6 grid grid-cols-2 gap-2 rounded-full border p-1 transition-colors ${themeStyles.toggleWrap}`}>
{([
{ value: 'workspace', label: dict.workspaceTab },
{ value: 'admin', label: dict.platformTab },
] as const).map((option) => {
const active = option.value === portal
return (
<button
key={option.value}
type="button"
onClick={() => handlePortalChange(option.value)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
active
? themeStyles.activeToggle
: themeStyles.inactiveToggle
}`}
>
{option.label}
</button>
)
})}
</div>
<LocalSignInForm dict={dict} portal={portal} />
</section>
</div>
</main>
)
}
function LocalSignInForm({ dict, portal }: {
dict: {
email: string
password: string
signIn: string
signingIn: string
verify: string
verifying: string
authCode: string
enterCode: string
back: string
forgotPassword: string
invalidCredentials: string
passwordNotSet: string
unexpectedError: string
}
portal: 'admin' | 'workspace'
}) {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const forgotPasswordHref = portal === 'admin' ? `${adminUrl}/forgot-password` : '/forgot-password'
useEffect(() => {
setStep('credentials')
setTotpCode('')
setError(null)
}, [portal])
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${adminUrl}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
if (portal === 'admin') {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
return
}
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: employeeRedirect })
router.push(employeeRedirect)
return
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
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 adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
return (
<>
{error ? (
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" 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" className="h-5 w-5" 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>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.signingIn : dict.signIn}
</button>
<div className="text-center">
<Link href={forgotPasswordHref} className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
{dict.forgotPassword}
</Link>
</div>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
{dict.enterCode}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input-field text-center text-xl tracking-[0.45em]"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
}}
className="w-full text-sm text-slate-500 hover:text-slate-700"
>
{dict.back}
</button>
</form>
)}
</>
)
}