fix 2fa and move communication language to setting
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 50s
Test / API Unit Tests (push) Failing after 1m6s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s

This commit is contained in:
root
2026-08-23 00:45:39 -04:00
parent c8d7a3954a
commit e3f80af47d
29 changed files with 890 additions and 255 deletions
@@ -18,6 +18,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
import { getMoroccanCityOptions } from '@/lib/moroccanCities'
type SectionKey = 'company' | 'carplace' | 'payments' | 'rental-policies' | 'insurance' | 'pricing' | 'accounting'
type CommunicationLocale = 'ar' | 'en' | 'fr'
type FeatureKey =
| 'settings.company_profile'
| 'settings.public_contact'
@@ -82,6 +83,24 @@ interface BrandSettings {
isListedOnCarplace: boolean
}
interface CommunicationSettings {
timezone: string
reminderLocalTime: string
enabledCommunicationLocales: CommunicationLocale[]
defaultCommunicationLocale: CommunicationLocale
contacts: Array<{
id?: string
employeeId?: string | null
email: string
locale?: CommunicationLocale | null
effectiveLocale?: CommunicationLocale
isPrimary: boolean
receivePaymentNotices: boolean
isActive: boolean
verified?: boolean
}>
}
interface ContractSettings {
fuelPolicy: string
fuelPolicyType: string
@@ -192,6 +211,9 @@ function SettingsPageContent() {
subscriptionStatus: 'Subscription status', manageSubscription: 'Manage subscription', requiredPlan: 'Requires',
lockedTitle: 'This settings area is not included in your current plan.', lockedBody: 'Your saved configuration is preserved and becomes editable again after upgrade.',
readOnly: 'This section is read-only while your subscription access is restricted.', companyHint: 'Default language affects Carplace text and generated contracts when available.',
officialLanguageHint: 'This is your official workspace language. Billing notices, notifications, Carplace text, and generated documents use it when available.',
communicationTitle: 'Billing communication', communicationHelp: 'Payment notices use the official language by default. You can still restrict allowed notice languages and contact overrides.',
enabledLanguages: 'Enabled languages', timezone: 'Billing timezone', contactLanguage: 'Contact language', inheritDefault: 'Use official language',
publicProfile: 'Public profile', carplaceBasics: 'Carplace basics', premiumBranding: 'Available on GROWTH',
paymentsBody: 'Rental payments are recorded as bank transfer or check. Online checkout is not available.',
policies: 'Fuel, driver, and damage policies', additionalDriver: 'Additional-driver automation', insuranceNew: 'New insurance policy',
@@ -213,6 +235,9 @@ function SettingsPageContent() {
subscriptionStatus: 'Statut abonnement', manageSubscription: 'Gérer labonnement', requiredPlan: 'Requiert',
lockedTitle: 'Cette section nest pas incluse dans votre plan actuel.', lockedBody: 'La configuration enregistrée est conservée et redevient modifiable après mise à niveau.',
readOnly: 'Cette section est en lecture seule pendant la restriction daccès.', companyHint: 'La langue par défaut affecte la vitrine et les contrats générés si disponibles.',
officialLanguageHint: 'Cest la langue officielle de votre espace. Les avis de paiement, notifications, textes Carplace et documents générés lutilisent si disponible.',
communicationTitle: 'Communication de facturation', communicationHelp: 'Les avis de paiement utilisent la langue officielle par défaut. Vous pouvez limiter les langues autorisées et les exceptions par contact.',
enabledLanguages: 'Langues activées', timezone: 'Fuseau de facturation', contactLanguage: 'Langue du contact', inheritDefault: 'Utiliser la langue officielle',
publicProfile: 'Profil public', carplaceBasics: 'Paramètres Carplace', premiumBranding: 'Disponible avec GROWTH',
paymentsBody: 'Les paiements de location sont enregistrés par virement bancaire ou chèque. Le paiement en ligne nest pas disponible.',
policies: 'Carburant, conducteur et dommages', additionalDriver: 'Automatisation conducteur additionnel', insuranceNew: 'Nouvelle police',
@@ -234,6 +259,9 @@ function SettingsPageContent() {
subscriptionStatus: 'حالة الاشتراك', manageSubscription: 'إدارة الاشتراك', requiredPlan: 'يتطلب',
lockedTitle: 'هذا القسم غير مشمول في خطتك الحالية.', lockedBody: 'يتم الاحتفاظ بالإعدادات المحفوظة وتعود قابلة للتعديل بعد الترقية.',
readOnly: 'هذا القسم للقراءة فقط أثناء تقييد الوصول.', companyHint: 'تؤثر اللغة الافتراضية على الواجهة والعقود عند توفرها.',
officialLanguageHint: 'هذه هي اللغة الرسمية لمساحة العمل. تستخدمها إشعارات الدفع والتنبيهات ونصوص Carplace والمستندات عند توفرها.',
communicationTitle: 'تواصل الفوترة', communicationHelp: 'تستخدم إشعارات الدفع اللغة الرسمية افتراضياً. يمكن تقييد اللغات المسموحة أو تخصيص لغة كل جهة اتصال.',
enabledLanguages: 'اللغات المفعلة', timezone: 'المنطقة الزمنية للفوترة', contactLanguage: 'لغة جهة الاتصال', inheritDefault: 'استخدام اللغة الرسمية',
publicProfile: 'الملف العام', carplaceBasics: 'أساسيات الواجهة', premiumBranding: 'متاح في GROWTH',
paymentsBody: 'يتم تسجيل مدفوعات الكراء بالتحويل البنكي أو الشيك. الدفع الإلكتروني غير متاح.',
policies: 'سياسات الوقود والسائق والأضرار', additionalDriver: 'أتمتة السائق الإضافي', insuranceNew: 'سياسة تأمين جديدة',
@@ -253,6 +281,7 @@ function SettingsPageContent() {
const [menu, setMenu] = useState<MenuItem[]>([])
const [entitlements, setEntitlements] = useState<Entitlements | null>(null)
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
@@ -301,6 +330,9 @@ function SettingsPageContent() {
if ((activeSection === 'company' || activeSection === 'carplace' || activeSection === 'payments') && !brand) {
setBrand(await apiFetch<BrandSettings | null>('/companies/me/brand'))
}
if (activeSection === 'company' && !communicationSettings) {
setCommunicationSettings(await apiFetch<CommunicationSettings>('/subscriptions/communication-settings'))
}
if (activeSection === 'rental-policies' && !contractSettings) {
setContractSettings(withRentalPolicyDefaults(await apiFetch<ContractSettings | null>('/companies/me/contract-settings'), language))
}
@@ -320,7 +352,7 @@ function SettingsPageContent() {
}
}
loadSection()
}, [activeSection, accountingSettings, brand, contractSettings, insurancePolicies.length, pricingRules.length, entitlements, language])
}, [activeSection, accountingSettings, brand, communicationSettings, contractSettings, insurancePolicies.length, pricingRules.length, entitlements, language])
useEffect(() => {
if (activeSection !== 'rental-policies') return
@@ -331,6 +363,9 @@ function SettingsPageContent() {
if (!brand) return
setSaving(true); setError(null); setMessage(null)
try {
const officialLanguage = (brand.defaultLocale === 'ar' || brand.defaultLocale === 'fr' || brand.defaultLocale === 'en')
? brand.defaultLocale
: language
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
@@ -340,11 +375,25 @@ function SettingsPageContent() {
publicEmail: brand.publicEmail || undefined,
publicPhone: brand.publicPhone || undefined, publicAddress: brand.publicAddress || undefined,
publicCity: brand.publicCity || undefined, publicCountry: brand.publicCountry || undefined,
websiteUrl: brand.websiteUrl || undefined, defaultLocale: brand.defaultLocale || undefined,
websiteUrl: brand.websiteUrl || undefined, defaultLocale: officialLanguage,
whatsappNumber: brand.whatsappNumber || undefined, defaultCurrency: brand.defaultCurrency || undefined,
isListedOnCarplace: brand.isListedOnCarplace,
}),
})
if (communicationSettings) {
const enabledCommunicationLocales = Array.from(new Set([...communicationSettings.enabledCommunicationLocales, officialLanguage as CommunicationLocale]))
const savedCommunicationSettings = await apiFetch<CommunicationSettings>('/subscriptions/communication-settings', {
method: 'PUT',
body: JSON.stringify({
timezone: communicationSettings.timezone,
reminderLocalTime: communicationSettings.reminderLocalTime,
enabledCommunicationLocales,
defaultCommunicationLocale: officialLanguage,
contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact),
}),
})
setCommunicationSettings(savedCommunicationSettings)
}
setBrand(updated); setMessage(copy.saved)
} catch (err: any) {
setError(err.message ?? 'Failed to save brand settings')
@@ -513,10 +562,80 @@ function SettingsPageContent() {
</div>
<Input label={copy.labels.country} value={brand.publicCountry ?? 'Morocco'} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicCountry: v })} />
<Input label={copy.labels.websiteUrl} value={brand.websiteUrl ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, websiteUrl: v })} />
<Select label={copy.labels.defaultLocale} value={brand.defaultLocale ?? language} disabled={!canEdit('settings.locale_currency')} options={['en', 'fr', 'ar']} onChange={(v) => setBrand({ ...brand, defaultLocale: v })} />
<Select label={copy.labels.defaultLocale} value={brand.defaultLocale ?? language} disabled={!canEdit('settings.locale_currency')} options={['en', 'fr', 'ar']} onChange={(v) => {
const officialLanguage = v as CommunicationLocale
setBrand({ ...brand, defaultLocale: officialLanguage })
setCommunicationSettings((current) => current ? {
...current,
enabledCommunicationLocales: Array.from(new Set([...current.enabledCommunicationLocales, officialLanguage])),
defaultCommunicationLocale: officialLanguage,
} : current)
}} />
<Select label={copy.labels.defaultCurrency} value={brand.defaultCurrency ?? 'MAD'} disabled={!canEdit('settings.locale_currency')} options={['MAD', 'EUR', 'USD']} onChange={(v) => setBrand({ ...brand, defaultCurrency: v })} />
</div>
<p className="mt-3 text-xs text-slate-500">{copy.companyHint}</p>
<p className="mt-3 text-xs text-slate-500">{copy.officialLanguageHint}</p>
{communicationSettings ? (
<div className="mt-6 border-t border-slate-200 pt-5">
<h4 className="text-sm font-semibold text-slate-900">{copy.communicationTitle}</h4>
<p className="mt-1 text-xs text-slate-500">{copy.communicationHelp}</p>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<p className="text-sm font-medium text-slate-700">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
const officialLanguage = (brand.defaultLocale ?? language) as CommunicationLocale
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase text-slate-700">
<input
type="checkbox"
checked={checked}
disabled={locale === officialLanguage || !canEdit('settings.locale_currency')}
onChange={() => setCommunicationSettings((current) => {
if (!current) return current
const next = checked
? current.enabledCommunicationLocales.filter((item) => item !== locale)
: [...current.enabledCommunicationLocales, locale]
if (next.length === 0 || !next.includes(officialLanguage)) return current
return {
...current,
enabledCommunicationLocales: next,
contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact),
}
})}
/>
{locale}
</label>
)
})}
</div>
</div>
<Input label={copy.timezone} value={communicationSettings.timezone} disabled={!canEdit('settings.locale_currency')} onChange={(v) => setCommunicationSettings({ ...communicationSettings, timezone: v })} />
</div>
<div className="mt-4 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center">
<div>
<p className="text-sm font-medium text-slate-900">{contact.email}</p>
<p className="text-xs text-slate-500">{contact.isPrimary ? 'Primary / ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} / {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
disabled={!canEdit('settings.locale_currency')}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm text-slate-900"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
</div>
) : null}
</SectionCard>
)}
@@ -93,25 +93,6 @@ interface UpgradeAcceptResult {
instructions: Record<string, string> | null
}
type CommunicationLocale = 'ar' | 'en' | 'fr'
interface CommunicationSettings {
timezone: string
reminderLocalTime: string
enabledCommunicationLocales: CommunicationLocale[]
defaultCommunicationLocale: CommunicationLocale
contacts: Array<{
id?: string
employeeId?: string | null
email: string
locale?: CommunicationLocale | null
effectiveLocale?: CommunicationLocale
isPrimary: boolean
receivePaymentNotices: boolean
isActive: boolean
verified?: boolean
}>
}
interface PlanFeature {
id: string
plan: Plan
@@ -256,8 +237,6 @@ export default function SubscriptionPage() {
const [submittingEvidence, setSubmittingEvidence] = useState(false)
const [checkoutIdempotencyKey, setCheckoutIdempotencyKey] = useState<string | null>(null)
const [submissionIdempotencyKey, setSubmissionIdempotencyKey] = useState<string | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(null)
const [savingCommunicationSettings, setSavingCommunicationSettings] = useState(false)
const copy = {
en: {
title: 'Subscription',
@@ -307,15 +286,6 @@ export default function SubscriptionPage() {
manualDetailsTitle: 'Payment details',
manualDetailsHelp: 'Enter the payment number and attach the supporting file before submitting it for finance review.',
evidenceSubmitted: 'Evidence submitted and locked for finance review.',
communicationTitle: 'Billing communication settings',
communicationHelp: 'Choose the languages your company permits for future payment notices. Each contact receives one notice in their effective language.',
enabledLanguages: 'Enabled languages',
defaultLanguage: 'Default language',
timezone: 'Billing timezone',
contactLanguage: 'Contact language',
inheritDefault: 'Inherit company default',
saveSettings: 'Save communication settings',
settingsSaved: 'Communication settings saved.',
monthly: 'Monthly',
annual: 'Annual (save 20%)',
active: 'Active',
@@ -400,15 +370,6 @@ export default function SubscriptionPage() {
manualDetailsTitle: 'Détails du paiement',
manualDetailsHelp: 'Saisissez le numéro de paiement et joignez le justificatif avant de lenvoyer à la finance.',
evidenceSubmitted: 'Justificatifs soumis et verrouillés pour vérification.',
communicationTitle: 'Paramètres de communication de facturation',
communicationHelp: 'Choisissez les langues autorisées pour les prochains avis de paiement. Chaque contact reçoit un seul avis dans sa langue effective.',
enabledLanguages: 'Langues activées',
defaultLanguage: 'Langue par défaut',
timezone: 'Fuseau horaire de facturation',
contactLanguage: 'Langue du contact',
inheritDefault: 'Hériter de la langue par défaut',
saveSettings: 'Enregistrer les paramètres',
settingsSaved: 'Paramètres de communication enregistrés.',
monthly: 'Mensuel',
annual: 'Annuel (économie 20%)',
active: 'Actif',
@@ -493,15 +454,6 @@ export default function SubscriptionPage() {
manualDetailsTitle: 'تفاصيل الدفع',
manualDetailsHelp: 'أدخل رقم الدفع وأرفق المستند الداعم قبل إرساله إلى فريق المالية.',
evidenceSubmitted: 'تم إرسال المستندات وقفلها لمراجعة فريق المالية.',
communicationTitle: 'إعدادات اتصالات الفوترة',
communicationHelp: 'اختر اللغات التي تسمح بها الشركة لإشعارات الدفع المستقبلية. يتلقى كل مسؤول إشعاراً واحداً بلغته الفعلية.',
enabledLanguages: 'اللغات المفعّلة',
defaultLanguage: 'اللغة الافتراضية',
timezone: 'المنطقة الزمنية للفوترة',
contactLanguage: 'لغة جهة الاتصال',
inheritDefault: 'استخدام لغة الشركة الافتراضية',
saveSettings: 'حفظ إعدادات الاتصال',
settingsSaved: 'تم حفظ إعدادات الاتصال.',
monthly: 'شهري',
annual: 'سنوي (توفير 20%)',
active: 'نشط',
@@ -586,12 +538,10 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
apiFetch<CommunicationSettings>('/subscriptions/communication-settings'),
fetchPlanData(),
])
.then(([sub, inv, options, settings]) => {
.then(([sub, inv, options]) => {
setPaymentOptions(options.methods ?? [])
setCommunicationSettings(settings)
const firstEnabled = options.methods?.find((option) => option.enabled)
if (firstEnabled) setSelectedMethod(firstEnabled.method)
if (sub) {
@@ -786,29 +736,6 @@ export default function SubscriptionPage() {
}
}
async function saveCommunicationSettings() {
if (!communicationSettings) return
setSavingCommunicationSettings(true)
setError(null)
try {
const saved = await apiFetch<CommunicationSettings>('/subscriptions/communication-settings', {
method: 'PUT',
body: JSON.stringify({
timezone: communicationSettings.timezone,
reminderLocalTime: communicationSettings.reminderLocalTime,
enabledCommunicationLocales: communicationSettings.enabledCommunicationLocales,
defaultCommunicationLocale: communicationSettings.defaultCommunicationLocale,
contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact),
}),
})
setCommunicationSettings(saved)
} catch (err: any) {
setError(err.message)
} finally {
setSavingCommunicationSettings(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
@@ -1194,79 +1121,6 @@ export default function SubscriptionPage() {
) : null}
</div>
{communicationSettings ? (
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.communicationTitle}</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.communicationHelp}</p>
<div className="mt-5 grid gap-5 lg:grid-cols-3">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-zinc-300">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase text-slate-700 dark:border-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
checked={checked}
onChange={() => setCommunicationSettings((current) => {
if (!current) return current
const next = checked
? current.enabledCommunicationLocales.filter((item) => item !== locale)
: [...current.enabledCommunicationLocales, locale]
if (next.length === 0) return current
return {
...current,
enabledCommunicationLocales: next,
defaultCommunicationLocale: next.includes(current.defaultCommunicationLocale) ? current.defaultCommunicationLocale : next[0],
contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact),
}
})}
/>
{locale}
</label>
)
})}
</div>
</div>
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
{copy.defaultLanguage}
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
{copy.timezone}
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
</label>
</div>
<div className="mt-5 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center dark:border-zinc-700">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-zinc-100">{contact.email}</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600 dark:text-zinc-300">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
<button type="button" onClick={saveCommunicationSettings} disabled={savingCommunicationSettings} className="btn-primary mt-5">
{savingCommunicationSettings ? copy.loading : copy.saveSettings}
</button>
</div>
) : null}
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 dark:border-zinc-800">
@@ -311,6 +311,25 @@ type DashboardDictionary = {
theme: string
light: string
dark: string
security2fa: {
security: string
enable2fa: string
close: string
emailCode: string
authenticatorApp: string
totp: string
preparingSetup: string
emailCodeSent: (email: string) => string
scanAuthenticator: string
noQrCode: string
sixDigitCode: string
codePlaceholder: string
enterSixDigitCode: string
failedStart: string
invalidCode: string
verifying: string
chooseAnotherMethod: string
}
fleet: FleetDict
vehicleDetail: VehicleDetailDict
calendar: CalendarDict
@@ -365,6 +384,25 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
theme: 'Theme',
light: 'Light',
dark: 'Dark',
security2fa: {
security: 'Security',
enable2fa: 'Enable 2FA',
close: 'Close',
emailCode: 'Email code',
authenticatorApp: 'Authenticator app',
totp: 'TOTP',
preparingSetup: 'Preparing setup...',
emailCodeSent: (email) => `Enter the 6-digit code sent to ${email}.`,
scanAuthenticator: 'Scan the QR code or enter the setup key manually.',
noQrCode: 'No QR code',
sixDigitCode: '6-digit code',
codePlaceholder: '000000',
enterSixDigitCode: 'Enter the 6-digit code.',
failedStart: 'Failed to start 2FA setup.',
invalidCode: 'Invalid 2FA code.',
verifying: 'Verifying...',
chooseAnotherMethod: 'Choose another method',
},
fleet: {
statusLabels: { AVAILABLE: 'Available', RESERVED: 'Reserved', READY: 'Ready for pickup', RENTED: 'On rent', RETURNED: 'Returned', NEEDS_CLEANING: 'Needs cleaning', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Damage review', OUT_OF_SERVICE: 'Out of service' },
logMaintenance: 'Log Maintenance',
@@ -735,6 +773,25 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
security2fa: {
security: 'Sécurité',
enable2fa: 'Activer la 2FA',
close: 'Fermer',
emailCode: 'Code par e-mail',
authenticatorApp: 'Application dauthentification',
totp: 'TOTP',
preparingSetup: 'Préparation de la configuration...',
emailCodeSent: (email) => `Saisissez le code à 6 chiffres envoyé à ${email}.`,
scanAuthenticator: 'Scannez le QR code ou saisissez la clé de configuration manuellement.',
noQrCode: 'Aucun QR code',
sixDigitCode: 'Code à 6 chiffres',
codePlaceholder: '000000',
enterSixDigitCode: 'Saisissez le code à 6 chiffres.',
failedStart: 'Échec du démarrage de la configuration 2FA.',
invalidCode: 'Code 2FA invalide.',
verifying: 'Vérification...',
chooseAnotherMethod: 'Choisir une autre méthode',
},
fleet: {
statusLabels: { AVAILABLE: 'Disponible', RESERVED: 'Réservé', READY: 'Prêt pour remise', RENTED: 'En location', RETURNED: 'Rendu', NEEDS_CLEANING: 'Nettoyage requis', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Révision dommages', OUT_OF_SERVICE: 'Hors service' },
logMaintenance: 'Journal de maintenance',
@@ -1105,6 +1162,25 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
security2fa: {
security: 'الأمان',
enable2fa: 'تفعيل المصادقة الثنائية',
close: 'إغلاق',
emailCode: 'رمز البريد الإلكتروني',
authenticatorApp: 'تطبيق المصادقة',
totp: 'TOTP',
preparingSetup: 'جارٍ تحضير الإعداد...',
emailCodeSent: (email) => `أدخل الرمز المكون من 6 أرقام المرسل إلى ${email}.`,
scanAuthenticator: 'امسح رمز QR أو أدخل مفتاح الإعداد يدويًا.',
noQrCode: 'لا يوجد رمز QR',
sixDigitCode: 'رمز من 6 أرقام',
codePlaceholder: '000000',
enterSixDigitCode: 'أدخل الرمز المكون من 6 أرقام.',
failedStart: 'فشل بدء إعداد المصادقة الثنائية.',
invalidCode: 'رمز المصادقة الثنائية غير صحيح.',
verifying: 'جارٍ التحقق...',
chooseAnotherMethod: 'اختيار طريقة أخرى',
},
fleet: {
statusLabels: { AVAILABLE: 'متاح', RESERVED: 'محجوز', READY: 'جاهز للتسليم', RENTED: 'قيد التأجير', RETURNED: 'مُعاد', NEEDS_CLEANING: 'يحتاج تنظيف', MAINTENANCE: 'صيانة', DAMAGE_REVIEW: 'مراجعة أضرار', OUT_OF_SERVICE: 'خارج الخدمة' },
logMaintenance: 'تسجيل الصيانة',
+213 -1
View File
@@ -1,9 +1,10 @@
'use client'
import Link from 'next/link'
import { Bell, Search, Settings } from 'lucide-react'
import { Bell, Search, Settings, ShieldCheck } from 'lucide-react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveRealtimeSocketTarget } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -35,6 +36,13 @@ export default function TopBar() {
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const [socketEnabled, setSocketEnabled] = useState(false)
const [employee, setEmployee] = useState<{
email: string
firstName: string
lastName: string
totpEnabled?: boolean
} | null>(null)
const [securitySetupOpen, setSecuritySetupOpen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
@@ -156,10 +164,12 @@ export default function TopBar() {
email: string
firstName: string
lastName: string
totpEnabled?: boolean
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
setEmployee(employee)
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
@@ -167,6 +177,7 @@ export default function TopBar() {
})
.catch(() => {
if (cancelled) return
setEmployee(null)
setUserInitials(computeInitials(dict.workspaceUser))
})
@@ -286,10 +297,211 @@ export default function TopBar() {
<Settings className="h-5 w-5" />
</Link>
{employee && !employee.totpEnabled ? (
<button
type="button"
onClick={() => setSecuritySetupOpen(true)}
className="hidden items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm font-semibold text-orange-700 transition-colors hover:bg-orange-100 dark:border-orange-400/20 dark:bg-orange-400/10 dark:text-orange-200 dark:hover:bg-orange-400/15 md:flex"
>
<ShieldCheck className="h-4 w-4" />
{dict.security2fa.enable2fa}
</button>
) : null}
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
</div>
{mounted && employee && securitySetupOpen
? createPortal(
<Employee2FASetupDialog
employee={employee}
copy={dict.security2fa}
onClose={() => setSecuritySetupOpen(false)}
onEnrolled={(updatedEmployee) => {
setEmployee(updatedEmployee)
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(updatedEmployee))
setSecuritySetupOpen(false)
}}
/>,
document.body,
)
: null}
</header>
)
}
function Employee2FASetupDialog({
employee,
copy,
onClose,
onEnrolled,
}: {
employee: { email: string; firstName: string; lastName: string; totpEnabled?: boolean }
copy: ReturnType<typeof useDashboardI18n>['dict']['security2fa']
onClose: () => void
onEnrolled: (employee: { email: string; firstName: string; lastName: string; totpEnabled?: boolean }) => void
}) {
type SetupMethod = 'email' | 'authenticator'
const [method, setMethod] = useState<SetupMethod | null>(null)
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [loadingSetup, setLoadingSetup] = useState(false)
const [verifying, setVerifying] = useState(false)
const [error, setError] = useState<string | null>(null)
async function startSetup(nextMethod: SetupMethod) {
setMethod(nextMethod)
setCode('')
setError(null)
setLoadingSetup(true)
try {
const data = await apiFetch<{ secret?: string; qrCode?: string }>(
nextMethod === 'email' ? '/auth/employee/2fa/email/setup' : '/auth/employee/2fa/setup',
{ method: 'POST', body: JSON.stringify({}) },
)
setSecret(data.secret ?? '')
setQrCode(data.qrCode ?? '')
} catch (err: any) {
setError(err?.message ?? copy.failedStart)
} finally {
setLoadingSetup(false)
}
}
async function verifyCode(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedCode = code.trim()
if (!/^\d{6}$/.test(normalizedCode)) {
setError(copy.enterSixDigitCode)
return
}
setError(null)
setVerifying(true)
try {
const data = await apiFetch<{ employee: typeof employee }>(
method === 'email' ? '/auth/employee/2fa/email/verify' : '/auth/employee/2fa/verify',
{ method: 'POST', body: JSON.stringify({ code: normalizedCode }) },
)
onEnrolled(data.employee ?? { ...employee, totpEnabled: true })
} catch (err: any) {
setError(err?.message ?? copy.invalidCode)
} finally {
setVerifying(false)
}
}
return (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/45 p-6 backdrop-blur-sm">
<section className="w-full max-w-2xl rounded-2xl border border-blue-200/80 bg-white p-6 text-blue-950 shadow-[0_24px_80px_rgba(15,23,42,0.22)] dark:border-blue-400/15 dark:bg-[#0d1728] dark:text-slate-100">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-orange-700 dark:text-orange-300">{copy.security}</p>
<h2 className="mt-2 text-xl font-black">{copy.enable2fa}</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{employee.email}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-blue-200 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-blue-50 dark:border-blue-400/20 dark:text-slate-300 dark:hover:bg-blue-400/10"
>
{copy.close}
</button>
</div>
<form onSubmit={verifyCode} className="mt-6 space-y-5">
{!method ? (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => startSetup('email')}
className="rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:hover:border-orange-400/50"
>
<span className="block text-sm font-semibold">{copy.emailCode}</span>
<span className="mt-2 block text-sm text-slate-500 dark:text-slate-400">{employee.email}</span>
</button>
<button
type="button"
onClick={() => startSetup('authenticator')}
className="rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:hover:border-orange-400/50"
>
<span className="block text-sm font-semibold">{copy.authenticatorApp}</span>
<span className="mt-2 block text-sm text-slate-500 dark:text-slate-400">{copy.totp}</span>
</button>
</div>
) : loadingSetup ? (
<div className="flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-sm text-slate-600 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
{copy.preparingSetup}
</div>
) : method === 'email' ? (
<div className="rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-sm text-slate-600 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:text-slate-300">
{copy.emailCodeSent(employee.email)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-xl border border-blue-200 bg-white p-3 dark:border-blue-400/15">
{qrCode ? <img src={qrCode} alt={copy.enable2fa} className="h-full w-full object-contain" /> : <span className="text-sm text-slate-500">{copy.noQrCode}</span>}
</div>
<div>
<p className="text-sm font-semibold">{copy.authenticatorApp}</p>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{copy.scanAuthenticator}</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-950 dark:border-blue-400/15 dark:bg-blue-950/70 dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold">{copy.sixDigitCode}</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
placeholder={copy.codePlaceholder}
disabled={!method || loadingSetup || verifying}
className="w-full rounded-xl border border-blue-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-blue-950 outline-none transition focus:ring-2 focus:ring-orange-500 disabled:opacity-60 dark:border-blue-400/15 dark:bg-blue-950/70 dark:text-slate-100"
/>
</label>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 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}
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="submit"
disabled={!method || loadingSetup || verifying || code.length !== 6}
className="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-500 dark:hover:bg-orange-400"
>
{verifying ? copy.verifying : copy.enable2fa}
</button>
{method ? (
<button
type="button"
onClick={() => {
setMethod(null)
setCode('')
setError(null)
setSecret('')
setQrCode('')
}}
className="rounded-full border border-blue-200 px-6 py-3 text-sm font-semibold text-slate-600 transition hover:bg-blue-50 dark:border-blue-400/20 dark:text-slate-300 dark:hover:bg-blue-400/10"
>
{copy.chooseAnotherMethod}
</button>
) : null}
</div>
</form>
</section>
</div>
)
}