diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx index f19154d..d397fa3 100644 --- a/apps/admin/src/app/dashboard/billing/page.tsx +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -128,7 +128,7 @@ export default function AdminBillingPage() { eyebrow: 'Plateforme', mrr: 'MRR (MAD)', active: 'Actifs', - trialing: 'Essai', + trialing: 'En essai', pastDue: 'En retard', cancelled: 'Annulés', company: 'Entreprise', @@ -155,9 +155,9 @@ export default function AdminBillingPage() { provider: 'Fournisseur', paidAt: 'Payé le', date: 'Date', - invoiceNo: 'N° Facture', + invoiceNo: 'Facture n°', download: 'PDF', - cancelAtEnd: 'Annulé en fin de période', + cancelAtEnd: 'Sera annulé en fin de période', }, ar: { title: 'الفوترة', @@ -193,7 +193,7 @@ export default function AdminBillingPage() { date: 'التاريخ', invoiceNo: 'رقم الفاتورة', download: 'PDF', - cancelAtEnd: 'يُلغى في نهاية الفترة', + cancelAtEnd: 'سيُلغى في نهاية الفترة', }, }[language] diff --git a/apps/admin/src/app/dashboard/companies/page.tsx b/apps/admin/src/app/dashboard/companies/page.tsx index 7ffe85e..2196049 100644 --- a/apps/admin/src/app/dashboard/companies/page.tsx +++ b/apps/admin/src/app/dashboard/companies/page.tsx @@ -58,7 +58,7 @@ export default function AdminCompaniesPage() { }, ar: { title: 'الشركات', - search: 'ابحث بالاسم أو الـ slug…', + search: 'ابحث بالاسم أو بالمُعرّف المختصر…', loading: 'جارٍ التحميل…', empty: 'لم يتم العثور على شركات', company: 'الشركة', @@ -66,7 +66,7 @@ export default function AdminCompaniesPage() { status: 'الحالة', plan: 'الخطة', fleet: 'الأسطول', - vehicles: 'سيارات', + vehicles: 'مركبة', view: 'عرض', suspend: 'تعليق', reactivate: 'إعادة التفعيل', diff --git a/apps/admin/src/app/dashboard/page.tsx b/apps/admin/src/app/dashboard/page.tsx index 790f0bf..2acb8e7 100644 --- a/apps/admin/src/app/dashboard/page.tsx +++ b/apps/admin/src/app/dashboard/page.tsx @@ -27,20 +27,20 @@ export default function AdminDashboardPage() { }, fr: { kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'], - platformOverview: 'Vue plateforme', + platformOverview: 'Vue d’ensemble de la plateforme', cards: [ - ['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'], - ['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'], - ['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'], + ['Entreprises', 'Lister, rechercher, suspendre, réactiver et consulter l’état des abonnements.', 'Voir tout →'], + ['Locataires', 'Gérer l’assistance liée au blocage et au déblocage des locataires de la marketplace.', 'Voir tout →'], + ['Journaux d’audit', 'Consulter la trace complète de chaque action d’administration sur la plateforme.', 'Voir les journaux →'], ], }, ar: { kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'], platformOverview: 'نظرة عامة على المنصة', cards: [ - ['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'], - ['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'], - ['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'], + ['الشركات', 'اعرض الشركات وابحث عنها وعلّقها وأعد تفعيلها وراجع حالة الاشتراك.', 'عرض الكل ←'], + ['المستأجرون', 'إدارة طلبات الدعم الخاصة بحظر مستأجري السوق وفك الحظر عنهم.', 'عرض الكل ←'], + ['سجلات التدقيق', 'مراجعة السجل الكامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'], ], }, }[language] diff --git a/apps/admin/src/app/dashboard/renters/page.tsx b/apps/admin/src/app/dashboard/renters/page.tsx index 608a032..37c3684 100644 --- a/apps/admin/src/app/dashboard/renters/page.tsx +++ b/apps/admin/src/app/dashboard/renters/page.tsx @@ -36,10 +36,10 @@ export default function AdminRentersPage() { title: 'Locataires', search: 'Rechercher des locataires…', name: 'Nom', - email: 'Email', + email: 'E-mail', phone: 'Téléphone', status: 'Statut', - joined: 'Inscrit', + joined: 'Date d’inscription', loading: 'Chargement…', empty: 'Aucun locataire trouvé', blocked: 'Bloqué', @@ -54,7 +54,7 @@ export default function AdminRentersPage() { email: 'البريد الإلكتروني', phone: 'الهاتف', status: 'الحالة', - joined: 'تاريخ الانضمام', + joined: 'تاريخ التسجيل', loading: 'جارٍ التحميل…', empty: 'لم يتم العثور على مستأجرين', blocked: 'محظور', diff --git a/apps/api/src/data/platform-content.json b/apps/api/src/data/platform-content.json index 54b1a04..20196ff 100644 --- a/apps/api/src/data/platform-content.json +++ b/apps/api/src/data/platform-content.json @@ -12,7 +12,7 @@ ], "heroKicker": "RentalDriveGo", "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 company’s own branded checkout.", + "heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.", "startTrial": "Start free trial", "exploreVehicles": "Explore vehicles", "surfaceLabel": "Marketplace surface", @@ -23,11 +23,11 @@ "brandedFlows": "Branded booking flows", "multiTenant": "Multi-tenant operations", "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.", "renterKicker": "Renter experience", "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": [ { "title": "Unified publishing", @@ -83,8 +83,8 @@ ], "stepLabel": "Step", "readyKicker": "Ready to launch", - "readyTitle": "A marketplace homepage should sell the system 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.", + "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 tied to your company.", "viewPricing": "View pricing", "createWorkspace": "Create workspace" }, diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx index 3dc1b5e..7f3b835 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx @@ -182,8 +182,8 @@ export default function BillingPage() { cancel: 'Annuler', savingPayment: 'Enregistrement…', savePayment: 'Enregistrer le paiement', - chargeHelp: 'Paiement enregistre un règlement sur le solde de la réservation.', - depositHelp: 'Dépôt enregistre l’encaissement du dépôt de garantie remboursable.', + chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.', + depositHelp: 'Le dépôt enregistre l’encaissement du dépôt de garantie remboursable.', invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.', paymentActionDisabled: 'Aucun solde dû', paymentsRecorded: (count: number) => `${count} paiement(s)`, @@ -257,7 +257,7 @@ export default function BillingPage() { cancel: 'إلغاء', savingPayment: 'جارٍ الحفظ…', savePayment: 'حفظ الدفعة', - chargeHelp: 'الدفعة تسجل مبلغاً مستلماً على رصيد الحجز.', + chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.', depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.', invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.', paymentActionDisabled: 'لا يوجد رصيد مستحق', diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx index 85a61e6..d93bfa3 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx @@ -22,7 +22,7 @@ export default function CustomersPage() { const copy = { en: { title: 'Customers', - subtitle: 'Company-scoped CRM with license validation and risk flags.', + subtitle: 'A company CRM with license validation and risk flags.', customer: 'Customer', contact: 'Contact', license: 'License', @@ -36,13 +36,13 @@ export default function CustomersPage() { }, fr: { title: 'Clients', - subtitle: 'CRM de l’entreprise avec validation de permis et indicateurs de risque.', + subtitle: 'CRM de l’entreprise avec validation des permis et indicateurs de risque.', customer: 'Client', contact: 'Contact', license: 'Permis', flags: 'Signalements', imageUploaded: 'Image téléversée', - imageMissing: 'Pas d’image', + imageMissing: 'Aucune image', noPhone: 'Pas de téléphone', flagged: 'Signalé', clear: 'Aucun risque', @@ -50,16 +50,16 @@ export default function CustomersPage() { }, ar: { title: 'العملاء', - subtitle: 'نظام CRM خاص بالشركة مع التحقق من الرخصة ومؤشرات المخاطر.', + subtitle: 'نظام إدارة عملاء خاص بالشركة مع التحقق من الرخص ومؤشرات المخاطر.', customer: 'العميل', contact: 'التواصل', license: 'الرخصة', - flags: 'الإشارات', + flags: 'العلامات', imageUploaded: 'تم رفع الصورة', imageMissing: 'لا توجد صورة', noPhone: 'لا يوجد هاتف', - flagged: 'مُعلّم', - clear: 'سليم', + flagged: 'تم تمييزه', + clear: 'لا توجد مخاطر', empty: 'لا يوجد عملاء حتى الآن.', }, }[language] diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx index 4ac89cf..2ee236b 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx @@ -93,7 +93,7 @@ export default function OnlineReservationsPage() { refresh: 'Actualiser', pendingApproval: 'En attente de validation', loading: 'Chargement…', - allCaughtUp: 'Tout est à jour, aucune demande en attente.', + allCaughtUp: 'Tout est à jour. Aucune demande en attente.', customer: 'Client', vehicle: 'Véhicule', dates: 'Dates', @@ -102,9 +102,9 @@ export default function OnlineReservationsPage() { }, ar: { title: 'الحجوزات الإلكترونية', - subtitle: 'طلبات مقدمة من العملاء عبر السوق. قم بالتأكيد أو الرفض لكل طلب.', + subtitle: 'طلبات أرسلها العملاء عبر السوق. أكّد كل طلب أو ارفضه.', refresh: 'تحديث', - pendingApproval: 'بانتظار الموافقة', + pendingApproval: 'في انتظار الموافقة', loading: 'جارٍ التحميل…', allCaughtUp: 'لا توجد طلبات معلقة حالياً.', customer: 'العميل', diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx index ad01971..c2d261c 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx @@ -40,7 +40,7 @@ export default function ReportsPage() { const copy = { en: { 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', exporting: 'Exporting…', bookings: 'Bookings', @@ -60,15 +60,15 @@ export default function ReportsPage() { }, fr: { title: 'Rapports', - subtitle: 'Exports prêts pour le comptable avec totaux assurance, conducteurs supplémentaires et règles tarifaires.', - exportCsv: 'Exporter CSV', + subtitle: 'Exports prêts pour la comptabilité avec les totaux d’assurance, de conducteurs supplémentaires et de règles tarifaires.', + exportCsv: 'Exporter en CSV', exporting: 'Export en cours…', bookings: 'Réservations', rental: 'Location', insurance: 'Assurance', drivers: 'Conducteurs', discounts: 'Remises', - collected: 'Collecté', + collected: 'Encaissé', reservation: 'Réservation', vehicle: 'Véhicule', period: 'Période', @@ -80,7 +80,7 @@ export default function ReportsPage() { }, ar: { title: 'التقارير', - subtitle: 'تصدير جاهز للمحاسبة مع إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.', + subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.', exportCsv: 'تصدير CSV', exporting: 'جارٍ التصدير…', bookings: 'الحجوزات', diff --git a/apps/dashboard/src/components/team/InviteModal.tsx b/apps/dashboard/src/components/team/InviteModal.tsx index c50f3b9..d6f491f 100644 --- a/apps/dashboard/src/components/team/InviteModal.tsx +++ b/apps/dashboard/src/components/team/InviteModal.tsx @@ -13,7 +13,7 @@ interface Props { const copy = { en: { 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', lastName: 'Last name', email: 'Email address', @@ -27,7 +27,7 @@ const copy = { { value: 'MANAGER' as const, 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'], }, { @@ -40,10 +40,10 @@ const copy = { }, fr: { 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', lastName: 'Nom', - email: 'Adresse email', + email: 'Adresse e-mail', role: 'Rôle', cancel: 'Annuler', sending: 'Envoi…', @@ -54,8 +54,8 @@ const copy = { { value: 'MANAGER' as const, label: 'Manager', - description: 'Opérations complètes — sans facturation ni paramètres équipe', - permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation permis'], + 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 des permis'], }, { value: 'AGENT' as const, @@ -67,7 +67,7 @@ const copy = { }, ar: { title: 'دعوة عضو في الفريق', - subtitle: 'سيتلقى بريداً إلكترونياً للانضمام إلى لوحة التحكم', + subtitle: 'سيتلقى رسالة بريد إلكتروني للانضمام إلى مساحة العمل.', firstName: 'الاسم الأول', lastName: 'اسم العائلة', email: 'البريد الإلكتروني', @@ -81,7 +81,7 @@ const copy = { { value: 'MANAGER' as const, label: 'مدير', - description: 'عمليات أسطول كاملة — بدون الفوترة أو إعدادات الفريق', + description: 'صلاحيات تشغيل كاملة للأسطول، من دون الفوترة أو إعدادات الفريق', permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'], }, { diff --git a/apps/dashboard/src/components/team/PermissionsMatrix.tsx b/apps/dashboard/src/components/team/PermissionsMatrix.tsx index bfd7edd..79890d3 100644 --- a/apps/dashboard/src/components/team/PermissionsMatrix.tsx +++ b/apps/dashboard/src/components/team/PermissionsMatrix.tsx @@ -68,9 +68,9 @@ const copy = { ], agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''], }, - fr: { - 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.', + fr: { + heading: 'Permissions par rôle', + subtitle: 'Ce que chaque rôle peut faire dans l’espace de travail. Les propriétaires ont un accès complet à l’ensemble du système.', colFeature: 'Fonctionnalité', colManager: 'Manager', colAgent: 'Agent', @@ -81,7 +81,7 @@ const copy = { 'Voir la flotte et les véhicules', 'Ajouter / modifier des véhicules', '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', 'Créer des réservations', 'Annuler des réservations', @@ -90,20 +90,20 @@ const copy = { 'CRM clients — consultation', 'CRM clients — modification', 'Approuver les permis de conduire', - 'Signaler / blacklister un client', + 'Signaler / bloquer un client', 'Offres et promotions', 'Analyses et rapports', 'Contrat et facture PDF', 'Facturation et abonnement', - 'Inviter / supprimer du personnel', + 'Inviter / supprimer des membres de l’équipe', 'Paramètres marque et site', 'Paramètres de paiement', ], - agentNotes: ['', '', '', '', '', '', 'Les siennes', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''], + agentNotes: ['', '', '', '', '', '', 'Les siennes uniquement', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''], }, ar: { heading: 'صلاحيات الأدوار', - subtitle: 'ما يمكن لكل دور القيام به في لوحة التحكم. يملك الملاك وصولاً كاملاً لكل شيء.', + subtitle: 'ما يمكن لكل دور القيام به داخل مساحة العمل. يملك المالكون صلاحية كاملة على كل شيء.', colFeature: 'الميزة', colManager: 'المدير', colAgent: 'الوكيل', @@ -120,13 +120,13 @@ const copy = { 'إلغاء الحجوزات', 'الاستلام / الإرجاع', 'فحص الأضرار', - 'CRM العملاء — عرض', - 'CRM العملاء — تعديل', + 'إدارة العملاء — عرض', + 'إدارة العملاء — تعديل', 'الموافقة على رخص القيادة', - 'تعليم / حظر العميل', - 'العروض والترويجات', + 'تمييز العميل / حظره', + 'العروض والحملات الترويجية', 'التحليلات والتقارير', - 'العقد وفاتورة PDF', + 'العقد والفاتورة بصيغة PDF', 'الفوترة والاشتراك', 'دعوة / إزالة الموظفين', 'إعدادات العلامة والموقع', diff --git a/apps/marketplace/src/app/explore/[slug]/page.tsx b/apps/marketplace/src/app/explore/[slug]/page.tsx index 2d91cea..4880d1c 100644 --- a/apps/marketplace/src/app/explore/[slug]/page.tsx +++ b/apps/marketplace/src/app/explore/[slug]/page.tsx @@ -45,7 +45,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s unavailable: 'Marketplace indisponible', 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.", - backToExplore: 'Retour à explorer', + backToExplore: 'Retour à l’exploration', partner: 'Partenaire marketplace', vehicles: 'véhicules', offers: 'offres actives', @@ -62,10 +62,10 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s ar: { unavailable: 'السوق غير متاح', unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.', - unavailableBody: 'واجهة marketplace تعمل، لكن قاعدة البيانات غير متاحة في هذا البيئة المحلية.', + unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.', backToExplore: 'العودة إلى الاستكشاف', partner: 'شريك في السوق', - vehicles: 'سيارات', + vehicles: 'مركبات', offers: 'عروض نشطة', rating: 'التقييم', new: 'جديد', diff --git a/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx b/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx index a3ba89f..8e38c5d 100644 --- a/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx +++ b/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx @@ -60,7 +60,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st 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.", backToFleet: 'Retour à la flotte', - backToExplore: 'Retour à explorer', + backToExplore: 'Retour à l’exploration', noPhotos: 'Aucune photo disponible.', rentalCompany: 'Société de location', perDay: '/ jour', @@ -81,7 +81,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st ar: { unavailable: 'السوق غير متاح', unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.', - unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.', + unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.', backToFleet: 'العودة إلى الأسطول', backToExplore: 'العودة إلى الاستكشاف', noPhotos: 'لا توجد صور متاحة.', diff --git a/apps/marketplace/src/app/pricing/PricingClient.tsx b/apps/marketplace/src/app/pricing/PricingClient.tsx index 1c648b3..09c0830 100644 --- a/apps/marketplace/src/app/pricing/PricingClient.tsx +++ b/apps/marketplace/src/app/pricing/PricingClient.tsx @@ -87,24 +87,24 @@ const copy = { annual: 'Annuel', yearly: '/ an', 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', perMonth: '/mois', billedAs: 'Facturé à', 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 n’est requise.', }, ar: { monthly: 'شهري', annual: 'سنوي', yearly: '/ سنة', youSave: 'توفر', - withAnnual: 'مع الفوترة السنوية — دفعة واحدة كل سنة.', + withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.', mostPopular: 'الأكثر شيوعاً', perMonth: '/شهر', billedAs: 'يتم الفوترة بمبلغ', getStarted: 'ابدأ الآن', - footer: 'جميع الخطط تشمل تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.', + footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.', }, } as const diff --git a/apps/marketplace/src/app/pricing/PricingPageContent.tsx b/apps/marketplace/src/app/pricing/PricingPageContent.tsx index d1af969..867d097 100644 --- a/apps/marketplace/src/app/pricing/PricingPageContent.tsx +++ b/apps/marketplace/src/app/pricing/PricingPageContent.tsx @@ -19,24 +19,24 @@ const copy = { fr: { kicker: 'Tarifs', 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', 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.'], - ['Qu’est-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord flotte, actif ou non.'], + ['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.'], + ['Qu’est-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord de flotte, qu’il soit actif ou non.'], ['Y a-t-il des frais d’installation ?', 'Aucun frais d’installation. Vous payez uniquement l’abonnement 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: { kicker: 'الأسعار', title: 'منصة واحدة لكل أحجام الأساطيل.', - body: 'ابدأ مجاناً لمدة 14 يوماً ثم اختر الخطة التي تنمو مع نشاطك. يمكنك تغيير الخطة أو الإلغاء في أي وقت.', + body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.', faq: 'الأسئلة الشائعة', items: [ ['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'], ['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'], - ['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط الاشتراك الشهري أو السنوي.'], + ['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'], ['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'], ], }, diff --git a/apps/marketplace/src/app/renter/dashboard/page.tsx b/apps/marketplace/src/app/renter/dashboard/page.tsx index 4772bb9..a134468 100644 --- a/apps/marketplace/src/app/renter/dashboard/page.tsx +++ b/apps/marketplace/src/app/renter/dashboard/page.tsx @@ -59,7 +59,7 @@ export default function RenterDashboardPage() { fr: { loadProfile: 'Impossible de charger le profil.', reachServer: 'Impossible de joindre le serveur.', - backToExplore: 'Retour à explorer', + backToExplore: 'Retour à l’exploration', renterDashboard: 'Espace client', welcome: 'Bon retour,', exploreVehicles: 'Explorer les véhicules', @@ -72,7 +72,7 @@ export default function RenterDashboardPage() { yourProfile: 'Votre profil', firstName: 'Prénom', lastName: 'Nom', - email: 'Email', + email: 'E-mail', phone: 'Téléphone', savedCompanies: 'Entreprises sauvegardées', noneSaved: 'Aucune pour le moment', @@ -85,7 +85,7 @@ export default function RenterDashboardPage() { loadProfile: 'تعذر تحميل الملف الشخصي.', reachServer: 'تعذر الوصول إلى الخادم.', backToExplore: 'العودة إلى الاستكشاف', - renterDashboard: 'لوحة المستأجر', + renterDashboard: 'بوابة المستأجر', welcome: 'مرحباً بعودتك،', exploreVehicles: 'استكشف السيارات', logout: 'تسجيل الخروج', diff --git a/apps/marketplace/src/app/renter/notifications/page.tsx b/apps/marketplace/src/app/renter/notifications/page.tsx index 6d51f63..5fb3bf0 100644 --- a/apps/marketplace/src/app/renter/notifications/page.tsx +++ b/apps/marketplace/src/app/renter/notifications/page.tsx @@ -35,7 +35,7 @@ export default function RenterNotificationsPage() { renter: 'Client', title: 'Notifications', markAll: 'Tout marquer comme lu', - back: 'Retour au dashboard', + back: 'Retour au tableau de bord', loading: 'Chargement des notifications…', empty: 'Aucune notification client pour le moment.', read: 'Lu', @@ -47,12 +47,12 @@ export default function RenterNotificationsPage() { updateAll: 'تعذر تحديث الإشعارات.', renter: 'المستأجر', title: 'الإشعارات', - markAll: 'تحديد الكل كمقروء', - back: 'العودة إلى اللوحة', + markAll: 'تعيين الكل كمقروء', + back: 'العودة إلى بوابة المستأجر', loading: 'جارٍ تحميل الإشعارات…', empty: 'لا توجد إشعارات للمستأجر حالياً.', read: 'مقروء', - markRead: 'تحديد كمقروء', + markRead: 'تعيين كمقروء', }, }[language] const [notifications, setNotifications] = useState([]) diff --git a/apps/marketplace/src/app/renter/saved-companies/page.tsx b/apps/marketplace/src/app/renter/saved-companies/page.tsx index aabcac0..890f020 100644 --- a/apps/marketplace/src/app/renter/saved-companies/page.tsx +++ b/apps/marketplace/src/app/renter/saved-companies/page.tsx @@ -24,7 +24,7 @@ export default function RenterSavedCompaniesPage() { load: 'Impossible de charger les entreprises sauvegardées.', renter: 'Client', title: 'Entreprises sauvegardées', - back: 'Retour au dashboard', + back: 'Retour au tableau de bord', loading: 'Chargement des entreprises sauvegardées…', empty: 'Vous n’avez encore enregistré aucune entreprise.', browse: 'Parcourir la marketplace', @@ -34,7 +34,7 @@ export default function RenterSavedCompaniesPage() { load: 'تعذر تحميل الشركات المحفوظة.', renter: 'المستأجر', title: 'الشركات المحفوظة', - back: 'العودة إلى اللوحة', + back: 'العودة إلى بوابة المستأجر', loading: 'جارٍ تحميل الشركات المحفوظة…', empty: 'لم تقم بحفظ أي شركة بعد.', browse: 'تصفح السوق', diff --git a/apps/marketplace/src/components/BookingForm.tsx b/apps/marketplace/src/components/BookingForm.tsx index 7ff7f58..4dcf079 100644 --- a/apps/marketplace/src/components/BookingForm.tsx +++ b/apps/marketplace/src/components/BookingForm.tsx @@ -23,18 +23,19 @@ const copy = { submit: 'Request reservation', submitting: 'Sending…', 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.', required: 'Please fill in all required fields.', perDay: '/ day', estimatedTotal: 'Estimated total', days: 'day(s)', + genericError: 'Something went wrong. Please try again.', }, fr: { title: 'Réserver ce véhicule', firstName: 'Prénom', lastName: 'Nom', - email: 'Email', + email: 'E-mail', phone: 'Téléphone', startDate: 'Date de départ', endDate: 'Date de retour', @@ -42,12 +43,13 @@ const copy = { submit: 'Demander une réservation', submitting: 'Envoi…', 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. L’entreprise va l’examiner et vous contactera prochainement.", invalidDates: 'La date de retour doit être après la date de départ.', required: 'Veuillez remplir tous les champs obligatoires.', perDay: '/ jour', estimatedTotal: 'Total estimé', days: 'jour(s)', + genericError: 'Une erreur est survenue. Veuillez réessayer.', }, ar: { title: 'احجز هذه السيارة', @@ -56,7 +58,7 @@ const copy = { email: 'البريد الإلكتروني', phone: 'الهاتف', startDate: 'تاريخ الاستلام', - endDate: 'تاريخ الإعادة', + endDate: 'تاريخ الإرجاع', notes: 'ملاحظات (اختيارية)', submit: 'طلب حجز', submitting: 'جارٍ الإرسال…', @@ -67,6 +69,7 @@ const copy = { perDay: '/ يوم', estimatedTotal: 'الإجمالي التقديري', days: 'يوم', + genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', }, } as const @@ -123,7 +126,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props }) setSuccess(true) } catch (err: any) { - setError(err.message ?? 'Something went wrong. Please try again.') + setError(err.message ?? t.genericError) } finally { setSubmitting(false) } diff --git a/docs/security/VULNERABILITY_FIXES_2026-05-22.md b/docs/security/VULNERABILITY_FIXES_2026-05-22.md new file mode 100644 index 0000000..441d28e --- /dev/null +++ b/docs/security/VULNERABILITY_FIXES_2026-05-22.md @@ -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": "", "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//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' ` 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` | diff --git a/packages/types/src/marketplace-homepage.ts b/packages/types/src/marketplace-homepage.ts index ce16e42..2a0b564 100644 --- a/packages/types/src/marketplace-homepage.ts +++ b/packages/types/src/marketplace-homepage.ts @@ -77,7 +77,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = { heroKicker: 'RentalDriveGo', 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 company’s own branded checkout.', + 'Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.', startTrial: 'Start free trial', exploreVehicles: 'Explore vehicles', surfaceLabel: 'Marketplace surface', @@ -89,13 +89,13 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = { brandedFlows: 'Branded booking flows', multiTenant: 'Multi-tenant operations', 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.', renterKicker: 'Renter experience', 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.', + 'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.', pillars: [ ['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.'], @@ -121,9 +121,9 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = { ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), stepLabel: 'Step', 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.', + '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', createWorkspace: 'Create workspace', },