Compare commits
154 Commits
main
..
2739f14e45
| Author | SHA1 | Date | |
|---|---|---|---|
| 2739f14e45 | |||
| 4f9107758e | |||
| 8ee9bc0bca | |||
| 6c50458aca | |||
| aca56e7ad9 | |||
| 7da3957ee1 | |||
| d4c460c3cb | |||
| e02c811ffd | |||
| 95accb780d | |||
| ddaccff28c | |||
| 44f59423ec | |||
| af5b7fda7e | |||
| b52c6305b5 | |||
| b5a224dc04 | |||
| 6b97c895e0 | |||
| 37a6ed8a76 | |||
| 6eeba99c45 | |||
| 919f583677 | |||
| f9b793a05f | |||
| 7744d533fd | |||
| ab3ce8e366 | |||
| e5bde57613 | |||
| b22983650d | |||
| 64df4439d3 | |||
| 30c4997656 | |||
| 1c441a164c | |||
| 99ce11eac3 | |||
| f98ac2c4fa | |||
| f1fff38400 | |||
| 80a597bc10 | |||
| 560da1cadf | |||
| a896b1b4e8 | |||
| a5c0512dfa | |||
| 56d936be35 | |||
| 00a1d8e3a8 | |||
| 8d479b050d | |||
| 03f6c26940 | |||
| 52c1ecffe2 | |||
| e0b2239f66 | |||
| e6110d7faa | |||
| 570a58477a | |||
| 994bf3610b | |||
| f985151f0a | |||
| b1ed88f963 | |||
| 2f78937b6f | |||
| 48064fee7a | |||
| 11ed6566b9 | |||
| 72107396a1 | |||
| a9305fa4a2 | |||
| da1841e6b9 | |||
| e9dfbeb591 | |||
| b5a974ee14 | |||
| 29886c3397 | |||
| 8f57ca9d76 | |||
| 0df950f2b1 | |||
| 05d7b1bf8c | |||
| a6a628ce66 | |||
| edf4ef8f84 | |||
| 044f8cc2ee | |||
| f0c5d72523 | |||
| 3f3b9d9e6a | |||
| 1a1c418087 | |||
| 9ab73e8ce6 | |||
| 5ec91230c1 | |||
| 8b3eb588f2 | |||
| 797e01eece | |||
| 2ffbc203e0 | |||
| 3680c5d5f2 | |||
| 0d969ab095 | |||
| 8ed572b3bd | |||
| 914ae839a7 | |||
| 813eb5404c | |||
| c8bde121fc | |||
| 33b6bb55f0 | |||
| 8d0b7a5ceb | |||
| 1606ea0053 | |||
| c9cbe479aa | |||
| 95376d3223 | |||
| 5ef9efb8a9 | |||
| 9bd0938951 | |||
| bf97a072dd | |||
| 31e06a1d61 | |||
| f5292f8b6c | |||
| c6cebdd125 | |||
| 9dc084ee50 | |||
| 90401189f8 | |||
| 07fba16ff2 | |||
| fd10d0db6a | |||
| b82a5d4633 | |||
| 4268e5c379 | |||
| dcd62f35ac | |||
| 8f93bb89f6 | |||
| 48ecde2a51 | |||
| bae4942276 | |||
| fc4d099d4f | |||
| 3e856e093d | |||
| 72f2a13574 | |||
| b9197bcb5d | |||
| 99c429d69c | |||
| 191fb3cb4e | |||
| cc462e2c01 | |||
| 159d89524d | |||
| c1eff5259e | |||
| eb2d4fead0 | |||
| bd96a72ce2 | |||
| e6a7b00596 | |||
| 0ca3127bce | |||
| db43862cea | |||
| f009ca10c6 | |||
| e74681e810 | |||
| 32170acbb3 | |||
| d138949a12 | |||
| 4ab34c4d76 | |||
| fb15883f8e | |||
| d4f7de5762 | |||
| 7ac0098c7f | |||
| f30f6e9796 | |||
| 5ea1780134 | |||
| 0b73504455 | |||
| 8be98062b2 | |||
| 5bc6772f64 | |||
| 459f6c953e | |||
| d81565ca71 | |||
| 58a3787f29 | |||
| 52a6c95d1e | |||
| 84285335a4 | |||
| f52e53519c | |||
| be98e3e65d | |||
| 1294109c55 | |||
| 1b929dd892 | |||
| f6f6d4db06 | |||
| 340a55c400 | |||
| 1e05a7409f | |||
| 321cb2344d | |||
| c01a551b7f | |||
| 7e4fb6d62a | |||
| 4408f7cc8f | |||
| 57befa124c | |||
| e1fa3f9fe5 | |||
| a8c4619576 | |||
| 942c0b282a | |||
| 0edd90732e | |||
| 593ff202a3 | |||
| f317fa12dd | |||
| 89621a163b | |||
| 1a39aa8433 | |||
| 193aeae834 | |||
| 0e1b6f0338 | |||
| cd9fe155ba | |||
| 1f87786437 | |||
| 56b9bb8bc4 | |||
| f5ab64ecea | |||
| 00834dcb7e | |||
| f1c7fec162 |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cp *)",
|
||||
"Bash(npm run type-check *)",
|
||||
"Bash(npm run test *)",
|
||||
"Bash(npm run build *)",
|
||||
"Bash(npm run lint *)",
|
||||
"Bash(docker *)",
|
||||
"Bash(Bash(curl *))",
|
||||
"Bash(Bash(nc *))",
|
||||
"Bash(Bash(curl *))",
|
||||
"Bash(Bash(jq *))",
|
||||
"Bash(curl *)",
|
||||
"Bash(find *)",
|
||||
"Bash(docker-compose *)",
|
||||
"Bash(npm run docker:dev:start:tools *)",
|
||||
"Bash(npm *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
--artifact-server-path=/tmp/act-artifacts
|
||||
@@ -8,9 +8,13 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.next
|
||||
*/.next
|
||||
dist
|
||||
coverage
|
||||
tmp
|
||||
.env.local
|
||||
.env.*.local
|
||||
# Exclude any stale pages-router directories (all apps use App Router)
|
||||
apps/*/pages
|
||||
apps/*/src/pages
|
||||
|
||||
|
||||
+25
-13
@@ -1,36 +1,48 @@
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_HOST_LOCAL=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=password
|
||||
REDIS_URL=redis://redis:6379
|
||||
API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
DASHBOARD_URL=http://localhost:3001
|
||||
JWT_SECRET=dev-secret
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
ADMIN_URL=http://localhost:3000/admin
|
||||
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
|
||||
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
||||
# Asset prefix for the dashboard/admin dev servers so browsers load JS chunks from the
|
||||
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
|
||||
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
||||
ADMIN_SEED_PASSWORD=changeme123
|
||||
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
|
||||
ADMIN_SEED_PASSWORD=placeholder
|
||||
ADMIN_SEED_FIRST_NAME=Platform
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
CLERK_SECRET_KEY=placeholder
|
||||
NODE_ENV=development
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:4000
|
||||
|
||||
# Email - disable Resend (placeholder), use SMTP instead
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_API_KEY=placeholder
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=xmhg ibqy muoc rntc
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
|
||||
@@ -1,58 +1,89 @@
|
||||
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
MARKETPLACE_DOMAIN=rentaldrivego.ma
|
||||
ACME_EMAIL=ops@example.com
|
||||
API_DOMAIN=api.rentaldrivego.ma
|
||||
DASHBOARD_DOMAIN=dashboard.rentaldrivego.ma
|
||||
ADMIN_DOMAIN=admin.rentaldrivego.ma
|
||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
|
||||
REGISTRY_DOMAIN=registry.rentaldrivego.ma
|
||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
||||
|
||||
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
|
||||
# ── Optional prebuilt image source ────────────────────────────────────────────
|
||||
# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION
|
||||
# automatically during registry-backed deploys, so these can stay commented out.
|
||||
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
|
||||
# APP_VERSION=latest
|
||||
REGISTRY_HOST=registry.rentaldrivego.ma
|
||||
REGISTRY_USER=rentaldrivego_registry
|
||||
REGISTRY_PASSWORD=placeholder
|
||||
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
||||
REGISTRY_HTTP_SECRET=placeholder
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=placeholder
|
||||
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# ── API ────────────────────────────────────────────────────────────────────────
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
# SSR server-side calls go via the internal Docker network
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
# Next.js rewrites between the marketplace and internal apps also use Docker-network URLs
|
||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ADMIN_INTERNAL_URL=http://admin:3002
|
||||
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
||||
API_URL=https://api.rentaldrivego.ma
|
||||
# Baked into Next.js bundles at build time — MUST be set before running `npm run build`
|
||||
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
|
||||
# ── Frontend public URLs ───────────────────────────────────────────────────────
|
||||
|
||||
# ── Frontend public URLs ──────────────────────────────────────────────────────
|
||||
# Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps)
|
||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
||||
NEXT_PUBLIC_ADMIN_URL=https://admin.rentaldrivego.ma
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
||||
DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed browser origins. REQUIRED in production.
|
||||
CORS_ORIGINS=https://rentaldrivego.ma,https://dashboard.rentaldrivego.ma,https://admin.rentaldrivego.ma
|
||||
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
POSTGRES_PASSWORD=24DY@1u5FLCkNMeiO@p
|
||||
NODE_ENV=production
|
||||
|
||||
# ── Email (choose one: Resend API or SMTP) ────────────────────────────────────
|
||||
# Option A — Resend
|
||||
RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||
|
||||
# ── Email ─────────────────────────────────────────────────────────────────────
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
# Option B — SMTP
|
||||
# MAIL_HOST=smtp.rentaldrivego.ma
|
||||
# MAIL_PORT=587
|
||||
# MAIL_USERNAME=smtp-user
|
||||
# MAIL_PASSWORD=smtp-password
|
||||
# MAIL_SCHEME=smtp
|
||||
# MAIL_FROM_ADDRESS=noreply@rentaldrivego.ma
|
||||
# MAIL_FROM_NAME=RentalDriveGo
|
||||
# Option A — Resend
|
||||
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
# Option B — SMTP (Gmail)
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
|
||||
|
||||
# ── Firebase push notifications (optional) ────────────────────────────────────
|
||||
# FIREBASE_PROJECT_ID=your-firebase-project-id
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Traefik domains
|
||||
ACME_EMAIL=ops@example.com
|
||||
API_DOMAIN=api.example.com
|
||||
PUBLIC_SITE_DOMAIN=example.com
|
||||
PGMANAGE_DOMAIN=pgmanage.example.com
|
||||
PORTAINER_DOMAIN=portainer.example.com
|
||||
REGISTRY_DOMAIN=registry.example.com
|
||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
||||
|
||||
# Database
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=placeholder
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# API
|
||||
API_PORT=4000
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
||||
|
||||
# Frontend public URLs
|
||||
NEXT_PUBLIC_MARKETING_URL=https://example.com
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://example.com
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
||||
DASHBOARD_URL=https://example.com/dashboard
|
||||
ADMIN_URL=https://example.com/admin
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=https://example.com,https://www.example.com
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
NODE_ENV=production
|
||||
|
||||
# File storage
|
||||
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/storage
|
||||
|
||||
# PgManage
|
||||
PGMANAGE_DEFAULT_USERNAME=admin
|
||||
PGMANAGE_DEFAULT_PASSWORD=change-me
|
||||
|
||||
# Email
|
||||
EMAIL_FROM=noreply@example.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=your-smtp-user@example.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_FROM_ADDRESS=noreply@example.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=support@example.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
REDIS_URL=redis://redis:6379
|
||||
API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
JWT_SECRET=test-secret
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
NODE_ENV=test
|
||||
|
||||
+16
-16
@@ -4,7 +4,7 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Database ──────────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/rentaldrivego"
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
|
||||
# ─── API ───────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
@@ -12,13 +12,13 @@ API_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
|
||||
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
|
||||
# ─── Clerk (Company employee auth) ────────────────────────────
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
||||
CLERK_SECRET_KEY=sk_test_...
|
||||
CLERK_SECRET_KEY=placeholder
|
||||
CLERK_WEBHOOK_SECRET=whsec_...
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
@@ -28,14 +28,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
|
||||
# ─── AmanPay (Primary payment provider) ───────────────────────
|
||||
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
|
||||
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
|
||||
AMANPAY_SECRET_KEY=your-amanpay-secret-key
|
||||
AMANPAY_SECRET_KEY=placeholder
|
||||
AMANPAY_BASE_URL=https://api.amanpay.net
|
||||
AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret
|
||||
AMANPAY_WEBHOOK_SECRET=placeholder
|
||||
|
||||
# ─── PayPal (Secondary payment provider) ──────────────────────
|
||||
# RentalDriveGo's own PayPal account (for collecting subscription fees)
|
||||
PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
PAYPAL_CLIENT_SECRET=your-paypal-client-secret
|
||||
PAYPAL_CLIENT_SECRET=placeholder
|
||||
PAYPAL_BASE_URL=https://api-m.paypal.com
|
||||
# Use https://api-m.sandbox.paypal.com for sandbox
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
@@ -43,23 +43,23 @@ NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
# ─── Cloudinary (Vehicle + brand photos) ──────────────────────
|
||||
CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||
CLOUDINARY_API_KEY=your-api-key
|
||||
CLOUDINARY_API_SECRET=your-api-secret
|
||||
CLOUDINARY_API_SECRET=placeholder
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||
|
||||
# ─── Resend (Email) ────────────────────────────────────────────
|
||||
RESEND_API_KEY=re_...
|
||||
EMAIL_FROM=noreply@rentaldrivego.com
|
||||
RESEND_API_KEY=placeholder
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
|
||||
# ─── Twilio (SMS + WhatsApp) ───────────────────────────────────
|
||||
TWILIO_ACCOUNT_SID=AC...
|
||||
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||
TWILIO_AUTH_TOKEN=placeholder
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
|
||||
|
||||
# ─── Firebase (Push notifications) ───────────────────────────
|
||||
FIREBASE_PROJECT_ID=your-project-id
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_PRIVATE_KEY=placeholder
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
@@ -75,16 +75,16 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ─── App URLs ──────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
||||
# Public site is subdomain-based; use this for local dev:
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
DASHBOARD_URL=http://localhost:3001
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
|
||||
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
||||
ADMIN_SEED_PASSWORD=changeme123
|
||||
ADMIN_SEED_PASSWORD=placeholder
|
||||
ADMIN_SEED_FIRST_NAME=Super
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Gitea built-in container registry (HTTPS on port 443)
|
||||
REGISTRY_HOST: 192.168.3.80
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
# VPS deployment target
|
||||
DEPLOY_ROOT: /opt/rentaldrivego
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_repository: ${{ steps.image-meta.outputs.repository }}
|
||||
docker_image: ${{ steps.image-meta.outputs.full }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker image metadata
|
||||
id: image-meta
|
||||
run: |
|
||||
# Gitea container registry path: host/owner/repo
|
||||
REPO="${{ gitea.repository }}"
|
||||
TAG="${{ gitea.sha }}"
|
||||
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check Docker registry credentials
|
||||
id: registry-check
|
||||
run: |
|
||||
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install Docker CLI
|
||||
if: steps.registry-check.outputs.available == 'true'
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker.io
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
if: steps.registry-check.outputs.available == 'true'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login "$REGISTRY_HOST" \
|
||||
-u "${{ gitea.actor }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE_PATH }}
|
||||
push: ${{ steps.registry-check.outputs.available == 'true' }}
|
||||
tags: |
|
||||
${{ steps.image-meta.outputs.full }}
|
||||
${{ steps.image-meta.outputs.latest }}
|
||||
build-args: |
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ADMIN_INTERNAL_URL=http://admin:3002
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=${{ secrets.NEXT_PUBLIC_MARKETPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL=${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL=${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-image]
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
|
||||
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
|
||||
- name: Check deploy credentials
|
||||
id: check-creds
|
||||
run: |
|
||||
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
|
||||
[ -z "${{ secrets.VPS_IP }}" ] || \
|
||||
[ -z "${{ secrets.VPS_USER }}" ]; then
|
||||
echo "VPS secrets not fully configured — skipping deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install SSH client
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client
|
||||
|
||||
- name: Set up SSH key
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync deployment assets
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \
|
||||
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'"
|
||||
scp docker-compose.production.yml \
|
||||
docker-compose.portainer.production.yml \
|
||||
docker-compose.registry.production.yml \
|
||||
docker-compose.registry.local.yml \
|
||||
traefik.yaml \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/"
|
||||
scp scripts/docker-prod-common.sh \
|
||||
scripts/docker-prod-deploy.sh \
|
||||
scripts/docker-prod-up-registry.sh \
|
||||
scripts/docker-registry-local-up.sh \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
|
||||
scp docker/pgmanage/override.py \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
|
||||
|
||||
- name: Run deploy script on VPS
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
||||
set -e
|
||||
cd '$DEPLOY_ROOT'
|
||||
APP_IMAGE='$IMAGE_REPOSITORY' \
|
||||
APP_VERSION='${{ gitea.sha }}' \
|
||||
REGISTRY_HOST='$REGISTRY_HOST' \
|
||||
REGISTRY_USER='${{ gitea.actor }}' \
|
||||
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
"
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
pull_request:
|
||||
branches: [develop]
|
||||
|
||||
concurrency:
|
||||
group: test-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
|
||||
jobs:
|
||||
api-tests:
|
||||
name: API Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run db:generate
|
||||
- run: npm run type-check
|
||||
- run: npm run test:api
|
||||
|
||||
marketplace-tests:
|
||||
name: Marketplace Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:marketplace
|
||||
|
||||
admin-tests:
|
||||
name: Admin Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run test:admin
|
||||
|
||||
dashboard-tests:
|
||||
name: Dashboard Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:dashboard
|
||||
|
||||
integration-tests:
|
||||
name: API Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL: redis://redis:6379
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret
|
||||
JWT_EXPIRY: 8h
|
||||
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run db:generate
|
||||
- run: npm run db:deploy
|
||||
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
- run: npm run test:api:integration
|
||||
@@ -7,6 +7,7 @@ node_modules/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
apps/api/storage/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
+313
-17
@@ -1,28 +1,324 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
DEPLOY_ROOT: /opt/rentaldrivego
|
||||
# Required: disable TLS so the docker client can reach the dind daemon
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
|
||||
build:
|
||||
# ====================================
|
||||
# TEST STAGE
|
||||
# Runs on every push and merge request
|
||||
# ====================================
|
||||
|
||||
api_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run db:generate
|
||||
script:
|
||||
- npm run type-check
|
||||
- npm run test:api
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
marketplace_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run build --workspace @rentaldrivego/types
|
||||
script:
|
||||
- npm run test:marketplace
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
admin_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npm run test:admin
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
dashboard_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run build --workspace @rentaldrivego/types
|
||||
script:
|
||||
- npm run test:dashboard
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
integration_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
services:
|
||||
- name: postgres:16-alpine
|
||||
alias: postgres
|
||||
variables:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
- name: redis:7-alpine
|
||||
alias: redis
|
||||
variables:
|
||||
# Used by db:deploy (Prisma migrations) and any code reading DATABASE_URL directly
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/rentaldrivego_test"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
NODE_ENV: test
|
||||
before_script:
|
||||
- npm ci
|
||||
# Overwrite the local .env.test so vitest integration config gets CI service hostnames
|
||||
# (the file uses postgres/redis aliases, not localhost)
|
||||
- |
|
||||
cat > apps/api/.env.test << 'EOF'
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL=redis://redis:6379
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRY=8h
|
||||
NODE_ENV=test
|
||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||
EOF
|
||||
script:
|
||||
- npm run db:generate
|
||||
- npm run db:deploy
|
||||
- npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
- npm run test:api:integration
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
# ====================================
|
||||
# BUILD STAGE
|
||||
# ====================================
|
||||
|
||||
build_image:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:dind
|
||||
script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker build -t $DOCKER_IMAGE .
|
||||
- docker push $DOCKER_IMAGE
|
||||
only:
|
||||
- main
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
- name: docker:24-dind
|
||||
alias: docker
|
||||
command: ["--tls=false"]
|
||||
variables:
|
||||
HEALTHCHECK_TCP_PORT: "2375"
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client
|
||||
- |
|
||||
attempts=0
|
||||
until docker info >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 60 ]; then
|
||||
echo "Docker daemon did not become ready after 60 seconds." >&2
|
||||
echo "On self-hosted runners, docker:dind requires the runner Docker executor to run with privileged = true." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for Docker daemon..."
|
||||
sleep 1
|
||||
done
|
||||
# Use one registry mode at a time:
|
||||
# 1) explicit REGISTRY_* vars for external/custom registries
|
||||
# 2) GitLab's built-in CI_REGISTRY_* vars for the integrated registry
|
||||
- |
|
||||
if [ -n "${REGISTRY_HOST:-}${REGISTRY_USER:-}${REGISTRY_PASSWORD:-}${REGISTRY_IMAGE:-}" ]; then
|
||||
test -n "${REGISTRY_HOST:-}" || { echo "REGISTRY_HOST is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_USER:-}" || { echo "REGISTRY_USER is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_PASSWORD:-}" || { echo "REGISTRY_PASSWORD is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_IMAGE:-}" || { echo "REGISTRY_IMAGE is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
export IMAGE_REPOSITORY="$REGISTRY_IMAGE"
|
||||
else
|
||||
export REGISTRY_HOST="${CI_REGISTRY:-}"
|
||||
export REGISTRY_USER="${CI_REGISTRY_USER:-}"
|
||||
export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-}"
|
||||
export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-}"
|
||||
fi
|
||||
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
|
||||
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
|
||||
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
|
||||
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
|
||||
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
|
||||
- export DOCKER_IMAGE_LATEST="$IMAGE_REPOSITORY:latest"
|
||||
- |
|
||||
echo "--- Registry Connectivity Preflight ---"
|
||||
echo "REGISTRY_HOST=$REGISTRY_HOST"
|
||||
cat /etc/resolv.conf || true
|
||||
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
getent hosts "$REGISTRY_HOST" || true
|
||||
elif command -v nslookup >/dev/null 2>&1; then
|
||||
nslookup "$REGISTRY_HOST" || true
|
||||
else
|
||||
echo "No host lookup tool available in runner image."
|
||||
fi
|
||||
|
||||
probe_output="$(wget -S --spider --timeout=15 --tries=1 "https://$REGISTRY_HOST/v2/" 2>&1 || true)"
|
||||
printf '%s\n' "$probe_output"
|
||||
|
||||
printf '%s\n' "$probe_output" | grep -F "401 Unauthorized" >/dev/null || {
|
||||
echo "Registry preflight failed: expected HTTPS /v2/ to return 401 Unauthorized before docker login." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! printf '%s\n' "$probe_output" | grep -Fi "Docker-Distribution-Api-Version: registry/2.0" >/dev/null; then
|
||||
echo "Registry preflight note: registry API header was not visible in wget output; continuing because HTTPS /v2/ returned 401." >&2
|
||||
fi
|
||||
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY"
|
||||
- ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE"
|
||||
- ssh $VPS_USER@$VPS_IP "docker stop myapp || true"
|
||||
- ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE"
|
||||
- echo "--- Building Docker Image ---"
|
||||
- |
|
||||
for var_name in NEXT_PUBLIC_API_URL NEXT_PUBLIC_MARKETPLACE_URL NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_ADMIN_URL; do
|
||||
var_value="$(printenv "$var_name" || true)"
|
||||
|
||||
if [ -z "$var_value" ]; then
|
||||
echo "$var_name is not set. Refusing to build a production image with missing public frontend URLs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$var_value" in
|
||||
*example.com*)
|
||||
echo "$var_name=$var_value still uses the placeholder example.com domain. Update the GitLab CI/CD variable before building." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
|
||||
- |
|
||||
export API_INTERNAL_URL="${API_INTERNAL_URL:-http://api:4000/api/v1}"
|
||||
export DASHBOARD_INTERNAL_URL="${DASHBOARD_INTERNAL_URL:-http://dashboard:3001}"
|
||||
export ADMIN_INTERNAL_URL="${ADMIN_INTERNAL_URL:-http://admin:3002}"
|
||||
|
||||
docker build \
|
||||
--build-arg API_INTERNAL_URL="$API_INTERNAL_URL" \
|
||||
--build-arg DASHBOARD_INTERNAL_URL="$DASHBOARD_INTERNAL_URL" \
|
||||
--build-arg ADMIN_INTERNAL_URL="$ADMIN_INTERNAL_URL" \
|
||||
--build-arg NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
--build-arg NEXT_PUBLIC_MARKETPLACE_URL="$NEXT_PUBLIC_MARKETPLACE_URL" \
|
||||
--build-arg NEXT_PUBLIC_DASHBOARD_URL="$NEXT_PUBLIC_DASHBOARD_URL" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_URL="$NEXT_PUBLIC_ADMIN_URL" \
|
||||
-t "$DOCKER_IMAGE" \
|
||||
-t "$DOCKER_IMAGE_LATEST" \
|
||||
-f "$DOCKERFILE_PATH" .
|
||||
- echo "--- Pushing to Registry ---"
|
||||
- docker push "$DOCKER_IMAGE"
|
||||
- docker push "$DOCKER_IMAGE_LATEST"
|
||||
rules:
|
||||
# On the default branch, always attempt the image build so missing registry
|
||||
# configuration fails loudly in before_script instead of skipping the job.
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
- when: never
|
||||
|
||||
# ====================================
|
||||
# DEPLOY STAGE
|
||||
# ====================================
|
||||
|
||||
deploy_to_vps:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
needs:
|
||||
- build_image
|
||||
before_script:
|
||||
- |
|
||||
if [ -n "${REGISTRY_HOST:-}${REGISTRY_USER:-}${REGISTRY_PASSWORD:-}${REGISTRY_IMAGE:-}" ]; then
|
||||
test -n "${REGISTRY_HOST:-}" || { echo "REGISTRY_HOST is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_USER:-}" || { echo "REGISTRY_USER is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_PASSWORD:-}" || { echo "REGISTRY_PASSWORD is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_IMAGE:-}" || { echo "REGISTRY_IMAGE is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
export IMAGE_REPOSITORY="$REGISTRY_IMAGE"
|
||||
else
|
||||
export REGISTRY_HOST="${CI_REGISTRY:-}"
|
||||
export REGISTRY_USER="${CI_REGISTRY_USER:-}"
|
||||
export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-}"
|
||||
export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-}"
|
||||
fi
|
||||
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
|
||||
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
|
||||
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
|
||||
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
|
||||
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
|
||||
- apk add --no-cache openssh-client
|
||||
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
|
||||
- eval "$(ssh-agent -s)"
|
||||
- |
|
||||
key_file="$(mktemp)"
|
||||
decoded_key_file="$(mktemp)"
|
||||
escaped_key_file="$(mktemp)"
|
||||
|
||||
cleanup_key_files() {
|
||||
rm -f "$key_file" "$decoded_key_file" "$escaped_key_file"
|
||||
}
|
||||
trap cleanup_key_files EXIT
|
||||
|
||||
if [ -f "${SSH_PRIVATE_KEY:-}" ]; then
|
||||
tr -d '\r' < "$SSH_PRIVATE_KEY" > "$key_file"
|
||||
else
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_file"
|
||||
fi
|
||||
|
||||
chmod 600 "$key_file"
|
||||
|
||||
if ! ssh-keygen -y -f "$key_file" >/dev/null 2>&1; then
|
||||
if [ ! -f "${SSH_PRIVATE_KEY:-}" ]; then
|
||||
printf '%b' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$escaped_key_file"
|
||||
if ssh-keygen -y -f "$escaped_key_file" >/dev/null 2>&1; then
|
||||
cp "$escaped_key_file" "$key_file"
|
||||
elif printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\r\n\t ' | base64 -d > "$decoded_key_file" 2>/dev/null; then
|
||||
tr -d '\r' < "$decoded_key_file" > "$key_file"
|
||||
fi
|
||||
chmod 600 "$key_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
ssh-keygen -y -f "$key_file" >/dev/null 2>&1 || {
|
||||
echo "SSH_PRIVATE_KEY must be an unencrypted OpenSSH private key. Supported formats: GitLab file variable, raw multiline key, single-line key with literal \\n escapes, or base64-encoded key." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ssh-add "$key_file"
|
||||
cleanup_key_files
|
||||
trap - EXIT
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
# Scan and trust the VPS host key (avoids manual known_hosts management)
|
||||
- ssh-keyscan -H "$VPS_IP" >> ~/.ssh/known_hosts
|
||||
- chmod 644 ~/.ssh/known_hosts
|
||||
script:
|
||||
- echo "--- Deploying $DOCKER_IMAGE to VPS ---"
|
||||
- |
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||
|
||||
echo "-- Syncing deployment assets --"
|
||||
ssh "$VPS_USER@$VPS_IP" "
|
||||
set -e
|
||||
mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'
|
||||
"
|
||||
scp docker-compose.production.yml docker-compose.portainer.production.yml docker-compose.registry.production.yml docker-compose.registry.local.yml traefik.yaml "$VPS_USER@$VPS_IP:$DEPLOY_ROOT/"
|
||||
scp scripts/docker-prod-common.sh scripts/docker-prod-deploy.sh scripts/docker-prod-up-registry.sh scripts/docker-registry-local-up.sh "$VPS_USER@$VPS_IP:$DEPLOY_ROOT/scripts/"
|
||||
scp docker/pgmanage/override.py "$VPS_USER@$VPS_IP:$DEPLOY_ROOT/docker/pgmanage/"
|
||||
|
||||
echo "-- Running deploy script on VPS --"
|
||||
ssh "$VPS_USER@$VPS_IP" "
|
||||
set -e
|
||||
cd '$DEPLOY_ROOT'
|
||||
APP_IMAGE='$IMAGE_REPOSITORY' \
|
||||
APP_VERSION='$CI_COMMIT_SHORT_SHA' \
|
||||
REGISTRY_HOST='$REGISTRY_HOST' \
|
||||
REGISTRY_USER='$REGISTRY_USER' \
|
||||
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
"
|
||||
rules:
|
||||
# Deploy only when both registry access and VPS credentials are available.
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && ((($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE)) && $SSH_PRIVATE_KEY && $VPS_IP && $VPS_USER)'
|
||||
when: manual
|
||||
- when: never
|
||||
|
||||
Binary file not shown.
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"save-serve.language": "en"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Agent Instructions
|
||||
|
||||
Always communicate in English.
|
||||
|
||||
Use English for:
|
||||
- Responses
|
||||
- Code comments
|
||||
- Documentation
|
||||
- Commit messages
|
||||
- Pull request descriptions
|
||||
- Error analysis
|
||||
|
||||
Only use another language if explicitly requested.
|
||||
@@ -1,248 +0,0 @@
|
||||
## Docker Environments
|
||||
|
||||
Three Docker environments are available:
|
||||
|
||||
- `Dockerfile.dev` with `docker-compose.dev.yml`
|
||||
- `Dockerfile.test` with `docker-compose.test.yml`
|
||||
- `Dockerfile.production` with `docker-compose.production.yml`
|
||||
- `docker-compose.pgmanage.yml` for a standalone pgManage container
|
||||
|
||||
### Development
|
||||
|
||||
Use the full dev stack for local work with hot reload and bundled Postgres and Redis:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile full up --build
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
- marketplace: `http://localhost:3000`
|
||||
- dashboard: `http://localhost:3001`
|
||||
- admin: `http://localhost:3002`
|
||||
- public-site: `http://localhost:3003`
|
||||
- api: `http://localhost:4000`
|
||||
- pgAdmin: `http://localhost:5050`
|
||||
|
||||
Each dev app now runs in its own container and can be started independently with a profile tag:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile api up --build
|
||||
docker compose -f docker-compose.dev.yml --profile marketplace up --build
|
||||
docker compose -f docker-compose.dev.yml --profile dashboard up --build
|
||||
docker compose -f docker-compose.dev.yml --profile admin up --build
|
||||
docker compose -f docker-compose.dev.yml --profile public-site up --build
|
||||
docker compose -f docker-compose.dev.yml --profile tools up --build
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies.
|
||||
- frontend profiles also start `api` and its dependencies automatically.
|
||||
- `tools` starts only `pgadmin` plus its required `postgres` dependency.
|
||||
|
||||
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
|
||||
|
||||
Default dev platform administrator:
|
||||
|
||||
- email: `admin@rentaldrivego.com`
|
||||
- password: `changeme123`
|
||||
|
||||
If you intentionally want a fresh dev bootstrap:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
```
|
||||
|
||||
If you want to keep the database and only apply new schema changes manually:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"
|
||||
```
|
||||
|
||||
pgAdmin dev login:
|
||||
|
||||
- email: `admin@rentaldrivego.local`
|
||||
- email: `admin@rentaldrivego.dev`
|
||||
- password: `admin`
|
||||
|
||||
pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`.
|
||||
|
||||
pgAdmin Postgres connection:
|
||||
|
||||
- host: `postgres`
|
||||
- port: `5432`
|
||||
- database: `rentaldrivego`
|
||||
- username: `postgres`
|
||||
- password: `password`
|
||||
|
||||
### Standalone pgManage
|
||||
|
||||
If you want a standalone Postgres management UI without starting the full development stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.pgmanage.yml up -d
|
||||
```
|
||||
|
||||
It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`.
|
||||
From inside the container, connect to the local Postgres service through `host.docker.internal:5432`.
|
||||
|
||||
### Test
|
||||
|
||||
Use the test stack to run repeatable containerized verification:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
The test container runs:
|
||||
|
||||
- `npm run db:deploy`
|
||||
- `npm run db:generate`
|
||||
- `npm run type-check`
|
||||
- `npm run build`
|
||||
|
||||
### Production
|
||||
|
||||
The production stack runs behind **Traefik** (reverse proxy + automatic HTTPS via Let's Encrypt). All services communicate over a private Docker network (`internal`). Traefik reaches public-facing services via a separate `traefik-proxy` network.
|
||||
|
||||
#### 1. Point DNS to your server
|
||||
|
||||
Add an A record for every subdomain to your server's public IP before deploying so Let's Encrypt can issue certificates:
|
||||
|
||||
| Subdomain | Service |
|
||||
|---|---|
|
||||
| `rentaldrivego.ma` | marketplace + public site |
|
||||
| `api.rentaldrivego.ma` | API |
|
||||
| `dashboard.rentaldrivego.ma` | dashboard |
|
||||
| `admin.rentaldrivego.ma` | admin panel |
|
||||
| `pgmanage.rentaldrivego.ma` | pgManage (DB admin) |
|
||||
|
||||
#### 2. Install Docker and clone the repo
|
||||
|
||||
```bash
|
||||
# Install Docker (if not already installed)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
git clone <repo-url> rentaldrivego
|
||||
cd rentaldrivego
|
||||
```
|
||||
|
||||
#### 3. Create the shared Traefik network
|
||||
|
||||
Only needs to be done once per server. If it already exists this is a no-op.
|
||||
|
||||
```bash
|
||||
docker network create traefik-proxy
|
||||
```
|
||||
|
||||
#### 4. Configure environment variables
|
||||
|
||||
```bash
|
||||
cp .env.docker.production.example .env.docker.production
|
||||
```
|
||||
|
||||
Open `.env.docker.production` and fill in every value. The minimum required secrets are:
|
||||
|
||||
| Variable | What to set |
|
||||
|---|---|
|
||||
| `POSTGRES_PASSWORD` | Strong random password |
|
||||
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
|
||||
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
|
||||
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||
|
||||
All domain vars are pre-filled with `rentaldrivego.ma` subdomains and do not need changing unless you use a different domain.
|
||||
|
||||
#### 5. Start Traefik
|
||||
|
||||
Traefik must be running before the app stack so it can wire up routes at startup.
|
||||
|
||||
```bash
|
||||
docker compose -f traefik.yaml up -d
|
||||
```
|
||||
|
||||
#### 6. Build and start the app stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
```
|
||||
|
||||
Docker will:
|
||||
1. Build the monorepo image
|
||||
2. Run database migrations (`migrate` service)
|
||||
3. Start all app services (api, marketplace, dashboard, admin, public-site, pgmanage)
|
||||
|
||||
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
|
||||
|
||||
#### Updating after a code change
|
||||
|
||||
Pull the latest code and rebuild only the changed service:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.production.yml up --build -d --no-deps <service>
|
||||
# e.g. to redeploy only the API:
|
||||
docker compose -f docker-compose.production.yml up --build -d --no-deps api
|
||||
```
|
||||
|
||||
To rebuild everything:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
```
|
||||
|
||||
#### Apply database migrations without downtime
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml run --rm migrate
|
||||
```
|
||||
|
||||
#### View logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
|
||||
# Single service
|
||||
docker compose -f docker-compose.production.yml logs -f api
|
||||
```
|
||||
|
||||
#### Stop the stack
|
||||
|
||||
```bash
|
||||
# Stop containers but keep volumes (data is preserved)
|
||||
docker compose -f docker-compose.production.yml down
|
||||
|
||||
# Stop and delete all data (destructive — irreversible)
|
||||
docker compose -f docker-compose.production.yml down -v
|
||||
```
|
||||
|
||||
#### pgManage (DB admin UI)
|
||||
|
||||
pgManage is available at `https://pgmanage.rentaldrivego.ma`. To connect to the production database, add a connection inside pgManage with:
|
||||
|
||||
- **Host:** `localhost`
|
||||
- **Port:** `5432`
|
||||
- **Database:** `rentaldrivego`
|
||||
- **Username:** `postgres`
|
||||
- **Password:** value of `POSTGRES_PASSWORD` from `.env.docker.production`
|
||||
|
||||
### Notes
|
||||
|
||||
- The production image builds the whole monorepo once, then each service overrides its runtime command.
|
||||
- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume.
|
||||
- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser.
|
||||
- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image.
|
||||
- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database.
|
||||
- If you need database schema updates inside Docker, run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate
|
||||
```
|
||||
|
||||
If a cached base image still fails during `npm ci`, refresh it and rebuild without cache:
|
||||
|
||||
```bash
|
||||
docker pull node:20-bookworm
|
||||
docker compose -f docker-compose.dev.yml build --no-cache dashboard
|
||||
```
|
||||
@@ -3,10 +3,12 @@ FROM node:20-bookworm
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
COPY config ./config
|
||||
|
||||
RUN npm ci --no-fund --no-audit
|
||||
|
||||
|
||||
+35
-6
@@ -7,6 +7,26 @@ RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
COPY config ./config
|
||||
|
||||
# These URLs are read by Next.js config during `next build`, so the production
|
||||
# image must bake Docker-network service URLs instead of localhost defaults.
|
||||
ARG API_INTERNAL_URL=http://api:4000/api/v1
|
||||
ARG DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ARG ADMIN_INTERNAL_URL=http://admin:3002
|
||||
|
||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_MARKETPLACE_URL
|
||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_URL
|
||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
||||
DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
|
||||
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
||||
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL
|
||||
|
||||
RUN npm install
|
||||
RUN npm run db:generate
|
||||
@@ -18,13 +38,22 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
||||
&& groupadd --system --gid 10001 app \
|
||||
&& useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \
|
||||
&& mkdir -p /var/lib/rentaldrivego/storage/public /var/lib/rentaldrivego/storage/private /tmp/rentaldrivego \
|
||||
&& chown -R app:app /app /var/lib/rentaldrivego /tmp/rentaldrivego
|
||||
|
||||
COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps ./apps
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
||||
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=app:app /app/apps ./apps
|
||||
COPY --from=builder --chown=app:app /app/packages ./packages
|
||||
COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"]
|
||||
|
||||
EXPOSE 3000 3001 3002 4000
|
||||
|
||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ RUN npm install
|
||||
|
||||
ENV NODE_ENV=test
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"]
|
||||
CMD ["sh", "-c", "npm run db:deploy && npm run db:generate && npm run type-check && npm run build && npm run test:api:integration"]
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Security Hardening Application Report
|
||||
|
||||
Generated: 2026-06-09
|
||||
Project: RentalDriveGo / Car Management System
|
||||
Input archive: `/mnt/data/car_management_system_plan_applied(1).zip`
|
||||
Plan applied: `/mnt/data/SECURITY_HARDENING_IMPLEMENTATION_PLAN(1).md`
|
||||
|
||||
## Executive result
|
||||
|
||||
The uploaded project was inspected and the security hardening plan was applied as far as safely possible inside the source archive. The archive already contained a broad previous hardening pass. I did not blindly trust that state, because that is how software becomes an expensive apology letter. I re-audited the implementation against the plan and applied additional corrections where the code still contradicted the target security model.
|
||||
|
||||
The final output includes a patched project archive, this report, a changed-file list, and an incremental diff for the additional changes made during this pass.
|
||||
|
||||
## What was already present in the uploaded project
|
||||
|
||||
The project already contained many of the plan-aligned building blocks:
|
||||
|
||||
- Centralized JWT helpers for actor tokens.
|
||||
- HttpOnly session cookie helpers for admin, employee, and renter sessions.
|
||||
- Company authorization policy helpers.
|
||||
- Admin 2FA and fresh-2FA enforcement middleware.
|
||||
- Public booking access-token helpers.
|
||||
- Webhook idempotency helpers.
|
||||
- Hashed `CompanyApiKey` model and migration.
|
||||
- `ReservationPublicAccess` model and migration.
|
||||
- `WebhookEvent` model and migration.
|
||||
- Upload validation helpers and public/private storage separation.
|
||||
- Request ID and sanitized error response middleware.
|
||||
- Production Docker/Traefik hardening, including Redis authentication and `x-middleware-subrequest` blocking.
|
||||
- Static security scan script and CI security gates.
|
||||
|
||||
That base work was useful, but it had a few security and test-consistency gaps.
|
||||
|
||||
## Additional changes applied in this pass
|
||||
|
||||
### 1. Removed legacy plaintext company API key storage
|
||||
|
||||
The plan requires company API keys to be hash-only. The code had introduced `CompanyApiKey`, but the legacy `Company.apiKey` field still existed in the Prisma schema and middleware still allowed a fallback lookup against that plaintext field when `ALLOW_LEGACY_COMPANY_API_KEYS=true`.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Removed `Company.apiKey` from `packages/database/prisma/schema.prisma`.
|
||||
- Removed `apiKey` from the local `Company` TypeScript interface in `packages/database/src/index.ts` and `packages/database/src/index.d.ts`.
|
||||
- Removed the legacy plaintext fallback path from `apps/api/src/middleware/requireApiKey.ts`.
|
||||
- Added migration `20260609233000_drop_legacy_company_api_key` to drop the old column and unique index.
|
||||
- Rewrote `requireApiKey` tests around prefix lookup, hash comparison, revocation, and `lastUsedAt` updates.
|
||||
|
||||
Security effect: raw company API keys are no longer accepted through the legacy company column and are no longer represented in the current Prisma schema.
|
||||
|
||||
### 2. Centralized Socket.io token verification
|
||||
|
||||
The main API process still verified Socket.io auth tokens directly with `jwt.verify(token, JWT_SECRET)` and did not enforce issuer, audience, actor type, or allowed algorithm. This contradicted the session-authentication phase of the plan.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Added `verifyAnyActorToken()` to `apps/api/src/security/tokens.ts`.
|
||||
- Updated `apps/api/src/index.ts` to use centralized actor-token verification for Socket.io authentication.
|
||||
- Removed the direct `jsonwebtoken` import from the main API entrypoint.
|
||||
|
||||
Security effect: Socket.io no longer accepts tokens that bypass the centralized actor-token constraints.
|
||||
|
||||
### 3. Hardened employee password-reset JWT verification
|
||||
|
||||
Employee password-reset tokens were signed and verified directly with the JWT secret and no issuer, audience, or algorithm constraints.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Added explicit `HS256` signing for employee password-reset tokens.
|
||||
- Added issuer `rentaldrivego-api`.
|
||||
- Added audience `employee_password_reset`.
|
||||
- Added matching verification constraints.
|
||||
|
||||
Security effect: reset tokens now reject wrong issuer, wrong audience, or wrong algorithm instead of relying on a bare shared-secret verification.
|
||||
|
||||
### 4. Repaired test fixtures and middleware tests
|
||||
|
||||
Some tests still expected pre-hardening behavior. That is a bad smell: tests defending old weaknesses are basically tiny lobbyists for future incidents.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Updated API-key middleware tests to validate hashed-key behavior instead of plaintext `Company.apiKey` lookup.
|
||||
- Updated auth middleware tests to match the centralized token verifier behavior.
|
||||
- Updated integration test helper token generation to use `signActorToken()` so generated test tokens include issuer and audience.
|
||||
|
||||
Security effect: future test runs are less likely to push developers back toward weaker auth behavior.
|
||||
|
||||
## Verification performed in this sandbox
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---:|---|
|
||||
| Static security scan | PASS | `npm run security:static` completed successfully. |
|
||||
| Critical production dependency audit | PASS | `npm audit --package-lock-only --omit=dev --audit-level=critical` exited successfully. |
|
||||
| JSON syntax check | PASS | Root and app `package.json` files and lockfile parsed successfully. |
|
||||
| Shell syntax check | PASS | Shell scripts and production entrypoint parsed with `bash -n`. |
|
||||
| YAML parse check | PASS | `docker-compose.production.yml` and `.gitlab-ci.yml` parsed successfully. |
|
||||
| Node script syntax | PASS | `scripts/security-static-check.mjs` passed `node --check`. |
|
||||
| Full dependency install | NOT COMPLETED | `npm ci --ignore-scripts --prefer-offline` could not complete in this sandbox. |
|
||||
| Full type-check/test/build | NOT RUN | Requires dependencies to be installed. Run in CI or a normal development environment. |
|
||||
| Docker compose config/render | NOT RUN | Docker is unavailable in this sandbox. |
|
||||
| Prisma generate/migrate | NOT RUN | Requires dependency installation and a normal Prisma/DB environment. |
|
||||
|
||||
## Dependency audit note
|
||||
|
||||
The critical audit gate passes. The audit still reports moderate findings involving `postcss` through Next.js and `uuid` through Firebase/cron-related dependency chains. The lockfile’s suggested fixes require forced or breaking upgrades, so I did not casually smash the dependency graph with a hammer and call the mess “security.” Those should be handled in a controlled dependency-upgrade ticket with full frontend and notification regression testing.
|
||||
|
||||
## Files changed by this pass
|
||||
|
||||
- `apps/api/src/index.ts`
|
||||
- `apps/api/src/middleware/requireApiKey.ts`
|
||||
- `apps/api/src/middleware/requireApiKey.test.ts`
|
||||
- `apps/api/src/middleware/requireCompanyAuth.test.ts`
|
||||
- `apps/api/src/middleware/requireRenterAuth.test.ts`
|
||||
- `apps/api/src/modules/auth/auth.employee.service.ts`
|
||||
- `apps/api/src/security/tokens.ts`
|
||||
- `apps/api/src/tests/helpers/fixtures.ts`
|
||||
- `packages/database/prisma/schema.prisma`
|
||||
- `packages/database/prisma/migrations/20260609233000_drop_legacy_company_api_key/migration.sql`
|
||||
- `packages/database/src/index.ts`
|
||||
- `packages/database/src/index.d.ts`
|
||||
|
||||
See also:
|
||||
|
||||
- `security_hardening_incremental.diff`
|
||||
- `security_hardening_changed_files.txt`
|
||||
|
||||
## Phase status against the hardening plan
|
||||
|
||||
| Phase | Status | Evidence / caveat |
|
||||
|---|---:|---|
|
||||
| Phase 0: Emergency stabilization | Partial / needs operator action | Static scan passes, placeholders are present, but real production secret rotation cannot be performed inside the archive. |
|
||||
| Phase 1: Sessions and authentication | Substantially applied | HttpOnly session helpers and centralized actor JWT verification exist; Socket.io and reset-token gaps were corrected in this pass. |
|
||||
| Phase 2: Authorization and tenant isolation | Substantially applied | Company policy middleware exists; tenant-safe patterns are present in many modules. Full proof requires test suite and code review across all repository methods. |
|
||||
| Phase 3: Public booking privacy | Applied at source level | Public access token model/helper and safe public booking flow are present. Must be verified with integration tests. |
|
||||
| Phase 4: Admin hardening | Applied at source level | Mandatory 2FA and fresh-2FA middleware are present. Enrollment and recovery-code workflows still need production validation. |
|
||||
| Phase 5: API key hardening | Strengthened in this pass | Legacy plaintext company API key storage/fallback removed; hash-only `CompanyApiKey` path remains. |
|
||||
| Phase 6: Payments and webhooks | Applied at source level | Raw-body webhook handling and idempotency helpers are present. Must be verified against provider test events. |
|
||||
| Phase 7: Upload and storage hardening | Applied at source level | Magic-byte validation and public/private storage split are present. Must be verified with upload abuse tests. |
|
||||
| Phase 8: Rate limiting, errors, browser security | Applied at source level | Request IDs, sanitized errors, rate limit middleware, and security headers are present. Must be verified in deployed environment. |
|
||||
| Phase 9: Deployment hardening | Applied at config level | Redis auth, non-public DB tooling, private networks, and Traefik header blocking are present. Must be verified on the real host. |
|
||||
| Phase 10: Observability, auditability, jobs | Partial | Logging/audit hooks exist, but queue migration and operational observability need dedicated validation. |
|
||||
| CI/CD security gates | Applied at config level | Security scan and critical audit gates exist; full CI must run outside this sandbox. |
|
||||
|
||||
## Required follow-up before launch
|
||||
|
||||
1. Rotate all real production secrets and invalidate old sessions/API keys where appropriate.
|
||||
2. Run `npm ci` in CI or a normal development environment.
|
||||
3. Run `npm run db:generate` and apply the new migration after backup.
|
||||
4. Run full type-check, unit tests, integration tests, e2e tests, and builds.
|
||||
5. Run payment-provider webhook test events using real sandbox provider signatures.
|
||||
6. Verify public/private storage behavior with real uploaded files.
|
||||
7. Verify Redis and PostgreSQL are not externally reachable from the production host.
|
||||
8. Run container image build and Trivy scan.
|
||||
9. Confirm admin 2FA enrollment and fresh-2FA gates before enabling privileged admin actions in production.
|
||||
10. Document any deferrals with owner, risk acceptance, compensating control, deadline, and ticket number.
|
||||
|
||||
## Launch recommendation
|
||||
|
||||
Do not launch publicly yet based only on the patched archive. The source now better matches the plan, and the critical static/audit checks pass, but the hard launch gate still depends on full CI, Prisma migration validation, deployment verification, and production secret rotation.
|
||||
|
||||
The patched archive is suitable for the next CI/staging pass. Treat it as implementation-ready source, not production clearance. Production clearance comes from reproducible evidence, not vibes in a ZIP file.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Security Hardening Leftover Application Report
|
||||
|
||||
Project: RentalDriveGo / Car Management System
|
||||
Input archive: `car_management_system_hardened_applied.zip`
|
||||
Output archive: `car_management_system_leftover_applied.zip`
|
||||
Date: 2026-06-09
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model.
|
||||
|
||||
This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements.
|
||||
|
||||
## Applied Changes
|
||||
|
||||
### 1. Removed remaining browser-side employee token assumptions
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/dashboard/src/lib/api.ts`
|
||||
- `apps/dashboard/src/components/layout/TopBar.tsx`
|
||||
- `apps/dashboard/src/components/layout/Sidebar.tsx`
|
||||
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
|
||||
- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`
|
||||
|
||||
What changed:
|
||||
|
||||
- Removed dashboard use of employee auth tokens from `localStorage`.
|
||||
- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies.
|
||||
- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens.
|
||||
- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`.
|
||||
- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Reduces script-readable authentication exposure.
|
||||
- Aligns the dashboard with the intended HttpOnly session-cookie model.
|
||||
- Prevents UI code from treating a readable JWT as the authority for employee identity.
|
||||
|
||||
### 2. Added Socket.io HttpOnly-cookie session support
|
||||
|
||||
Changed file:
|
||||
|
||||
- `apps/api/src/index.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- Added Socket.io session-token extraction from HttpOnly cookies.
|
||||
- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies.
|
||||
- Reused centralized actor-token verification.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Real-time dashboard connections no longer require JavaScript-readable employee tokens.
|
||||
- Socket authentication now follows the same actor-token validation path used elsewhere.
|
||||
|
||||
### 3. Added app-layer `x-middleware-subrequest` blocking
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/dashboard/src/middleware.ts`
|
||||
- `apps/marketplace/src/middleware.ts`
|
||||
- `apps/admin/src/middleware.ts`
|
||||
- `apps/dashboard/src/middleware.test.ts`
|
||||
- `apps/marketplace/src/middleware.test.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
||||
- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer.
|
||||
- Added/updated middleware tests for the rejection path.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Adds defense in depth beyond reverse-proxy filtering.
|
||||
- Prevents the project from depending on a single infrastructure control for this bypass class.
|
||||
|
||||
### 4. Hardened admin/browser fetch behavior
|
||||
|
||||
Changed files include:
|
||||
|
||||
- `apps/admin/src/lib/api.ts`
|
||||
- `apps/admin/src/app/dashboard/admin-users/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/renters/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/companies/[id]/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/containers/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/pricing/page.tsx`
|
||||
- `apps/admin/src/app/forgot-password/page.tsx`
|
||||
- `apps/admin/src/app/reset-password/page.tsx`
|
||||
|
||||
What changed:
|
||||
|
||||
- Admin API wrapper uses `credentials: 'include'`.
|
||||
- Manual admin fetch calls now include credentials where they directly call the admin API.
|
||||
- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Admin browser requests now consistently rely on the HttpOnly admin session cookie.
|
||||
- Removes misleading bearer-token scaffolding from the admin UI.
|
||||
|
||||
### 5. Improved actor-aware rate limiting
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/api/src/middleware/rateLimiter.ts`
|
||||
- `apps/api/src/app.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens.
|
||||
- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys.
|
||||
- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Authenticated traffic is limited by actor identity instead of only coarse IP data.
|
||||
- Login and admin-auth abuse get stricter protection.
|
||||
- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment.
|
||||
|
||||
### 6. Added admin 2FA recovery-code backend workflow
|
||||
|
||||
Changed files:
|
||||
|
||||
- `packages/database/prisma/schema.prisma`
|
||||
- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql`
|
||||
- `apps/api/src/modules/admin/admin.repo.ts`
|
||||
- `apps/api/src/modules/admin/admin.service.ts`
|
||||
- `apps/api/src/modules/admin/admin.schemas.ts`
|
||||
- `apps/api/src/modules/admin/admin.routes.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- Added `AdminRecoveryCode` model.
|
||||
- Recovery codes are stored as bcrypt hashes, not plaintext.
|
||||
- TOTP enrollment now issues one-time recovery codes.
|
||||
- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA.
|
||||
- Login can consume a valid unused recovery code when TOTP is enabled.
|
||||
- Recovery-code issuance and use are audited.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext.
|
||||
- Preserves one-time-use semantics.
|
||||
- Adds auditability for recovery-code lifecycle events.
|
||||
|
||||
Limitation:
|
||||
|
||||
- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side.
|
||||
|
||||
### 7. Strengthened static scanning for auth-token regressions
|
||||
|
||||
Changed file:
|
||||
|
||||
- `scripts/security-static-check.mjs`
|
||||
|
||||
What changed:
|
||||
|
||||
- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`.
|
||||
- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens.
|
||||
|
||||
### 8. Updated documentation to match the hardened model
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/dashboard/README.md`
|
||||
- `memory/project_auth_architecture.md`
|
||||
- `docs/project-design/COOKIE_POLICY.md`
|
||||
- `apps/api/src/swagger/openapi.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording.
|
||||
- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts.
|
||||
- Updated cookie policy references from legacy `employee_token` wording to `employee_session`.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern.
|
||||
|
||||
## Validation Performed
|
||||
|
||||
The following checks were run successfully in the available environment:
|
||||
|
||||
```bash
|
||||
npm run security:static
|
||||
node --check scripts/security-static-check.mjs
|
||||
# JSON parse validation for package.json and package-lock.json files
|
||||
# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml
|
||||
bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh
|
||||
# TypeScript/TSX syntax transpile check over 507 source files
|
||||
npm audit --package-lock-only --omit=dev --audit-level=critical
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- Security static check: passed.
|
||||
- Static-check script syntax: passed.
|
||||
- Package JSON validation: passed.
|
||||
- YAML validation: passed.
|
||||
- Shell syntax validation: passed.
|
||||
- TypeScript/TSX syntax transpile validation: passed.
|
||||
- Critical production dependency audit: passed.
|
||||
|
||||
Audit note:
|
||||
|
||||
- `npm audit --audit-level=critical` exited successfully.
|
||||
- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass.
|
||||
|
||||
## Not Fully Verified in This Environment
|
||||
|
||||
The following items require a real development/CI/deployment environment:
|
||||
|
||||
- `npm ci` from a clean checkout.
|
||||
- Full workspace typecheck.
|
||||
- Full unit, integration, security, and e2e test suites.
|
||||
- Prisma client generation.
|
||||
- Applying the new database migration to a real database.
|
||||
- Database backup/restore verification.
|
||||
- Docker image build and runtime validation.
|
||||
- Container scanning with Trivy or equivalent.
|
||||
- Live reverse-proxy validation for `x-middleware-subrequest` blocking.
|
||||
- Redis-backed distributed rate-limit validation across multiple API containers.
|
||||
- Provider webhook sandbox tests.
|
||||
- Live admin 2FA recovery-code UX verification.
|
||||
|
||||
## Remaining Launch-Gate Work
|
||||
|
||||
These are still not things source edits can prove by themselves:
|
||||
|
||||
1. Rotate all real production secrets.
|
||||
2. Confirm no real secrets exist in repository history or image layers.
|
||||
3. Apply and verify the new `admin_recovery_codes` migration.
|
||||
4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan.
|
||||
5. Confirm Redis and PostgreSQL are private in production.
|
||||
6. Confirm DB management tools are not publicly reachable.
|
||||
7. Confirm production containers run non-root with reduced capabilities and resource limits.
|
||||
8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads.
|
||||
9. Confirm private files are inaccessible through static routes in the deployed environment.
|
||||
10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely.
|
||||
|
||||
## Changed Files
|
||||
|
||||
See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff.
|
||||
|
||||
## Final Assessment
|
||||
|
||||
This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation.
|
||||
|
||||
However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard.
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
const ADMIN_BASE_PATH = '/admin'
|
||||
|
||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
|
||||
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
|
||||
|
||||
const nextConfig = {
|
||||
basePath: ADMIN_BASE_PATH,
|
||||
...(assetPrefix ? { assetPrefix } : {}),
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -18,12 +30,24 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/storage/:path*',
|
||||
destination: `${apiOrigin}/storage/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
+18
-11
@@ -3,28 +3,35 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3002",
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3002",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3002",
|
||||
"type-check": "tsc --noEmit"
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "next start -H 0.0.0.0 -p 3002",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rentaldrivego/types": "*",
|
||||
"next": "14.2.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dayjs": "^1.11.11",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "^16.2.7",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"zod": "^3.23.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"recharts": "^2.12.7",
|
||||
"lucide-react": "^0.376.0"
|
||||
"tailwindcss": "^3.4.3",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
function resolveAdminNextPath(next: string | null) {
|
||||
const fallback = '/admin/dashboard'
|
||||
if (!next) return fallback
|
||||
|
||||
if (/^https?:\/\//i.test(next)) return next
|
||||
if (next.startsWith('/admin/')) return next
|
||||
if (next === '/admin') return '/admin/dashboard'
|
||||
if (next.startsWith('/')) return `/admin${next}`
|
||||
|
||||
return `/admin/${next.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export default function AuthRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('token')
|
||||
const next = params.get('next') || '/dashboard'
|
||||
const next = resolveAdminNextPath(params.get('next'))
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
// Clear the token from the URL
|
||||
// Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie.
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
|
||||
router.replace(next)
|
||||
}, [router])
|
||||
window.location.replace(next)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canAccessAdminMetrics } from './AdminSessionContext'
|
||||
|
||||
describe('canAccessAdminMetrics', () => {
|
||||
it('allows finance and higher roles after admin 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'FINANCE', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPPORT', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPER_ADMIN', totpEnabled: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('denies viewer role and admins who still need 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'VIEWER', totpEnabled: true })).toBe(false)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: false })).toBe(false)
|
||||
expect(canAccessAdminMetrics(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
|
||||
|
||||
export type AdminSessionUser = {
|
||||
id: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
totpEnabled?: boolean
|
||||
}
|
||||
|
||||
const METRICS_ALLOWED_ROLES = new Set<AdminRole>(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE'])
|
||||
|
||||
const AdminSessionContext = createContext<AdminSessionUser | null>(null)
|
||||
|
||||
export function AdminSessionProvider({
|
||||
admin,
|
||||
children,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <AdminSessionContext.Provider value={admin}>{children}</AdminSessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useAdminSession() {
|
||||
return useContext(AdminSessionContext)
|
||||
}
|
||||
|
||||
export function canAccessAdminMetrics(admin: Pick<AdminSessionUser, 'role' | 'totpEnabled'> | null | undefined) {
|
||||
return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role))
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
@@ -16,22 +15,29 @@ interface AdminUser {
|
||||
}
|
||||
|
||||
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
||||
const EMPTY_FORM = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'SUPPORT',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
@@ -45,32 +51,69 @@ export default function AdminUsersPage() {
|
||||
|
||||
useEffect(() => { fetchAdmins() }, [])
|
||||
|
||||
async function createAdmin(e: React.FormEvent) {
|
||||
function openCreateModal() {
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function openEditModal(admin: AdminUser) {
|
||||
setEditingAdminId(admin.id)
|
||||
setForm({
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
email: admin.email,
|
||||
password: '',
|
||||
role: admin.role,
|
||||
isActive: admin.isActive,
|
||||
})
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setShowModal(false)
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function saveAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(form),
|
||||
const isEditing = Boolean(editingAdminId)
|
||||
const payload = {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
isActive: form.isActive,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
}
|
||||
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
||||
method: isEditing ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
|
||||
setShowModal(false)
|
||||
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
if (!res.ok) throw new Error(json?.message ?? `Failed to ${isEditing ? 'update' : 'create'} admin`)
|
||||
closeModal()
|
||||
await fetchAdmins()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
|
||||
ADMIN: 'text-sky-400 bg-sky-950/40',
|
||||
SUPPORT: 'text-amber-400 bg-amber-950/40',
|
||||
SUPPORT: 'text-orange-400 bg-orange-950/40',
|
||||
FINANCE: 'text-violet-400 bg-violet-950/40',
|
||||
VIEWER: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
@@ -83,7 +126,7 @@ export default function AdminUsersPage() {
|
||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
+ New admin
|
||||
@@ -103,13 +146,14 @@ export default function AdminUsersPage() {
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Permissions</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Joined</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
) : admins.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
|
||||
@@ -130,6 +174,15 @@ export default function AdminUsersPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(a)}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -138,13 +191,13 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">New admin user</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
<h2 className="text-lg font-semibold">{editingAdminId ? 'Edit admin user' : 'New admin user'}</h2>
|
||||
<button onClick={closeModal} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createAdmin} className="space-y-4">
|
||||
<form onSubmit={saveAdmin} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||
@@ -176,11 +229,13 @@ export default function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Password</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
required={!editingAdminId}
|
||||
minLength={editingAdminId ? undefined : 8}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
@@ -196,10 +251,21 @@ export default function AdminUsersPage() {
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.isActive ? 'active' : 'inactive'}
|
||||
onChange={(e) => setForm({ ...form, isActive: e.target.value === 'active' })}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={creating} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{creating ? 'Creating…' : 'Create admin'}
|
||||
<button type="button" onClick={closeModal} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{saving ? (editingAdminId ? 'Saving…' : 'Creating…') : (editingAdminId ? 'Save changes' : 'Create admin')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
@@ -17,7 +16,7 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
CREATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
UPDATE: 'text-sky-400 bg-sky-950/40',
|
||||
DELETE: 'text-red-400 bg-red-950/40',
|
||||
SUSPEND: 'text-amber-400 bg-amber-950/40',
|
||||
SUSPEND: 'text-orange-400 bg-orange-950/40',
|
||||
ACTIVATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
LOGIN: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
@@ -29,13 +28,12 @@ export default function AdminAuditLogsPage() {
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/audit-logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setLogs(json.data ?? []))
|
||||
.then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.data ?? [])))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
PUBLIC_HOMEPAGE_GRID_COLUMNS,
|
||||
PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
getPublicHomepageSectionLimits,
|
||||
type PublicHomepageLayout,
|
||||
type PublicHomepageLayoutItem,
|
||||
type PublicHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
type Props = {
|
||||
layout: PublicHomepageLayout
|
||||
availableSections: PublicHomepageSectionType[]
|
||||
onChange: (layout: PublicHomepageLayout) => void
|
||||
onAddSection: (type: PublicHomepageSectionType) => void
|
||||
onRemoveSection: (type: PublicHomepageSectionType) => void
|
||||
}
|
||||
|
||||
type Interaction = {
|
||||
itemId: string
|
||||
mode: 'move' | 'resize'
|
||||
startX: number
|
||||
startY: number
|
||||
origin: PublicHomepageLayoutItem
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 52
|
||||
|
||||
const SECTION_META: Record<PublicHomepageSectionType, { label: string; tint: string; description: string }> = {
|
||||
hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' },
|
||||
offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' },
|
||||
vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' },
|
||||
pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const [interaction, setInteraction] = useState<Interaction | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!interaction) return
|
||||
const snap = interaction
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
|
||||
const deltaCols = Math.round((event.clientX - snap.startX) / colWidth)
|
||||
const deltaRows = Math.round((event.clientY - snap.startY) / ROW_HEIGHT)
|
||||
const limits = getPublicHomepageSectionLimits(snap.origin.type)
|
||||
|
||||
onChange({
|
||||
items: layout.items.map((item) => {
|
||||
if (item.id !== snap.itemId) return item
|
||||
if (snap.mode === 'move') {
|
||||
return {
|
||||
...item,
|
||||
x: clamp(snap.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.w + 1),
|
||||
y: clamp(snap.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.h + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const nextW = clamp(
|
||||
snap.origin.w + deltaCols,
|
||||
limits.minW,
|
||||
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.x + 1),
|
||||
)
|
||||
const nextH = clamp(
|
||||
snap.origin.h + deltaRows,
|
||||
limits.minH,
|
||||
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.y + 1),
|
||||
)
|
||||
|
||||
return {
|
||||
...item,
|
||||
w: nextW,
|
||||
h: nextH,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
setInteraction(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [interaction, layout.items, onChange])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4">
|
||||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-100">Homepage layout</h4>
|
||||
<p className="mt-1 text-xs text-zinc-500">Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSections.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onAddSection(type)}
|
||||
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300 transition hover:bg-emerald-500/20"
|
||||
>
|
||||
Add {SECTION_META[type].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative mt-4 hidden overflow-hidden rounded-[1.5rem] border border-zinc-800 bg-zinc-950 lg:block"
|
||||
style={{
|
||||
height: ROW_HEIGHT * PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${100 / PUBLIC_HOMEPAGE_GRID_COLUMNS}% ${ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="absolute overflow-hidden rounded-[1.25rem] border border-white/10 bg-zinc-900/95 shadow-xl shadow-black/30"
|
||||
style={{
|
||||
left: `${((item.x - 1) / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
top: `${(item.y - 1) * ROW_HEIGHT}px`,
|
||||
width: `${(item.w / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
height: `${item.h * ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'move',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className={`flex w-full cursor-grab items-start justify-between bg-gradient-to-r ${meta.tint} px-4 py-3 text-left active:cursor-grabbing`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">Drag</p>
|
||||
<p className="mt-1 text-sm font-black text-white">{meta.label}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-black/20 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
{item.w}x{item.h}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex h-[calc(100%-60px)] flex-col justify-between p-4">
|
||||
<p className="max-w-xs text-sm leading-6 text-zinc-300">{meta.description}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">x{item.x} y{item.y}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Resize ${meta.label}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'resize',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className="absolute bottom-2 right-2 h-7 w-7 rounded-full border border-white/15 bg-white/10 text-white"
|
||||
>
|
||||
<span className="block rotate-45 text-sm">+</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:hidden">
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">{meta.label}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{meta.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-zinc-500">Desktop drag canvas is available on larger screens.</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
@@ -12,6 +11,7 @@ interface Company {
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
contractSettings: { legalName: string | null } | null
|
||||
subscription: { plan: string; status: string } | null
|
||||
_count: { employees: number; vehicles: number }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
PENDING: 'text-orange-400 bg-orange-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function AdminCompaniesPage() {
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
company: 'Company',
|
||||
legalName: 'Legal name',
|
||||
slug: 'Slug',
|
||||
status: 'Status',
|
||||
plan: 'Plan',
|
||||
@@ -48,6 +49,7 @@ export default function AdminCompaniesPage() {
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
company: 'Entreprise',
|
||||
legalName: 'Raison sociale',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
plan: 'Plan',
|
||||
@@ -59,15 +61,16 @@ export default function AdminCompaniesPage() {
|
||||
},
|
||||
ar: {
|
||||
title: 'الشركات',
|
||||
search: 'ابحث بالاسم أو الـ slug…',
|
||||
search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
legalName: 'الاسم القانوني',
|
||||
slug: 'Slug',
|
||||
status: 'الحالة',
|
||||
plan: 'الخطة',
|
||||
fleet: 'الأسطول',
|
||||
vehicles: 'سيارات',
|
||||
vehicles: 'مركبة',
|
||||
view: 'عرض',
|
||||
suspend: 'تعليق',
|
||||
reactivate: 'إعادة التفعيل',
|
||||
@@ -81,16 +84,16 @@ export default function AdminCompaniesPage() {
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
async function fetchCompanies() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setCompanies(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setCompanies(list)
|
||||
setFiltered(list)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -107,11 +110,11 @@ export default function AdminCompaniesPage() {
|
||||
|
||||
async function changeStatus(id: string, status: string) {
|
||||
setActioning(id)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
@@ -162,6 +165,9 @@ export default function AdminCompaniesPage() {
|
||||
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.name}</p>
|
||||
{c.contractSettings?.legalName && c.contractSettings.legalName !== c.name ? (
|
||||
<p className="text-xs text-zinc-400">{copy.legalName}: {c.contractSettings.legalName}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-zinc-500">{c.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||
|
||||
@@ -26,8 +25,7 @@ interface CompanyContainer {
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||
return { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
@@ -35,7 +33,7 @@ function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-yellow-900 text-yellow-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-orange-900 text-orange-300' },
|
||||
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
||||
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
||||
@@ -58,7 +56,7 @@ function LogsModal({ companyId, companyName, onClose }: { companyId: string; com
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json()
|
||||
setLogs(json.data?.logs ?? '')
|
||||
} catch {
|
||||
@@ -72,7 +70,7 @@ function LogsModal({ companyId, companyName, onClose }: { companyId: string; com
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/70 p-4" onClick={onClose}>
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<div>
|
||||
@@ -127,7 +125,7 @@ export default function ContainersPage() {
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers`, { headers: authHeaders() })
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||
@@ -152,7 +150,7 @@ export default function ContainersPage() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||
setProvisionResults(json.data?.results ?? [])
|
||||
@@ -171,9 +169,9 @@ export default function ContainersPage() {
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||
const url =
|
||||
action === 'remove'
|
||||
? `${API_BASE}/admin/containers/${companyId}`
|
||||
: `${API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders() })
|
||||
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
||||
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
||||
@@ -273,7 +271,7 @@ export default function ContainersPage() {
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-yellow-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-orange-400' },
|
||||
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
@@ -419,7 +417,7 @@ function ActionButton({
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
||||
yellow: 'border-yellow-800 text-yellow-400 hover:bg-yellow-900/40',
|
||||
yellow: 'border-orange-800 text-orange-400 hover:bg-orange-900/40',
|
||||
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
||||
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const params = new URLSearchParams({
|
||||
portal: 'admin',
|
||||
next: nextPath || '/dashboard',
|
||||
@@ -25,43 +27,69 @@ const navLinks = [
|
||||
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||
{ href: '/dashboard/menu-management', key: 'menuManagement', icon: 'M4 6h16M4 12h16M4 18h10' },
|
||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
]
|
||||
|
||||
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { dict } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
let cancelled = false
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return
|
||||
if (response.ok) {
|
||||
const json = await response.json().catch(() => null)
|
||||
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
|
||||
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
} else {
|
||||
return
|
||||
}
|
||||
setAdmin(resolvedAdmin)
|
||||
setReady(true)
|
||||
} else {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
void fetch('/admin/api/v1/admin/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-zinc-950">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" aria-label={dict.loading} />
|
||||
<div className="flex h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label={dict.loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900 transition-colors">
|
||||
<AdminSessionProvider admin={admin as AdminSessionUser}>
|
||||
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-zinc-100">RentalDriveGo</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
@@ -71,7 +99,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||
active ? 'bg-orange-600 text-white dark:bg-orange-500 dark:text-white' : 'text-stone-500 hover:text-blue-900 hover:bg-stone-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -85,20 +113,21 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-red-400"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex flex-col items-center gap-3 border-t border-zinc-800 pt-4">
|
||||
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950 transition-colors">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
|
||||
</div>
|
||||
</AdminSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
|
||||
|
||||
type CompanyOption = {
|
||||
id: string
|
||||
name: string
|
||||
subscription?: { plan?: string | null } | null
|
||||
status: string
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
id: string
|
||||
systemKey: string | null
|
||||
label: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
icon: string | null
|
||||
parentId: string | null
|
||||
displayOrder: number
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
parent?: { id: string; label: string } | null
|
||||
roleVisibilities: Array<{ role: EmployeeRole }>
|
||||
subscriptionAssignments: Array<{ plan: Plan; displayOrder: number; isActive: boolean }>
|
||||
companyAssignments: Array<{
|
||||
companyId: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
company: { id: string; name: string }
|
||||
}>
|
||||
}
|
||||
|
||||
type AuditLog = {
|
||||
id: string
|
||||
action: string
|
||||
resource: string
|
||||
resourceId: string | null
|
||||
createdAt: string
|
||||
adminUser: { firstName: string; lastName: string; email: string } | null
|
||||
}
|
||||
|
||||
type PreviewItem = {
|
||||
id: string
|
||||
label: string
|
||||
systemKey: string | null
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
displayOrder: number
|
||||
visible: boolean
|
||||
source: 'subscription' | 'company' | 'none'
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
type PreviewResponse = {
|
||||
company: { id: string; name: string; status: string; subscription: { plan: Plan; status: string } | null }
|
||||
role: EmployeeRole
|
||||
subscriptionPlan: Plan | null
|
||||
items: PreviewItem[]
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
label: string
|
||||
systemKey: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string
|
||||
icon: string
|
||||
parentId: string
|
||||
displayOrder: string
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
roles: EmployeeRole[]
|
||||
plans: Plan[]
|
||||
companyIds: string[]
|
||||
}
|
||||
|
||||
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
|
||||
|
||||
const INPUT =
|
||||
'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-2 focus:ring-orange-500'
|
||||
const LABEL = 'text-xs font-medium uppercase tracking-wider text-zinc-500'
|
||||
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
label: '',
|
||||
systemKey: '',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '',
|
||||
icon: '',
|
||||
parentId: '',
|
||||
displayOrder: '0',
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
roles: ['OWNER', 'MANAGER', 'AGENT'],
|
||||
plans: ['STARTER', 'GROWTH', 'PRO'],
|
||||
companyIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
function formFromItem(item: MenuItem): FormState {
|
||||
return {
|
||||
label: item.label,
|
||||
systemKey: item.systemKey ?? '',
|
||||
itemType: item.itemType,
|
||||
routeOrUrl: item.routeOrUrl ?? '',
|
||||
icon: item.icon ?? '',
|
||||
parentId: item.parentId ?? '',
|
||||
displayOrder: String(item.displayOrder),
|
||||
openInNewTab: item.openInNewTab,
|
||||
isRequired: item.isRequired,
|
||||
isActive: item.isActive,
|
||||
roles: item.roleVisibilities.map((entry) => entry.role),
|
||||
plans: item.subscriptionAssignments.map((entry) => entry.plan),
|
||||
companyIds: item.companyAssignments.map((entry) => entry.companyId),
|
||||
}
|
||||
}
|
||||
|
||||
function chip(text: string, tone = 'default') {
|
||||
const toneClass =
|
||||
tone === 'success'
|
||||
? 'bg-emerald-950/40 text-emerald-400'
|
||||
: tone === 'warn'
|
||||
? 'bg-orange-950/40 text-orange-400'
|
||||
: 'bg-zinc-800 text-zinc-300'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MenuManagementPage() {
|
||||
const [items, setItems] = useState<MenuItem[]>([])
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
|
||||
const [preview, setPreview] = useState<PreviewResponse | null>(null)
|
||||
const [previewCompanyId, setPreviewCompanyId] = useState('')
|
||||
const [previewRole, setPreviewRole] = useState<EmployeeRole>('OWNER')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() => items.filter((item) => item.itemType === 'PARENT_MENU' && item.id !== editingId),
|
||||
[items, editingId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [menuItems, companiesPage, auditPage] = await Promise.all([
|
||||
api<MenuItem[]>('/admin/menu-items'),
|
||||
api<{ data: CompanyOption[] }>('/admin/companies?page=1&pageSize=100'),
|
||||
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
|
||||
])
|
||||
setItems(menuItems)
|
||||
setCompanies(companiesPage.data)
|
||||
setAuditLogs(auditPage.data)
|
||||
if (!previewCompanyId && companiesPage.data[0]?.id) {
|
||||
setPreviewCompanyId(companiesPage.data[0].id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load menu management data.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function toggleArrayValue<K extends 'roles' | 'plans' | 'companyIds'>(key: K, value: FormState[K][number]) {
|
||||
setForm((prev) => {
|
||||
const values = prev[key] as string[]
|
||||
return {
|
||||
...prev,
|
||||
[key]: values.includes(value as string)
|
||||
? values.filter((entry) => entry !== value)
|
||||
: [...values, value as string],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshLists() {
|
||||
const [menuItems, auditPage] = await Promise.all([
|
||||
api<MenuItem[]>('/admin/menu-items'),
|
||||
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
|
||||
])
|
||||
setItems(menuItems)
|
||||
setAuditLogs(auditPage.data)
|
||||
}
|
||||
|
||||
async function submitForm(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
const payload = {
|
||||
label: form.label,
|
||||
systemKey: form.systemKey || null,
|
||||
itemType: form.itemType,
|
||||
routeOrUrl: form.routeOrUrl || null,
|
||||
icon: form.icon || null,
|
||||
parentId: form.parentId || null,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
openInNewTab: form.openInNewTab,
|
||||
isRequired: form.isRequired,
|
||||
isActive: form.isActive,
|
||||
roles: form.roles,
|
||||
subscriptionPlans: form.plans.map((plan) => ({
|
||||
plan,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
isActive: true,
|
||||
})),
|
||||
companyAssignments: form.companyIds.map((companyId) => ({
|
||||
companyId,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
isActive: true,
|
||||
})),
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await api(`/admin/menu-items/${editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setSuccess('Menu item updated.')
|
||||
} else {
|
||||
await api('/admin/menu-items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setSuccess('Menu item created.')
|
||||
}
|
||||
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save menu item.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
function startEdit(item: MenuItem) {
|
||||
setEditingId(item.id)
|
||||
setForm(formFromItem(item))
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function toggleStatus(item: MenuItem) {
|
||||
try {
|
||||
setError(null)
|
||||
await api(`/admin/menu-items/${item.id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !item.isActive }),
|
||||
})
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update status.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: MenuItem) {
|
||||
if (!window.confirm(`Delete "${item.label}"?`)) return
|
||||
try {
|
||||
setError(null)
|
||||
await api(`/admin/menu-items/${item.id}`, { method: 'DELETE' })
|
||||
if (editingId === item.id) {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
}
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to delete menu item.')
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
if (!previewCompanyId) return
|
||||
try {
|
||||
setPreviewing(true)
|
||||
setError(null)
|
||||
const result = await api<PreviewResponse>('/admin/menu-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ companyId: previewCompanyId, role: previewRole }),
|
||||
})
|
||||
setPreview(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load menu preview.')
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shell py-8">
|
||||
<div className="panel p-8 text-sm text-zinc-500">
|
||||
Loading menu management…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6 text-zinc-100">
|
||||
<section className="panel p-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Menu Management</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm text-zinc-400">
|
||||
Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
className="rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-400"
|
||||
>
|
||||
Create menu item
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(error || success) && (
|
||||
<section className="space-y-3">
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
{success && <div className="panel p-4 text-sm text-emerald-400">{success}</div>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="px-5 pt-5 text-xl font-semibold text-zinc-100">Menu items</h2>
|
||||
<p className="px-5 pb-1 pt-1 text-sm text-zinc-400">{items.length} configured items</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Item</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Assignments</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="align-top transition-colors hover:bg-zinc-800/30">
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.isRequired && chip('Required', 'warn')}
|
||||
{item.systemKey && chip(item.systemKey)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
{item.parent ? ` • child of ${item.parent.label}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-xs text-zinc-300">{item.itemType}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.subscriptionAssignments.length > 0
|
||||
? item.subscriptionAssignments.map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.plan}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-xs text-zinc-500">No plan assignments</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.companyAssignments.slice(0, 3).map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.company.name}
|
||||
</span>
|
||||
))}
|
||||
{item.companyAssignments.length > 3 && chip(`+${item.companyAssignments.length - 3} more`)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.roleVisibilities.map((entry) => (
|
||||
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{entry.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => startEdit(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleStatus(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
{item.isActive ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button type="button" onClick={() => removeItem(item)} className="rounded-lg border border-red-900/60 bg-red-950/20 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-900/30">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-zinc-100">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
|
||||
</p>
|
||||
</div>
|
||||
{editingId && (
|
||||
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-400 hover:text-orange-300">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-5" onSubmit={submitForm}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Label</span>
|
||||
<input className={INPUT} value={form.label} onChange={(e) => updateForm('label', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>System key</span>
|
||||
<input className={INPUT} value={form.systemKey} onChange={(e) => updateForm('systemKey', e.target.value)} placeholder="dashboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Type</span>
|
||||
<select className={INPUT} value={form.itemType} onChange={(e) => updateForm('itemType', e.target.value as MenuItemType)}>
|
||||
{ITEM_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>Icon</span>
|
||||
<input className={INPUT} value={form.icon} onChange={(e) => updateForm('icon', e.target.value)} placeholder="LayoutDashboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Route or URL</span>
|
||||
<input className={INPUT} value={form.routeOrUrl} onChange={(e) => updateForm('routeOrUrl', e.target.value)} placeholder="/reports or https://…" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>Parent menu</span>
|
||||
<select className={INPUT} value={form.parentId} onChange={(e) => updateForm('parentId', e.target.value)}>
|
||||
<option value="">No parent</option>
|
||||
{parentOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Display order</span>
|
||||
<input className={INPUT} type="number" min="0" value={form.displayOrder} onChange={(e) => updateForm('displayOrder', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(e) => updateForm('isActive', e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.openInNewTab} onChange={(e) => updateForm('openInNewTab', e.target.checked)} />
|
||||
New tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isRequired} onChange={(e) => updateForm('isRequired', e.target.checked)} />
|
||||
Required system item
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Role visibility</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{ROLES.map((role) => (
|
||||
<label key={role} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.roles.includes(role)} onChange={() => toggleArrayValue('roles', role)} />
|
||||
{role}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Subscription plans</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PLANS.map((plan) => (
|
||||
<label key={plan} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.plans.includes(plan)} onChange={() => toggleArrayValue('plans', plan)} />
|
||||
{plan}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Company-specific assignments</p>
|
||||
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-700 bg-zinc-900/30 p-3">
|
||||
{companies.map((company) => (
|
||||
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm text-zinc-200 transition-colors hover:bg-zinc-800/40">
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
|
||||
<span>{company.name}</span>
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{company.subscription?.plan ?? 'No plan'} • {company.status}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-lg bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-orange-400 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Saving…' : editingId ? 'Update menu item' : 'Create menu item'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Preview company menu</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Test the generated menu for a company and role before troubleshooting or saving follow-up changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[220px]">
|
||||
<label className={LABEL}>Company</label>
|
||||
<select className={INPUT} value={previewCompanyId} onChange={(e) => setPreviewCompanyId(e.target.value)}>
|
||||
{companies.map((company) => (
|
||||
<option key={company.id} value={company.id}>{company.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[160px]">
|
||||
<label className={LABEL}>Role</label>
|
||||
<select className={INPUT} value={previewRole} onChange={(e) => setPreviewRole(e.target.value as EmployeeRole)}>
|
||||
{ROLES.map((role) => <option key={role} value={role}>{role}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPreview}
|
||||
disabled={previewing || !previewCompanyId}
|
||||
className="rounded-lg bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-orange-400 disabled:opacity-60"
|
||||
>
|
||||
{previewing ? 'Previewing…' : 'Run preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<p className="text-sm font-semibold text-zinc-100">{preview.company.name}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{preview.items.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
|
||||
{chip(item.source === 'none' ? 'unassigned' : item.source)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-zinc-300">
|
||||
{item.reasons.map((reason, index) => (
|
||||
<li key={`${item.id}-${index}`}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Recent menu audit activity</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Recent platform-side changes to menu items and assignments.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{auditLogs.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-zinc-100">{entry.action}</p>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{entry.adminUser
|
||||
? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}`
|
||||
: 'Unknown admin'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
locale: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
company: { name: string } | null
|
||||
companyId: string | null
|
||||
renterId: string | null
|
||||
}
|
||||
|
||||
interface Paginated {
|
||||
data: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'text-sky-400 bg-sky-950/40',
|
||||
SMS: 'text-emerald-400 bg-emerald-950/40',
|
||||
WHATSAPP: 'text-teal-400 bg-teal-950/40',
|
||||
IN_APP: 'text-violet-400 bg-violet-950/40',
|
||||
PUSH: 'text-orange-400 bg-orange-950/40',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: 'text-yellow-400 bg-yellow-950/40',
|
||||
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||
FAILED: 'text-red-400 bg-red-950/40',
|
||||
READ: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const [result, setResult] = useState<Paginated | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterCompany, setFilterCompany] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
function load(p: number) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterCompany) params.set('companyId', filterCompany)
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setResult(json.data ?? null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
load(1)
|
||||
}, [filterChannel, filterStatus, filterCompany])
|
||||
|
||||
function goToPage(p: number) {
|
||||
setPage(p)
|
||||
load(p)
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Full audit log of every notification sent across all companies.
|
||||
</p>
|
||||
</div>
|
||||
{result && (
|
||||
<span className="rounded-full bg-zinc-800 px-3 py-1 text-sm text-zinc-300">
|
||||
{result.total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
{CHANNELS.map((ch) => <option key={ch} value={ch}>{ch}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Sent at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||
) : result.data.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||
{item.type.replaceAll('_', ' ')}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CHANNEL_BADGE[item.channel] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 max-w-[220px]">
|
||||
<p className="truncate text-sm text-zinc-200">{item.title}</p>
|
||||
<p className="truncate text-xs text-zinc-500">{item.body}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
|
||||
<span className="text-xs text-zinc-500">
|
||||
Page {page} of {totalPages} — {result?.total.toLocaleString()} records
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
|
||||
|
||||
interface Metrics {
|
||||
totalCompanies: number
|
||||
@@ -16,6 +16,7 @@ interface Metrics {
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const admin = useAdminSession()
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
@@ -28,20 +29,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]
|
||||
@@ -49,16 +50,27 @@ export default function AdminDashboardPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/metrics`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
if (!canAccessAdminMetrics(admin)) {
|
||||
setMetrics(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setMetrics(json.data))
|
||||
.then(async (response) => {
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setMetrics(null)
|
||||
return
|
||||
}
|
||||
setMetrics((json?.data ?? json) as Metrics)
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [admin])
|
||||
|
||||
const kpis = metrics
|
||||
? [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Renter {
|
||||
id: string
|
||||
@@ -37,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é',
|
||||
@@ -55,7 +54,7 @@ export default function AdminRentersPage() {
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
status: 'الحالة',
|
||||
joined: 'تاريخ الانضمام',
|
||||
joined: 'تاريخ التسجيل',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على مستأجرين',
|
||||
blocked: 'محظور',
|
||||
@@ -71,18 +70,17 @@ export default function AdminRentersPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchRenters() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/renters`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
setRenters(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setRenters(list)
|
||||
setFiltered(list)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -103,9 +101,9 @@ export default function AdminRentersPage() {
|
||||
setActioning(id)
|
||||
try {
|
||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
||||
const res = await fetch(`${API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchRenters()
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
type MarketplaceLanguage,
|
||||
} from '@rentaldrivego/types'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
@@ -29,7 +28,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
PENDING: 'text-orange-400 bg-orange-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
@@ -95,12 +94,12 @@ export default function AdminSiteConfigPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Configuration du site',
|
||||
description: 'Modifiez ici la homepage principale de la marketplace, ou ouvrez une entreprise pour gérer sa homepage publique et son menu.',
|
||||
homepageTitle: 'Homepage du site principal',
|
||||
homepageDescription: 'Cette section contrôle la homepage marketplace affichée sur le site principal RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la homepage',
|
||||
description: 'Modifiez ici la page d’accueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
homepageTitle: 'Page d’accueil du site principal',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil marketplace affichée sur le site principal de RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la page d’accueil',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Homepage marketplace enregistrée.',
|
||||
homepageSaved: 'Page d’accueil marketplace enregistrée.',
|
||||
search: 'Rechercher par entreprise ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
@@ -112,7 +111,7 @@ export default function AdminSiteConfigPage() {
|
||||
manageCompany: 'Ouvrir l’entreprise',
|
||||
companiesTitle: 'Sites de marque des entreprises',
|
||||
languageLabel: 'Langue',
|
||||
hero: 'Hero',
|
||||
hero: 'Bloc principal',
|
||||
surface: 'Bloc marketplace',
|
||||
companySection: 'Bloc opérateur',
|
||||
renterSection: 'Bloc client',
|
||||
@@ -122,8 +121,8 @@ export default function AdminSiteConfigPage() {
|
||||
closingSection: 'Appel à l’action final',
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
homepageSections: 'Sections de la homepage',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la homepage marketplace principale.',
|
||||
homepageSections: 'Sections de la page d’accueil',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil marketplace principale.',
|
||||
addSection: 'Ajouter',
|
||||
removeSection: 'Retirer',
|
||||
expand: 'Ouvrir',
|
||||
@@ -184,23 +183,22 @@ export default function AdminSiteConfigPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const [companiesRes, homepageRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
|
||||
const companiesJson = await companiesRes.json()
|
||||
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
||||
setCompanies(companiesJson.data ?? [])
|
||||
setFiltered(companiesJson.data ?? [])
|
||||
setCompanies(companiesJson.data?.data ?? [])
|
||||
setFiltered(companiesJson.data?.data ?? [])
|
||||
|
||||
const homepageJson = await homepageRes.json()
|
||||
if (homepageRes.ok && homepageJson?.data) {
|
||||
@@ -277,15 +275,13 @@ export default function AdminSiteConfigPage() {
|
||||
async function saveHomepage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ homepage }),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -308,7 +304,9 @@ export default function AdminSiteConfigPage() {
|
||||
pillars: copy.valueSection,
|
||||
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
||||
features: copy.featureSection,
|
||||
howitworks: language === 'fr' ? 'Fonctionnement' : language === 'ar' ? 'كيفية العمل' : 'How it works',
|
||||
steps: copy.stepsSection,
|
||||
testimonials: language === 'fr' ? 'Témoignages' : language === 'ar' ? 'الشهادات' : 'Testimonials',
|
||||
closing: copy.closingSection,
|
||||
}
|
||||
|
||||
@@ -416,7 +414,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700">{activeContent.heroKicker}</p>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700">{activeContent.heroKicker}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
|
||||
@@ -432,17 +430,17 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="space-y-10 px-5 py-6 lg:px-8">
|
||||
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<h4 className="text-4xl font-black tracking-[-0.04em] text-stone-950">{activeContent.heroTitle}</h4>
|
||||
<h4 className="text-4xl font-black tracking-[-0.04em] text-blue-950">{activeContent.heroTitle}</h4>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-stone-600">{activeContent.heroBody}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<span className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
|
||||
<span className="rounded-full bg-orange-600 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
|
||||
<span className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-700">{activeContent.exploreVehicles}</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeSections.includes('surface') ? (
|
||||
<div className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
|
||||
<div className="rounded-[1.5rem] bg-[#06132e] p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
@@ -463,9 +461,9 @@ export default function AdminSiteConfigPage() {
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
|
||||
<section className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
|
||||
<section className="rounded-[1.5rem] bg-[#06132e] p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
@@ -481,7 +479,7 @@ export default function AdminSiteConfigPage() {
|
||||
{activeContent.pillars.map((pillar, index) => (
|
||||
<article
|
||||
key={`${pillar.title}-${index}`}
|
||||
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-stone-900 bg-stone-950 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
|
||||
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-orange-700 bg-orange-600 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
|
||||
>
|
||||
<p className={`text-xs font-bold uppercase tracking-[0.24em] ${index === 1 ? 'text-stone-300' : 'text-stone-400'}`}>0{index + 1}</p>
|
||||
<h5 className="mt-3 text-xl font-black">{pillar.title}</h5>
|
||||
@@ -493,12 +491,12 @@ export default function AdminSiteConfigPage() {
|
||||
{activeSections.includes('audiences') ? <section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.companyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.companyTitle}</h5>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.companyTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.renterKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.renterTitle}</h5>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700">{activeContent.renterKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.renterTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
|
||||
</article>
|
||||
</section> : null}
|
||||
@@ -510,7 +508,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.features.map((feature, index) => (
|
||||
<div key={`${feature}-${index}`} className="flex items-start gap-3 rounded-[1rem] border border-stone-200 bg-white px-4 py-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
|
||||
@@ -521,13 +519,13 @@ export default function AdminSiteConfigPage() {
|
||||
) : <div />}
|
||||
{activeSections.includes('steps') ? (
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.stepsTitle}</h5>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.stepsTitle}</h5>
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.steps.map((step) => (
|
||||
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.stepLabel} {step.step}</p>
|
||||
<h6 className="mt-2 text-lg font-black text-stone-950">{step.title}</h6>
|
||||
<h6 className="mt-2 text-lg font-black text-blue-950">{step.title}</h6>
|
||||
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -536,13 +534,13 @@ export default function AdminSiteConfigPage() {
|
||||
) : <div />}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-stone-950 px-6 py-7 text-white">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{activeContent.readyKicker}</p>
|
||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-[#06132e] px-6 py-7 text-white">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-4 max-w-3xl text-3xl font-black tracking-[-0.04em]">{activeContent.readyTitle}</h5>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-8 text-stone-300">{activeContent.readyBody}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/20 px-5 py-3 text-sm font-semibold">{activeContent.viewPricing}</span>
|
||||
<span className="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-stone-950">{activeContent.createWorkspace}</span>
|
||||
<span className="rounded-full bg-orange-300 px-5 py-3 text-sm font-semibold text-blue-950">{activeContent.createWorkspace}</span>
|
||||
</div>
|
||||
</section> : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => ({
|
||||
redirect: vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })),
|
||||
}))
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
redirect: nextServer.redirect,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('admin favicon route', () => {
|
||||
it('redirects favicon requests to the generated icon route on the same origin', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves forwarded path base through the request URL origin only', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/admin/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
export default function AdminForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -17,9 +16,10 @@ export default function AdminForgotPasswordPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
@@ -36,7 +36,7 @@ export default function AdminForgotPasswordPage() {
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Forgot password</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Enter your admin email to receive a reset link.</p>
|
||||
</div>
|
||||
@@ -58,20 +58,20 @@ export default function AdminForgotPasswordPage() {
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Email</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
className="inline-flex w-full justify-center rounded-full bg-stone-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-50 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
+112
-13
@@ -11,7 +11,9 @@ html.dark {
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-50 antialiased transition-colors;
|
||||
@apply text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@@ -19,17 +21,114 @@ body {
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
|
||||
@apply rounded-[1.75rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1128 0%, #0d1b38 35%, #07101e 100%);
|
||||
}
|
||||
|
||||
html.dark .panel {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.light body {
|
||||
background-color: rgb(248 250 252);
|
||||
color: rgb(15 23 42);
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
/* ── Dark mode: zinc classes → deep navy blue ─────────────── */
|
||||
.bg-zinc-950 {
|
||||
background-color: rgb(6 13 30);
|
||||
}
|
||||
|
||||
.bg-zinc-950\/90 {
|
||||
background-color: rgb(6 13 30 / 0.9);
|
||||
}
|
||||
|
||||
.bg-zinc-900 {
|
||||
background-color: rgb(11 25 55);
|
||||
}
|
||||
|
||||
.bg-zinc-800,
|
||||
.bg-zinc-800\/50 {
|
||||
background-color: rgb(20 42 90);
|
||||
}
|
||||
|
||||
.border-zinc-800,
|
||||
.border-zinc-700 {
|
||||
border-color: rgb(30 60 130);
|
||||
}
|
||||
|
||||
.text-zinc-100,
|
||||
.text-zinc-200 {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.text-zinc-500 {
|
||||
color: rgb(148 163 184);
|
||||
}
|
||||
|
||||
.text-zinc-400,
|
||||
.text-zinc-300 {
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.hover\:bg-zinc-800:hover,
|
||||
.hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(30 55 115);
|
||||
}
|
||||
|
||||
.hover\:text-zinc-200:hover {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
/* ── Primary action (emerald class → orange) ──────────────── */
|
||||
.text-emerald-400 {
|
||||
color: rgb(251 146 60);
|
||||
}
|
||||
|
||||
.bg-emerald-600 {
|
||||
background-color: rgb(234 88 12);
|
||||
}
|
||||
|
||||
.hover\:bg-emerald-500:hover {
|
||||
background-color: rgb(194 65 12);
|
||||
}
|
||||
|
||||
.dark .bg-emerald-600 {
|
||||
background-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
.dark .hover\:bg-emerald-500:hover {
|
||||
background-color: rgb(251 146 60);
|
||||
}
|
||||
|
||||
.bg-emerald-900\/30,
|
||||
.bg-emerald-950\/40,
|
||||
.bg-emerald-950\/50 {
|
||||
background-color: rgb(154 52 18 / 0.22);
|
||||
}
|
||||
|
||||
.border-emerald-500 {
|
||||
border-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
.focus\:ring-emerald-500:focus {
|
||||
--tw-ring-color: rgb(234 88 12 / 0.45);
|
||||
}
|
||||
|
||||
.hover\:bg-emerald-900\/50:hover {
|
||||
background-color: rgb(154 52 18 / 0.35);
|
||||
}
|
||||
|
||||
/* ── Light mode: zinc classes → white / stone-light ──────── */
|
||||
.light .bg-zinc-950 {
|
||||
background-color: rgb(248 250 252);
|
||||
background-color: rgb(255 253 248);
|
||||
}
|
||||
|
||||
.light .bg-zinc-950\/90 {
|
||||
@@ -37,39 +136,39 @@ body {
|
||||
}
|
||||
|
||||
.light .bg-zinc-900 {
|
||||
background-color: rgb(255 255 255);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
.light .bg-zinc-800,
|
||||
.light .bg-zinc-800\/50 {
|
||||
background-color: rgb(241 245 249);
|
||||
background-color: rgb(245 245 244);
|
||||
}
|
||||
|
||||
.light .border-zinc-800,
|
||||
.light .border-zinc-700 {
|
||||
border-color: rgb(226 232 240);
|
||||
border-color: rgb(231 229 228);
|
||||
}
|
||||
|
||||
.light .text-zinc-100,
|
||||
.light .text-zinc-200 {
|
||||
color: rgb(15 23 42);
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-zinc-500 {
|
||||
color: rgb(100 116 139);
|
||||
color: rgb(120 113 108);
|
||||
}
|
||||
|
||||
.light .text-zinc-400,
|
||||
.light .text-zinc-300 {
|
||||
color: rgb(71 85 105);
|
||||
color: rgb(87 83 78);
|
||||
}
|
||||
|
||||
.light .hover\:bg-zinc-800:hover,
|
||||
.light .hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(241 245 249);
|
||||
background-color: rgb(245 245 244);
|
||||
}
|
||||
|
||||
.light .hover\:text-zinc-200:hover {
|
||||
color: rgb(15 23 42);
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<html lang="en" className="light" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var theme=localStorage.getItem('admin-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -2,10 +2,10 @@ import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const requestHeaders = headers()
|
||||
export default async function AdminLoginPage() {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
|
||||
@@ -4,8 +4,7 @@ import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
export default function AdminResetPasswordPage() {
|
||||
return (
|
||||
@@ -33,9 +32,10 @@ function AdminResetPasswordContent() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -78,7 +78,7 @@ function AdminResetPasswordContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/login')}
|
||||
className="mt-2 w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors"
|
||||
className="mt-2 inline-flex w-full justify-center rounded-full bg-stone-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@ function AdminResetPasswordContent() {
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">New password</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
@@ -102,11 +102,11 @@ function AdminResetPasswordContent() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Confirm password</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
@@ -114,13 +114,13 @@ function AdminResetPasswordContent() {
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
className="inline-flex w-full justify-center rounded-full bg-stone-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-50 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? 'Resetting…' : 'Reset password'}
|
||||
</button>
|
||||
@@ -141,7 +141,7 @@ function AdminResetShell({ children }: { children: React.ReactNode }) {
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Reset password</h1>
|
||||
</div>
|
||||
<div className="panel p-8">{children}</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${path}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import AdminRootPage from './page'
|
||||
|
||||
describe('admin public redirects', () => {
|
||||
it('sends the admin root to the login page', () => {
|
||||
expect(() => AdminRootPage()).toThrow('NEXT_REDIRECT:/login')
|
||||
expect(navigation.redirect).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||
export type AdminTheme = 'light' | 'dark'
|
||||
@@ -28,6 +28,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
auditLogs: 'Audit Logs',
|
||||
adminUsers: 'Admin Users',
|
||||
billing: 'Billing',
|
||||
pricing: 'Pricing',
|
||||
notifications: 'Notifications',
|
||||
menuManagement: 'Menu Management',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
@@ -48,6 +51,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
auditLogs: "Journaux d'audit",
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
billing: 'Facturation',
|
||||
pricing: 'Tarification',
|
||||
notifications: 'Notifications',
|
||||
menuManagement: 'Gestion du menu',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
@@ -68,6 +74,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
billing: 'الفوترة',
|
||||
pricing: 'الأسعار',
|
||||
notifications: 'الإشعارات',
|
||||
menuManagement: 'إدارة القوائم',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
@@ -81,6 +90,14 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
},
|
||||
}
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
type AdminI18nContext = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
@@ -93,11 +110,14 @@ const Context = createContext<AdminI18nContext | null>(null)
|
||||
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
const [theme, setTheme] = useState<AdminTheme>('dark')
|
||||
const [theme, setTheme] = useState<AdminTheme>('light')
|
||||
// Skip the very first write so we don't overwrite a stored preference with
|
||||
// the default 'en' value before the hydration read-effect has applied it.
|
||||
const skipFirstLangWrite = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
const storedTheme = window.localStorage.getItem('admin-theme')
|
||||
const storedTheme = window.localStorage.getItem(SHARED_THEME_KEY) ?? window.localStorage.getItem('admin-theme')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
@@ -115,6 +135,10 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
if (skipFirstLangWrite.current) {
|
||||
skipFirstLangWrite.current = false
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem('admin-language', language)
|
||||
}, [language])
|
||||
|
||||
@@ -123,6 +147,8 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
document.documentElement.classList.add(theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.body.dataset.theme = theme
|
||||
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
|
||||
window.localStorage.setItem(SHARED_THEME_KEY, theme)
|
||||
window.localStorage.setItem('admin-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
@@ -140,67 +166,146 @@ export function useAdminI18n() {
|
||||
}
|
||||
|
||||
export function AdminLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useAdminI18n()
|
||||
const { language, setLanguage } = useAdminI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
const current = languageMeta[language]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{current.flag}</span>
|
||||
<span>{current.shortLabel}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const meta = languageMeta[value]
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
onClick={() => { setLanguage(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
<span aria-hidden="true">{meta.flag}</span>
|
||||
<span>{meta.shortLabel}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminThemeSwitcher() {
|
||||
const { theme, setTheme, dict } = useAdminI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
{dict.theme}
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{theme === 'light' ? dict.light : dict.dark}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
onClick={() => { setTheme(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : dict.dark}
|
||||
{value === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={active ? 'text-inherit' : ''}>{value === 'light' ? dict.light : dict.dark}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicFooter() {
|
||||
const { language } = useAdminI18n()
|
||||
const currentLanguage = languageMeta[language]
|
||||
const dict = {
|
||||
en: {
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
preferences: 'Préférences d’administration',
|
||||
},
|
||||
ar: {
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-4 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/72">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<div className="flex items-center gap-3 text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
|
||||
<p>{dict.preferences}</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2.5 py-1 text-stone-700 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200">
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicHeader() {
|
||||
const { language } = useAdminI18n()
|
||||
const currentLanguage = languageMeta[language]
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/76">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-stone-500 dark:text-stone-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<span className="inline-flex min-w-[3.5rem] items-center justify-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2.5 py-2 text-xs font-semibold text-stone-700 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200">
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
</span>
|
||||
<Link href="/login" className="rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/components/PublicHeader', () => ({ default: function MockAdminHeader() { return React.createElement('admin-header') } }))
|
||||
vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter() { return React.createElement('admin-footer') } }))
|
||||
|
||||
import PublicShell from './PublicShell'
|
||||
|
||||
function childTypes(node: React.ReactElement): string[] {
|
||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||
const type = child.type as any
|
||||
if (typeof type === 'string') return type
|
||||
return type.displayName ?? type.name ?? 'anonymous'
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin PublicShell', () => {
|
||||
it('always renders the admin public chrome around children', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section', { id: 'login' }) })
|
||||
|
||||
expect(childTypes(shell)).toEqual(['MockAdminHeader', 'div', 'MockAdminFooter'])
|
||||
})
|
||||
|
||||
it('uses a flex column root so footer placement stays stable', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section') })
|
||||
|
||||
expect(shell.props.className).toContain('flex')
|
||||
expect(shell.props.className).toContain('min-h-screen')
|
||||
expect(shell.props.className).toContain('flex-col')
|
||||
})
|
||||
})
|
||||
@@ -1,59 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import React from "react";
|
||||
|
||||
import PublicFooter from '@/components/PublicFooter'
|
||||
import PublicHeader from '@/components/PublicHeader'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useAdminI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
preferences: 'Preferences admin',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<PublicHeader />
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('adminFetch', () => {
|
||||
it('requests through the server API base and returns the data envelope', async () => {
|
||||
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { companies: [] } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/companies', 'admin-token')).resolves.toEqual({ companies: [] })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://internal-api/api/v1/admin/companies', {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits authorization when no token is supplied', async () => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await adminFetch('/public')
|
||||
|
||||
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual({ cache: 'no-store', credentials: 'include' })
|
||||
})
|
||||
|
||||
it('throws the API message from failed responses', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, json: async () => ({ message: 'Admin only' }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/menu')).rejects.toThrow('Admin only')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
export const ADMIN_API_BASE =
|
||||
typeof window === 'undefined'
|
||||
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1')
|
||||
|
||||
export async function adminFetch<T>(path: string, token?: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
export async function adminFetch<T>(path: string, _token?: string): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl, resolveServerAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('admin app URL resolution', () => {
|
||||
it('keeps server fallback unchanged without a browser window', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin')).toBe('http://localhost:3002/admin')
|
||||
})
|
||||
|
||||
it('removes trailing slash for localhost browser fallbacks', () => {
|
||||
installWindow('127.0.0.1')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin/')).toBe('http://localhost:3002/admin')
|
||||
})
|
||||
|
||||
it('rewrites fallback origin to the current browser host in production', () => {
|
||||
installWindow('admin.rentaldrivego.ma')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin')).toBe('https://admin.rentaldrivego.ma/admin')
|
||||
})
|
||||
|
||||
it('resolves server app URLs from forwarded host and protocol', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', 'admin.example.com', 'https')).toBe('https://admin.example.com:3002/admin')
|
||||
})
|
||||
|
||||
it('falls back when host is missing or the fallback is not parseable', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
|
||||
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
export function resolveBrowserAppUrl(fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const h = window.location.hostname
|
||||
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = window.location.protocol
|
||||
target.hostname = window.location.hostname
|
||||
target.hostname = h
|
||||
target.port = ''
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
if (request.headers.has('x-middleware-subrequest')) {
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,13 +15,27 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://user:replace-with-password@host:5432/db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=replace-with-64-byte-random-secret
|
||||
JWT_EXPIRY=8h
|
||||
NODE_ENV=test
|
||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||
+26
-6
@@ -3,10 +3,24 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node --env-file=../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/index.ts",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "tsc",
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "node dist/index.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"pretest": "npm run build --workspace @rentaldrivego/types",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"pretest:integration": "npm run build --workspace @rentaldrivego/types",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:integration:watch": "vitest --config vitest.integration.config.ts",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:e2e:watch": "vitest --config vitest.e2e.config.ts",
|
||||
"test:api": "vitest run --config vitest.api.config.ts",
|
||||
"test:api:watch": "vitest --config vitest.api.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^3.4.3",
|
||||
@@ -22,17 +36,19 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^8.0.10",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"resend": "^3.2.0",
|
||||
"socket.io": "^4.7.5",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"twilio": "^5.1.0",
|
||||
"zod": "^3.23.0"
|
||||
"zod": "^3.23.0",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -47,7 +63,11 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
import { openApiDocument } from './swagger/openapi'
|
||||
import { getPublicStorageRoot } from './lib/storage'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||
import { requestIdMiddleware } from './middleware/requestId'
|
||||
|
||||
// ─── Module routes ────────────────────────────────────────────
|
||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||
import companyAuthRouter from './modules/auth/auth.company.routes'
|
||||
import employeeAuthRouter from './modules/auth/auth.employee.routes'
|
||||
import renterAuthRouter from './modules/auth/auth.renter.routes'
|
||||
import teamRouter from './modules/team/team.routes'
|
||||
import offersRouter from './modules/offers/offer.routes'
|
||||
import analyticsRouter from './modules/analytics/analytics.routes'
|
||||
import notificationsRouter from './modules/notifications/notification.routes'
|
||||
import adminRouter from './modules/admin/admin.routes'
|
||||
import subscriptionsRouter, {
|
||||
subscriptionPublicRouter,
|
||||
subscriptionWebhookRouter,
|
||||
} from './modules/subscriptions/subscription.routes'
|
||||
import paymentsRouter from './modules/payments/payment.routes'
|
||||
import customersRouter from './modules/customers/customer.routes'
|
||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||
import companiesRouter from './modules/companies/company.routes'
|
||||
import reservationsRouter from './modules/reservations/reservation.routes'
|
||||
import marketplaceRouter from './modules/marketplace/marketplace.routes'
|
||||
import siteRouter from './modules/site/site.routes'
|
||||
import reviewsRouter from './modules/reviews/review.routes'
|
||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||
import licenseValidationRouter from './modules/licenses/license.validation.routes'
|
||||
|
||||
// ─── Centralized error handling ───────────────────────────────
|
||||
import { errorMiddleware } from './http/errors/errorMiddleware'
|
||||
|
||||
const v1 = '/api/v1'
|
||||
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:4000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:4000',
|
||||
]
|
||||
|
||||
export const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
|
||||
: defaultCorsOrigins
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/features`, description: 'Subscription plan features' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
|
||||
export function createApp() {
|
||||
const app = express()
|
||||
const corsMiddleware = cors({ origin: corsOrigins, credentials: true })
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
|
||||
app.use(requestIdMiddleware)
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers['x-middleware-subrequest']) {
|
||||
return res.status(400).json({ error: 'bad_request', message: 'Unsupported internal request header', statusCode: 400 })
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(corsMiddleware)
|
||||
|
||||
// Customer identity documents must never be anonymously retrievable from the
|
||||
// public storage mount, even if an older raw storage URL leaks.
|
||||
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
|
||||
res.status(404).end()
|
||||
})
|
||||
|
||||
// Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin
|
||||
// by the browser. Without this header, helmet's default CORP: same-origin reaches
|
||||
// 404 responses (when express.static calls next() on a miss) and the browser blocks
|
||||
// the response even for broken-image cases, producing ERR_BLOCKED_BY_RESPONSE.
|
||||
app.use('/storage', (_req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
|
||||
next()
|
||||
})
|
||||
|
||||
// Serve only explicitly public uploaded assets. Private documents are resolved
|
||||
// through authenticated API routes such as /customers/:id/license-image.
|
||||
app.use('/storage', express.static(getPublicStorageRoot()))
|
||||
|
||||
// Swagger UI — mounted before helmet so its assets are not blocked by CSP
|
||||
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
|
||||
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
|
||||
|
||||
// Webhooks must use raw body BEFORE express.json(); signature verification
|
||||
// must never reconstruct the payload with JSON.stringify(req.body).
|
||||
app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter)
|
||||
app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' }))
|
||||
app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' }))
|
||||
|
||||
// Let /storage responses manage CORP explicitly so missing files still return
|
||||
// a normal cross-origin 404 instead of being blocked by Helmet's default
|
||||
// same-origin policy. Keep CSP explicit instead of letting browser security
|
||||
// drift into wishful thinking with headers.
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
||||
connectSrc: ["'self'", 'https:', 'wss:'],
|
||||
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null,
|
||||
},
|
||||
},
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
frameguard: { action: 'deny' },
|
||||
}))
|
||||
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ─────────────────────────────────────────────
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
app.use(`${v1}/admin/auth`, authLimiter)
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
|
||||
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||
|
||||
// ─── Health / Docs ──────────────────────────────────────────
|
||||
app.get(v1, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
health: '/health',
|
||||
docs: `${v1}/docs`,
|
||||
openApi: `${v1}/openapi.json`,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
routes: routeDocs,
|
||||
swaggerUi: '/docs',
|
||||
openApi: '/api/v1/openapi.json',
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Error handler ──────────────────────────────────────────
|
||||
app.use(errorMiddleware)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -7,12 +7,14 @@
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"howitworks",
|
||||
"steps",
|
||||
"testimonials",
|
||||
"closing"
|
||||
],
|
||||
"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 +25,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",
|
||||
@@ -63,12 +65,31 @@
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
"howitworksKicker": "GETTING STARTED",
|
||||
"howitworksTitle": "How It Works",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Create Account",
|
||||
"description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Add Your Fleet",
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "Start Selling",
|
||||
"description": "Renters discover your fleet, and bookings flow directly to your branded checkout."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "How companies launch",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Create your company workspace",
|
||||
"body": "Pick a plan, launch your 14-day trial, and verify the owner account."
|
||||
"body": "Pick a plan, launch your 90-day trial, and verify the owner account."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
@@ -83,8 +104,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"
|
||||
},
|
||||
@@ -95,15 +116,17 @@
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"howitworks",
|
||||
"steps",
|
||||
"testimonials",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine marketplace plus nette et plus forte.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.",
|
||||
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Surface marketplace",
|
||||
"surfaceLabel": "Vitrine marketplace",
|
||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
@@ -111,15 +134,15 @@
|
||||
"brandedFlows": "Parcours de marque",
|
||||
"multiTenant": "Opérations multi-tenant",
|
||||
"companyKicker": "Flux opérateur",
|
||||
"companyTitle": "Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.",
|
||||
"companyTitle": "Pilotez l’inventaire, les offres, la facturation et l’équipe depuis un seul centre de contrôle.",
|
||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||
"renterKicker": "Expérience client",
|
||||
"renterTitle": "Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.",
|
||||
"renterBody": "La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
||||
"renterBody": "La marketplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque."
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte marketplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
@@ -127,7 +150,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Revenus en direct",
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement."
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise afin de préserver la relation client et l’encaissement."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
@@ -141,22 +164,41 @@
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "Paiements détenus par la société"
|
||||
"label": "Paiements gérés par l’entreprise"
|
||||
}
|
||||
],
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement multi-photos",
|
||||
"Gestion de flotte avec téléversement de plusieurs photos",
|
||||
"Offres marketplace et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
"howitworksKicker": "MISE EN ROUTE",
|
||||
"howitworksTitle": "Comment ça marche",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Créer un compte",
|
||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Ajouter votre flotte",
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "Commencer à vendre",
|
||||
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "Comment les entreprises démarrent",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Créez votre espace entreprise",
|
||||
"body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire."
|
||||
"body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
@@ -171,8 +213,8 @@
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "Une homepage marketplace doit vendre le système en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.",
|
||||
"readyTitle": "La page d’accueil marketplace doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
@@ -183,12 +225,14 @@
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"howitworks",
|
||||
"steps",
|
||||
"testimonials",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "واجهة سوق أوضح وأقوى.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.",
|
||||
"heroTitle": "واجهة سوق أكثر وضوحاً وتأثيراً.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.",
|
||||
"startTrial": "ابدأ التجربة المجانية",
|
||||
"exploreVehicles": "استكشف السيارات",
|
||||
"surfaceLabel": "واجهة السوق",
|
||||
@@ -198,24 +242,24 @@
|
||||
"trustedFleets": "أساطيل موثوقة",
|
||||
"brandedFlows": "مسارات حجز مخصصة",
|
||||
"multiTenant": "عمليات متعددة الشركات",
|
||||
"companyKicker": "تدفق المشغل",
|
||||
"companyKicker": "سير عمل المشغل",
|
||||
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
||||
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
||||
"renterKicker": "تجربة المستأجر",
|
||||
"renterTitle": "دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.",
|
||||
"renterBody": "السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.",
|
||||
"renterTitle": "دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.",
|
||||
"renterBody": "هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "نشر موحد",
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
},
|
||||
{
|
||||
"title": "اكتشاف مؤهل",
|
||||
"body": "العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة."
|
||||
"body": "تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة."
|
||||
},
|
||||
{
|
||||
"title": "مسار إيراد مباشر",
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه."
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
@@ -232,19 +276,38 @@
|
||||
"label": "مدفوعات مملوكة للشركة"
|
||||
}
|
||||
],
|
||||
"featureLabel": "ما الذي يغطيه المنتج",
|
||||
"featureLabel": "ما الذي يقدمه المنتج",
|
||||
"features": [
|
||||
"إدارة الأسطول مع رفع عدة صور",
|
||||
"عروض السوق والتحويل إلى مسار الحجز",
|
||||
"موقع حجز عام مخصص لكل شركة",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"howitworksKicker": "البدء",
|
||||
"howitworksTitle": "كيف يعمل",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "إنشاء حساب",
|
||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "أضف أسطولك",
|
||||
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "ابدأ البيع",
|
||||
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "أنشئ مساحة شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك."
|
||||
"title": "أنشئ مساحة عمل شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
@@ -260,7 +323,7 @@
|
||||
"stepLabel": "الخطوة",
|
||||
"readyKicker": "جاهز للانطلاق",
|
||||
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.",
|
||||
"viewPricing": "عرض الأسعار",
|
||||
"createWorkspace": "إنشاء المساحة"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
|
||||
import { errorMiddleware } from './errorMiddleware'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
function handle(error: unknown) {
|
||||
const res = createResponseStub()
|
||||
errorMiddleware(error, {} as any, res, vi.fn())
|
||||
return res
|
||||
}
|
||||
|
||||
describe('AppError subclasses', () => {
|
||||
it.each([
|
||||
[new ValidationError('Bad payload'), 400, 'validation_error'],
|
||||
[new UnauthorizedError(), 401, 'unauthorized'],
|
||||
[new ForbiddenError(), 403, 'forbidden'],
|
||||
[new NotFoundError(), 404, 'not_found'],
|
||||
[new ConflictError(), 409, 'conflict'],
|
||||
])('sets stable status and error code for %s', (error, statusCode, code) => {
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect(error.statusCode).toBe(statusCode)
|
||||
expect(error.error).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorMiddleware', () => {
|
||||
it('normalizes Zod errors into validation responses', () => {
|
||||
const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
|
||||
expect(result.success).toBe(false)
|
||||
|
||||
const res = handle(result.success ? new Error('unexpected') : result.error)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
statusCode: 400,
|
||||
issues: expect.any(Array),
|
||||
}))
|
||||
})
|
||||
|
||||
it('normalizes Prisma not found errors', () => {
|
||||
const res = handle({ code: 'P2025' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'not_found',
|
||||
message: 'Resource not found',
|
||||
statusCode: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Prisma unique constraint errors', () => {
|
||||
const res = handle({ code: 'P2002' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(409)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'conflict',
|
||||
message: 'A resource with this value already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves AppError metadata in the response body', () => {
|
||||
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'payment_required',
|
||||
message: 'Plan required',
|
||||
statusCode: 402,
|
||||
requiredPlan: 'PRO',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a 500 internal error response', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const res = handle(new Error('boom'))
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined })
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { AppError } from './index'
|
||||
|
||||
function withRequestId(req: Request, payload: Record<string, unknown>) {
|
||||
return { ...payload, requestId: req.requestId }
|
||||
}
|
||||
|
||||
export function errorMiddleware(err: any, req: Request, res: Response, _next: NextFunction) {
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json(withRequestId(req, {
|
||||
error: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
issues: err.issues,
|
||||
statusCode: 400,
|
||||
}))
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json(withRequestId(req, { error: 'not_found', message: 'Resource not found', statusCode: 404 }))
|
||||
}
|
||||
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
||||
return res.status(err.statusCode).json(withRequestId(req, {
|
||||
error: err.error,
|
||||
message: err.statusCode >= 500 ? 'Internal server error' : err.message,
|
||||
statusCode: err.statusCode,
|
||||
...(err.statusCode >= 500 ? {} : err.data),
|
||||
}))
|
||||
}
|
||||
|
||||
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500
|
||||
const safeStatusCode = statusCode >= 400 && statusCode < 600 ? statusCode : 500
|
||||
|
||||
if (safeStatusCode >= 500) {
|
||||
console.error('[API Error]', { requestId: req.requestId, err })
|
||||
}
|
||||
|
||||
res.status(safeStatusCode).json(withRequestId(req, {
|
||||
error: safeStatusCode >= 500 ? 'internal_error' : (err.code ?? 'request_error'),
|
||||
message: safeStatusCode >= 500 ? 'Internal server error' : (err.message ?? 'Request failed'),
|
||||
statusCode: safeStatusCode,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class AppError extends Error {
|
||||
readonly statusCode: number
|
||||
readonly error: string
|
||||
readonly data?: Record<string, unknown>
|
||||
|
||||
constructor(message: string, statusCode: number, error: string, data?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.error = error
|
||||
this.data = data
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message = 'Validation error') {
|
||||
super(message, 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(message, 404, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'A resource with this value already exists') {
|
||||
super(message, 409, 'conflict')
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'forbidden')
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401, 'unauthorized')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Response } from 'express'
|
||||
|
||||
export function ok<T>(res: Response, data: T): void {
|
||||
res.json({ data })
|
||||
}
|
||||
|
||||
export function created<T>(res: Response, data: T): void {
|
||||
res.status(201).json({ data })
|
||||
}
|
||||
|
||||
export function noContent(res: Response): void {
|
||||
res.status(204).end()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Response } from 'express'
|
||||
import { created, noContent, ok } from './index'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
res.end.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('http/respond helpers', () => {
|
||||
it('ok responds with the expected data envelope', () => {
|
||||
const res = createResponseStub()
|
||||
const payload = { id: 'vehicle_1', name: 'Dacia Logan' }
|
||||
|
||||
ok(res, payload)
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(res.json).toHaveBeenCalledWith({ data: payload })
|
||||
})
|
||||
|
||||
it('created responds with status 201 and the expected data envelope', () => {
|
||||
const res = createResponseStub()
|
||||
const payload = { id: 'reservation_1' }
|
||||
|
||||
created(res, payload)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(201)
|
||||
expect(res.json).toHaveBeenCalledWith({ data: payload })
|
||||
})
|
||||
|
||||
it('noContent responds with status 204 and no body', () => {
|
||||
const res = createResponseStub()
|
||||
|
||||
noContent(res)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(204)
|
||||
expect(res.end).toHaveBeenCalledWith()
|
||||
expect(res.json).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import path from 'path'
|
||||
import multer from 'multer'
|
||||
import { ValidationError } from '../errors'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
|
||||
['image/jpeg', ['.jpg', '.jpeg']],
|
||||
['image/png', ['.png']],
|
||||
['image/webp', ['.webp']],
|
||||
['image/gif', ['.gif']],
|
||||
])
|
||||
|
||||
/**
|
||||
* Shared multer instance used by all upload endpoints.
|
||||
* Files are kept in memory so services receive a Buffer — no temp-file cleanup needed.
|
||||
*/
|
||||
export const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE, files: 20 },
|
||||
})
|
||||
|
||||
type DetectedFile = { mime: string; ext: string }
|
||||
|
||||
export function detectImageType(buffer: Buffer): DetectedFile | null {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return { mime: 'image/jpeg', ext: '.jpg' }
|
||||
}
|
||||
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 &&
|
||||
buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a
|
||||
) {
|
||||
return { mime: 'image/png', ext: '.png' }
|
||||
}
|
||||
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||
return { mime: 'image/webp', ext: '.webp' }
|
||||
}
|
||||
|
||||
if (buffer.length >= 6) {
|
||||
const sig = buffer.subarray(0, 6).toString('ascii')
|
||||
if (sig === 'GIF87a' || sig === 'GIF89a') return { mime: 'image/gif', ext: '.gif' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function assertSafeImageContent(file: Express.Multer.File) {
|
||||
const detected = detectImageType(file.buffer)
|
||||
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
|
||||
throw new ValidationError(`Unsupported or spoofed image file: ${file.originalname}`)
|
||||
}
|
||||
|
||||
if (file.mimetype !== detected.mime) {
|
||||
throw new ValidationError(`MIME type does not match file content for "${file.originalname}"`)
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
const allowedExtensions = ALLOWED_IMAGE_TYPES.get(detected.mime) ?? []
|
||||
if (ext && !allowedExtensions.includes(ext)) {
|
||||
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a file was provided and is an image by content, not by client claims.
|
||||
*/
|
||||
export function assertImageFile(
|
||||
file: Express.Multer.File | undefined,
|
||||
fieldLabel = 'file',
|
||||
): asserts file is Express.Multer.File {
|
||||
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
|
||||
assertSafeImageContent(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that at least one file was provided and all files are image content.
|
||||
*/
|
||||
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
|
||||
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
|
||||
for (const file of files) assertSafeImageContent(file)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ValidationError } from '../errors'
|
||||
import { assertImageFile, assertImageFiles } from './index'
|
||||
|
||||
const file = (overrides: Partial<Express.Multer.File> = {}) => ({
|
||||
fieldname: 'file',
|
||||
originalname: 'photo.jpg',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 123,
|
||||
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xdb]),
|
||||
stream: undefined as any,
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('upload assertions', () => {
|
||||
it('accepts a single image file and narrows the route input', () => {
|
||||
expect(() => assertImageFile(file())).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects missing single file uploads with a field-specific error', () => {
|
||||
expect(() => assertImageFile(undefined, 'license image')).toThrow(ValidationError)
|
||||
expect(() => assertImageFile(undefined, 'license image')).toThrow('A license image is required')
|
||||
})
|
||||
|
||||
it('rejects non-image single file uploads', () => {
|
||||
expect(() => assertImageFile(file({ mimetype: 'application/pdf', originalname: 'license.pdf' }))).toThrow('MIME type does not match file content')
|
||||
})
|
||||
|
||||
it('accepts multiple image files and rejects empty or mixed batches', () => {
|
||||
expect(() => assertImageFiles([file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) })])).not.toThrow()
|
||||
expect(() => assertImageFiles([], 'inspection photos')).toThrow('At least one inspection photos file is required')
|
||||
expect(() => assertImageFiles([
|
||||
file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }),
|
||||
file({ originalname: 'report.pdf', mimetype: 'application/pdf', buffer: Buffer.from('%PDF') }),
|
||||
])).toThrow('Unsupported or spoofed image file: report.pdf')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Request } from 'express'
|
||||
import type { ZodTypeAny, output } from 'zod'
|
||||
|
||||
export function parseBody<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.body)
|
||||
}
|
||||
|
||||
export function parseQuery<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.query)
|
||||
}
|
||||
|
||||
export function parseParams<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.params)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Request } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { parseBody, parseParams, parseQuery } from './index'
|
||||
|
||||
describe('http/validate parsers', () => {
|
||||
it('parseBody returns typed and coerced request body values', () => {
|
||||
const schema = z.object({ seats: z.coerce.number().int().min(1), brand: z.string().min(1) })
|
||||
const req = { body: { seats: '5', brand: 'Toyota' } } as Request
|
||||
|
||||
expect(parseBody(schema, req)).toEqual({ seats: 5, brand: 'Toyota' })
|
||||
})
|
||||
|
||||
it('parseQuery validates query values and throws ZodError for invalid input', () => {
|
||||
const schema = z.object({ page: z.coerce.number().int().positive() })
|
||||
const req = { query: { page: '0' } } as unknown as Request
|
||||
|
||||
expect(() => parseQuery(schema, req)).toThrow('Number must be greater than 0')
|
||||
})
|
||||
|
||||
it('parseParams returns validated path parameters', () => {
|
||||
const schema = z.object({ vehicleId: z.string().min(1) })
|
||||
const req = { params: { vehicleId: 'veh_123' } } as unknown as Request
|
||||
|
||||
expect(parseParams(schema, req)).toEqual({ vehicleId: 'veh_123' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Request } from 'express'
|
||||
import { ValidationError } from './errors'
|
||||
|
||||
export function getRawBodyString(req: Request) {
|
||||
if (!Buffer.isBuffer(req.body)) {
|
||||
throw new ValidationError('Webhook route must be mounted with express.raw before JSON parsing')
|
||||
}
|
||||
return req.body.toString('utf8')
|
||||
}
|
||||
|
||||
export function parseRawJsonBody<T = unknown>(req: Request): T {
|
||||
const rawBody = getRawBodyString(req)
|
||||
try {
|
||||
return JSON.parse(rawBody) as T
|
||||
} catch {
|
||||
throw new ValidationError('Malformed webhook JSON')
|
||||
}
|
||||
}
|
||||
+190
-207
@@ -1,95 +1,62 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import http from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Socket } from 'socket.io'
|
||||
import cron from 'node-cron'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { z } from 'zod'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||
import { assertStorageConfiguration } from './lib/storage'
|
||||
import { createApp, corsOrigins } from './app'
|
||||
import { verifyAnyActorToken } from './security/tokens'
|
||||
import { getSessionCookieName } from './security/sessionCookies'
|
||||
import { sendNotification } from './services/notificationService'
|
||||
import {
|
||||
runTrialExpirationJob,
|
||||
runPaymentPendingTimeoutJob,
|
||||
runPastDueTimeoutJob,
|
||||
runSuspensionTimeoutJob,
|
||||
runPeriodEndCancellationJob,
|
||||
} from './modules/subscriptions/subscription.service'
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────
|
||||
import webhookRouter from './routes/webhooks'
|
||||
import companyAuthRouter from './routes/auth.company'
|
||||
import employeeAuthRouter from './routes/auth.employee'
|
||||
import renterAuthRouter from './routes/auth.renter'
|
||||
import vehiclesRouter from './routes/vehicles'
|
||||
import reservationsRouter from './routes/reservations'
|
||||
import teamRouter from './routes/team'
|
||||
import customersRouter from './routes/customers'
|
||||
import offersRouter from './routes/offers'
|
||||
import analyticsRouter from './routes/analytics'
|
||||
import notificationsRouter from './routes/notifications'
|
||||
import marketplaceRouter from './routes/marketplace'
|
||||
import adminRouter from './routes/admin'
|
||||
import companiesRouter from './routes/companies'
|
||||
import subscriptionsRouter from './routes/subscriptions'
|
||||
import siteRouter from './routes/site'
|
||||
import paymentsRouter from './routes/payments'
|
||||
|
||||
const app = express()
|
||||
const app = createApp()
|
||||
const server = http.createServer(app)
|
||||
|
||||
// Trust the first hop from a reverse proxy so req.ip and rate-limiting
|
||||
// use the real client IP (from X-Forwarded-For) rather than the proxy's IP.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
const v1 = '/api/v1'
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001',
|
||||
'http://127.0.0.1:3002',
|
||||
'http://127.0.0.1:3003',
|
||||
]
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
||||
: defaultCorsOrigins
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
assertStorageConfiguration()
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||
})
|
||||
|
||||
const redisMessageSchema = z.object({
|
||||
type: z.string(),
|
||||
payload: z.unknown(),
|
||||
})
|
||||
|
||||
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getSocketSessionToken(socket: Socket): string | undefined {
|
||||
const explicitToken = socket.handshake.auth?.token
|
||||
if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim()
|
||||
|
||||
const cookieHeader = socket.request.headers.cookie
|
||||
return (
|
||||
readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ??
|
||||
readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ??
|
||||
readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticate socket connections via JWT before joining user rooms
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
const token = getSocketSessionToken(socket)
|
||||
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
const payload = verifyAnyActorToken(token)
|
||||
;(socket as any).authenticatedUserId = payload.sub
|
||||
next()
|
||||
} catch {
|
||||
@@ -112,140 +79,13 @@ subscriber.psubscribe('notifications:*', (err) => {
|
||||
subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
const validated = redisMessageSchema.parse(parsed)
|
||||
const userId = channel.replace('notifications:', '')
|
||||
io.to(`user:${userId}`).emit('notification', validated)
|
||||
io.to(`user:${userId}`).emit('notification', parsed)
|
||||
} catch (err) {
|
||||
console.error('[Redis] Invalid notification message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Middleware ────────────────────────────────────────────────
|
||||
// Serve local file storage
|
||||
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
|
||||
|
||||
// Webhook must use raw body BEFORE express.json()
|
||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
||||
|
||||
app.use(helmet())
|
||||
app.use(cors({ origin: corsOrigins, credentials: true }))
|
||||
app.use(morgan('combined'))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ───────────────────────────────────────────────
|
||||
// Auth routes: strict brute-force protection
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
// Admin routes: dedicated limit
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
// Public unauthenticated routes
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
|
||||
// Authenticated company/renter routes
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
|
||||
// ─── Health check ─────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
docsUrl: '/docs',
|
||||
routes: routeDocs,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/docs', (_req, res) => {
|
||||
const rows = routeDocs
|
||||
.map(
|
||||
(route) => `
|
||||
<tr>
|
||||
<td>${route.method}</td>
|
||||
<td><code>${route.path}</code></td>
|
||||
<td>${route.description}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>RentalDriveGo API Docs</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { margin-bottom: 8px; }
|
||||
p { color: #475569; }
|
||||
.meta { margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RentalDriveGo API</h1>
|
||||
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// ─── Error handler ────────────────────────────────────────────
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const statusCode = err.statusCode ?? 500
|
||||
const message = err.message ?? 'Internal server error'
|
||||
const code = err.code ?? 'internal_error'
|
||||
|
||||
if (statusCode >= 500) {
|
||||
console.error('[API Error]', err)
|
||||
}
|
||||
|
||||
// Zod validation error
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
|
||||
}
|
||||
|
||||
// Prisma not found
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
|
||||
}
|
||||
|
||||
// Prisma unique constraint
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
|
||||
}
|
||||
|
||||
res.status(statusCode).json({ error: code, message, statusCode })
|
||||
})
|
||||
|
||||
// ─── Scheduled jobs ───────────────────────────────────────────
|
||||
|
||||
// Daily: flag expiring/expired licenses
|
||||
@@ -262,6 +102,32 @@ cron.schedule('0 8 * * *', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Hourly: expire trials that ended without payment
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
const n = await runTrialExpirationJob()
|
||||
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
|
||||
})
|
||||
|
||||
// Hourly: payment_pending → past_due after 7 days
|
||||
cron.schedule('15 * * * *', async () => {
|
||||
const n = await runPaymentPendingTimeoutJob()
|
||||
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
|
||||
})
|
||||
|
||||
// Hourly: past_due → suspended after 7 days
|
||||
cron.schedule('30 * * * *', async () => {
|
||||
const n = await runPastDueTimeoutJob()
|
||||
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
|
||||
})
|
||||
|
||||
// Daily: suspended → cancelled after 16 days; and period-end cancellations
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
const nSuspend = await runSuspensionTimeoutJob()
|
||||
const nPeriod = await runPeriodEndCancellationJob()
|
||||
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
|
||||
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
|
||||
})
|
||||
|
||||
// Daily: send trial-ending reminders (3 days before trial end)
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
@@ -272,17 +138,134 @@ cron.schedule('0 9 * * *', async () => {
|
||||
for (const sub of subscriptions) {
|
||||
const owner = sub.company.employees[0]
|
||||
if (owner) {
|
||||
await prisma.notification.create({
|
||||
data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 14-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' },
|
||||
await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
companyId: sub.companyId,
|
||||
employeeId: owner.id,
|
||||
channels: ['IN_APP'],
|
||||
templateKey: 'subscription.trial_ending',
|
||||
templateVariables: {
|
||||
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
|
||||
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
|
||||
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
|
||||
// old overdue log is superseded and notifications stop automatically.
|
||||
const allCandidates = await prisma.maintenanceLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nextDueAt: { lte: in30Days } },
|
||||
{ nextDueMileage: { not: null } },
|
||||
],
|
||||
},
|
||||
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
|
||||
orderBy: { performedAt: 'desc' },
|
||||
})
|
||||
|
||||
// Keep only the most-recent log per vehicle+type combination
|
||||
const latestByKey = new Map<string, typeof allCandidates[number]>()
|
||||
for (const log of allCandidates) {
|
||||
const key = `${log.vehicleId}:${log.type}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, log)
|
||||
}
|
||||
|
||||
for (const log of latestByKey.values()) {
|
||||
const vehicle = log.vehicle
|
||||
const company = vehicle.company
|
||||
const recipient = company.employees[0]
|
||||
if (!recipient) continue
|
||||
|
||||
// Determine date-based urgency
|
||||
let isOverdueByDate = false
|
||||
let daysLeft: number | null = null
|
||||
let dueSoonByDate = false
|
||||
if (log.nextDueAt) {
|
||||
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
isOverdueByDate = log.nextDueAt <= now
|
||||
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
|
||||
}
|
||||
|
||||
// Determine odometer-based urgency
|
||||
let isOverdueByOdometer = false
|
||||
let kmLeft: number | null = null
|
||||
let dueSoonByOdometer = false
|
||||
if (log.nextDueMileage != null && vehicle.mileage != null) {
|
||||
kmLeft = log.nextDueMileage - vehicle.mileage
|
||||
isOverdueByOdometer = kmLeft <= 0
|
||||
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
|
||||
}
|
||||
|
||||
// Skip if the latest log is no longer due (owner has updated it)
|
||||
const isOverdue = isOverdueByDate || isOverdueByOdometer
|
||||
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||
if (!isOverdue && !isDueSoon) continue
|
||||
|
||||
// Dedup: don't send more than once per day for the same log
|
||||
const alreadySent = await prisma.notification.findFirst({
|
||||
where: {
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
companyId: company.id,
|
||||
createdAt: { gte: oneDayAgo },
|
||||
data: { path: ['maintenanceLogId'], equals: log.id },
|
||||
},
|
||||
})
|
||||
if (alreadySent) continue
|
||||
|
||||
// Build human-readable description
|
||||
const dueParts: string[] = []
|
||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
|
||||
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
|
||||
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
|
||||
|
||||
const title = isOverdue
|
||||
? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}`
|
||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
||||
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
maintenanceLogId: log.id,
|
||||
maintenanceType: log.type,
|
||||
isOverdue,
|
||||
daysLeft,
|
||||
kmLeft,
|
||||
isOverdueByDate,
|
||||
isOverdueByOdometer,
|
||||
},
|
||||
companyId: company.id,
|
||||
employeeId: recipient.id,
|
||||
channel: 'IN_APP',
|
||||
status: 'DELIVERED',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────
|
||||
const PORT = process.env.API_PORT ?? 4000
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[API] Server running on port ${PORT}`)
|
||||
const PORT = Number(process.env.API_PORT ?? 4000)
|
||||
const HOST = process.env.API_HOST ?? '0.0.0.0'
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`[API] Server running on ${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
export { app, io }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
marketplaceReservationEmail,
|
||||
resetPasswordEmail,
|
||||
signupEmail,
|
||||
} from './emailTranslations'
|
||||
|
||||
describe('emailTranslations', () => {
|
||||
const trialEnd = new Date('2026-07-15T12:00:00.000Z')
|
||||
|
||||
it('renders signup subjects in every supported locale', () => {
|
||||
expect(signupEmail.subject('en')).toContain('workspace')
|
||||
expect(signupEmail.subject('fr')).toContain('espace')
|
||||
expect(signupEmail.subject('ar')).toContain('جاهزة')
|
||||
})
|
||||
|
||||
it('renders localized signup text with billing and provider details', () => {
|
||||
const text = signupEmail.text({
|
||||
firstName: 'Aya',
|
||||
companyName: 'Atlas Cars',
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'AmanPay',
|
||||
trialEnd,
|
||||
}, 'fr')
|
||||
|
||||
expect(text).toContain('Bonjour Aya')
|
||||
expect(text).toContain('Atlas Cars')
|
||||
expect(text).toContain('Forfait : PRO (annuel)')
|
||||
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
|
||||
})
|
||||
|
||||
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
|
||||
const html = resetPasswordEmail.html('https://example.test/reset/token', 'Mina', 45, 'ar')
|
||||
|
||||
expect(html).toContain('dir="rtl"')
|
||||
expect(html).toContain('https://example.test/reset/token')
|
||||
expect(html).toContain('45')
|
||||
})
|
||||
|
||||
it('includes optional contact phone in marketplace reservation HTML when present', () => {
|
||||
const html = marketplaceReservationEmail.html({
|
||||
firstName: 'Yassine',
|
||||
vehicleYear: 2024,
|
||||
vehicleMake: 'Dacia',
|
||||
vehicleModel: 'Duster',
|
||||
companyName: 'Atlas Cars',
|
||||
startDate: new Date('2026-08-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-08-05T00:00:00.000Z'),
|
||||
totalDays: 4,
|
||||
rateDisplay: '400.00',
|
||||
totalDisplay: '1600.00',
|
||||
email: 'yassine@example.test',
|
||||
phone: '+212600000000',
|
||||
}, 'en')
|
||||
|
||||
expect(html).toContain('2024 Dacia Duster')
|
||||
expect(html).toContain('Atlas Cars')
|
||||
expect(html).toContain('yassine@example.test or +212600000000')
|
||||
})
|
||||
|
||||
it('formats dates with the locale-specific formatter', () => {
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'en')).toContain('2026')
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'fr')).toContain('2026')
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toMatch(/2026|٢٠٢٦/)
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toContain('فبراير')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
export type Lang = 'en' | 'fr' | 'ar'
|
||||
|
||||
function t<T>(map: Record<Lang, T>, lang: Lang): T {
|
||||
return map[lang] ?? map['fr']
|
||||
}
|
||||
|
||||
const dateLocale: Record<Lang, string> = { en: 'en-GB', fr: 'fr-FR', ar: 'ar-MA' }
|
||||
|
||||
export function formatDate(date: Date, lang: Lang, opts?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleDateString(dateLocale[lang], opts ?? { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ─── Signup confirmation ──────────────────────────────────────────────────────
|
||||
|
||||
export const signupEmail = {
|
||||
subject: (lang: Lang) => t({
|
||||
en: 'Your workspace is ready — RentalDriveGo',
|
||||
fr: 'Votre espace de travail est prêt — RentalDriveGo',
|
||||
ar: 'مساحة عملك جاهزة — RentalDriveGo',
|
||||
}, lang),
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
currency: string
|
||||
paymentProvider: string
|
||||
trialEnd: Date
|
||||
}, lang: Lang): string => {
|
||||
const trialStr = formatDate(opts.trialEnd, lang)
|
||||
return t({
|
||||
en: [
|
||||
`Hi ${opts.firstName},`,
|
||||
'',
|
||||
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||
`Currency: ${opts.currency}`,
|
||||
`Primary payment provider: ${opts.paymentProvider}`,
|
||||
`Free trial ends on ${trialStr}.`,
|
||||
'',
|
||||
'Your workspace is ready. Sign in with the email and password you chose during signup.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
fr: [
|
||||
`Bonjour ${opts.firstName},`,
|
||||
'',
|
||||
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
|
||||
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
|
||||
`Devise : ${opts.currency}`,
|
||||
`Fournisseur de paiement principal : ${opts.paymentProvider}`,
|
||||
`La période d'essai gratuit se termine le ${trialStr}.`,
|
||||
'',
|
||||
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
ar: [
|
||||
`مرحباً ${opts.firstName}،`,
|
||||
'',
|
||||
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
|
||||
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
|
||||
`العملة: ${opts.currency}`,
|
||||
`مزود الدفع الرئيسي: ${opts.paymentProvider}`,
|
||||
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
|
||||
'',
|
||||
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
}, lang)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Password reset ───────────────────────────────────────────────────────────
|
||||
|
||||
export const resetPasswordEmail = {
|
||||
subject: (lang: Lang) => t({
|
||||
en: 'Reset your RentalDriveGo password',
|
||||
fr: 'Réinitialisez votre mot de passe RentalDriveGo',
|
||||
ar: 'إعادة تعيين كلمة مرور RentalDriveGo',
|
||||
}, lang),
|
||||
|
||||
html: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => {
|
||||
const isRtl = lang === 'ar'
|
||||
const dir = isRtl ? 'rtl' : 'ltr'
|
||||
return t({
|
||||
en: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
|
||||
<p>This link expires in ${expireMinutes} minutes. If you did not request this, you can safely ignore this email.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
fr: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>Bonjour ${firstName},</p>
|
||||
<p>Vous avez demandé la réinitialisation du mot de passe de votre compte RentalDriveGo.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Réinitialiser mon mot de passe</a></p>
|
||||
<p>Ce lien expire dans ${expireMinutes} minutes. Si vous n'avez pas fait cette demande, ignorez simplement cet e-mail.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
ar: `<div dir="rtl" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>مرحباً ${firstName}،</p>
|
||||
<p>طلبت إعادة تعيين كلمة مرور حساب RentalDriveGo الخاص بك.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">إعادة تعيين كلمة المرور</a></p>
|
||||
<p>تنتهي صلاحية هذا الرابط خلال ${expireMinutes} دقيقة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني بأمان.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
}, lang)
|
||||
},
|
||||
|
||||
text: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => t({
|
||||
en: `Hi ${firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${expireMinutes} minutes.\n\nRentalDriveGo`,
|
||||
fr: `Bonjour ${firstName},\n\nRéinitialisez votre mot de passe ici : ${resetUrl}\n\nCe lien expire dans ${expireMinutes} minutes.\n\nRentalDriveGo`,
|
||||
ar: `مرحباً ${firstName}،\n\nأعد تعيين كلمة مرورك هنا: ${resetUrl}\n\nينتهي هذا الرابط خلال ${expireMinutes} دقيقة.\n\nRentalDriveGo`,
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Marketplace reservation request ─────────────────────────────────────────
|
||||
|
||||
export const marketplaceReservationEmail = {
|
||||
subject: (vehicleName: string, lang: Lang) => t({
|
||||
en: `Reservation Request Received — ${vehicleName}`,
|
||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||
ar: `تم استلام طلب الحجز — ${vehicleName}`,
|
||||
}, lang),
|
||||
|
||||
html: (opts: {
|
||||
firstName: string
|
||||
vehicleYear: number
|
||||
vehicleMake: string
|
||||
vehicleModel: string
|
||||
companyName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalDays: number
|
||||
rateDisplay: string
|
||||
totalDisplay: string
|
||||
email: string
|
||||
phone?: string
|
||||
}, lang: Lang): string => {
|
||||
const startStr = formatDate(opts.startDate, lang)
|
||||
const endStr = formatDate(opts.endDate, lang)
|
||||
const isRtl = lang === 'ar'
|
||||
const l = t({
|
||||
en: {
|
||||
greeting: `Hi ${opts.firstName},`,
|
||||
intro: 'Your reservation request has been received and is pending company confirmation.',
|
||||
company: 'Company', pickup: 'Pick-up date', returnD: 'Return date',
|
||||
duration: 'Duration', daily: 'Daily rate', total: 'Estimated total', days: 'day(s)',
|
||||
footer: `The company will review your request and get in touch shortly. You may be contacted at ${opts.email}${opts.phone ? ` or ${opts.phone}` : ''}.`,
|
||||
},
|
||||
fr: {
|
||||
greeting: `Bonjour ${opts.firstName},`,
|
||||
intro: 'Votre demande de réservation a été reçue et est en attente de confirmation.',
|
||||
company: 'Société', pickup: 'Date de prise en charge', returnD: 'Date de retour',
|
||||
duration: 'Durée', daily: 'Tarif journalier', total: 'Total estimé', days: 'jour(s)',
|
||||
footer: `La société examinera votre demande et vous contactera prochainement. Vous pouvez être contacté à ${opts.email}${opts.phone ? ` ou ${opts.phone}` : ''}.`,
|
||||
},
|
||||
ar: {
|
||||
greeting: `مرحباً ${opts.firstName}،`,
|
||||
intro: 'تم استلام طلب الحجز وهو في انتظار تأكيد الشركة.',
|
||||
company: 'الشركة', pickup: 'تاريخ الاستلام', returnD: 'تاريخ الإرجاع',
|
||||
duration: 'المدة', daily: 'السعر اليومي', total: 'الإجمالي التقديري', days: 'يوم',
|
||||
footer: `ستراجع الشركة طلبك وستتواصل معك قريباً. يمكن التواصل معك على ${opts.email}${opts.phone ? ` أو ${opts.phone}` : ''}.`,
|
||||
},
|
||||
}, lang)
|
||||
|
||||
return `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
|
||||
<h2 style="margin-bottom:4px">${l.greeting}</h2>
|
||||
<p style="color:#57534e">${l.intro}</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<h3 style="margin-bottom:12px">${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel}</h3>
|
||||
<p><strong>${l.company}:</strong> ${opts.companyName}</p>
|
||||
<p><strong>${l.pickup}:</strong> ${startStr}</p>
|
||||
<p><strong>${l.returnD}:</strong> ${endStr}</p>
|
||||
<p><strong>${l.duration}:</strong> ${opts.totalDays} ${l.days}</p>
|
||||
<p><strong>${l.daily}:</strong> ${opts.rateDisplay} MAD</p>
|
||||
<p><strong>${l.total}:</strong> ${opts.totalDisplay} MAD</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<p style="color:#57534e;font-size:13px">${l.footer}</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
vehicleYear: number
|
||||
vehicleMake: string
|
||||
vehicleModel: string
|
||||
companyName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalDays: number
|
||||
rateDisplay: string
|
||||
totalDisplay: string
|
||||
}, lang: Lang): string => {
|
||||
const startStr = formatDate(opts.startDate, lang)
|
||||
const endStr = formatDate(opts.endDate, lang)
|
||||
return t({
|
||||
en: `Hi ${opts.firstName},\n\nYour reservation request for the ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} from ${opts.companyName} has been received.\n\nDates: ${startStr} → ${endStr}\nDuration: ${opts.totalDays} day(s)\nDaily rate: ${opts.rateDisplay} MAD\nEstimated total: ${opts.totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
|
||||
fr: `Bonjour ${opts.firstName},\n\nVotre demande de réservation pour la ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} auprès de ${opts.companyName} a été reçue.\n\nDates : ${startStr} → ${endStr}\nDurée : ${opts.totalDays} jour(s)\nTarif journalier : ${opts.rateDisplay} MAD\nTotal estimé : ${opts.totalDisplay} MAD\n\nLa société confirmera votre demande prochainement.\n\nMerci !`,
|
||||
ar: `مرحباً ${opts.firstName}،\n\nتم استلام طلب حجزك لسيارة ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} من ${opts.companyName}.\n\nالتواريخ: ${startStr} → ${endStr}\nالمدة: ${opts.totalDays} يوم\nالسعر اليومي: ${opts.rateDisplay} MAD\nالإجمالي التقديري: ${opts.totalDisplay} MAD\n\nستؤكد الشركة طلبك قريباً.\n\nشكراً!`,
|
||||
}, lang)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Booking confirmed ────────────────────────────────────────────────────────
|
||||
|
||||
export const bookingConfirmedNotif = {
|
||||
title: (lang: Lang) => t({
|
||||
en: 'Booking Confirmed',
|
||||
fr: 'Réservation confirmée',
|
||||
ar: 'تم تأكيد الحجز',
|
||||
}, lang),
|
||||
body: (lang: Lang) => t({
|
||||
en: 'Your booking has been confirmed.',
|
||||
fr: 'Votre réservation a été confirmée.',
|
||||
ar: 'تم تأكيد حجزك.',
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Post-rental review request ───────────────────────────────────────────────
|
||||
|
||||
export const reviewRequestEmail = {
|
||||
subject: (vehicleLabel: string, lang: Lang) => t({
|
||||
en: `How was your rental? — ${vehicleLabel}`,
|
||||
fr: `Comment s'est passée votre location ? — ${vehicleLabel}`,
|
||||
ar: `كيف كانت تجربة الإيجار؟ — ${vehicleLabel}`,
|
||||
}, lang),
|
||||
|
||||
html: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
vehicleLabel: string
|
||||
reviewUrl: string
|
||||
}, lang: Lang): string => {
|
||||
const isRtl = lang === 'ar'
|
||||
const l = t({
|
||||
en: {
|
||||
greeting: `Hi ${opts.firstName},`,
|
||||
body1: `Thank you for renting with <strong>${opts.companyName}</strong>. We hope you enjoyed your <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: 'Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.',
|
||||
cta: 'Leave a review',
|
||||
disclaimer: 'This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.',
|
||||
},
|
||||
fr: {
|
||||
greeting: `Bonjour ${opts.firstName},`,
|
||||
body1: `Merci d'avoir loué chez <strong>${opts.companyName}</strong>. Nous espérons que vous avez apprécié votre <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: "Votre avis aide la société à s'améliorer et aide les autres clients à faire des choix éclairés. Cela ne prend que 30 secondes.",
|
||||
cta: 'Laisser un avis',
|
||||
disclaimer: "Ce lien est unique à votre réservation et ne peut être utilisé qu'une seule fois. Si vous n'avez pas loué de véhicule récemment, vous pouvez ignorer cet e-mail.",
|
||||
},
|
||||
ar: {
|
||||
greeting: `مرحباً ${opts.firstName}،`,
|
||||
body1: `شكراً لاختيار <strong>${opts.companyName}</strong>. نأمل أنك استمتعت بـ <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: 'تساعد ملاحظاتك الشركة على التحسين وتساعد العملاء الآخرين على اتخاذ قرارات مستنيرة. لا يستغرق الأمر سوى 30 ثانية.',
|
||||
cta: 'اترك تقييماً',
|
||||
disclaimer: 'هذا الرابط فريد لحجزك ولا يمكن استخدامه إلا مرة واحدة. إذا لم تستأجر مركبة مؤخراً، يمكنك تجاهل هذا البريد الإلكتروني.',
|
||||
},
|
||||
}, lang)
|
||||
|
||||
return `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
|
||||
<h2 style="margin-bottom:4px">${l.greeting}</h2>
|
||||
<p style="color:#57534e">${l.body1}</p>
|
||||
<p style="color:#57534e">${l.body2}</p>
|
||||
<div style="margin:32px 0;text-align:center">
|
||||
<a href="${opts.reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
|
||||
${l.cta}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#a8a29e;font-size:12px">${l.disclaimer}</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
vehicleLabel: string
|
||||
reviewUrl: string
|
||||
}, lang: Lang): string => t({
|
||||
en: `Hi ${opts.firstName},\n\nThank you for renting with ${opts.companyName}. We hope you enjoyed your ${opts.vehicleLabel}.\n\nLeave a review here (one-time link):\n${opts.reviewUrl}\n\nThank you!`,
|
||||
fr: `Bonjour ${opts.firstName},\n\nMerci d'avoir loué chez ${opts.companyName}. Nous espérons que vous avez apprécié votre ${opts.vehicleLabel}.\n\nLaissez un avis ici (lien unique) :\n${opts.reviewUrl}\n\nMerci !`,
|
||||
ar: `مرحباً ${opts.firstName}،\n\nشكراً لاختيار ${opts.companyName}. نأمل أنك استمتعت بـ ${opts.vehicleLabel}.\n\nاترك تقييماً هنا (رابط فريد):\n${opts.reviewUrl}\n\nشكراً!`,
|
||||
}, lang),
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeAndFormat, toTitleCase, toTitleCaseAll, toLowerCase, toUpperCase, preserveOriginal, getMaxLength } from './inputValidation'
|
||||
|
||||
describe('toTitleCase', () => {
|
||||
it('uppercases first letter and lowercases rest of each word', () => {
|
||||
expect(toTitleCase('john doe')).toBe('John Doe')
|
||||
expect(toTitleCase('MARIA')).toBe('Maria')
|
||||
expect(toTitleCase('new york')).toBe('New York')
|
||||
})
|
||||
|
||||
it('preserves numbers and special chars that survive sanitization', () => {
|
||||
expect(toTitleCase('123 main st.')).toBe('123 Main St.')
|
||||
})
|
||||
|
||||
it('handles single word', () => {
|
||||
expect(toTitleCase('hello')).toBe('Hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toTitleCaseAll', () => {
|
||||
it('uppercases first letter of every word', () => {
|
||||
expect(toTitleCaseAll('acme corporation ltd.')).toBe('Acme Corporation Ltd.')
|
||||
expect(toTitleCaseAll('123 main street, apt 4b')).toBe('123 Main Street, Apt 4b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toLowerCase', () => {
|
||||
it('lowercases all characters', () => {
|
||||
expect(toLowerCase('John.Doe@Example.COM')).toBe('john.doe@example.com')
|
||||
expect(toLowerCase('ALLCAPS')).toBe('allcaps')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toUpperCase', () => {
|
||||
it('uppercases letters, leaves numbers unchanged', () => {
|
||||
expect(toUpperCase('abc-123')).toBe('ABC-123')
|
||||
expect(toUpperCase('ab123cd')).toBe('AB123CD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserveOriginal', () => {
|
||||
it('returns the string unchanged', () => {
|
||||
expect(preserveOriginal('SW1A 1AA')).toBe('SW1A 1AA')
|
||||
expect(preserveOriginal('12345')).toBe('12345')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeAndFormat', () => {
|
||||
// ─── Title Case fields
|
||||
|
||||
it('formats name fields (title case, max 50)', () => {
|
||||
expect(sanitizeAndFormat('john doe', 'name')).toBe('John Doe')
|
||||
expect(sanitizeAndFormat('MARIA', 'name')).toBe('Maria')
|
||||
})
|
||||
|
||||
it('formats streetAddress (title case, max 100)', () => {
|
||||
expect(sanitizeAndFormat('123 main st.', 'streetAddress')).toBe('123 Main St')
|
||||
expect(sanitizeAndFormat('elm street 42', 'streetAddress')).toBe('Elm Street 42')
|
||||
})
|
||||
|
||||
it('formats city (title case, max 50)', () => {
|
||||
expect(sanitizeAndFormat('new york', 'city')).toBe('New York')
|
||||
})
|
||||
|
||||
it('formats pickupLocation and returnLocation', () => {
|
||||
expect(sanitizeAndFormat('airport terminal 1', 'pickupLocation')).toBe('Airport Terminal 1')
|
||||
expect(sanitizeAndFormat('downtown garage', 'returnLocation')).toBe('Downtown Garage')
|
||||
})
|
||||
|
||||
it('formats country (title case)', () => {
|
||||
expect(sanitizeAndFormat('united kingdom', 'country')).toBe('United Kingdom')
|
||||
})
|
||||
|
||||
// ─── Title Case All fields
|
||||
|
||||
it('formats fullAddress (title case all, max 200)', () => {
|
||||
expect(sanitizeAndFormat('123 main street, apt 4b', 'fullAddress')).toBe('123 Main Street, Apt 4b')
|
||||
})
|
||||
|
||||
it('formats commercialName (title case all, max 100)', () => {
|
||||
expect(sanitizeAndFormat('acme corporation', 'commercialName')).toBe('Acme Corporation')
|
||||
})
|
||||
|
||||
it('formats legalCompanyName (title case all, max 100)', () => {
|
||||
expect(sanitizeAndFormat('global rentals llc', 'legalCompanyName')).toBe('Global Rentals Llc')
|
||||
})
|
||||
|
||||
// ─── Email
|
||||
|
||||
it('formats email (lowercase)', () => {
|
||||
expect(sanitizeAndFormat('John.Doe@Example.COM', 'email')).toBe('john.doe@example.com')
|
||||
})
|
||||
|
||||
// ─── Uppercase fields
|
||||
|
||||
it('formats licensePlate (uppercase, preserves hyphens)', () => {
|
||||
expect(sanitizeAndFormat('abc-123', 'licensePlate')).toBe('ABC-123')
|
||||
})
|
||||
|
||||
it('formats VIN (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('1hgbh41jxmn109186', 'vin')).toBe('1HGBH41JXMN109186')
|
||||
})
|
||||
|
||||
it('formats passportNumber (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('ab123456', 'passportNumber')).toBe('AB123456')
|
||||
})
|
||||
|
||||
it('formats driverLicenseNumber (uppercase, preserves hyphens)', () => {
|
||||
expect(sanitizeAndFormat('d123-456-789', 'driverLicenseNumber')).toBe('D123-456-789')
|
||||
})
|
||||
|
||||
it('formats licenseCategory (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('b', 'licenseCategory')).toBe('B')
|
||||
})
|
||||
|
||||
// ─── Car fields
|
||||
|
||||
it('formats carMark (title case)', () => {
|
||||
expect(sanitizeAndFormat('TOYOTA', 'carMark')).toBe('Toyota')
|
||||
expect(sanitizeAndFormat('mercedes-benz', 'carMark')).toBe('Mercedes-Benz')
|
||||
})
|
||||
|
||||
it('formats carModel (title case)', () => {
|
||||
expect(sanitizeAndFormat('corolla', 'carModel')).toBe('Corolla')
|
||||
})
|
||||
|
||||
it('formats carColor (title case)', () => {
|
||||
expect(sanitizeAndFormat('midnight-blue', 'carColor')).toBe('Midnight-Blue')
|
||||
})
|
||||
|
||||
// ─── ZIP Code (preserved)
|
||||
|
||||
it('preserves ZIP code format', () => {
|
||||
expect(sanitizeAndFormat('12345', 'zipCode')).toBe('12345')
|
||||
expect(sanitizeAndFormat('SW1A 1AA', 'zipCode')).toBe('SW1A 1AA')
|
||||
})
|
||||
|
||||
// ─── Sanitization
|
||||
|
||||
it('removes disallowed characters', () => {
|
||||
expect(sanitizeAndFormat('John!@#', 'name')).toBe('John')
|
||||
})
|
||||
|
||||
it('strips disallowed characters including angle brackets', () => {
|
||||
const result = sanitizeAndFormat('test<script>alert(1)</script>', 'name')
|
||||
expect(result).toBe('Testscriptalertscript')
|
||||
})
|
||||
|
||||
// ─── Truncation
|
||||
|
||||
it('truncates to max length', () => {
|
||||
const longName = 'A'.repeat(60)
|
||||
expect(sanitizeAndFormat(longName, 'name')).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('does not truncate strings within limit', () => {
|
||||
expect(sanitizeAndFormat('John', 'name')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxLength', () => {
|
||||
it('returns correct max lengths', () => {
|
||||
expect(getMaxLength('name')).toBe(50)
|
||||
expect(getMaxLength('streetAddress')).toBe(100)
|
||||
expect(getMaxLength('fullAddress')).toBe(200)
|
||||
expect(getMaxLength('email')).toBe(100)
|
||||
expect(getMaxLength('licensePlate')).toBe(30)
|
||||
expect(getMaxLength('zipCode')).toBe(10)
|
||||
expect(getMaxLength('carMark')).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* User Input Validation and Formatting Library
|
||||
*
|
||||
* Implements the rules defined in docs/input_validation_plan.md.
|
||||
* Provides formatting functions and a Zod-integrated pipeline that:
|
||||
* 1. Removes disallowed characters
|
||||
* 2. Trims leading/trailing whitespace
|
||||
* 3. Validates format (email)
|
||||
* 4. Applies case transformation
|
||||
* 5. Truncates to max length
|
||||
*/
|
||||
|
||||
// ─── Character class definitions ───────────────────────────────
|
||||
|
||||
const LETTERS_NUMBERS = 'a-zA-Z0-9'
|
||||
const LETTERS_NUMBERS_HYPHEN = `${LETTERS_NUMBERS}\\-`
|
||||
const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
|
||||
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
|
||||
const EXTENDED_TEXT = `${BASE_TEXT},`
|
||||
|
||||
// ─── Field type definitions ────────────────────────────────────
|
||||
|
||||
export type FieldType =
|
||||
| 'name' | 'nationality' | 'streetAddress' | 'city'
|
||||
| 'pickupLocation' | 'returnLocation' | 'country'
|
||||
| 'fullAddress' | 'commercialName' | 'legalCompanyName'
|
||||
| 'email'
|
||||
| 'licensePlate' | 'vin' | 'cin' | 'passportNumber'
|
||||
| 'internationalPermitNumber' | 'driverLicenseNumber'
|
||||
| 'licenseCategory' | 'iceNumber' | 'commercialRegistry'
|
||||
| 'carMark' | 'carModel' | 'carColor'
|
||||
| 'zipCode'
|
||||
|
||||
interface FieldConfig {
|
||||
maxLength: number
|
||||
allowedPattern: RegExp
|
||||
transform: (value: string) => string
|
||||
}
|
||||
|
||||
// ─── Formatting functions ──────────────────────────────────────
|
||||
|
||||
/** Uppercase first letter of each word, lowercase the rest */
|
||||
export function toTitleCase(str: string): string {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
/** Uppercase first letter of every word */
|
||||
export function toTitleCaseAll(str: string): string {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
export function toLowerCase(str: string): string {
|
||||
return str.toLowerCase()
|
||||
}
|
||||
|
||||
/** Uppercase letters only, leave numbers unchanged */
|
||||
export function toUpperCase(str: string): string {
|
||||
return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
export function preserveOriginal(str: string): string {
|
||||
return str
|
||||
}
|
||||
|
||||
// ─── Field configuration ───────────────────────────────────────
|
||||
|
||||
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
|
||||
// Group 1: Title Case
|
||||
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
|
||||
// Group 2: Title Case All
|
||||
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
|
||||
// Group 3: Lowercase — email
|
||||
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
||||
|
||||
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
|
||||
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
vin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
cin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
passportNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
licenseCategory: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
iceNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
commercialRegistry: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
|
||||
// Group 5: Car fields
|
||||
carMark: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
||||
carModel: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
||||
carColor: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
||||
|
||||
// Group 6: Preserved (spaces for UK postcodes like "SW1A 1AA")
|
||||
zipCode: { maxLength: 10, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: preserveOriginal },
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────
|
||||
|
||||
/** Full processing pipeline: sanitize → trim → format → truncate */
|
||||
export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
|
||||
const config = FIELD_CONFIGS[fieldType]
|
||||
|
||||
// Step 1: Remove disallowed characters
|
||||
let sanitized = ''
|
||||
for (const char of value) {
|
||||
if (config.allowedPattern.test(char)) {
|
||||
sanitized += char
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Trim
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
// Step 3: Apply case transformation
|
||||
const formatted = config.transform(sanitized)
|
||||
|
||||
// Step 4: Truncate
|
||||
if (formatted.length > config.maxLength) {
|
||||
return formatted.slice(0, config.maxLength)
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/** Get the max length for a given field type */
|
||||
export function getMaxLength(fieldType: FieldType): number {
|
||||
return FIELD_CONFIGS[fieldType].maxLength
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isDatabaseUnavailableError } from './isDatabaseUnavailable'
|
||||
|
||||
describe('isDatabaseUnavailableError', () => {
|
||||
it('detects Prisma P1001 connection failures', () => {
|
||||
expect(isDatabaseUnavailableError({ code: 'P1001' })).toBe(true)
|
||||
})
|
||||
|
||||
it('detects database reachability failures by message', () => {
|
||||
expect(isDatabaseUnavailableError({ message: "Can't reach database server at postgres:5432" })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unrelated, null, and primitive errors', () => {
|
||||
expect(isDatabaseUnavailableError({ code: 'P2002' })).toBe(false)
|
||||
expect(isDatabaseUnavailableError(new Error('network hiccup'))).toBe(false)
|
||||
expect(isDatabaseUnavailableError(null)).toBe(false)
|
||||
expect(isDatabaseUnavailableError('P1001')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient } from '@rentaldrivego/database'
|
||||
import { PrismaClient } from '@rentaldrivego/database/client'
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient }
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertStorageConfiguration,
|
||||
deleteImage,
|
||||
getPrivateStorageRoot,
|
||||
getProtectedCustomerLicenseImageUrl,
|
||||
getPublicStorageRoot,
|
||||
getStorageRoot,
|
||||
resolveStoredFilePath,
|
||||
uploadImage,
|
||||
} from './storage'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
describe('storage', () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
it('uses FILE_STORAGE_ROOT when configured and builds protected customer license URLs from dashboard URL', () => {
|
||||
process.env.FILE_STORAGE_ROOT = '/tmp/rdg-storage'
|
||||
process.env.DASHBOARD_URL = 'https://dashboard.rentaldrivego.test/dashboard/'
|
||||
|
||||
expect(getStorageRoot()).toBe('/tmp/rdg-storage')
|
||||
expect(getPublicStorageRoot()).toBe('/tmp/rdg-storage/public')
|
||||
expect(getPrivateStorageRoot()).toBe('/tmp/rdg-storage/private')
|
||||
expect(getProtectedCustomerLicenseImageUrl('customer_1')).toBe('https://dashboard.rentaldrivego.test/dashboard/api/v1/customers/customer_1/license-image')
|
||||
})
|
||||
|
||||
it('rejects implicit in-app storage in production', () => {
|
||||
delete process.env.FILE_STORAGE_ROOT
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(() => assertStorageConfiguration()).toThrow('FILE_STORAGE_ROOT must be set in production')
|
||||
})
|
||||
|
||||
it('uploads, resolves, and deletes files under the configured storage root', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
|
||||
process.env.FILE_STORAGE_ROOT = root
|
||||
process.env.API_URL = 'https://api.rentaldrivego.test/'
|
||||
|
||||
const url = await uploadImage(Buffer.from('image-bytes'), 'customers/licenses', 'customer_1')
|
||||
|
||||
expect(url).toBe('https://api.rentaldrivego.test/storage/customers/licenses/customer_1.jpg')
|
||||
const filePath = resolveStoredFilePath(url)
|
||||
expect(filePath).toBe(path.join(root, 'private/customers/licenses/customer_1.jpg'))
|
||||
expect(fs.readFileSync(filePath!, 'utf8')).toBe('image-bytes')
|
||||
|
||||
await deleteImage(url)
|
||||
expect(fs.existsSync(filePath!)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores non-storage URLs when resolving or deleting files', async () => {
|
||||
process.env.FILE_STORAGE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
|
||||
|
||||
expect(resolveStoredFilePath('https://cdn.test/not-storage/file.jpg')).toBeNull()
|
||||
await expect(deleteImage('https://cdn.test/not-storage/file.jpg')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
+117
-10
@@ -2,18 +2,111 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const STORAGE_ROOT = path.resolve(__dirname, 'storage')
|
||||
const APP_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..')
|
||||
const APP_SOURCE_ROOT = path.join(APP_PACKAGE_ROOT, 'src')
|
||||
const APP_DIST_ROOT = path.join(APP_PACKAGE_ROOT, 'dist')
|
||||
const DEFAULT_STORAGE_ROOT = path.join(APP_PACKAGE_ROOT, 'storage')
|
||||
const LEGACY_STORAGE_ROOTS = [
|
||||
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
|
||||
]
|
||||
|
||||
export type StorageVisibility = 'public' | 'private'
|
||||
|
||||
export function getStorageRoot(): string {
|
||||
return process.env.FILE_STORAGE_ROOT
|
||||
? path.resolve(process.env.FILE_STORAGE_ROOT)
|
||||
: DEFAULT_STORAGE_ROOT
|
||||
}
|
||||
|
||||
export function getPublicStorageRoot(): string {
|
||||
return path.join(getStorageRoot(), 'public')
|
||||
}
|
||||
|
||||
export function getPrivateStorageRoot(): string {
|
||||
return path.join(getStorageRoot(), 'private')
|
||||
}
|
||||
|
||||
export function getLegacyStorageRoots(): string[] {
|
||||
return LEGACY_STORAGE_ROOTS
|
||||
}
|
||||
|
||||
function isWithinPath(targetPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, targetPath)
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
export function assertStorageConfiguration(): string {
|
||||
const storageRoot = getStorageRoot()
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
|
||||
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
|
||||
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
|
||||
if (invalidRoot) {
|
||||
throw new Error(
|
||||
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return storageRoot
|
||||
}
|
||||
|
||||
function ensureStorageRoot(visibility: StorageVisibility): string {
|
||||
assertStorageConfiguration()
|
||||
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
function inferVisibility(folder: string): StorageVisibility {
|
||||
const normalized = folder.replace(/\\/g, '/')
|
||||
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/inspections?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/internal\//.test(`/${normalized}/`)) return 'private'
|
||||
return 'public'
|
||||
}
|
||||
|
||||
function getApiBase(): string {
|
||||
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function getDashboardBase(): string {
|
||||
return (
|
||||
process.env.DASHBOARD_URL ??
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ??
|
||||
'http://localhost:3000/dashboard'
|
||||
).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function getStorageRelativePath(imageUrl: string): string | null {
|
||||
const marker = '/storage/'
|
||||
const idx = imageUrl.indexOf(marker)
|
||||
if (idx === -1) return null
|
||||
return imageUrl.slice(idx + marker.length)
|
||||
}
|
||||
|
||||
export function getProtectedCustomerLicenseImageUrl(customerId: string): string {
|
||||
return `${getDashboardBase()}/api/v1/customers/${customerId}/license-image`
|
||||
}
|
||||
|
||||
export async function uploadImage(
|
||||
buffer: Buffer,
|
||||
folder: string,
|
||||
publicId?: string
|
||||
publicId?: string,
|
||||
visibility: StorageVisibility = inferVisibility(folder),
|
||||
): Promise<string> {
|
||||
const folderPath = path.join(STORAGE_ROOT, folder)
|
||||
const storageRoot = ensureStorageRoot(visibility)
|
||||
const folderPath = path.join(storageRoot, folder)
|
||||
if (!isWithinPath(folderPath, storageRoot)) {
|
||||
throw new Error('Upload path escapes storage root')
|
||||
}
|
||||
fs.mkdirSync(folderPath, { recursive: true })
|
||||
|
||||
const filename = publicId
|
||||
@@ -25,11 +118,25 @@ export async function uploadImage(
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
||||
const marker = '/storage/'
|
||||
const idx = imageUrl.indexOf(marker)
|
||||
if (idx === -1) return
|
||||
const relative = imageUrl.slice(idx + marker.length)
|
||||
const filePath = path.join(STORAGE_ROOT, relative)
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
||||
export function resolveStoredFilePath(imageUrl: string): string | null {
|
||||
const relative = getStorageRelativePath(imageUrl)
|
||||
if (!relative) return null
|
||||
|
||||
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
|
||||
for (const root of roots) {
|
||||
const filePath = path.join(root, relative)
|
||||
if (!isWithinPath(filePath, root)) continue
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
||||
const filePath = resolveStoredFilePath(imageUrl)
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 418 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,155 @@
|
||||
import { z } from 'zod'
|
||||
import { sanitizeAndFormat, getMaxLength } from './inputValidation'
|
||||
import type { FieldType } from './inputValidation'
|
||||
|
||||
// ─── Regex constants ───────────────────────────────────────────
|
||||
|
||||
const GLOBAL_ALLOWED = /^[a-zA-Z0-9@\-/\s]*$/
|
||||
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
|
||||
|
||||
/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
|
||||
const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
|
||||
|
||||
// ─── Phone sanitization ───────────────────────────────────────
|
||||
|
||||
function sanitizePhone(raw: string): string {
|
||||
let out = ''
|
||||
for (const ch of raw) {
|
||||
if (/[\d\s+]/.test(ch)) out += ch
|
||||
}
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
// ─── Required fields ───────────────────────────────────────────
|
||||
|
||||
function applyFieldRules(fieldType: FieldType) {
|
||||
const maxLength = getMaxLength(fieldType)
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'This field is required' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizeAndFormat(val, fieldType))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => val.length <= maxLength,
|
||||
{ message: 'Maximum ' + maxLength + ' characters allowed' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Optional fields ───────────────────────────────────────────
|
||||
|
||||
function applyOptionalFieldRules(fieldType: FieldType) {
|
||||
const maxLength = getMaxLength(fieldType)
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizeAndFormat(val, fieldType)
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || val.length <= maxLength,
|
||||
{ message: 'Maximum ' + maxLength + ' characters allowed' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Exported helpers ──────────────────────────────────────────
|
||||
|
||||
export function textField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalTextField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function titleCaseAllField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalTitleCaseAllField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function upperField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalUpperField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function carTextField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalCarTextField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function zipField() {
|
||||
return applyFieldRules('zipCode')
|
||||
}
|
||||
export function optionalZipField() {
|
||||
return applyOptionalFieldRules('zipCode')
|
||||
}
|
||||
|
||||
// ─── Email fields ──────────────────────────────────────────────
|
||||
|
||||
export function emailField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Email is required' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizeAndFormat(val, 'email'))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => EMAIL_REGEX.test(val),
|
||||
{ message: 'Please enter a valid email address' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalEmailField() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizeAndFormat(val, 'email')
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || EMAIL_REGEX.test(val),
|
||||
{ message: 'Please enter a valid email address' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Phone fields ──────────────────────────────────────────────
|
||||
|
||||
export function phoneField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Phone number is required' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizePhone(val))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => MA_PHONE_REGEX.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizePhone(val)
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || MA_PHONE_REGEX.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
import { getCookie, sendForbidden, sendPaymentRequired, sendUnauthorized } from './authHelpers'
|
||||
|
||||
function responseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('authHelpers', () => {
|
||||
it('extracts and decodes a cookie value by name', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
cookie: 'theme=dark; employee_session=abc%20123%3D; other=value',
|
||||
},
|
||||
} as Request
|
||||
|
||||
expect(getCookie(req, 'employee_session')).toBe('abc 123=')
|
||||
})
|
||||
|
||||
it('returns null when the cookie header or requested cookie is missing', () => {
|
||||
expect(getCookie({ headers: {} } as Request, 'employee_session')).toBeNull()
|
||||
expect(getCookie({ headers: { cookie: 'theme=dark' } } as Request, 'employee_session')).toBeNull()
|
||||
})
|
||||
|
||||
it('sends a uniform unauthorized response', () => {
|
||||
const res = responseStub()
|
||||
|
||||
sendUnauthorized(res, 'invalid_token', 'Invalid token')
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
|
||||
})
|
||||
|
||||
it('sends forbidden responses with optional metadata', () => {
|
||||
const res = responseStub()
|
||||
|
||||
sendForbidden(res, 'forbidden', 'Nope', { requiredRole: 'OWNER' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'forbidden',
|
||||
message: 'Nope',
|
||||
statusCode: 403,
|
||||
requiredRole: 'OWNER',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends payment-required responses with optional metadata', () => {
|
||||
const res = responseStub()
|
||||
|
||||
sendPaymentRequired(res, 'subscription_suspended', 'Pay up', { billingUrl: '/billing' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'subscription_suspended',
|
||||
message: 'Pay up',
|
||||
statusCode: 402,
|
||||
billingUrl: '/billing',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
/**
|
||||
* Sends a uniform auth-error JSON response.
|
||||
* All auth middleware must go through this function so the shape is identical
|
||||
* to what the errorMiddleware produces for AppError instances.
|
||||
*/
|
||||
export function sendUnauthorized(res: Response, error: string, message: string) {
|
||||
return res.status(401).json({ error, message, statusCode: 401 })
|
||||
}
|
||||
|
||||
export function getCookie(req: Request, name: string): string | null {
|
||||
const cookieHeader = req.headers.cookie
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) {
|
||||
return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getBearerToken(req: Request): string | null {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) return null
|
||||
const token = authHeader.slice(7).trim()
|
||||
return token.length > 0 ? token : null
|
||||
}
|
||||
|
||||
export function getAuthToken(req: Request, cookieName?: string): string | null {
|
||||
// Prefer HttpOnly cookies over bearer tokens so stale script-readable tokens
|
||||
// cannot shadow a valid server-managed session during migration.
|
||||
return (cookieName ? getCookie(req, cookieName) : null) ?? getBearerToken(req)
|
||||
}
|
||||
|
||||
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(403).json({ error, message, statusCode: 403, ...extra })
|
||||
}
|
||||
|
||||
export function sendPaymentRequired(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(402).json({ error, message, statusCode: 402, ...extra })
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const createdLimiters: any[] = []
|
||||
|
||||
vi.mock('express-rate-limit', () => ({
|
||||
default: vi.fn((config: any) => {
|
||||
createdLimiters.push(config)
|
||||
return config
|
||||
}),
|
||||
ipKeyGenerator: vi.fn((ip: string) => `ip:${ip}`),
|
||||
}))
|
||||
|
||||
vi.mock('../security/tokens', () => ({
|
||||
verifyAnyActorToken: vi.fn((token: string) => {
|
||||
if (token === 'employee-token') return { type: 'employee', sub: 'employee_1' }
|
||||
if (token === 'renter-token') return { type: 'renter', sub: 'renter_1' }
|
||||
throw new Error('Invalid actor token')
|
||||
}),
|
||||
}))
|
||||
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
|
||||
describe('rateLimiter middleware configuration', () => {
|
||||
beforeEach(() => {
|
||||
createdLimiters.length = 0
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('configures auth limiter to count failed attempts only', async () => {
|
||||
const { authLimiter } = await import('./rateLimiter')
|
||||
|
||||
expect(authLimiter.max).toBe(20)
|
||||
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
|
||||
expect(authLimiter.skipSuccessfulRequests).toBe(true)
|
||||
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
|
||||
expect(rateLimit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keys general API limits by verified actor identity before falling back to request context', async () => {
|
||||
const { apiLimiter } = await import('./rateLimiter')
|
||||
|
||||
expect(apiLimiter.keyGenerator({
|
||||
ip: '203.0.113.10',
|
||||
headers: { cookie: 'theme=dark; employee_session=employee-token' },
|
||||
companyId: 'company_1',
|
||||
} as any)).toBe('ip:203.0.113.10:employee:employee_1')
|
||||
expect(apiLimiter.keyGenerator({
|
||||
ip: '203.0.113.10',
|
||||
headers: { authorization: 'Bearer renter-token' },
|
||||
renterId: 'legacy_renter_context',
|
||||
} as any)).toBe('ip:203.0.113.10:renter:renter_1')
|
||||
expect(apiLimiter.keyGenerator({
|
||||
ip: '203.0.113.10',
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
companyId: 'company_1',
|
||||
} as any)).toBe('ip:203.0.113.10:company_1')
|
||||
expect(apiLimiter.keyGenerator({ ip: '203.0.113.10', headers: {}, renterId: 'renter_1' } as any)).toBe('ip:203.0.113.10:renter_1')
|
||||
expect(ipKeyGenerator).toHaveBeenCalledWith('203.0.113.10')
|
||||
})
|
||||
|
||||
it('uses tighter public and admin limits with explicit 429 payloads', async () => {
|
||||
const { publicLimiter, adminLimiter } = await import('./rateLimiter')
|
||||
|
||||
expect(publicLimiter.max).toBe(60)
|
||||
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
|
||||
expect(adminLimiter.max).toBe(100)
|
||||
expect(adminLimiter.message.message).toBe('Too many admin requests')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,67 @@
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
import type { Request } from 'express'
|
||||
import { verifyAnyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
getSessionCookieName('admin'),
|
||||
getSessionCookieName('employee'),
|
||||
getSessionCookieName('renter'),
|
||||
]
|
||||
|
||||
function readCookie(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getBearerToken(req: Request): string | null {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) return null
|
||||
const token = authHeader.slice(7).trim()
|
||||
return token || null
|
||||
}
|
||||
|
||||
function getRequestToken(req: Request): string | null {
|
||||
const cookieHeader = req.headers.cookie
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const token = readCookie(cookieHeader, name)
|
||||
if (token) return token
|
||||
}
|
||||
|
||||
return getBearerToken(req)
|
||||
}
|
||||
|
||||
function getAuthenticatedActorKey(req: Request): string | null {
|
||||
const token = getRequestToken(req)
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
const payload = verifyAnyActorToken(token)
|
||||
return `${payload.type}:${payload.sub}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
||||
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
|
||||
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
|
||||
// attempts count toward the cap.
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
||||
})
|
||||
@@ -23,9 +74,10 @@ export const apiLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
const actorKey = getAuthenticatedActorKey(req)
|
||||
const companyId = (req as any).companyId ?? ''
|
||||
const renterId = (req as any).renterId ?? ''
|
||||
return `${ip}:${companyId || renterId}`
|
||||
return `${ip}:${actorKey || companyId || renterId || 'anonymous'}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
@@ -46,6 +98,29 @@ export const adminLimiter = rateLimit({
|
||||
max: 100,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
||||
})
|
||||
|
||||
|
||||
// Applied after authentication so limits can include actor identity rather than
|
||||
// pretending every employee behind the same NAT is the same organism.
|
||||
export const actorLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 240,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
const actorKey = getAuthenticatedActorKey(req)
|
||||
const companyId = (req as any).companyId ?? ''
|
||||
const employeeId = (req as any).employee?.id ?? ''
|
||||
const renterId = (req as any).renterId ?? ''
|
||||
const adminId = (req as any).admin?.id ?? ''
|
||||
return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const incoming = req.headers['x-request-id']
|
||||
const requestId = Array.isArray(incoming) ? incoming[0] : incoming
|
||||
req.requestId = requestId && requestId.length <= 128 ? requestId : `req_${crypto.randomUUID()}`
|
||||
res.setHeader('X-Request-Id', req.requestId)
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { verify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
adminUser: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireAdminAuth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects requests without an admin bearer token', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid token signatures', async () => {
|
||||
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||
const req = { headers: { authorization: 'Bearer bad' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-admin token types', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects inactive admin accounts', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches the admin record for valid admin tokens', async () => {
|
||||
const admin = { id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true }
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(prisma.adminUser.findUnique).toHaveBeenCalledWith({ where: { id: 'admin_1' } })
|
||||
expect(req.admin).toEqual(admin)
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('blocks non-enrolled admins from privileged routes', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireAdminRole middleware', () => {
|
||||
it('requires requireAdminAuth to run first', () => {
|
||||
const req = {} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('SUPPORT' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks admins below the required rank', () => {
|
||||
const req = { admin: { role: 'VIEWER' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('FINANCE' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'forbidden',
|
||||
message: 'This action requires the FINANCE role or higher',
|
||||
statusCode: 403,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows admins at or above the required rank', () => {
|
||||
const req = { admin: { role: 'ADMIN' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('SUPPORT' as any)(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { AdminRole } from '@rentaldrivego/database'
|
||||
import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
import { verifyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
const ROLE_RANK: Record<AdminRole, number> = {
|
||||
SUPER_ADMIN: 5,
|
||||
@@ -11,50 +13,82 @@ const ROLE_RANK: Record<AdminRole, number> = {
|
||||
VIEWER: 1,
|
||||
}
|
||||
|
||||
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
|
||||
'/auth/me',
|
||||
'/auth/logout',
|
||||
'/auth/2fa/setup',
|
||||
'/auth/2fa/verify',
|
||||
])
|
||||
|
||||
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
|
||||
|
||||
function is2faEnrollmentExempt(req: Request) {
|
||||
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a valid admin session token.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.admin — the full AdminUser record
|
||||
*/
|
||||
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const token = getAuthToken(req, getSessionCookieName('admin'))
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
let payload: { sub: string; type: string; last2faAt?: number }
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'admin') {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
|
||||
payload = verifyActorToken(token, 'admin')
|
||||
} catch {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||
if (!admin || !admin.isActive) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
||||
}
|
||||
|
||||
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
|
||||
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
|
||||
next()
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the authenticated admin to have at least `minimumRole`.
|
||||
* Must be applied after `requireAdminAuth`.
|
||||
*/
|
||||
export function requireAdminRole(minimumRole: AdminRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const admin = req.admin
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
const rank = ROLE_RANK[admin.role] ?? 0
|
||||
const required = ROLE_RANK[minimumRole] ?? 99
|
||||
|
||||
if (rank < required) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: `This action requires the ${minimumRole} role or higher`,
|
||||
statusCode: 403,
|
||||
})
|
||||
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunction) {
|
||||
const admin = req.admin
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
if (!admin.totpEnabled) {
|
||||
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
|
||||
}
|
||||
|
||||
const last2faAt = req.adminAuthLast2faAt
|
||||
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { generateCompanyApiKey } from '../security/apiKeys'
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
companyApiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireApiKey } from './requireApiKey'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireApiKey middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects requests with no x-api-key header', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'missing_api_key',
|
||||
message: 'API key required in x-api-key header',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed API keys before database lookup', async () => {
|
||||
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'invalid_api_key',
|
||||
message: 'Invalid API key',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown API key prefixes', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { prefix: generated.prefix },
|
||||
include: { company: true },
|
||||
})
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'invalid_api_key',
|
||||
message: 'Invalid API key',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects revoked API keys', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
id: 'key_1',
|
||||
companyId: 'company_1',
|
||||
prefix: generated.prefix,
|
||||
keyHash: generated.keyHash,
|
||||
revokedAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||
company: { id: 'company_1', name: 'Atlas Cars' },
|
||||
})
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects API keys whose secret does not match the stored hash', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
const other = generateCompanyApiKey()
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
id: 'key_1',
|
||||
companyId: 'company_1',
|
||||
prefix: generated.prefix,
|
||||
keyHash: other.keyHash,
|
||||
revokedAt: null,
|
||||
company: { id: 'company_1', name: 'Atlas Cars' },
|
||||
})
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
const company = { id: 'company_1', name: 'Atlas Cars' }
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
id: 'key_1',
|
||||
companyId: company.id,
|
||||
prefix: generated.prefix,
|
||||
keyHash: generated.keyHash,
|
||||
revokedAt: null,
|
||||
company,
|
||||
})
|
||||
vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: 'key_1' },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
})
|
||||
expect(req.company).toEqual(company)
|
||||
expect(req.companyId).toBe('company_1')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(res.json).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getApiKeyPrefix, hashApiKey, timingSafeEqualHex } from '../security/apiKeys'
|
||||
|
||||
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined
|
||||
@@ -8,12 +9,27 @@ export async function requireApiKey(req: Request, res: Response, next: NextFunct
|
||||
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { apiKey } })
|
||||
if (!company) {
|
||||
const prefix = getApiKeyPrefix(apiKey)
|
||||
if (!prefix) {
|
||||
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||
}
|
||||
|
||||
req.company = company
|
||||
req.companyId = company.id
|
||||
const keyRecord = await (prisma as any).companyApiKey.findUnique({
|
||||
where: { prefix },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
const hashed = hashApiKey(apiKey)
|
||||
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
|
||||
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||
}
|
||||
|
||||
await (prisma as any).companyApiKey.update({
|
||||
where: { id: keyRecord.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
})
|
||||
|
||||
req.company = keyRecord.company
|
||||
req.companyId = keyRecord.companyId
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { verify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth, requireCompanyDocumentAuth } from './requireCompanyAuth'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireCompanyAuth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects missing bearer tokens', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid tokens', async () => {
|
||||
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||
const req = { headers: { authorization: 'Bearer bad' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-employee token types', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects inactive or missing employees', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches employee and company context for active employees', async () => {
|
||||
const employee = { id: 'emp_1', companyId: 'company_1', isActive: true, company: { id: 'company_1', name: 'Atlas' } }
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(prisma.employee.findUnique).toHaveBeenCalledWith({ where: { id: 'emp_1' }, include: { company: true } })
|
||||
expect(req.employee).toEqual(employee)
|
||||
expect(req.company).toEqual(employee.company)
|
||||
expect(req.companyId).toBe('company_1')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts employee_session cookies for document routes', async () => {
|
||||
const employee = { id: 'emp_2', companyId: 'company_2', isActive: true, company: { id: 'company_2' } }
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_2', type: 'employee' } as any)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
|
||||
const req = { headers: { cookie: 'employee_session=cookie-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyDocumentAuth(req, res, next)
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'rentaldrivego-api',
|
||||
audience: 'employee',
|
||||
})
|
||||
expect(req.companyId).toBe('company_2')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user