463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
'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 d’authentification',
|
||
enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification.',
|
||
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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|