436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import Image from 'next/image'
|
|
import Link from 'next/link'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
|
import PublicShell from '@/components/layout/PublicShell'
|
|
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
|
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
|
|
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
|
|
|
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
|
|
|
function notifyParent(message: Record<string, unknown>) {
|
|
if (typeof window === 'undefined' || window.parent === window) return
|
|
window.parent.postMessage(message, '*')
|
|
}
|
|
|
|
export default function SignInPageClient({ embedded = false }: { embedded?: boolean }) {
|
|
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const requestedLanguage = searchParams.get('lang')
|
|
const requestedTheme = searchParams.get('theme')
|
|
const initializedFromQuery = useRef(false)
|
|
const dict = {
|
|
en: {
|
|
brandName: 'Space Agency',
|
|
title: 'Sign in',
|
|
subtitle: 'Enter your credentials to access your account.',
|
|
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?',
|
|
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: {
|
|
brandName: 'Espace agence',
|
|
title: 'Connexion',
|
|
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
|
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é ?',
|
|
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é ? »`,
|
|
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
|
},
|
|
ar: {
|
|
brandName: 'مساحة الوكالة',
|
|
title: 'تسجيل الدخول',
|
|
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
|
email: 'البريد الإلكتروني',
|
|
password: 'كلمة المرور',
|
|
signIn: 'تسجيل الدخول',
|
|
signingIn: 'جارٍ تسجيل الدخول…',
|
|
verify: 'تحقق من الرمز',
|
|
verifying: 'جارٍ التحقق…',
|
|
authCode: 'رمز المصادقة',
|
|
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
|
back: 'العودة إلى بيانات الدخول',
|
|
forgotPassword: 'نسيت كلمة المرور؟',
|
|
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
|
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
|
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
|
},
|
|
}[language]
|
|
|
|
useEffect(() => {
|
|
if (initializedFromQuery.current) return
|
|
|
|
if (
|
|
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
|
|
requestedLanguage !== language
|
|
) {
|
|
setLanguage(requestedLanguage)
|
|
}
|
|
|
|
if (
|
|
(requestedTheme === 'light' || requestedTheme === 'dark') &&
|
|
requestedTheme !== theme
|
|
) {
|
|
setTheme(requestedTheme)
|
|
}
|
|
|
|
initializedFromQuery.current = true
|
|
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
|
|
|
|
useEffect(() => {
|
|
if (!initializedFromQuery.current) return
|
|
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
let changed = false
|
|
|
|
if (params.get('lang') !== language) {
|
|
params.set('lang', language)
|
|
changed = true
|
|
}
|
|
|
|
if (params.get('theme') !== theme) {
|
|
params.set('theme', theme)
|
|
changed = true
|
|
}
|
|
|
|
// useSearchParams() can return empty params during hydration without a Suspense boundary.
|
|
// Always preserve embedded=1 when the component was server-rendered as embedded so
|
|
// the router.replace doesn't strip it and trigger an unintended redirect.
|
|
if (embedded && params.get('embedded') !== '1') {
|
|
params.set('embedded', '1')
|
|
changed = true
|
|
}
|
|
|
|
if (!changed) return
|
|
|
|
const nextQuery = params.toString()
|
|
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
|
|
}, [embedded, language, pathname, router, searchParams, theme])
|
|
|
|
useEffect(() => {
|
|
const currentPath = window.location.pathname + (window.location.search || '')
|
|
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
|
}, [pathname, searchParams])
|
|
|
|
return (
|
|
<PublicShell embedded={embedded}>
|
|
<main className="flex flex-1 items-center justify-center px-4 py-12">
|
|
<div className="w-full max-w-md">
|
|
<div className="mb-8 text-center">
|
|
<div className="flex justify-center">
|
|
<a href={marketplaceUrl} target="_top">
|
|
<Image
|
|
src={DASHBOARD_LOGO_SRC}
|
|
alt="RentalDriveGo"
|
|
width={104}
|
|
height={104}
|
|
priority
|
|
className="h-24 w-24 rounded-[1.75rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950/80"
|
|
/>
|
|
</a>
|
|
</div>
|
|
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
|
|
RentalDriveGo
|
|
</p>
|
|
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
|
|
{dict.brandName}
|
|
</h1>
|
|
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
|
|
{dict.subtitle}
|
|
</p>
|
|
<div className="mt-4 flex items-center justify-center gap-2">
|
|
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
|
<button
|
|
key={lang}
|
|
type="button"
|
|
onClick={() => setLanguage(lang)}
|
|
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-orange-700 dark:text-orange-300 ${
|
|
language === lang
|
|
? 'ring-1 ring-current opacity-100'
|
|
: 'opacity-45 hover:opacity-80'
|
|
}`}
|
|
>
|
|
{lang}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
|
|
<LocalSignInForm dict={dict} />
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</PublicShell>
|
|
)
|
|
}
|
|
|
|
function LocalSignInForm({
|
|
dict,
|
|
}: {
|
|
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
|
|
}
|
|
}) {
|
|
const searchParams = useSearchParams()
|
|
const { setLanguage } = useDashboardI18n()
|
|
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'
|
|
|
|
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 {
|
|
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) {
|
|
const targetPath = toPublicDashboardPath(employeeRedirect)
|
|
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') {
|
|
setLanguage(prefLang)
|
|
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
|
}
|
|
}
|
|
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
|
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
|
// Use a document navigation here so the authenticated dashboard bootstraps
|
|
// from a fresh request after the token cookie and localStorage are set.
|
|
window.location.replace(targetPath)
|
|
return
|
|
}
|
|
|
|
if (empJson?.error === 'password_not_set') {
|
|
setError(dict.passwordNotSet)
|
|
return
|
|
}
|
|
|
|
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 = `${adminUrl}/dashboard`
|
|
return
|
|
}
|
|
|
|
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
|
setStep('totp')
|
|
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 = `${adminUrl}/dashboard`
|
|
return
|
|
}
|
|
|
|
setError(dict.invalidCredentials)
|
|
} catch {
|
|
setError(dict.unexpectedError)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{error ? (
|
|
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{step === 'credentials' ? (
|
|
<form onSubmit={handleCredentials} className="space-y-5">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.email}</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="owner@company.com"
|
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.password}</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
|
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="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
|
>
|
|
{loading ? dict.signingIn : dict.signIn}
|
|
</button>
|
|
|
|
<div className="text-center">
|
|
<Link href="/forgot-password" className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100">
|
|
{dict.forgotPassword}
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<form onSubmit={handleTotp} className="space-y-5">
|
|
<div className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 px-4 py-4 text-center text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300">
|
|
{dict.enterCode}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
|
|
<input
|
|
type="text"
|
|
inputMode="text"
|
|
pattern="[0-9A-Za-z-]{6,14}"
|
|
maxLength={14}
|
|
required
|
|
value={totpCode}
|
|
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
|
|
placeholder="000000 or XXXX-XXXX-XXXX"
|
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
|
>
|
|
{loading ? dict.verifying : dict.verify}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStep('credentials')
|
|
setTotpCode('')
|
|
setError(null)
|
|
}}
|
|
className="w-full text-sm text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
|
|
>
|
|
{dict.back}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</>
|
|
)
|
|
}
|