This commit is contained in:
root
2026-05-22 02:43:13 -04:00
parent 191fb3cb4e
commit 99c429d69c
21 changed files with 276 additions and 94 deletions
@@ -128,7 +128,7 @@ export default function AdminBillingPage() {
eyebrow: 'Plateforme', eyebrow: 'Plateforme',
mrr: 'MRR (MAD)', mrr: 'MRR (MAD)',
active: 'Actifs', active: 'Actifs',
trialing: 'Essai', trialing: 'En essai',
pastDue: 'En retard', pastDue: 'En retard',
cancelled: 'Annulés', cancelled: 'Annulés',
company: 'Entreprise', company: 'Entreprise',
@@ -155,9 +155,9 @@ export default function AdminBillingPage() {
provider: 'Fournisseur', provider: 'Fournisseur',
paidAt: 'Payé le', paidAt: 'Payé le',
date: 'Date', date: 'Date',
invoiceNo: 'Facture', invoiceNo: 'Facture',
download: 'PDF', download: 'PDF',
cancelAtEnd: 'Annulé en fin de période', cancelAtEnd: 'Sera annulé en fin de période',
}, },
ar: { ar: {
title: 'الفوترة', title: 'الفوترة',
@@ -193,7 +193,7 @@ export default function AdminBillingPage() {
date: 'التاريخ', date: 'التاريخ',
invoiceNo: 'رقم الفاتورة', invoiceNo: 'رقم الفاتورة',
download: 'PDF', download: 'PDF',
cancelAtEnd: 'يُلغى في نهاية الفترة', cancelAtEnd: 'سيُلغى في نهاية الفترة',
}, },
}[language] }[language]
@@ -58,7 +58,7 @@ export default function AdminCompaniesPage() {
}, },
ar: { ar: {
title: 'الشركات', title: 'الشركات',
search: 'ابحث بالاسم أو الـ slug…', search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
loading: 'جارٍ التحميل…', loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على شركات', empty: 'لم يتم العثور على شركات',
company: 'الشركة', company: 'الشركة',
@@ -66,7 +66,7 @@ export default function AdminCompaniesPage() {
status: 'الحالة', status: 'الحالة',
plan: 'الخطة', plan: 'الخطة',
fleet: 'الأسطول', fleet: 'الأسطول',
vehicles: 'سيارات', vehicles: 'مركبة',
view: 'عرض', view: 'عرض',
suspend: 'تعليق', suspend: 'تعليق',
reactivate: 'إعادة التفعيل', reactivate: 'إعادة التفعيل',
+7 -7
View File
@@ -27,20 +27,20 @@ export default function AdminDashboardPage() {
}, },
fr: { fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'], kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
platformOverview: 'Vue plateforme', platformOverview: 'Vue densemble de la plateforme',
cards: [ cards: [
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'], ['Entreprises', 'Lister, rechercher, suspendre, réactiver et consulter l’état des abonnements.', 'Voir tout →'],
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'], ['Locataires', 'Gérer lassistance liée au blocage et au déblocage des locataires de la marketplace.', 'Voir tout →'],
['Journaux daudit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'], ['Journaux daudit', 'Consulter la trace complète de chaque action dadministration sur la plateforme.', 'Voir les journaux →'],
], ],
}, },
ar: { ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'], kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
platformOverview: 'نظرة عامة على المنصة', platformOverview: 'نظرة عامة على المنصة',
cards: [ cards: [
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'], ['الشركات', 'اعرض الشركات وابحث عنها وعلّقها وأعد تفعيلها وراجع حالة الاشتراك.', 'عرض الكل ←'],
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'], ['المستأجرون', 'إدارة طلبات الدعم الخاصة بحظر مستأجري السوق وفك الحظر عنهم.', 'عرض الكل ←'],
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'], ['سجلات التدقيق', 'مراجعة السجل الكامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
], ],
}, },
}[language] }[language]
@@ -36,10 +36,10 @@ export default function AdminRentersPage() {
title: 'Locataires', title: 'Locataires',
search: 'Rechercher des locataires…', search: 'Rechercher des locataires…',
name: 'Nom', name: 'Nom',
email: 'Email', email: 'E-mail',
phone: 'Téléphone', phone: 'Téléphone',
status: 'Statut', status: 'Statut',
joined: 'Inscrit', joined: 'Date dinscription',
loading: 'Chargement…', loading: 'Chargement…',
empty: 'Aucun locataire trouvé', empty: 'Aucun locataire trouvé',
blocked: 'Bloqué', blocked: 'Bloqué',
@@ -54,7 +54,7 @@ export default function AdminRentersPage() {
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
phone: 'الهاتف', phone: 'الهاتف',
status: 'الحالة', status: 'الحالة',
joined: 'تاريخ الانضمام', joined: 'تاريخ التسجيل',
loading: 'جارٍ التحميل…', loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على مستأجرين', empty: 'لم يتم العثور على مستأجرين',
blocked: 'محظور', blocked: 'محظور',
+5 -5
View File
@@ -12,7 +12,7 @@
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
"heroTitle": "Marketplace discovery with a sharper front door.", "heroTitle": "Marketplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the companys own branded checkout.", "heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial", "startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles", "exploreVehicles": "Explore vehicles",
"surfaceLabel": "Marketplace surface", "surfaceLabel": "Marketplace surface",
@@ -23,11 +23,11 @@
"brandedFlows": "Branded booking flows", "brandedFlows": "Branded booking flows",
"multiTenant": "Multi-tenant operations", "multiTenant": "Multi-tenant operations",
"companyKicker": "Operator workflow", "companyKicker": "Operator workflow",
"companyTitle": "Control inventory, offers, billing, and staff from one command layer.", "companyTitle": "Manage inventory, offers, billing, and staff from one control layer.",
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.", "companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
"renterKicker": "Renter experience", "renterKicker": "Renter experience",
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.", "renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.", "renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
"pillars": [ "pillars": [
{ {
"title": "Unified publishing", "title": "Unified publishing",
@@ -83,8 +83,8 @@
], ],
"stepLabel": "Step", "stepLabel": "Step",
"readyKicker": "Ready to launch", "readyKicker": "Ready to launch",
"readyTitle": "A marketplace homepage should sell the system in seconds.", "readyTitle": "The marketplace homepage should explain the product in seconds.",
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.", "readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
"viewPricing": "View pricing", "viewPricing": "View pricing",
"createWorkspace": "Create workspace" "createWorkspace": "Create workspace"
}, },
@@ -182,8 +182,8 @@ export default function BillingPage() {
cancel: 'Annuler', cancel: 'Annuler',
savingPayment: 'Enregistrement…', savingPayment: 'Enregistrement…',
savePayment: 'Enregistrer le paiement', savePayment: 'Enregistrer le paiement',
chargeHelp: 'Paiement enregistre un règlement sur le solde de la réservation.', chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.',
depositHelp: 'Dépôt enregistre lencaissement du dépôt de garantie remboursable.', depositHelp: 'Le dépôt enregistre lencaissement du dépôt de garantie remboursable.',
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.', invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
paymentActionDisabled: 'Aucun solde dû', paymentActionDisabled: 'Aucun solde dû',
paymentsRecorded: (count: number) => `${count} paiement(s)`, paymentsRecorded: (count: number) => `${count} paiement(s)`,
@@ -257,7 +257,7 @@ export default function BillingPage() {
cancel: 'إلغاء', cancel: 'إلغاء',
savingPayment: 'جارٍ الحفظ…', savingPayment: 'جارٍ الحفظ…',
savePayment: 'حفظ الدفعة', savePayment: 'حفظ الدفعة',
chargeHelp: 'الدفعة تسجل مبلغاً مستلماً على رصيد الحجز.', chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.',
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.', depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.', invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
paymentActionDisabled: 'لا يوجد رصيد مستحق', paymentActionDisabled: 'لا يوجد رصيد مستحق',
@@ -22,7 +22,7 @@ export default function CustomersPage() {
const copy = { const copy = {
en: { en: {
title: 'Customers', title: 'Customers',
subtitle: 'Company-scoped CRM with license validation and risk flags.', subtitle: 'A company CRM with license validation and risk flags.',
customer: 'Customer', customer: 'Customer',
contact: 'Contact', contact: 'Contact',
license: 'License', license: 'License',
@@ -36,13 +36,13 @@ export default function CustomersPage() {
}, },
fr: { fr: {
title: 'Clients', title: 'Clients',
subtitle: 'CRM de lentreprise avec validation de permis et indicateurs de risque.', subtitle: 'CRM de lentreprise avec validation des permis et indicateurs de risque.',
customer: 'Client', customer: 'Client',
contact: 'Contact', contact: 'Contact',
license: 'Permis', license: 'Permis',
flags: 'Signalements', flags: 'Signalements',
imageUploaded: 'Image téléversée', imageUploaded: 'Image téléversée',
imageMissing: 'Pas dimage', imageMissing: 'Aucune image',
noPhone: 'Pas de téléphone', noPhone: 'Pas de téléphone',
flagged: 'Signalé', flagged: 'Signalé',
clear: 'Aucun risque', clear: 'Aucun risque',
@@ -50,16 +50,16 @@ export default function CustomersPage() {
}, },
ar: { ar: {
title: 'العملاء', title: 'العملاء',
subtitle: 'نظام CRM خاص بالشركة مع التحقق من الرخصة ومؤشرات المخاطر.', subtitle: 'نظام إدارة عملاء خاص بالشركة مع التحقق من الرخص ومؤشرات المخاطر.',
customer: 'العميل', customer: 'العميل',
contact: 'التواصل', contact: 'التواصل',
license: 'الرخصة', license: 'الرخصة',
flags: 'الإشارات', flags: 'العلامات',
imageUploaded: 'تم رفع الصورة', imageUploaded: 'تم رفع الصورة',
imageMissing: 'لا توجد صورة', imageMissing: 'لا توجد صورة',
noPhone: 'لا يوجد هاتف', noPhone: 'لا يوجد هاتف',
flagged: 'مُعلّم', flagged: 'تم تمييزه',
clear: 'سليم', clear: 'لا توجد مخاطر',
empty: 'لا يوجد عملاء حتى الآن.', empty: 'لا يوجد عملاء حتى الآن.',
}, },
}[language] }[language]
@@ -93,7 +93,7 @@ export default function OnlineReservationsPage() {
refresh: 'Actualiser', refresh: 'Actualiser',
pendingApproval: 'En attente de validation', pendingApproval: 'En attente de validation',
loading: 'Chargement…', loading: 'Chargement…',
allCaughtUp: 'Tout est à jour, aucune demande en attente.', allCaughtUp: 'Tout est à jour. Aucune demande en attente.',
customer: 'Client', customer: 'Client',
vehicle: 'Véhicule', vehicle: 'Véhicule',
dates: 'Dates', dates: 'Dates',
@@ -102,9 +102,9 @@ export default function OnlineReservationsPage() {
}, },
ar: { ar: {
title: 'الحجوزات الإلكترونية', title: 'الحجوزات الإلكترونية',
subtitle: 'طلبات مقدمة من العملاء عبر السوق. قم بالتأكيد أو الرفض لكل طلب.', subtitle: 'طلبات أرسلها العملاء عبر السوق. أكّد كل طلب أو ارفضه.',
refresh: 'تحديث', refresh: 'تحديث',
pendingApproval: 'بانتظار الموافقة', pendingApproval: 'في انتظار الموافقة',
loading: 'جارٍ التحميل…', loading: 'جارٍ التحميل…',
allCaughtUp: 'لا توجد طلبات معلقة حالياً.', allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
customer: 'العميل', customer: 'العميل',
@@ -40,7 +40,7 @@ export default function ReportsPage() {
const copy = { const copy = {
en: { en: {
title: 'Reports', title: 'Reports',
subtitle: 'Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.', subtitle: 'Accountant-ready exports with totals for insurance, additional drivers, and pricing rules.',
exportCsv: 'Export CSV', exportCsv: 'Export CSV',
exporting: 'Exporting…', exporting: 'Exporting…',
bookings: 'Bookings', bookings: 'Bookings',
@@ -60,15 +60,15 @@ export default function ReportsPage() {
}, },
fr: { fr: {
title: 'Rapports', title: 'Rapports',
subtitle: 'Exports prêts pour le comptable avec totaux assurance, conducteurs supplémentaires et règles tarifaires.', subtitle: 'Exports prêts pour la comptabilité avec les totaux dassurance, de conducteurs supplémentaires et de règles tarifaires.',
exportCsv: 'Exporter CSV', exportCsv: 'Exporter en CSV',
exporting: 'Export en cours…', exporting: 'Export en cours…',
bookings: 'Réservations', bookings: 'Réservations',
rental: 'Location', rental: 'Location',
insurance: 'Assurance', insurance: 'Assurance',
drivers: 'Conducteurs', drivers: 'Conducteurs',
discounts: 'Remises', discounts: 'Remises',
collected: 'Collecté', collected: 'Encaissé',
reservation: 'Réservation', reservation: 'Réservation',
vehicle: 'Véhicule', vehicle: 'Véhicule',
period: 'Période', period: 'Période',
@@ -80,7 +80,7 @@ export default function ReportsPage() {
}, },
ar: { ar: {
title: 'التقارير', title: 'التقارير',
subtitle: 'تصدير جاهز للمحاسبة مع إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.', subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV', exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…', exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات', bookings: 'الحجوزات',
@@ -13,7 +13,7 @@ interface Props {
const copy = { const copy = {
en: { en: {
title: 'Invite a team member', title: 'Invite a team member',
subtitle: "They'll receive an email invitation to join your dashboard", subtitle: "They'll receive an email invitation to join your workspace.",
firstName: 'First name', firstName: 'First name',
lastName: 'Last name', lastName: 'Last name',
email: 'Email address', email: 'Email address',
@@ -27,7 +27,7 @@ const copy = {
{ {
value: 'MANAGER' as const, value: 'MANAGER' as const,
label: 'Manager', label: 'Manager',
description: 'Full fleet ops — no billing or team settings', description: 'Full fleet operations, without billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'], permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
}, },
{ {
@@ -40,10 +40,10 @@ const copy = {
}, },
fr: { fr: {
title: 'Inviter un membre', title: 'Inviter un membre',
subtitle: "Il recevra un email d'invitation pour rejoindre votre tableau de bord", subtitle: "Cette personne recevra un e-mail d'invitation pour rejoindre votre espace de travail.",
firstName: 'Prénom', firstName: 'Prénom',
lastName: 'Nom', lastName: 'Nom',
email: 'Adresse email', email: 'Adresse e-mail',
role: 'Rôle', role: 'Rôle',
cancel: 'Annuler', cancel: 'Annuler',
sending: 'Envoi…', sending: 'Envoi…',
@@ -54,8 +54,8 @@ const copy = {
{ {
value: 'MANAGER' as const, value: 'MANAGER' as const,
label: 'Manager', label: 'Manager',
description: 'Opérations complètes sans facturation ni paramètres équipe', description: 'Opérations complètes, sans accès à la facturation ni aux paramètres de léquipe',
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation permis'], permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation des permis'],
}, },
{ {
value: 'AGENT' as const, value: 'AGENT' as const,
@@ -67,7 +67,7 @@ const copy = {
}, },
ar: { ar: {
title: 'دعوة عضو في الفريق', title: 'دعوة عضو في الفريق',
subtitle: 'سيتلقى بريداً إلكترونياً للانضمام إلى لوحة التحكم', subtitle: 'سيتلقى رسالة بريد إلكتروني للانضمام إلى مساحة العمل.',
firstName: 'الاسم الأول', firstName: 'الاسم الأول',
lastName: 'اسم العائلة', lastName: 'اسم العائلة',
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
@@ -81,7 +81,7 @@ const copy = {
{ {
value: 'MANAGER' as const, value: 'MANAGER' as const,
label: 'مدير', label: 'مدير',
description: 'عمليات أسطول كاملة — بدون الفوترة أو إعدادات الفريق', description: 'صلاحيات تشغيل كاملة للأسطول، من دون الفوترة أو إعدادات الفريق',
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'], permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
}, },
{ {
@@ -68,9 +68,9 @@ const copy = {
], ],
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''], agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
}, },
fr: { fr: {
heading: 'Permissions par rôle', heading: 'Permissions par rôle',
subtitle: 'Ce que chaque rôle peut faire dans le tableau de bord. Les propriétaires ont un accès complet.', subtitle: 'Ce que chaque rôle peut faire dans lespace de travail. Les propriétaires ont un accès complet à lensemble du système.',
colFeature: 'Fonctionnalité', colFeature: 'Fonctionnalité',
colManager: 'Manager', colManager: 'Manager',
colAgent: 'Agent', colAgent: 'Agent',
@@ -81,7 +81,7 @@ const copy = {
'Voir la flotte et les véhicules', 'Voir la flotte et les véhicules',
'Ajouter / modifier des véhicules', 'Ajouter / modifier des véhicules',
'Publier / dépublier un véhicule', 'Publier / dépublier un véhicule',
'Télécharger des photos de véhicule', 'Téléverser des photos de véhicule',
'Voir les réservations', 'Voir les réservations',
'Créer des réservations', 'Créer des réservations',
'Annuler des réservations', 'Annuler des réservations',
@@ -90,20 +90,20 @@ const copy = {
'CRM clients — consultation', 'CRM clients — consultation',
'CRM clients — modification', 'CRM clients — modification',
'Approuver les permis de conduire', 'Approuver les permis de conduire',
'Signaler / blacklister un client', 'Signaler / bloquer un client',
'Offres et promotions', 'Offres et promotions',
'Analyses et rapports', 'Analyses et rapports',
'Contrat et facture PDF', 'Contrat et facture PDF',
'Facturation et abonnement', 'Facturation et abonnement',
'Inviter / supprimer du personnel', 'Inviter / supprimer des membres de l’équipe',
'Paramètres marque et site', 'Paramètres marque et site',
'Paramètres de paiement', 'Paramètres de paiement',
], ],
agentNotes: ['', '', '', '', '', '', 'Les siennes', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''], agentNotes: ['', '', '', '', '', '', 'Les siennes uniquement', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
}, },
ar: { ar: {
heading: 'صلاحيات الأدوار', heading: 'صلاحيات الأدوار',
subtitle: 'ما يمكن لكل دور القيام به في لوحة التحكم. يملك الملاك وصولاً كاملاً لكل شيء.', subtitle: 'ما يمكن لكل دور القيام به داخل مساحة العمل. يملك المالكون صلاحية كاملة على كل شيء.',
colFeature: 'الميزة', colFeature: 'الميزة',
colManager: 'المدير', colManager: 'المدير',
colAgent: 'الوكيل', colAgent: 'الوكيل',
@@ -120,13 +120,13 @@ const copy = {
'إلغاء الحجوزات', 'إلغاء الحجوزات',
'الاستلام / الإرجاع', 'الاستلام / الإرجاع',
'فحص الأضرار', 'فحص الأضرار',
'CRM العملاء — عرض', 'إدارة العملاء — عرض',
'CRM العملاء — تعديل', 'إدارة العملاء — تعديل',
'الموافقة على رخص القيادة', 'الموافقة على رخص القيادة',
عليم / حظر العميل', مييز العميل / حظره',
'العروض والترويجات', 'العروض والحملات الترويجية',
'التحليلات والتقارير', 'التحليلات والتقارير',
'العقد وفاتورة PDF', 'العقد والفاتورة بصيغة PDF',
'الفوترة والاشتراك', 'الفوترة والاشتراك',
'دعوة / إزالة الموظفين', 'دعوة / إزالة الموظفين',
'إعدادات العلامة والموقع', 'إعدادات العلامة والموقع',
@@ -45,7 +45,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailable: 'Marketplace indisponible', unavailable: 'Marketplace indisponible',
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.", unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.", unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
backToExplore: 'Retour à explorer', backToExplore: 'Retour à lexploration',
partner: 'Partenaire marketplace', partner: 'Partenaire marketplace',
vehicles: 'véhicules', vehicles: 'véhicules',
offers: 'offres actives', offers: 'offres actives',
@@ -62,10 +62,10 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
ar: { ar: {
unavailable: 'السوق غير متاح', unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.', unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة marketplace تعمل، لكن قاعدة البيانات غير متاحة في هذا البيئة المحلية.', unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.',
backToExplore: 'العودة إلى الاستكشاف', backToExplore: 'العودة إلى الاستكشاف',
partner: 'شريك في السوق', partner: 'شريك في السوق',
vehicles: 'سيارات', vehicles: 'مركبات',
offers: 'عروض نشطة', offers: 'عروض نشطة',
rating: 'التقييم', rating: 'التقييم',
new: 'جديد', new: 'جديد',
@@ -60,7 +60,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.', unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.", unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
backToFleet: 'Retour à la flotte', backToFleet: 'Retour à la flotte',
backToExplore: 'Retour à explorer', backToExplore: 'Retour à lexploration',
noPhotos: 'Aucune photo disponible.', noPhotos: 'Aucune photo disponible.',
rentalCompany: 'Société de location', rentalCompany: 'Société de location',
perDay: '/ jour', perDay: '/ jour',
@@ -81,7 +81,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
ar: { ar: {
unavailable: 'السوق غير متاح', unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.', unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.', unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.',
backToFleet: 'العودة إلى الأسطول', backToFleet: 'العودة إلى الأسطول',
backToExplore: 'العودة إلى الاستكشاف', backToExplore: 'العودة إلى الاستكشاف',
noPhotos: 'لا توجد صور متاحة.', noPhotos: 'لا توجد صور متاحة.',
@@ -87,24 +87,24 @@ const copy = {
annual: 'Annuel', annual: 'Annuel',
yearly: '/ an', yearly: '/ an',
youSave: 'Vous économisez', youSave: 'Vous économisez',
withAnnual: 'avec la facturation annuelle prélevée en un seul paiement par an.', withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
mostPopular: 'Le plus populaire', mostPopular: 'Le plus populaire',
perMonth: '/mois', perMonth: '/mois',
billedAs: 'Facturé à', billedAs: 'Facturé à',
getStarted: 'Commencer', getStarted: 'Commencer',
footer: 'Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.', footer: 'Toutes les formules incluent un essai gratuit de 14 jours. Aucune carte bancaire nest requise.',
}, },
ar: { ar: {
monthly: 'شهري', monthly: 'شهري',
annual: 'سنوي', annual: 'سنوي',
yearly: '/ سنة', yearly: '/ سنة',
youSave: 'توفر', youSave: 'توفر',
withAnnual: 'مع الفوترة السنوية دفعة واحدة كل سنة.', withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
mostPopular: 'الأكثر شيوعاً', mostPopular: 'الأكثر شيوعاً',
perMonth: '/شهر', perMonth: '/شهر',
billedAs: 'يتم الفوترة بمبلغ', billedAs: 'يتم الفوترة بمبلغ',
getStarted: 'ابدأ الآن', getStarted: 'ابدأ الآن',
footer: 'جميع الخطط تشمل تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.', footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
}, },
} as const } as const
@@ -19,24 +19,24 @@ const copy = {
fr: { fr: {
kicker: 'Tarifs', kicker: 'Tarifs',
title: 'Une seule plateforme pour toutes les tailles de flotte.', title: 'Une seule plateforme pour toutes les tailles de flotte.',
body: 'Commencez gratuitement pendant 14 jours puis choisissez le plan qui accompagne votre activité. Changez ou annulez à tout moment.', body: 'Commencez gratuitement pendant 14 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
faq: 'Questions fréquentes', faq: 'Questions fréquentes',
items: [ items: [
['Puis-je changer de plan plus tard ?', 'Oui. Vous pouvez monter ou descendre à tout moment — les changements prennent effet au prochain cycle de facturation.'], ['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
['Quest-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord flotte, actif ou non.'], ['Quest-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord de flotte, quil soit actif ou non.'],
['Y a-t-il des frais dinstallation ?', 'Aucun frais dinstallation. Vous payez uniquement labonnement mensuel ou annuel.'], ['Y a-t-il des frais dinstallation ?', 'Aucun frais dinstallation. Vous payez uniquement labonnement mensuel ou annuel.'],
['Comment fonctionne la visibilité marketplace ?', 'Tous les plans payants incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'], ['Comment fonctionne la visibilité marketplace ?', 'Toutes les formules payantes incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
], ],
}, },
ar: { ar: {
kicker: 'الأسعار', kicker: 'الأسعار',
title: 'منصة واحدة لكل أحجام الأساطيل.', title: 'منصة واحدة لكل أحجام الأساطيل.',
body: 'ابدأ مجاناً لمدة 14 يوماً ثم اختر الخطة التي تنمو مع نشاطك. يمكنك تغيير الخطة أو الإلغاء في أي وقت.', body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.',
faq: 'الأسئلة الشائعة', faq: 'الأسئلة الشائعة',
items: [ items: [
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'], ['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'], ['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط الاشتراك الشهري أو السنوي.'], ['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'],
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'], ['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
], ],
}, },
@@ -59,7 +59,7 @@ export default function RenterDashboardPage() {
fr: { fr: {
loadProfile: 'Impossible de charger le profil.', loadProfile: 'Impossible de charger le profil.',
reachServer: 'Impossible de joindre le serveur.', reachServer: 'Impossible de joindre le serveur.',
backToExplore: 'Retour à explorer', backToExplore: 'Retour à lexploration',
renterDashboard: 'Espace client', renterDashboard: 'Espace client',
welcome: 'Bon retour,', welcome: 'Bon retour,',
exploreVehicles: 'Explorer les véhicules', exploreVehicles: 'Explorer les véhicules',
@@ -72,7 +72,7 @@ export default function RenterDashboardPage() {
yourProfile: 'Votre profil', yourProfile: 'Votre profil',
firstName: 'Prénom', firstName: 'Prénom',
lastName: 'Nom', lastName: 'Nom',
email: 'Email', email: 'E-mail',
phone: 'Téléphone', phone: 'Téléphone',
savedCompanies: 'Entreprises sauvegardées', savedCompanies: 'Entreprises sauvegardées',
noneSaved: 'Aucune pour le moment', noneSaved: 'Aucune pour le moment',
@@ -85,7 +85,7 @@ export default function RenterDashboardPage() {
loadProfile: 'تعذر تحميل الملف الشخصي.', loadProfile: 'تعذر تحميل الملف الشخصي.',
reachServer: 'تعذر الوصول إلى الخادم.', reachServer: 'تعذر الوصول إلى الخادم.',
backToExplore: 'العودة إلى الاستكشاف', backToExplore: 'العودة إلى الاستكشاف',
renterDashboard: 'لوحة المستأجر', renterDashboard: 'بوابة المستأجر',
welcome: 'مرحباً بعودتك،', welcome: 'مرحباً بعودتك،',
exploreVehicles: 'استكشف السيارات', exploreVehicles: 'استكشف السيارات',
logout: 'تسجيل الخروج', logout: 'تسجيل الخروج',
@@ -35,7 +35,7 @@ export default function RenterNotificationsPage() {
renter: 'Client', renter: 'Client',
title: 'Notifications', title: 'Notifications',
markAll: 'Tout marquer comme lu', markAll: 'Tout marquer comme lu',
back: 'Retour au dashboard', back: 'Retour au tableau de bord',
loading: 'Chargement des notifications…', loading: 'Chargement des notifications…',
empty: 'Aucune notification client pour le moment.', empty: 'Aucune notification client pour le moment.',
read: 'Lu', read: 'Lu',
@@ -47,12 +47,12 @@ export default function RenterNotificationsPage() {
updateAll: 'تعذر تحديث الإشعارات.', updateAll: 'تعذر تحديث الإشعارات.',
renter: 'المستأجر', renter: 'المستأجر',
title: 'الإشعارات', title: 'الإشعارات',
markAll: حديد الكل كمقروء', markAll: عيين الكل كمقروء',
back: 'العودة إلى اللوحة', back: 'العودة إلى بوابة المستأجر',
loading: 'جارٍ تحميل الإشعارات…', loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات للمستأجر حالياً.', empty: 'لا توجد إشعارات للمستأجر حالياً.',
read: 'مقروء', read: 'مقروء',
markRead: حديد كمقروء', markRead: عيين كمقروء',
}, },
}[language] }[language]
const [notifications, setNotifications] = useState<RenterNotification[]>([]) const [notifications, setNotifications] = useState<RenterNotification[]>([])
@@ -24,7 +24,7 @@ export default function RenterSavedCompaniesPage() {
load: 'Impossible de charger les entreprises sauvegardées.', load: 'Impossible de charger les entreprises sauvegardées.',
renter: 'Client', renter: 'Client',
title: 'Entreprises sauvegardées', title: 'Entreprises sauvegardées',
back: 'Retour au dashboard', back: 'Retour au tableau de bord',
loading: 'Chargement des entreprises sauvegardées…', loading: 'Chargement des entreprises sauvegardées…',
empty: 'Vous navez encore enregistré aucune entreprise.', empty: 'Vous navez encore enregistré aucune entreprise.',
browse: 'Parcourir la marketplace', browse: 'Parcourir la marketplace',
@@ -34,7 +34,7 @@ export default function RenterSavedCompaniesPage() {
load: 'تعذر تحميل الشركات المحفوظة.', load: 'تعذر تحميل الشركات المحفوظة.',
renter: 'المستأجر', renter: 'المستأجر',
title: 'الشركات المحفوظة', title: 'الشركات المحفوظة',
back: 'العودة إلى اللوحة', back: 'العودة إلى بوابة المستأجر',
loading: 'جارٍ تحميل الشركات المحفوظة…', loading: 'جارٍ تحميل الشركات المحفوظة…',
empty: 'لم تقم بحفظ أي شركة بعد.', empty: 'لم تقم بحفظ أي شركة بعد.',
browse: 'تصفح السوق', browse: 'تصفح السوق',
@@ -23,18 +23,19 @@ const copy = {
submit: 'Request reservation', submit: 'Request reservation',
submitting: 'Sending…', submitting: 'Sending…',
successTitle: 'Request received!', successTitle: 'Request received!',
successBody: 'Your reservation request has been sent. The company will review it and get in touch with you shortly.', successBody: 'Your reservation request has been sent. The company will review it and contact you shortly.',
invalidDates: 'Return date must be after pick-up date.', invalidDates: 'Return date must be after pick-up date.',
required: 'Please fill in all required fields.', required: 'Please fill in all required fields.',
perDay: '/ day', perDay: '/ day',
estimatedTotal: 'Estimated total', estimatedTotal: 'Estimated total',
days: 'day(s)', days: 'day(s)',
genericError: 'Something went wrong. Please try again.',
}, },
fr: { fr: {
title: 'Réserver ce véhicule', title: 'Réserver ce véhicule',
firstName: 'Prénom', firstName: 'Prénom',
lastName: 'Nom', lastName: 'Nom',
email: 'Email', email: 'E-mail',
phone: 'Téléphone', phone: 'Téléphone',
startDate: 'Date de départ', startDate: 'Date de départ',
endDate: 'Date de retour', endDate: 'Date de retour',
@@ -42,12 +43,13 @@ const copy = {
submit: 'Demander une réservation', submit: 'Demander une réservation',
submitting: 'Envoi…', submitting: 'Envoi…',
successTitle: 'Demande reçue !', successTitle: 'Demande reçue !',
successBody: "Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.", successBody: "Votre demande de réservation a été envoyée. Lentreprise va lexaminer et vous contactera prochainement.",
invalidDates: 'La date de retour doit être après la date de départ.', invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Veuillez remplir tous les champs obligatoires.', required: 'Veuillez remplir tous les champs obligatoires.',
perDay: '/ jour', perDay: '/ jour',
estimatedTotal: 'Total estimé', estimatedTotal: 'Total estimé',
days: 'jour(s)', days: 'jour(s)',
genericError: 'Une erreur est survenue. Veuillez réessayer.',
}, },
ar: { ar: {
title: 'احجز هذه السيارة', title: 'احجز هذه السيارة',
@@ -56,7 +58,7 @@ const copy = {
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
phone: 'الهاتف', phone: 'الهاتف',
startDate: 'تاريخ الاستلام', startDate: 'تاريخ الاستلام',
endDate: 'تاريخ الإعادة', endDate: 'تاريخ الإرجاع',
notes: 'ملاحظات (اختيارية)', notes: 'ملاحظات (اختيارية)',
submit: 'طلب حجز', submit: 'طلب حجز',
submitting: 'جارٍ الإرسال…', submitting: 'جارٍ الإرسال…',
@@ -67,6 +69,7 @@ const copy = {
perDay: '/ يوم', perDay: '/ يوم',
estimatedTotal: 'الإجمالي التقديري', estimatedTotal: 'الإجمالي التقديري',
days: 'يوم', days: 'يوم',
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
}, },
} as const } as const
@@ -123,7 +126,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
}) })
setSuccess(true) setSuccess(true)
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Something went wrong. Please try again.') setError(err.message ?? t.genericError)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -0,0 +1,179 @@
# Security Vulnerability Fix Report
**Date:** 2026-05-22
**Scope:** API (`apps/api`) — automated scan + manual review
**Branch:** `develop`
---
## Summary
A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, marketplace, public-site) found no confirmed vulnerabilities above the reporting threshold.
| # | Severity | Category | Status |
|---|----------|----------|--------|
| [VF-01](#vf-01--payment-credential-exposure) | High | Credential / Data Exposure | Fixed |
| [VF-02](#vf-02--webhook-signature-bypass) | High | Authentication Bypass | Fixed |
| [VF-03](#vf-03--idor-on-vehicle-maintenance-logs) | Medium-High | IDOR / Tenant Isolation | Fixed |
---
## VF-01 — Payment Credential Exposure
**Severity:** High
**Confidence:** 9/10
**Category:** Credential / Data Exposure
### Description
`GET /api/v1/companies/me/brand` returned the full `BrandSettings` database row, including the live payment gateway credentials `amanpaySecretKey`, `amanpayMerchantId`, `paypalEmail`, and `paypalMerchantId` in the JSON response. The endpoint had no role guard, so any authenticated employee — including the lowest-privilege `AGENT` role — could call it and receive the secret key in plaintext.
### Exploit Scenario
An `AGENT`-role employee calls `GET /api/v1/companies/me/brand` with a valid session token. The response body includes `amanpaySecretKey`. The attacker uses the merchant ID and secret key to interact directly with the AmanPay payment gateway on behalf of the company, initiating fraudulent charges or refunds outside the application.
### Root Cause
`presentBrand()` in `company.presenter.ts` was a transparent passthrough (`return brand`). The DB query had no `select` clause, so Prisma returned every column. The public-facing site module correctly stripped these fields (evidenced by explicit test assertions), but the authenticated dashboard endpoint did not.
### Fix
**File:** `apps/api/src/modules/companies/company.presenter.ts`
`presentBrand()` now destructures and drops all four credential fields before returning. The caller receives boolean presence indicators (`amanpayConfigured`, `paypalConfigured`) instead of the raw secrets.
```diff
export function presentBrand(brand: any) {
- return brand
+ if (!brand) return brand
+ const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
+ return {
+ ...safe,
+ amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
+ paypalConfigured: !!(paypalEmail || paypalMerchantId),
+ }
}
```
---
## VF-02 — Webhook Signature Bypass
**Severity:** High
**Confidence:** 8/10
**Category:** Authentication Bypass / Payment Fraud
### Description
Both payment and subscription webhook endpoints for AmanPay and PayPal used a short-circuit AND guard for signature verification:
```ts
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
return res.status(401).json({ error: 'invalid_signature' })
}
await service.handleAmanpayWebhook(req.body) // executed unconditionally
```
When `isConfigured()` returns `false` (i.e., credentials are absent or set to placeholder defaults), the entire guard short-circuits and the webhook handler executes on any unauthenticated request. The endpoints had no IP allowlist and were explicitly placed before `requireCompanyAuth`.
This affected 4 endpoints:
- `POST /api/v1/payments/webhooks/amanpay`
- `POST /api/v1/payments/webhooks/paypal`
- `POST /api/v1/subscriptions/webhooks/amanpay`
- `POST /api/v1/subscriptions/webhooks/paypal`
### Exploit Scenario
On any staging or misconfigured production environment where AmanPay credentials are not set, an attacker posts `{"transaction_id": "<known_id>", "status": "PAID"}` to `/api/v1/payments/webhooks/amanpay`. The handler finds the pending payment by `transactionId`, marks it succeeded, and unlocks the reservation — with no money changing hands. A valid `amanpayTransactionId` can leak through receipts, logs, or API responses visible to renters.
The same attack against `/api/v1/subscriptions/webhooks/amanpay` activates a paid subscription tier for free.
### Fix
**Files:** `apps/api/src/modules/payments/payment.routes.ts`, `apps/api/src/modules/subscriptions/subscription.routes.ts`
Inverted the guard logic: an unconfigured provider now immediately returns `401` instead of passing through. Applied to all 4 endpoints.
```diff
-if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
+if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
return res.status(401).json({ error: 'invalid_signature' })
}
```
```diff
-if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' })
+if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
```
---
## VF-03 — IDOR on Vehicle Maintenance Logs
**Severity:** Medium-High
**Confidence:** 9/10
**Category:** Insecure Direct Object Reference (IDOR) / Tenant Isolation Bypass
### Description
`GET /api/v1/vehicles/:id/maintenance` was protected by `requireCompanyAuth` and `requireTenant`, making `req.companyId` available. However, `req.companyId` was never forwarded to the service or repository. The database query used only `vehicleId` with no tenant filter:
```ts
// vehicle.repo.ts
prisma.maintenanceLog.findMany({ where: { vehicleId } })
```
An authenticated employee from Company A could fetch the full maintenance history of any vehicle belonging to any other company by knowing its UUID. The write path (`POST /:id/maintenance`) already performed the correct ownership check using `repo.findById(id, companyId)` — the read path did not.
### Exploit Scenario
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through marketplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.
### Fix
**Files:** `apps/api/src/modules/vehicles/vehicle.routes.ts`, `apps/api/src/modules/vehicles/vehicle.service.ts`
`req.companyId` is now threaded through to `getMaintenanceLogs`, which performs the same ownership pre-check already used by the write path. A cross-tenant request returns `404`.
```diff
// vehicle.routes.ts
-const logs = await service.getMaintenanceLogs(id)
+const logs = await service.getMaintenanceLogs(id, req.companyId)
```
```diff
// vehicle.service.ts
-export async function getMaintenanceLogs(id: string) {
- return repo.findMaintenanceLogs(id)
+export async function getMaintenanceLogs(id: string, companyId: string) {
+ const vehicle = await repo.findById(id, companyId)
+ if (!vehicle) throw new NotFoundError('Vehicle not found')
+ return repo.findMaintenanceLogs(id)
}
```
---
## Frontend Scan Results
A full scan of all four frontend applications (dashboard, admin, marketplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
### Hardening Recommendations (not vulnerabilities)
Two items were identified as best-practice improvements:
1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended.
2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the marketplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <marketplace-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.
---
## Files Changed
| File | Change |
|------|--------|
| `apps/api/src/modules/companies/company.presenter.ts` | Strip payment credentials in `presentBrand()` |
| `apps/api/src/modules/payments/payment.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
| `apps/api/src/modules/subscriptions/subscription.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
| `apps/api/src/modules/vehicles/vehicle.routes.ts` | Pass `companyId` to maintenance log service |
| `apps/api/src/modules/vehicles/vehicle.service.ts` | Add ownership check in `getMaintenanceLogs` |
+5 -5
View File
@@ -77,7 +77,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
heroKicker: 'RentalDriveGo', heroKicker: 'RentalDriveGo',
heroTitle: 'Marketplace discovery with a sharper front door.', heroTitle: 'Marketplace discovery with a sharper front door.',
heroBody: heroBody:
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the companys own branded checkout.', 'Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.',
startTrial: 'Start free trial', startTrial: 'Start free trial',
exploreVehicles: 'Explore vehicles', exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface', surfaceLabel: 'Marketplace surface',
@@ -89,13 +89,13 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
brandedFlows: 'Branded booking flows', brandedFlows: 'Branded booking flows',
multiTenant: 'Multi-tenant operations', multiTenant: 'Multi-tenant operations',
companyKicker: 'Operator workflow', companyKicker: 'Operator workflow',
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.', companyTitle: 'Manage inventory, offers, billing, and staff from one control layer.',
companyBody: companyBody:
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.', 'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
renterKicker: 'Renter experience', renterKicker: 'Renter experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.', renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
renterBody: renterBody:
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.', 'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.',
pillars: [ pillars: [
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'], ['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'], ['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
@@ -121,9 +121,9 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Step', stepLabel: 'Step',
readyKicker: 'Ready to launch', readyKicker: 'Ready to launch',
readyTitle: 'A marketplace homepage should sell the system in seconds.', readyTitle: 'The marketplace homepage should explain the product in seconds.',
readyBody: readyBody:
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.', 'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.',
viewPricing: 'View pricing', viewPricing: 'View pricing',
createWorkspace: 'Create workspace', createWorkspace: 'Create workspace',
}, },