Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f16e1ab26b | |||
| f8d8f050f8 | |||
| 831cd8c7af | |||
| 2a79ac8e63 | |||
| b220807d70 | |||
| 2de00868ad | |||
| 2959285425 | |||
| c27f0909df | |||
| 511ab4d03b | |||
| 00d01bdaf4 | |||
| 68bf9ca610 | |||
| f330efb1ad | |||
| e3f40410e9 | |||
| c21916559f | |||
| 7ff2dbb139 | |||
| ad5f5ebab7 | |||
| a947e48c49 | |||
| 6913c298ad | |||
| b45f84d62b | |||
| 781512399b | |||
| 56c3bcf666 | |||
| 9c11f3660d | |||
| 7c1bd2d0c0 | |||
| 1fd1ddf3f2 | |||
| 0394f0ea2a | |||
| 2993dd5b57 | |||
| e93b988146 | |||
| ea2bd215f6 | |||
| 13b54f07de | |||
| 75a1d39050 | |||
| 0dcbe18c54 | |||
| 8ef61d7005 | |||
| 56984c4ea7 | |||
| 330fc11791 | |||
| 0ddf67c754 | |||
| bcfe518af2 | |||
| 30631504f1 | |||
| 757ad41c9b | |||
| 97d5ecfcf0 | |||
| 63f8df1e38 | |||
| e2b564aea5 | |||
| 184a4bac8b | |||
| 9703de974a | |||
| 5dfd5b1814 | |||
| 13d0512048 | |||
| b0c78ad8a2 | |||
| d8b7b92cb1 | |||
| fb77e91730 | |||
| 9ad92765c1 | |||
| f22e0d45e1 | |||
| a752a399c2 | |||
| e1e2f55c93 | |||
| 85d1aad956 | |||
| 26ab64e473 | |||
| 1e100cf367 | |||
| 3d6607e6c8 | |||
| ae10f5272f | |||
| a8472ddeed | |||
| 6de0690b76 | |||
| 0c9f3f8635 | |||
| 8aab968e09 | |||
| bffda95a7c | |||
| ad88fe0148 | |||
| df83c9876d | |||
| abb3a18345 | |||
| a486f891cf | |||
| 9dd4a3d087 | |||
| 7f86435769 | |||
| e6460de4d0 | |||
| 59b58d02ea | |||
| a48285ecc5 | |||
| 7392b094e0 | |||
| 4d82cf9f1a | |||
| f51d6e4634 | |||
| dce4fdc77e | |||
| dc72970ff1 | |||
| 38ed8045ab | |||
| ab03ee3a4d | |||
| 0f083d8e5d | |||
| e36a59b0bc | |||
| 7962bf9a92 | |||
| 35172ab46b | |||
| d7fb7b7a7b | |||
| 256ff0814e | |||
| c5f614b3e4 | |||
| cd67db99ed | |||
| 81b24080ac | |||
| 3b142ca4c7 | |||
| 572d115003 | |||
| 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,2 @@
|
|||||||
|
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||||
|
--artifact-server-path=/tmp/act-artifacts
|
||||||
@@ -8,9 +8,13 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
.next
|
.next
|
||||||
|
*/.next
|
||||||
dist
|
dist
|
||||||
coverage
|
coverage
|
||||||
tmp
|
tmp
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
# Exclude any stale pages-router directories (all apps use App Router)
|
||||||
|
apps/*/pages
|
||||||
|
apps/*/src/pages
|
||||||
|
|
||||||
|
|||||||
+28
-15
@@ -1,36 +1,49 @@
|
|||||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego
|
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
|
REDIS_URL=redis://redis:6379
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
API_URL=http://localhost:4000
|
API_URL=http://localhost:4000
|
||||||
API_INTERNAL_URL=http://api:4000/api/v1
|
API_INTERNAL_URL=http://api:4000/api/v1
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
|
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
ADMIN_URL=http://localhost:3000/admin
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
|
||||||
DASHBOARD_URL=http://localhost:3001
|
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
||||||
JWT_SECRET=dev-secret
|
# Asset prefixes for apps served through the homepage dev proxy.
|
||||||
|
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||||
|
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||||
|
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
|
||||||
|
JWT_SECRET=placeholder
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
|
||||||
ADMIN_SEED_PASSWORD=changeme123
|
ADMIN_SEED_PASSWORD=placeholder
|
||||||
ADMIN_SEED_FIRST_NAME=Platform
|
ADMIN_SEED_FIRST_NAME=Platform
|
||||||
ADMIN_SEED_LAST_NAME=Admin
|
ADMIN_SEED_LAST_NAME=Admin
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||||
CLERK_SECRET_KEY=
|
CLERK_SECRET_KEY=placeholder
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
|
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
|
||||||
|
|
||||||
# Email - disable Resend (placeholder), use SMTP instead
|
# Email — Resend (primary) with SMTP fallback
|
||||||
RESEND_API_KEY=re_...
|
# Get your API key at https://resend.com/api-keys
|
||||||
EMAIL_FROM=rentaldrivego@gmail.com
|
RESEND_API_KEY=re_PLACEHOLDER
|
||||||
|
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||||
EMAIL_FROM_NAME=RentalDriveGo
|
EMAIL_FROM_NAME=RentalDriveGo
|
||||||
|
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||||
MAIL_HOST=smtp.gmail.com
|
MAIL_HOST=smtp.gmail.com
|
||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_SCHEME=smtp
|
MAIL_SCHEME=smtp
|
||||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||||
MAIL_PASSWORD=xmhg ibqy muoc rntc
|
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||||
MAIL_FROM_NAME=RentalDriveGo
|
MAIL_FROM_NAME=RentalDriveGo
|
||||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||||
|
|||||||
@@ -1,58 +1,91 @@
|
|||||||
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
||||||
ACME_EMAIL=rentaldrivego@gmail.com
|
ACME_EMAIL=rentaldrivego@gmail.com
|
||||||
MARKETPLACE_DOMAIN=rentaldrivego.ma
|
|
||||||
API_DOMAIN=api.rentaldrivego.ma
|
API_DOMAIN=api.rentaldrivego.ma
|
||||||
DASHBOARD_DOMAIN=dashboard.rentaldrivego.ma
|
|
||||||
ADMIN_DOMAIN=admin.rentaldrivego.ma
|
|
||||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||||
|
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
|
||||||
|
|
||||||
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
|
|
||||||
# ── API ────────────────────────────────────────────────────────────────────────
|
# ── Optional prebuilt image source ────────────────────────────────────────────
|
||||||
|
# Used for production deployments. CI injects IMAGE_TAG automatically during
|
||||||
|
# registry-backed deploys; set it manually when running docker compose directly.
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
# APP_IMAGE and APP_VERSION are still accepted by helper scripts for compatibility.
|
||||||
|
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
|
||||||
|
# APP_VERSION=latest
|
||||||
|
|
||||||
|
|
||||||
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||||
|
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
||||||
|
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||||
|
DATABASE_URL_FROM_POSTGRES=true
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=rentaldrivego
|
||||||
|
POSTGRES_USER=dbadmin
|
||||||
|
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
|
REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
# SSR server-side calls go via the internal Docker network
|
# SSR server-side calls go via the internal Docker network
|
||||||
API_INTERNAL_URL=http://api:4000/api/v1
|
API_INTERNAL_URL=http://api:4000/api/v1
|
||||||
|
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
|
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
||||||
API_URL=https://api.rentaldrivego.ma
|
API_URL=https://api.rentaldrivego.ma
|
||||||
# Baked into Next.js bundles at build time — MUST be set before running `npm run build`
|
# 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
|
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||||
|
|
||||||
# ── Frontend public URLs ───────────────────────────────────────────────────────
|
|
||||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
# ── Frontend public URLs ──────────────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma/explore
|
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||||
NEXT_PUBLIC_ADMIN_URL=https://admin.rentaldrivego.ma
|
SITE_ORIGIN=https://rentaldrivego.ma
|
||||||
|
|
||||||
|
# Carplace public routes sit under /carplace on the public site domain.
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
|
||||||
|
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||||
|
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||||
# Comma-separated list of allowed browser origins. REQUIRED in production.
|
# 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 ──────────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
|
||||||
POSTGRES_PASSWORD=24DY@1u5FLCkNMeiO@p
|
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# ── Email (choose one: Resend API or SMTP) ────────────────────────────────────
|
|
||||||
# Option A — Resend
|
# ── Email ─────────────────────────────────────────────────────────────────────
|
||||||
RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
EMAIL_FROM=rentaldrivego@gmail.com
|
||||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
|
||||||
EMAIL_FROM_NAME=RentalDriveGo
|
EMAIL_FROM_NAME=RentalDriveGo
|
||||||
# Option B — SMTP
|
# Option A — Resend
|
||||||
# MAIL_HOST=smtp.rentaldrivego.ma
|
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||||
# MAIL_PORT=587
|
# Option B — SMTP (Gmail)
|
||||||
# MAIL_USERNAME=smtp-user
|
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||||
# MAIL_PASSWORD=smtp-password
|
MAIL_HOST=smtp.gmail.com
|
||||||
# MAIL_SCHEME=smtp
|
MAIL_PORT=587
|
||||||
# MAIL_FROM_ADDRESS=noreply@rentaldrivego.ma
|
MAIL_SCHEME=smtp
|
||||||
# MAIL_FROM_NAME=RentalDriveGo
|
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||||
|
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||||
|
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 push notifications (optional) ────────────────────────────────────
|
||||||
# FIREBASE_PROJECT_ID=your-firebase-project-id
|
# FIREBASE_PROJECT_ID=your-firebase-project-id
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Traefik domains
|
||||||
|
ACME_EMAIL=rentaldrivego@gmail.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
|
||||||
|
|
||||||
|
# Image tag
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
|
# 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_PASSWORD=placeholder
|
||||||
|
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_HOMEPAGE_URL=https://example.com
|
||||||
|
NEXT_PUBLIC_STOREFRONT_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
|
REDIS_URL=redis://redis:6379
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
API_URL=http://localhost:4000
|
API_URL=http://localhost:4000
|
||||||
API_INTERNAL_URL=http://localhost:4000/api/v1
|
API_INTERNAL_URL=http://localhost:4000/api/v1
|
||||||
NEXT_PUBLIC_API_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
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
NODE_ENV=test
|
NODE_ENV=test
|
||||||
|
|||||||
+47
-19
@@ -4,7 +4,13 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ─── Database ──────────────────────────────────────────────────
|
# ─── Database ──────────────────────────────────────────────────
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/rentaldrivego"
|
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||||
|
DATABASE_URL_FROM_POSTGRES=true
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=rentaldrivego
|
||||||
|
POSTGRES_USER=dbadmin
|
||||||
|
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
|
|
||||||
# ─── API ───────────────────────────────────────────────────────
|
# ─── API ───────────────────────────────────────────────────────
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
@@ -12,13 +18,13 @@ API_URL=http://localhost:4000
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||||
|
|
||||||
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
|
|
||||||
# ─── Clerk (Company employee auth) ────────────────────────────
|
# ─── Clerk (Company employee auth) ────────────────────────────
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
||||||
CLERK_SECRET_KEY=sk_test_...
|
CLERK_SECRET_KEY=placeholder
|
||||||
CLERK_WEBHOOK_SECRET=whsec_...
|
CLERK_WEBHOOK_SECRET=whsec_...
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||||
@@ -28,14 +34,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
|
|||||||
# ─── AmanPay (Primary payment provider) ───────────────────────
|
# ─── AmanPay (Primary payment provider) ───────────────────────
|
||||||
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
|
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
|
||||||
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
|
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_BASE_URL=https://api.amanpay.net
|
||||||
AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret
|
AMANPAY_WEBHOOK_SECRET=placeholder
|
||||||
|
|
||||||
# ─── PayPal (Secondary payment provider) ──────────────────────
|
# ─── PayPal (Secondary payment provider) ──────────────────────
|
||||||
# RentalDriveGo's own PayPal account (for collecting subscription fees)
|
# RentalDriveGo's own PayPal account (for collecting subscription fees)
|
||||||
PAYPAL_CLIENT_ID=your-paypal-client-id
|
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
|
PAYPAL_BASE_URL=https://api-m.paypal.com
|
||||||
# Use https://api-m.sandbox.paypal.com for sandbox
|
# Use https://api-m.sandbox.paypal.com for sandbox
|
||||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
|
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||||
@@ -43,23 +49,23 @@ NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
|
|||||||
# ─── Cloudinary (Vehicle + brand photos) ──────────────────────
|
# ─── Cloudinary (Vehicle + brand photos) ──────────────────────
|
||||||
CLOUDINARY_CLOUD_NAME=your-cloud-name
|
CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||||
CLOUDINARY_API_KEY=your-api-key
|
CLOUDINARY_API_KEY=your-api-key
|
||||||
CLOUDINARY_API_SECRET=your-api-secret
|
CLOUDINARY_API_SECRET=placeholder
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||||
|
|
||||||
# ─── Resend (Email) ────────────────────────────────────────────
|
# ─── Resend (Email) ────────────────────────────────────────────
|
||||||
RESEND_API_KEY=re_...
|
RESEND_API_KEY=placeholder
|
||||||
EMAIL_FROM=noreply@rentaldrivego.com
|
EMAIL_FROM=rentaldrivego@gmail.com
|
||||||
EMAIL_FROM_NAME=RentalDriveGo
|
EMAIL_FROM_NAME=RentalDriveGo
|
||||||
|
|
||||||
# ─── Twilio (SMS + WhatsApp) ───────────────────────────────────
|
# ─── Twilio (SMS + WhatsApp) ───────────────────────────────────
|
||||||
TWILIO_ACCOUNT_SID=AC...
|
TWILIO_ACCOUNT_SID=AC...
|
||||||
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
TWILIO_AUTH_TOKEN=placeholder
|
||||||
TWILIO_PHONE_NUMBER=+1234567890
|
TWILIO_PHONE_NUMBER=+1234567890
|
||||||
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
|
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
|
||||||
|
|
||||||
# ─── Firebase (Push notifications) ───────────────────────────
|
# ─── Firebase (Push notifications) ───────────────────────────
|
||||||
FIREBASE_PROJECT_ID=your-project-id
|
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
|
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
|
||||||
NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key
|
NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key
|
||||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||||
@@ -71,22 +77,44 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
|
|||||||
# MAIL_* SMTP variables are intentionally omitted here.
|
# MAIL_* SMTP variables are intentionally omitted here.
|
||||||
|
|
||||||
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
||||||
|
REDIS_PASSWORD=replace-with-production-redis-password
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# ─── App URLs ──────────────────────────────────────────────────
|
# ─── App URLs ──────────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
# SITE_ORIGIN=https://your-production-domain.example
|
||||||
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_STOREFRONT_URL=http://localhost:3000/storefront
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
|
||||||
# Public site is subdomain-based; use this for local dev:
|
# Public site is subdomain-based; use this for local dev:
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||||
DASHBOARD_URL=http://localhost:3001
|
API_DOMAIN=api.rentaldrivego.ma
|
||||||
|
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||||
|
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
|
|
||||||
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
||||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
|
||||||
ADMIN_SEED_PASSWORD=changeme123
|
ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
ADMIN_SEED_FIRST_NAME=Super
|
ADMIN_SEED_FIRST_NAME=Super
|
||||||
ADMIN_SEED_LAST_NAME=Admin
|
ADMIN_SEED_LAST_NAME=Admin
|
||||||
|
|
||||||
|
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||||
|
MAIL_HOST=smtp.gmail.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_SCHEME=smtp
|
||||||
|
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||||
|
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||||
|
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||||
|
MAIL_FROM_NAME=RentalDriveGo
|
||||||
|
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||||
|
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0
|
||||||
|
|
||||||
# ─── Misc ──────────────────────────────────────────────────────
|
# ─── Misc ──────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
# TODO: Remove after installing internal CA certificate on the runner
|
||||||
|
GIT_SSL_NO_VERIFY: "true"
|
||||||
|
REGISTRY_HOST: 192.168.3.80
|
||||||
|
DEPLOY_REGISTRY_HOST: 10.0.0.4
|
||||||
|
DOCKERFILE_PATH: Dockerfile.production
|
||||||
|
DOCKER_PLATFORM: linux/amd64
|
||||||
|
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:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
|
||||||
|
fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
|
||||||
|
- name: Ensure Docker CLI is available
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "::error::Docker CLI is not available in this Gitea runner image."
|
||||||
|
echo "::error::Configure the runner label used by this job to an image that already includes Docker CLI, for example ubuntu-latest:docker://catthehacker/ubuntu:act-latest."
|
||||||
|
echo "::error::The current runner maps ubuntu-latest to node:20-bookworm, and this job cannot install docker.io because the job container has no external DNS."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker --version
|
||||||
|
docker info >/dev/null
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker buildx version
|
||||||
|
printf '[registry."%s"]\n insecure = true\n' "$REGISTRY_HOST" > /tmp/buildkitd.toml
|
||||||
|
if ! docker buildx inspect rentaldrivego-builder >/dev/null 2>&1; then
|
||||||
|
docker buildx create \
|
||||||
|
--name rentaldrivego-builder \
|
||||||
|
--driver docker-container \
|
||||||
|
--config /tmp/buildkitd.toml \
|
||||||
|
--use
|
||||||
|
else
|
||||||
|
docker buildx use rentaldrivego-builder
|
||||||
|
fi
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
|
- name: Docker image metadata
|
||||||
|
id: image-meta
|
||||||
|
run: |
|
||||||
|
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_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
|
||||||
|
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped"
|
||||||
|
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate public URLs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
|
||||||
|
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||||
|
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||||
|
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||||
|
run: |
|
||||||
|
for variable in \
|
||||||
|
NEXT_PUBLIC_API_URL \
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL \
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL \
|
||||||
|
NEXT_PUBLIC_DASHBOARD_URL \
|
||||||
|
NEXT_PUBLIC_ADMIN_URL \
|
||||||
|
SITE_ORIGIN
|
||||||
|
do
|
||||||
|
value="${!variable}"
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
echo "::error::$variable is missing — add it to Gitea Actions secrets"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$value" in
|
||||||
|
http://*|https://*) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::$variable must be an absolute URL (got: $value)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
if: steps.registry-check.outputs.available == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login "$REGISTRY_HOST" \
|
||||||
|
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
BUILDKIT_NO_CLIENT_TOKEN: "true"
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUTPUT_ARG="--load"
|
||||||
|
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
|
||||||
|
OUTPUT_ARG="--push"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--file "$DOCKERFILE_PATH" \
|
||||||
|
--platform "$DOCKER_PLATFORM" \
|
||||||
|
--tag "${{ steps.image-meta.outputs.full }}" \
|
||||||
|
--tag "${{ steps.image-meta.outputs.latest }}" \
|
||||||
|
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
|
||||||
|
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
|
||||||
|
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
|
||||||
|
--build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
|
||||||
|
"$OUTPUT_ARG" \
|
||||||
|
.
|
||||||
|
|
||||||
|
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:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
|
||||||
|
fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
if ! command -v ssh >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then
|
||||||
|
echo "::error::OpenSSH client tools are not available in this Gitea runner image."
|
||||||
|
echo "::error::Use a runner image that already includes ssh and scp; installing packages from apt is not reliable because the job container has no external DNS."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ssh -V
|
||||||
|
|
||||||
|
- 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_PASSWORD }}" | base64 | tr -d '\n')"
|
||||||
|
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
||||||
|
set -e
|
||||||
|
cd '$DEPLOY_ROOT'
|
||||||
|
APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
|
||||||
|
APP_VERSION='${{ gitea.sha }}' \
|
||||||
|
IMAGE_TAG='${{ gitea.sha }}' \
|
||||||
|
REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
|
||||||
|
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
|
||||||
|
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||||
|
bash scripts/docker-prod-deploy.sh
|
||||||
|
"
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: test-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
# TODO: Remove after installing internal CA certificate on the runner
|
||||||
|
GIT_SSL_NO_VERIFY: "true"
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: "5"
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
|
||||||
|
NPM_CONFIG_INCLUDE: "optional"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
type-check:
|
||||||
|
name: Type Check (all packages)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run db:generate
|
||||||
|
- run: npm run type-check
|
||||||
|
|
||||||
|
api-tests:
|
||||||
|
name: API Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run db:generate
|
||||||
|
- run: npm run test:api
|
||||||
|
|
||||||
|
homepage-tests:
|
||||||
|
name: Homepage Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run build --workspace @rentaldrivego/types
|
||||||
|
- run: npm run test:homepage
|
||||||
|
|
||||||
|
carplace-tests:
|
||||||
|
name: Carplace Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run build --workspace @rentaldrivego/types
|
||||||
|
- run: npm run test:carplace
|
||||||
|
|
||||||
|
admin-tests:
|
||||||
|
name: Admin Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run test:admin
|
||||||
|
|
||||||
|
dashboard-tests:
|
||||||
|
name: Dashboard Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run build --workspace @rentaldrivego/types
|
||||||
|
- run: npm run test:dashboard
|
||||||
|
|
||||||
|
integration-tests:
|
||||||
|
name: API Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
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:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- 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,11 +7,14 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
|
apps/api/storage/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.docker.production
|
||||||
|
production/.env.docker.production
|
||||||
|
|
||||||
# Turbo
|
# Turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|||||||
+313
-17
@@ -1,28 +1,324 @@
|
|||||||
stages:
|
stages:
|
||||||
|
- test
|
||||||
- build
|
- build
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
variables:
|
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'
|
||||||
|
|
||||||
|
storefront_tests:
|
||||||
|
stage: test
|
||||||
|
image: node:20-bookworm
|
||||||
|
before_script:
|
||||||
|
- npm ci
|
||||||
|
- npm run build --workspace @rentaldrivego/types
|
||||||
|
script:
|
||||||
|
- npm run test:storefront
|
||||||
|
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
|
stage: build
|
||||||
image: docker:24
|
image: docker:24
|
||||||
services:
|
services:
|
||||||
- docker:dind
|
- name: docker:24-dind
|
||||||
script:
|
alias: docker
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
command: ["--tls=false"]
|
||||||
- docker build -t $DOCKER_IMAGE .
|
variables:
|
||||||
- docker push $DOCKER_IMAGE
|
HEALTHCHECK_TCP_PORT: "2375"
|
||||||
only:
|
|
||||||
- main
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
stage: deploy
|
|
||||||
before_script:
|
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:
|
script:
|
||||||
- ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY"
|
- echo "--- Building Docker Image ---"
|
||||||
- ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE"
|
- |
|
||||||
- ssh $VPS_USER@$VPS_IP "docker stop myapp || true"
|
for var_name in NEXT_PUBLIC_API_URL NEXT_PUBLIC_MARKETPLACE_URL NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_ADMIN_URL; do
|
||||||
- ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE"
|
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.
@@ -0,0 +1,4 @@
|
|||||||
|
fetch-timeout=120000
|
||||||
|
fetch-retries=5
|
||||||
|
fetch-retry-mintimeout=10000
|
||||||
|
fetch-retry-maxtimeout=60000
|
||||||
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.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# RentalDriveGo Phase 16 Design Migration
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
The Phase 16 visual system has been applied at source level to the legacy application front ends in this archive:
|
||||||
|
|
||||||
|
- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy archive.
|
||||||
|
- `admin`: migrated to the Phase 16 token palette and component treatment; navigation was rebuilt as a responsive, accessible application shell.
|
||||||
|
- `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy.
|
||||||
|
- `storefront`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
|
||||||
|
- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
|
||||||
|
|
||||||
|
## Design rules applied
|
||||||
|
|
||||||
|
- Blue is the operational primary color.
|
||||||
|
- Orange is reserved for conversion emphasis and focus visibility.
|
||||||
|
- Green is restored to success/status meaning instead of being used as a disguised primary action.
|
||||||
|
- Light and dark themes share semantic tokens rather than unrelated hard-coded palettes.
|
||||||
|
- English and French remain LTR; Arabic continues to use document RTL and an Arabic font fallback stack.
|
||||||
|
- Interactive controls use a minimum 44px target, visible focus treatment, reduced-motion handling, and high-contrast fallbacks.
|
||||||
|
- Cards and panels use the Phase 16 radius, border, surface, and shadow hierarchy.
|
||||||
|
- The retired `RentalDriveGo` name was replaced with `RentalDriveGo` throughout all frontend source files.
|
||||||
|
|
||||||
|
## Important source finding
|
||||||
|
|
||||||
|
The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence and launch-governance package, not the Phase 16 source repository. It contains 98 files, while its manifest describes a separate 648-file repository that was not included. The legacy archive already contained most of that repository's marketing homepage implementation: 182 files match the Phase 16 manifest checksums exactly. That embedded homepage implementation was therefore used as the reliable visual source of truth rather than fabricating a new design from launch paperwork.
|
||||||
|
|
||||||
|
## Files added
|
||||||
|
|
||||||
|
- `admin/src/styles/phase16-tokens.css`
|
||||||
|
- `dashboard/src/styles/phase16-tokens.css`
|
||||||
|
- `storefront/src/styles/phase16-tokens.css`
|
||||||
|
- `scripts/validate-phase16-design.mjs`
|
||||||
|
- `DESIGN_MIGRATION_REPORT.md`
|
||||||
|
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
|
||||||
|
|
||||||
|
## Validation performed
|
||||||
|
|
||||||
|
- Parsed 633 TypeScript and TSX source files with TypeScript 5.8 syntax validation: passed.
|
||||||
|
- Checked 53 CSS files for balanced rule blocks: passed.
|
||||||
|
- Verified all three migrated apps import the Phase 16 tokens: passed.
|
||||||
|
- Verified no retired `RentalDriveGo` branding remains in frontend source: passed.
|
||||||
|
- Ran `node scripts/validate-phase16-design.mjs`: passed.
|
||||||
|
|
||||||
|
## Validation not claimed
|
||||||
|
|
||||||
|
A full dependency-backed build and browser regression suite was not run. The supplied legacy archive omits its root workspace configuration and the local packages referenced as `@rentaldrivego/types` and `@rentaldrivego/database`. The Phase 16 evidence upload also omits the source repository and dependency lockfile it references. Those are packaging defects in the inputs, not facts to conceal with optimistic prose.
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
+4
-1
@@ -3,13 +3,16 @@ FROM node:20-bookworm
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY .npmrc ./
|
||||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||||
COPY apps ./apps
|
COPY apps ./apps
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
|
COPY config ./config
|
||||||
|
|
||||||
RUN npm ci --no-fund --no-audit
|
RUN npm ci --no-fund --no-audit
|
||||||
|
|
||||||
EXPOSE 3000 3001 3002 3003 4000
|
EXPOSE 3000 3001 3002 3004 4000
|
||||||
|
|
||||||
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
|
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
|
||||||
|
|||||||
+47
-8
@@ -7,8 +7,36 @@ RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
|||||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||||
COPY apps ./apps
|
COPY apps ./apps
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
|
COPY config ./config
|
||||||
|
|
||||||
RUN npm install
|
# 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_HOMEPAGE_URL
|
||||||
|
ARG NEXT_PUBLIC_CARPLACE_URL
|
||||||
|
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||||
|
ARG NEXT_PUBLIC_ADMIN_URL
|
||||||
|
|
||||||
|
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||||
|
# Required in production — must be an absolute https:// URL with no path, query, or fragment.
|
||||||
|
ARG SITE_ORIGIN
|
||||||
|
|
||||||
|
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_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
|
||||||
|
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||||
|
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
||||||
|
SITE_ORIGIN=$SITE_ORIGIN
|
||||||
|
|
||||||
|
RUN npm ci --include=optional
|
||||||
RUN npm run db:generate
|
RUN npm run db:generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -16,15 +44,26 @@ FROM node:20-bookworm AS runner
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production \
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||||
|
|
||||||
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 --chown=app:app /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 --chown=app:app /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/apps ./apps
|
COPY --from=builder --chown=app:app /app/apps ./apps
|
||||||
COPY --from=builder /app/packages ./packages
|
COPY --from=builder --chown=app:app /app/packages ./packages
|
||||||
|
COPY --from=builder --chown=app:app /app/config ./config
|
||||||
|
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 3004 4000
|
||||||
|
|
||||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||||
|
|||||||
+1
-1
@@ -12,4 +12,4 @@ RUN npm install
|
|||||||
|
|
||||||
ENV NODE_ENV=test
|
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,33 @@
|
|||||||
|
# RentalDriveGo Public Chrome Refactor Report
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
1. Extracted the dashboard public navbar into `components/public/PublicNavbar.tsx`.
|
||||||
|
2. Extracted the dashboard public footer into `components/public/PublicFooter.tsx`.
|
||||||
|
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages.
|
||||||
|
4. Updated sign-in, create-account, forgot-password, reset-password, verify-email, onboarding, and invitation pages to use the public component API.
|
||||||
|
5. Preserved legacy component paths through compatibility exports.
|
||||||
|
6. Moved storefront navbar/footer assembly out of the Next.js route layout and into reusable public components.
|
||||||
|
7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
|
||||||
|
8. Added focused tests and implementation documentation.
|
||||||
|
|
||||||
|
## Deliberately unchanged
|
||||||
|
|
||||||
|
- Authentication requests and API endpoints
|
||||||
|
- Redirect behavior between storefront, dashboard, and admin applications
|
||||||
|
- Language and theme persistence
|
||||||
|
- Embedded workspace behavior
|
||||||
|
- Authenticated dashboard and admin navigation
|
||||||
|
|
||||||
|
## Validation performed
|
||||||
|
|
||||||
|
- Parsed all TypeScript and TSX source files successfully.
|
||||||
|
- Verified all relative and `@/` local imports resolve.
|
||||||
|
- Verified every `use client` directive remains the first statement.
|
||||||
|
- Verified sign-in and create-account import and render `PublicPageLayout`.
|
||||||
|
- Verified the storefront route layout no longer assembles navbar/footer inline.
|
||||||
|
- Verified embedded password-recovery navigation preserves `embedded=1`.
|
||||||
|
|
||||||
|
## Validation limitation
|
||||||
|
|
||||||
|
A complete Next.js build was not executed because this archive does not include the monorepo root, workspace packages, lockfile, or installed dependencies referenced by the application package files. Static source validation was completed instead; pretending that equals a production build would be charmingly irresponsible.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"project": "RentalDriveGo",
|
||||||
|
"migration": "Apply Phase 16 design system to legacy Archive.zip frontends",
|
||||||
|
"generated_at": "2026-06-26T02:14:35+00:00",
|
||||||
|
"source_archives": {
|
||||||
|
"legacy_archive": "Archive.zip",
|
||||||
|
"phase16_evidence": "RentalDriveGo_Phase16_Evidence_Package_v1.0.zip"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"homepage": "retained as embedded Phase 16 source of truth",
|
||||||
|
"admin": "migrated",
|
||||||
|
"dashboard": "migrated",
|
||||||
|
"storefront": "migrated",
|
||||||
|
"api": "unchanged"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"typescript_syntax_files": 633,
|
||||||
|
"typescript_syntax": "passed",
|
||||||
|
"css_files_checked": 53,
|
||||||
|
"css_balance": "passed",
|
||||||
|
"design_migration_validator": "passed",
|
||||||
|
"full_build": "not run because root workspace and local packages are absent from supplied archive"
|
||||||
|
},
|
||||||
|
"changed_file_count": 34,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "DESIGN_MIGRATION_REPORT.md",
|
||||||
|
"status": "added",
|
||||||
|
"bytes": 3260,
|
||||||
|
"sha256": "35c41d70484b917b38991791dfb2feb3e78234e4f30e2c99ba922d3d90ab83a6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/validate-phase16-design.mjs",
|
||||||
|
"status": "added",
|
||||||
|
"bytes": 1891,
|
||||||
|
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "storefront/src/app/layout.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 1833,
|
||||||
|
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "storefront/src/app/globals.css",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 7525,
|
||||||
|
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "storefront/src/components/StorefrontFooter.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 4632,
|
||||||
|
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "storefront/src/components/StorefrontHeader.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 10438,
|
||||||
|
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "storefront/src/styles/phase16-tokens.css",
|
||||||
|
"status": "added",
|
||||||
|
"bytes": 4214,
|
||||||
|
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 13979,
|
||||||
|
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/layout.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 2133,
|
||||||
|
"sha256": "09399551351e3c7015e6700c3892cf81f2b45cf4d1b835e01645580e73608c05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/globals.css",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 16161,
|
||||||
|
"sha256": "0d01004900a4a6c078e98167c910d02b5fa29b4ebeafe0520a45cb7f9274c8b8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/styles/phase16-tokens.css",
|
||||||
|
"status": "added",
|
||||||
|
"bytes": 4214,
|
||||||
|
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/components/layout/PublicFooter.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 8348,
|
||||||
|
"sha256": "657f96cc725459f7bf7a668fe616aee4d295625c15366204b5987176fd60c8b8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/components/layout/PublicHeader.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 10250,
|
||||||
|
"sha256": "3e5ace09e73afbb95f09a1acf164011764cc789474430962426793564b8db459"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/components/layout/Sidebar.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 18500,
|
||||||
|
"sha256": "d0f7571149a073c39b6a1b4331e6055d8347a8092bd858d7128a37a19847b4d7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 6888,
|
||||||
|
"sha256": "9b6bbc1479529c51d5dd65ce0dffa4ed6245a1de687c2cc9b2f62d3a396a8f83"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/onboarding/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 10593,
|
||||||
|
"sha256": "a2255be38766258da3495191ce793da21992b6f4c13520d760959b7eff5ccc8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/reset-password/ResetPasswordPageClient.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 11998,
|
||||||
|
"sha256": "3a8f0c268c825097da5a8814c26c2526103536b7b3e6f3f5d04e0311cd3fbe27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/verify-email/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 5176,
|
||||||
|
"sha256": "e1fc11c7e2af90c4b3614a1a8c6b9babe2ea5e9884eaa4e94fc0994462d554d6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 14464,
|
||||||
|
"sha256": "976e8f9244a8ca64e092373831970e3c4d7197ead33e3c478f6e8d9d375f9c3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 19760,
|
||||||
|
"sha256": "861d033b4b86792644538f69a38634d46d6c58d43e87bbeb67dcef806cf6ab24"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dashboard/src/app/(dashboard)/settings/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 42414,
|
||||||
|
"sha256": "c46bdfd9c1c14d471b1aa03cca5e6d91f49dd8405aa27502f040bd97c0d5a64d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/layout.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 1210,
|
||||||
|
"sha256": "aaa6c6aedb14972a159a9bcf9f8dc0bdedd2681d8a1c37be989644975c94c243"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/globals.css",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 8317,
|
||||||
|
"sha256": "4134ea739c645e1550e75d16fdf1b45c13392fd153250840c1272603adbd2832"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/styles/phase16-tokens.css",
|
||||||
|
"status": "added",
|
||||||
|
"bytes": 4214,
|
||||||
|
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/layout.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 10294,
|
||||||
|
"sha256": "8390dba373ae0f8d02e8d872f5b0f1736f60553bf33e90f8c07c02207dedadda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 13442,
|
||||||
|
"sha256": "87995a735f7665723c791a6d528a14ebd78e529105e1bfa14654221248b62ca5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/admin-users/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 12067,
|
||||||
|
"sha256": "64dfa83abc16aef56347c1d832edb2b30f5f11497a6cb2908c5158d891031983"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/audit-logs/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 4369,
|
||||||
|
"sha256": "0db9f45a4486dd4c3a8c89e82618fe49bf3f555aff9a7e8e16b028d2b53d72ef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/billing/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 48678,
|
||||||
|
"sha256": "598125e6be30d184c2277269e163bd3f54605a839a63041fa57ba27cb03cdadd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/companies/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 8622,
|
||||||
|
"sha256": "3513aa5a4ac525c0a6887cb3d77299c595b921e3e7f93001095f81f73f19fe31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/containers/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 18580,
|
||||||
|
"sha256": "d01c7cddedf9fb222554f8ea48fa44a515baae353b6675496eea7578b1c0a105"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/renters/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 6988,
|
||||||
|
"sha256": "5ae947e70d674b930a5fc39f1c0577c25bb7c2ca484c761129fe091019aa0034"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/site-config/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 41636,
|
||||||
|
"sha256": "800108cec0ea22c84ea8f60452ddb633e472237505a16a8bcc73ad89467b08ea"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "admin/src/app/dashboard/companies/[id]/page.tsx",
|
||||||
|
"status": "modified",
|
||||||
|
"bytes": 50874,
|
||||||
|
"sha256": "5b763c4280f9d561004dbb08faa945b7b868f48086ca3b0ac62cacb228db02d1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"package": "RentalDriveGo_Phase16_Operational_UI_v1.0",
|
||||||
|
"generatedOn": "2026-06-25",
|
||||||
|
"scope": [
|
||||||
|
"dashboard",
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"includedApplications": [
|
||||||
|
"homepage",
|
||||||
|
"storefront",
|
||||||
|
"dashboard",
|
||||||
|
"admin",
|
||||||
|
"api"
|
||||||
|
],
|
||||||
|
"designAuthority": "Phase 16 homepage implementation",
|
||||||
|
"routeCoverage": {
|
||||||
|
"admin": 12,
|
||||||
|
"dashboard": 19
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"typescriptSourceFilesParsed": 596,
|
||||||
|
"typescriptSyntaxFailures": 0,
|
||||||
|
"localImportsChecked": 190,
|
||||||
|
"unresolvedLocalImports": 0,
|
||||||
|
"designGate": "passed",
|
||||||
|
"productionBuild": "not run: missing monorepo root, workspace package, lockfile, and installed dependencies"
|
||||||
|
},
|
||||||
|
"keyFiles": [
|
||||||
|
"admin/src/styles/phase16-tokens.css",
|
||||||
|
"admin/src/app/globals.css",
|
||||||
|
"admin/src/app/dashboard/layout.tsx",
|
||||||
|
"dashboard/src/styles/phase16-tokens.css",
|
||||||
|
"dashboard/src/app/globals.css",
|
||||||
|
"dashboard/src/app/(dashboard)/layout.tsx",
|
||||||
|
"dashboard/src/components/layout/Sidebar.tsx",
|
||||||
|
"dashboard/src/components/layout/TopBar.tsx",
|
||||||
|
"scripts/validate-phase16-design.mjs"
|
||||||
|
],
|
||||||
|
"documentation": [
|
||||||
|
"PHASE16_OPERATIONAL_UI_REPORT.md",
|
||||||
|
"docs/PHASE16_OPERATIONAL_UI_GUIDE.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# RentalDriveGo Phase 16 Operational UI Migration Report
|
||||||
|
|
||||||
|
**Date:** 2026-06-25
|
||||||
|
**Scope:** Operator dashboard and platform admin
|
||||||
|
**Design authority:** Existing Phase 16 homepage implementation and its blue/orange semantic design system
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
The dashboard and admin applications now use the same visual language as the Phase 16 homepage rather than behaving like separate products. The migration changes shared shells, global tokens, navigation, page hierarchy, cards, forms, tables, status treatments, responsive behavior, dark mode, RTL behavior, and focus states. API business logic and route contracts were deliberately left unchanged.
|
||||||
|
|
||||||
|
## Repository recovery
|
||||||
|
|
||||||
|
The previous navbar/footer refactor archive omitted the `homepage` and `api` applications. This package was rebuilt from the complete Phase 16 archive, then overlaid with the public navbar/footer refactor before the operational UI migration. The final repository contains:
|
||||||
|
|
||||||
|
- `homepage`
|
||||||
|
- `storefront`
|
||||||
|
- `dashboard`
|
||||||
|
- `admin`
|
||||||
|
- `api`
|
||||||
|
- validation scripts and documentation
|
||||||
|
|
||||||
|
## Shared visual system
|
||||||
|
|
||||||
|
Both operational applications now consume matching `phase16-tokens.css` files with:
|
||||||
|
|
||||||
|
- Homepage blue as the operational action color
|
||||||
|
- Orange reserved for conversion, focus, warning, and selected emphasis
|
||||||
|
- Shared light and dark surface mappings
|
||||||
|
- Shared 10 px, 16 px, 24 px, and 32 px radius scale
|
||||||
|
- Shared 44 px minimum interactive target
|
||||||
|
- Shared 76 px topbar and 272 px desktop sidebar dimensions
|
||||||
|
- Shared card, overlay, and focus shadows
|
||||||
|
- Inter for Latin content and Noto Sans Arabic fallbacks for Arabic
|
||||||
|
|
||||||
|
## Dashboard changes
|
||||||
|
|
||||||
|
- Rebuilt the authenticated shell with the Phase 16 page background, sidebar, sticky topbar, content width, and responsive spacing.
|
||||||
|
- Reworked sidebar branding, active navigation, user card, status marker, RTL logical borders, and mobile behavior.
|
||||||
|
- Reworked the topbar search, page title, status pill, notifications, account controls, and semantic icon buttons.
|
||||||
|
- Migrated all 19 dashboard routes to the shared page-heading or hero pattern.
|
||||||
|
- Applied shared styling to overview metrics, quick actions, reservations, contracts, online reservations, fleet, customers, team, reviews, complaints, offers, billing, subscription, reports, notifications, and settings.
|
||||||
|
- Preserved printable contract styling as a document surface while aligning its surrounding application controls.
|
||||||
|
|
||||||
|
## Admin changes
|
||||||
|
|
||||||
|
- Rebuilt the admin shell with the same Phase 16 sidebar, topbar, page background, status treatment, mobile drawer, language controls, theme controls, and user card.
|
||||||
|
- Migrated all 12 admin routes to the shared page-heading or hero pattern.
|
||||||
|
- Standardized companies, company details, renters, administrators, billing, pricing, containers, audit logs, notifications, menu management, site configuration, and overview pages.
|
||||||
|
- Converted operational create/save/preview actions from orange to homepage blue. Orange remains available for promotions, conversion previews, warnings, and focus.
|
||||||
|
- Standardized dynamic form controls through the shared `.field` utility.
|
||||||
|
|
||||||
|
## Shared component and CSS contract
|
||||||
|
|
||||||
|
The main reusable classes are:
|
||||||
|
|
||||||
|
- `.rdg-app-shell`
|
||||||
|
- `.rdg-workspace`
|
||||||
|
- `.rdg-sidebar`
|
||||||
|
- `.rdg-nav-item`
|
||||||
|
- `.rdg-topbar`
|
||||||
|
- `.rdg-app-main`
|
||||||
|
- `.rdg-app-content`
|
||||||
|
- `.rdg-page-hero`
|
||||||
|
- `.rdg-page-heading`
|
||||||
|
- `.rdg-page-kicker`
|
||||||
|
- `.panel`
|
||||||
|
- `.card`
|
||||||
|
- `.field`
|
||||||
|
- `.btn-primary`
|
||||||
|
- `.btn-secondary`
|
||||||
|
- `.btn-conversion`
|
||||||
|
|
||||||
|
## Behavior preserved
|
||||||
|
|
||||||
|
- Existing routes and URL structure
|
||||||
|
- Authentication and access guards
|
||||||
|
- API calls and data mutations
|
||||||
|
- English, French, and Arabic support
|
||||||
|
- True RTL layout behavior through logical properties
|
||||||
|
- Light, dark, and system theme behavior
|
||||||
|
- Embedded authentication behavior through `embedded=1`
|
||||||
|
- Print behavior for contracts
|
||||||
|
|
||||||
|
## Validation completed
|
||||||
|
|
||||||
|
- 596 TypeScript/TSX source files parsed with zero syntax failures.
|
||||||
|
- 190 local and alias imports checked in admin/dashboard with zero unresolved paths.
|
||||||
|
- 31 operational route pages checked for a Phase 16 page heading or hero.
|
||||||
|
- CSS brace balance checked for the migrated stylesheets.
|
||||||
|
- Shared shell, token, responsive, forced-color, auth-layout, and retired-brand assertions passed.
|
||||||
|
- Final archive integrity is checked after packaging.
|
||||||
|
|
||||||
|
## Build limitation
|
||||||
|
|
||||||
|
A production Next.js build was not run because the supplied archive does not include the monorepo root, root lockfile, root workspace configuration, installed dependencies, or the referenced `@rentaldrivego/types` workspace. Each application invokes that absent workspace in its `prebuild` script. Claiming a clean production build under those conditions would be fiction with a progress bar.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 16 Operational UI Validation Report
|
||||||
|
|
||||||
|
**Validated:** 2026-06-25
|
||||||
|
**Result:** Static migration gates passed
|
||||||
|
|
||||||
|
## Passed checks
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---:|
|
||||||
|
| Frontend/API source TypeScript and TSX parsed | 596 files, 0 syntax failures |
|
||||||
|
| Admin/dashboard local and `@/` imports resolved | 190 imports, 0 unresolved |
|
||||||
|
| Dashboard operational routes using Phase 16 heading/hero | 19 of 19 |
|
||||||
|
| Admin operational routes using Phase 16 heading/hero | 12 of 12 |
|
||||||
|
| Dashboard/admin token files present | Passed |
|
||||||
|
| Shared sidebar, topbar, shell, panel, field, and action contracts present | Passed |
|
||||||
|
| CSS brace balance | Passed |
|
||||||
|
| Light/dark and Arabic RTL token mappings present | Passed |
|
||||||
|
| Reduced-motion and forced-colors rules present | Passed |
|
||||||
|
| Shared sign-in/create-account public layout retained | Passed |
|
||||||
|
| Shared storefront navbar/footer components retained | Passed |
|
||||||
|
| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches |
|
||||||
|
| Complete app directories retained | `homepage`, `storefront`, `dashboard`, `admin`, `api` |
|
||||||
|
|
||||||
|
## Reproducible design gate
|
||||||
|
|
||||||
|
Run this from the extracted repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/validate-phase16-design.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Phase 16 dashboard/admin design checks passed.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Not claimed
|
||||||
|
|
||||||
|
A production build, type-check against installed dependencies, integration test run, and browser visual regression run are not claimed. The supplied archive lacks the root workspace, root lockfile, `@rentaldrivego/types` package, and installed dependencies required by every application `prebuild` script.
|
||||||
@@ -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/storefront/src/middleware.ts`
|
||||||
|
- `apps/admin/src/middleware.ts`
|
||||||
|
- `apps/dashboard/src/middleware.test.ts`
|
||||||
|
- `apps/storefront/src/middleware.test.ts`
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
|
||||||
|
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
||||||
|
- Dashboard, storefront, 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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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} */
|
/** @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 apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||||
const apiUrl = new URL(apiOrigin)
|
const apiUrl = new URL(apiOrigin)
|
||||||
|
const ADMIN_BASE_PATH = '/admin'
|
||||||
|
|
||||||
|
// In Docker dev the admin app runs on port 3002 while the Carplace 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 = {
|
const nextConfig = {
|
||||||
|
basePath: ADMIN_BASE_PATH,
|
||||||
|
...(assetPrefix ? { assetPrefix } : {}),
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -18,12 +30,24 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
transpilePackages: ['@rentaldrivego/types'],
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: securityHeaders,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${apiOrigin}/api/:path*`,
|
destination: `${apiOrigin}/api/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/storage/:path*',
|
||||||
|
destination: `${apiOrigin}/storage/:path*`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -3,28 +3,39 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"build": "next build",
|
||||||
"start": "next start -p 3002",
|
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||||
"type-check": "tsc --noEmit"
|
"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": {
|
"dependencies": {
|
||||||
"@rentaldrivego/types": "*",
|
"@rentaldrivego/types": "*",
|
||||||
"next": "14.2.3",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"zod": "^3.23.0",
|
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
|
"firebase-admin": "^10.3.0",
|
||||||
|
"lucide-react": "^0.376.0",
|
||||||
|
"next": "^16.2.9",
|
||||||
|
"node-cron": "4.5.0",
|
||||||
|
"nodemailer": "9.0.1",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"lucide-react": "^0.376.0"
|
"tailwindcss": "^3.4.3",
|
||||||
|
"turbo": "2.10.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.0",
|
"@types/node": "^20.12.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
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() {
|
export default function AuthRedirectPage() {
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash
|
const hash = window.location.hash
|
||||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||||
const token = params.get('token')
|
const next = resolveAdminNextPath(params.get('next'))
|
||||||
const next = params.get('next') || '/dashboard'
|
|
||||||
|
|
||||||
if (token) {
|
// Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie.
|
||||||
localStorage.setItem('admin_token', token)
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
// Clear the token from the URL
|
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(next)
|
window.location.replace(next)
|
||||||
}, [router])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
<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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,22 +15,30 @@ interface AdminUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'SUPPORT',
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const [admins, setAdmins] = useState<AdminUser[]>([])
|
const [admins, setAdmins] = useState<AdminUser[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [form, setForm] = useState(EMPTY_FORM)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
async function fetchAdmins() {
|
async function fetchAdmins() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||||
@@ -45,32 +52,71 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchAdmins() }, [])
|
useEffect(() => { fetchAdmins() }, [])
|
||||||
|
|
||||||
async function createAdmin(e: React.FormEvent) {
|
function openCreateModal() {
|
||||||
|
setEditingAdminId(null)
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
setShowPassword(false)
|
||||||
|
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)
|
||||||
|
setShowPassword(false)
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdmin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setCreating(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
const isEditing = Boolean(editingAdminId)
|
||||||
method: 'POST',
|
const payload = {
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
firstName: form.firstName,
|
||||||
body: JSON.stringify(form),
|
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()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
|
if (!res.ok) throw new Error(json?.message ?? `Failed to ${isEditing ? 'update' : 'create'} admin`)
|
||||||
setShowModal(false)
|
closeModal()
|
||||||
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
|
||||||
await fetchAdmins()
|
await fetchAdmins()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
|
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
|
||||||
ADMIN: 'text-sky-400 bg-sky-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',
|
FINANCE: 'text-violet-400 bg-violet-950/40',
|
||||||
VIEWER: 'text-zinc-400 bg-zinc-800',
|
VIEWER: 'text-zinc-400 bg-zinc-800',
|
||||||
}
|
}
|
||||||
@@ -83,7 +129,7 @@ export default function AdminUsersPage() {
|
|||||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
+ New admin
|
+ New admin
|
||||||
@@ -103,13 +149,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">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">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">Joined</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-800/60">
|
<tbody className="divide-y divide-zinc-800/60">
|
||||||
{loading ? (
|
{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 ? (
|
) : 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) => (
|
) : admins.map((a) => (
|
||||||
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
|
<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>
|
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
|
||||||
@@ -130,6 +177,15 @@ export default function AdminUsersPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -138,13 +194,13 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModal && (
|
{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="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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold">New admin user</h2>
|
<h2 className="text-lg font-semibold">{editingAdminId ? 'Edit admin user' : 'New admin user'}</h2>
|
||||||
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
<button onClick={closeModal} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={createAdmin} className="space-y-4">
|
<form onSubmit={saveAdmin} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||||
@@ -176,15 +232,36 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<input
|
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||||
type="password"
|
</label>
|
||||||
required
|
<div className="relative">
|
||||||
minLength={8}
|
<input
|
||||||
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"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={form.password}
|
required={!editingAdminId}
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
minLength={editingAdminId ? undefined : 8}
|
||||||
/>
|
className="w-full px-3 py-2 pr-10 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 })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-zinc-500 hover:text-zinc-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||||
@@ -196,10 +273,21 @@ export default function AdminUsersPage() {
|
|||||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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="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={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">
|
<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">
|
||||||
{creating ? 'Creating…' : 'Create admin'}
|
{saving ? (editingAdminId ? 'Saving…' : 'Creating…') : (editingAdminId ? 'Save changes' : 'Create admin')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
interface AuditLog {
|
interface AuditLog {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,7 +16,7 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
CREATE: 'text-emerald-400 bg-emerald-950/40',
|
CREATE: 'text-emerald-400 bg-emerald-950/40',
|
||||||
UPDATE: 'text-sky-400 bg-sky-950/40',
|
UPDATE: 'text-sky-400 bg-sky-950/40',
|
||||||
DELETE: 'text-red-400 bg-red-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',
|
ACTIVATE: 'text-emerald-400 bg-emerald-950/40',
|
||||||
LOGIN: 'text-zinc-400 bg-zinc-800',
|
LOGIN: 'text-zinc-400 bg-zinc-800',
|
||||||
}
|
}
|
||||||
@@ -29,13 +28,12 @@ export default function AdminAuditLogsPage() {
|
|||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('admin_token') ?? ''
|
fetch(`${ADMIN_API_BASE}/admin/audit-logs`, {
|
||||||
fetch(`${API_BASE}/admin/audit-logs`, {
|
credentials: 'include',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.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))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false))
|
.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 { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useAdminI18n } from '@/components/I18nProvider'
|
import { useAdminI18n } from '@/components/I18nProvider'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,6 +11,7 @@ interface Company {
|
|||||||
slug: string
|
slug: string
|
||||||
status: string
|
status: string
|
||||||
email: string
|
email: string
|
||||||
|
contractSettings: { legalName: string | null } | null
|
||||||
subscription: { plan: string; status: string } | null
|
subscription: { plan: string; status: string } | null
|
||||||
_count: { employees: number; vehicles: number }
|
_count: { employees: number; vehicles: number }
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||||
SUSPENDED: 'text-red-400 bg-red-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',
|
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export default function AdminCompaniesPage() {
|
|||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
empty: 'No companies found',
|
empty: 'No companies found',
|
||||||
company: 'Company',
|
company: 'Company',
|
||||||
|
legalName: 'Legal name',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
plan: 'Plan',
|
plan: 'Plan',
|
||||||
@@ -48,6 +49,7 @@ export default function AdminCompaniesPage() {
|
|||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
empty: 'Aucune entreprise trouvée',
|
empty: 'Aucune entreprise trouvée',
|
||||||
company: 'Entreprise',
|
company: 'Entreprise',
|
||||||
|
legalName: 'Raison sociale',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
status: 'Statut',
|
status: 'Statut',
|
||||||
plan: 'Plan',
|
plan: 'Plan',
|
||||||
@@ -59,15 +61,16 @@ export default function AdminCompaniesPage() {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'الشركات',
|
title: 'الشركات',
|
||||||
search: 'ابحث بالاسم أو الـ slug…',
|
search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
|
||||||
loading: 'جارٍ التحميل…',
|
loading: 'جارٍ التحميل…',
|
||||||
empty: 'لم يتم العثور على شركات',
|
empty: 'لم يتم العثور على شركات',
|
||||||
company: 'الشركة',
|
company: 'الشركة',
|
||||||
|
legalName: 'الاسم القانوني',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
status: 'الحالة',
|
status: 'الحالة',
|
||||||
plan: 'الخطة',
|
plan: 'الخطة',
|
||||||
fleet: 'الأسطول',
|
fleet: 'الأسطول',
|
||||||
vehicles: 'سيارات',
|
vehicles: 'مركبة',
|
||||||
view: 'عرض',
|
view: 'عرض',
|
||||||
suspend: 'تعليق',
|
suspend: 'تعليق',
|
||||||
reactivate: 'إعادة التفعيل',
|
reactivate: 'إعادة التفعيل',
|
||||||
@@ -81,16 +84,16 @@ export default function AdminCompaniesPage() {
|
|||||||
const [actioning, setActioning] = useState<string | null>(null)
|
const [actioning, setActioning] = useState<string | null>(null)
|
||||||
|
|
||||||
async function fetchCompanies() {
|
async function fetchCompanies() {
|
||||||
const token = localStorage.getItem('admin_token')
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/companies`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||||
setCompanies(json.data ?? [])
|
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||||
setFiltered(json.data ?? [])
|
setCompanies(list)
|
||||||
|
setFiltered(list)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,11 +110,11 @@ export default function AdminCompaniesPage() {
|
|||||||
|
|
||||||
async function changeStatus(id: string, status: string) {
|
async function changeStatus(id: string, status: string) {
|
||||||
setActioning(id)
|
setActioning(id)
|
||||||
const token = localStorage.getItem('admin_token')
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Action failed')
|
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">
|
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<p className="font-medium text-zinc-100">{c.name}</p>
|
<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>
|
<p className="text-xs text-zinc-500">{c.email}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||||
|
|
||||||
@@ -26,8 +25,7 @@ interface CompanyContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
|
return { 'Content-Type': 'application/json' }
|
||||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||||
@@ -35,7 +33,7 @@ function StatusBadge({ status }: { status: ContainerStatus }) {
|
|||||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
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' },
|
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-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' },
|
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) => {
|
const fetchLogs = useCallback(async (lines: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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()
|
const json = await res.json()
|
||||||
setLogs(json.data?.logs ?? '')
|
setLogs(json.data?.logs ?? '')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -72,7 +70,7 @@ function LogsModal({ companyId, companyName, onClose }: { companyId: string; com
|
|||||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||||
|
|
||||||
return (
|
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 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 className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -127,7 +125,7 @@ export default function ContainersPage() {
|
|||||||
|
|
||||||
const fetchContainers = useCallback(async () => {
|
const fetchContainers = useCallback(async () => {
|
||||||
try {
|
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)
|
const json = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||||
@@ -152,7 +150,7 @@ export default function ContainersPage() {
|
|||||||
setProvisioning(true)
|
setProvisioning(true)
|
||||||
setProvisionResults(null)
|
setProvisionResults(null)
|
||||||
try {
|
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)
|
const json = await res.json().catch(() => null)
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||||
setProvisionResults(json.data?.results ?? [])
|
setProvisionResults(json.data?.results ?? [])
|
||||||
@@ -171,9 +169,9 @@ export default function ContainersPage() {
|
|||||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||||
const url =
|
const url =
|
||||||
action === 'remove'
|
action === 'remove'
|
||||||
? `${API_BASE}/admin/containers/${companyId}`
|
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
||||||
: `${API_BASE}/admin/containers/${companyId}/${action}`
|
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
|
||||||
const res = await fetch(url, { method, headers: authHeaders() })
|
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
|
||||||
const json = await res.json().catch(() => null)
|
const json = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
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: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
{ 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' },
|
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
<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> = {
|
const colorMap: Record<string, string> = {
|
||||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
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',
|
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-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',
|
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
useAdminI18n,
|
useAdminI18n,
|
||||||
} from '@/components/I18nProvider'
|
} from '@/components/I18nProvider'
|
||||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
|
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||||
|
|
||||||
function buildUnifiedLoginUrl(nextPath: string) {
|
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({
|
const params = new URLSearchParams({
|
||||||
portal: 'admin',
|
portal: 'admin',
|
||||||
next: nextPath || '/dashboard',
|
next: nextPath || '/dashboard',
|
||||||
@@ -25,80 +27,107 @@ 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/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/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/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/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/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 }) {
|
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { dict } = useAdminI18n()
|
const { dict } = useAdminI18n()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('admin_token')
|
let cancelled = false
|
||||||
if (!token) {
|
|
||||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
|
||||||
} else {
|
credentials: 'include',
|
||||||
setReady(true)
|
})
|
||||||
|
.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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAdmin(resolvedAdmin)
|
||||||
|
setReady(true)
|
||||||
|
} else {
|
||||||
|
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
localStorage.removeItem('admin_token')
|
void fetch('/admin/api/v1/admin/auth/logout', { method: 'POST', credentials: 'include' })
|
||||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-zinc-950">
|
<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 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" aria-label={dict.loading} />
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label={dict.loading} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
<AdminSessionProvider admin={admin as AdminSessionUser}>
|
||||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900 transition-colors">
|
<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">
|
||||||
<Link href="/" className="block px-5 py-6">
|
<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">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
<Link href="/" className="block px-5 py-6">
|
||||||
<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>
|
||||||
</Link>
|
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
|
||||||
<nav className="flex-1 px-3 space-y-0.5">
|
</Link>
|
||||||
{navLinks.map((link) => {
|
<nav className="flex-1 px-3 space-y-0.5">
|
||||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
{navLinks.map((link) => {
|
||||||
return (
|
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||||
<Link
|
return (
|
||||||
key={link.href}
|
<Link
|
||||||
href={link.href}
|
key={link.href}
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
href={link.href}
|
||||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||||
}`}
|
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}>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||||
{dict.nav[link.key]}
|
</svg>
|
||||||
</Link>
|
{dict.nav[link.key]}
|
||||||
)
|
</Link>
|
||||||
})}
|
)
|
||||||
</nav>
|
})}
|
||||||
<div className="px-3 py-4">
|
</nav>
|
||||||
<button
|
<div className="px-3 py-4">
|
||||||
onClick={handleLogout}
|
<button
|
||||||
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"
|
onClick={handleLogout}
|
||||||
>
|
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 className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
</svg>
|
<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" />
|
||||||
{dict.logout}
|
</svg>
|
||||||
</button>
|
{dict.logout}
|
||||||
<div className="mt-4 flex flex-col items-center gap-3 border-t border-zinc-800 pt-4">
|
</button>
|
||||||
<AdminLanguageSwitcher />
|
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
|
||||||
<AdminThemeSwitcher />
|
<AdminLanguageSwitcher />
|
||||||
|
<AdminThemeSwitcher />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</aside>
|
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
|
||||||
<main className="flex-1 overflow-y-auto bg-zinc-950 transition-colors">{children}</main>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { Activity, ArrowRight, Building2, ClipboardList, ShieldCheck, Users, WalletCards } from 'lucide-react'
|
||||||
import { useAdminI18n } from '@/components/I18nProvider'
|
import { useAdminI18n } from '@/components/I18nProvider'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
|
||||||
|
|
||||||
interface Metrics {
|
interface Metrics {
|
||||||
totalCompanies: number
|
totalCompanies: number
|
||||||
@@ -15,66 +16,177 @@ interface Metrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const { language, dict } = useAdminI18n()
|
const { language } = useAdminI18n()
|
||||||
|
const admin = useAdminSession()
|
||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||||
platformOverview: 'Platform overview',
|
brand: 'RentalDriveGo',
|
||||||
|
eyebrow: 'Operations command',
|
||||||
|
platformOverview: 'RentalDriveGo admin dashboard',
|
||||||
|
subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||||
|
liveSignal: 'Live platform signal',
|
||||||
|
readiness: 'Operational readiness',
|
||||||
|
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
||||||
|
quickActions: 'Quick actions',
|
||||||
|
mrr: 'Monthly recurring revenue',
|
||||||
|
noMetrics: 'Metrics are limited for this admin role.',
|
||||||
cards: [
|
cards: [
|
||||||
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'],
|
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
|
||||||
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'],
|
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||||
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'],
|
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
||||||
],
|
],
|
||||||
|
health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||||
platformOverview: 'Vue plateforme',
|
brand: 'RentalDriveGo',
|
||||||
|
eyebrow: 'Centre des opérations',
|
||||||
|
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
||||||
|
subtitle: 'Suivez la santé de la Carplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||||
|
liveSignal: 'Signal plateforme en direct',
|
||||||
|
readiness: 'Disponibilité opérationnelle',
|
||||||
|
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
||||||
|
quickActions: 'Actions rapides',
|
||||||
|
mrr: 'Revenu mensuel récurrent',
|
||||||
|
noMetrics: 'Les métriques sont limitées pour ce rôle admin.',
|
||||||
cards: [
|
cards: [
|
||||||
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'],
|
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
|
||||||
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'],
|
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||||
['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'],
|
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||||
],
|
],
|
||||||
|
health: ['Comptes locataires', 'Identité Carplace', 'Piste d’audit admin'],
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||||
platformOverview: 'نظرة عامة على المنصة',
|
brand: 'RentalDriveGo',
|
||||||
|
eyebrow: 'مركز العمليات',
|
||||||
|
platformOverview: 'لوحة إدارة RentalDriveGo',
|
||||||
|
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
|
||||||
|
liveSignal: 'مؤشر المنصة المباشر',
|
||||||
|
readiness: 'جاهزية التشغيل',
|
||||||
|
readinessBody: 'مسارات الإدارة الأساسية متصلة وجاهزة لدعم المنصة.',
|
||||||
|
quickActions: 'إجراءات سريعة',
|
||||||
|
mrr: 'الإيراد الشهري المتكرر',
|
||||||
|
noMetrics: 'المؤشرات محدودة لهذا الدور الإداري.',
|
||||||
cards: [
|
cards: [
|
||||||
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
|
||||||
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'],
|
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
|
||||||
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل RentalDriveGo.', 'عرض السجلات'],
|
||||||
],
|
],
|
||||||
|
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('admin_token') ?? ''
|
if (!canAccessAdminMetrics(admin)) {
|
||||||
fetch(`${API_BASE}/admin/metrics`, {
|
setMetrics(null)
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||||
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then(async (response) => {
|
||||||
.then((json) => setMetrics(json.data))
|
const json = await response.json().catch(() => null)
|
||||||
|
if (!response.ok) {
|
||||||
|
setMetrics(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMetrics((json?.data ?? json) as Metrics)
|
||||||
|
})
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [admin])
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US')
|
||||||
|
const currencyFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})
|
||||||
|
|
||||||
const kpis = metrics
|
const kpis = metrics
|
||||||
? [
|
? [
|
||||||
{ label: 'Total companies', value: metrics.totalCompanies },
|
{ label: copy.kpis[0], value: metrics.totalCompanies, icon: Building2, tone: 'text-blue-600 dark:text-blue-300', bg: 'bg-blue-50 dark:bg-blue-500/10' },
|
||||||
{ label: copy.kpis[1], value: metrics.activeCompanies },
|
{ label: copy.kpis[1], value: metrics.activeCompanies, icon: Activity, tone: 'text-emerald-600 dark:text-emerald-300', bg: 'bg-emerald-50 dark:bg-emerald-500/10' },
|
||||||
{ label: copy.kpis[2], value: metrics.totalRenters },
|
{ label: copy.kpis[2], value: metrics.totalRenters, icon: Users, tone: 'text-violet-600 dark:text-violet-300', bg: 'bg-violet-50 dark:bg-violet-500/10' },
|
||||||
{ label: copy.kpis[3], value: metrics.totalReservations },
|
{ label: copy.kpis[3], value: metrics.totalReservations, icon: ClipboardList, tone: 'text-orange-600 dark:text-orange-300', bg: 'bg-orange-50 dark:bg-orange-500/10' },
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
if (kpis.length > 0) kpis[0].label = copy.kpis[0]
|
|
||||||
|
const activeRate = metrics?.totalCompanies
|
||||||
|
? Math.round((metrics.activeCompanies / metrics.totalCompanies) * 100)
|
||||||
|
: 0
|
||||||
|
const renterRatio = metrics?.totalCompanies
|
||||||
|
? Math.round(metrics.totalRenters / Math.max(metrics.totalCompanies, 1))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const actionCards = [
|
||||||
|
{ href: '/dashboard/companies', icon: Building2, title: copy.cards[0][0], body: copy.cards[0][1], cta: copy.cards[0][2] },
|
||||||
|
{ href: '/dashboard/renters', icon: Users, title: copy.cards[1][0], body: copy.cards[1][1], cta: copy.cards[1][2] },
|
||||||
|
{ href: '/dashboard/audit-logs', icon: ShieldCheck, title: copy.cards[2][0], body: copy.cards[2][1], cta: copy.cards[2][2] },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell py-8 space-y-8">
|
<div className="shell py-8 space-y-8">
|
||||||
<div>
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
<section className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-[0_30px_90px_rgba(15,23,42,0.10)] transition-colors dark:border-blue-900/60 dark:bg-[#081427]/86 dark:shadow-[0_34px_100px_rgba(0,0,0,0.34)] sm:p-8">
|
||||||
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1>
|
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#2563eb,#f97316,#10b981)]" />
|
||||||
|
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{copy.eyebrow}</p>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-normal text-blue-950 dark:text-white sm:text-5xl">{copy.platformOverview}</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full max-w-[15rem] items-center gap-3 rounded-2xl border border-stone-200/80 bg-stone-50/90 p-3 dark:border-blue-900/60 dark:bg-[#0d1b38]/85">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-xl bg-blue-950 text-white dark:bg-orange-500">
|
||||||
|
<WalletCards className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.mrr}</p>
|
||||||
|
<p className="text-lg font-black text-blue-950 dark:text-white">{metrics?.mrr != null ? currencyFormatter.format(metrics.mrr) : '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||||
|
{copy.health.map((item) => (
|
||||||
|
<div key={item} className="rounded-2xl border border-stone-200/70 bg-white/70 px-4 py-3 text-sm font-semibold text-stone-700 dark:border-blue-900/50 dark:bg-[#0d1b38]/70 dark:text-slate-200">
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{copy.liveSignal}</p>
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-semibold text-zinc-200">{copy.readiness}</span>
|
||||||
|
<span className="font-black text-orange-600 dark:text-orange-300">{activeRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-blue-950">
|
||||||
|
<div className="h-full rounded-full bg-[linear-gradient(90deg,#2563eb,#f97316)]" style={{ width: `${Math.min(activeRate, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-zinc-500">{copy.readinessBody}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
|
||||||
|
<p className="text-xs text-zinc-500">{copy.kpis[1]}</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(metrics.activeCompanies) : '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
|
||||||
|
<p className="text-xs text-zinc-500">{copy.kpis[2]}</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(renterRatio) : '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -85,27 +197,49 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
|
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: kpis.map((kpi) => (
|
: kpis.length > 0 ? kpis.map((kpi) => {
|
||||||
<div key={kpi.label} className="panel p-6">
|
const Icon = kpi.icon
|
||||||
<p className="text-xs text-zinc-500">{kpi.label}</p>
|
return (
|
||||||
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p>
|
<div key={kpi.label} className="panel p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-zinc-500">{kpi.label}</p>
|
||||||
|
<p className="mt-2 text-3xl font-black text-zinc-200">{numberFormatter.format(kpi.value ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`grid h-10 w-10 place-items-center rounded-2xl ${kpi.bg}`}>
|
||||||
|
<Icon className={`h-5 w-5 ${kpi.tone}`} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<div className="panel p-6 md:col-span-2 lg:col-span-4">
|
||||||
|
<p className="text-sm text-zinc-500">{copy.noMetrics}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<section>
|
||||||
{[
|
<div className="mb-4 flex items-center justify-between">
|
||||||
['/dashboard/companies', ...copy.cards[0]],
|
<p className="text-sm font-bold text-blue-950 dark:text-white">{copy.quickActions}</p>
|
||||||
['/dashboard/renters', ...copy.cards[1]],
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{copy.brand}</p>
|
||||||
['/dashboard/audit-logs', ...copy.cards[2]],
|
</div>
|
||||||
].map(([href, title, body, cta]) => (
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
<Link key={href} href={href} className="panel p-6 hover:border-zinc-700 transition-colors block">
|
{actionCards.map(({ href, icon: Icon, title, body, cta }) => (
|
||||||
<p className="text-sm font-semibold text-zinc-200">{title}</p>
|
<Link key={href} href={href} className="panel group block p-6 hover:border-orange-300 dark:hover:border-orange-500/70">
|
||||||
<p className="mt-2 text-sm text-zinc-500">{body}</p>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<p className="mt-4 text-xs text-emerald-400 font-medium">{cta}</p>
|
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-950 text-white dark:bg-orange-500">
|
||||||
</Link>
|
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||||
))}
|
</div>
|
||||||
</div>
|
<ArrowRight className="h-4 w-4 text-zinc-500 transition-transform group-hover:translate-x-1 group-hover:text-orange-500" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 text-base font-bold text-zinc-200">{title}</p>
|
||||||
|
<p className="mt-2 min-h-[3rem] text-sm leading-6 text-zinc-500">{body}</p>
|
||||||
|
<p className="mt-5 text-xs font-bold uppercase tracking-[0.16em] text-emerald-400">{cta}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useAdminI18n } from '@/components/I18nProvider'
|
import { useAdminI18n } from '@/components/I18nProvider'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
interface Renter {
|
interface Renter {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,10 +36,10 @@ export default function AdminRentersPage() {
|
|||||||
title: 'Locataires',
|
title: 'Locataires',
|
||||||
search: 'Rechercher des locataires…',
|
search: 'Rechercher des locataires…',
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
email: 'Email',
|
email: 'E-mail',
|
||||||
phone: 'Téléphone',
|
phone: 'Téléphone',
|
||||||
status: 'Statut',
|
status: 'Statut',
|
||||||
joined: 'Inscrit',
|
joined: 'Date d’inscription',
|
||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
empty: 'Aucun locataire trouvé',
|
empty: 'Aucun locataire trouvé',
|
||||||
blocked: 'Bloqué',
|
blocked: 'Bloqué',
|
||||||
@@ -55,7 +54,7 @@ export default function AdminRentersPage() {
|
|||||||
email: 'البريد الإلكتروني',
|
email: 'البريد الإلكتروني',
|
||||||
phone: 'الهاتف',
|
phone: 'الهاتف',
|
||||||
status: 'الحالة',
|
status: 'الحالة',
|
||||||
joined: 'تاريخ الانضمام',
|
joined: 'تاريخ التسجيل',
|
||||||
loading: 'جارٍ التحميل…',
|
loading: 'جارٍ التحميل…',
|
||||||
empty: 'لم يتم العثور على مستأجرين',
|
empty: 'لم يتم العثور على مستأجرين',
|
||||||
blocked: 'محظور',
|
blocked: 'محظور',
|
||||||
@@ -71,18 +70,17 @@ export default function AdminRentersPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [actioning, setActioning] = useState<string | null>(null)
|
const [actioning, setActioning] = useState<string | null>(null)
|
||||||
|
|
||||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
|
||||||
|
|
||||||
async function fetchRenters() {
|
async function fetchRenters() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/renters`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||||
setRenters(json.data ?? [])
|
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||||
setFiltered(json.data ?? [])
|
setRenters(list)
|
||||||
|
setFiltered(list)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,9 +101,9 @@ export default function AdminRentersPage() {
|
|||||||
setActioning(id)
|
setActioning(id)
|
||||||
try {
|
try {
|
||||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
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',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Action failed')
|
if (!res.ok) throw new Error('Action failed')
|
||||||
await fetchRenters()
|
await fetchRenters()
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
cloneMarketplaceHomepageContent,
|
cloneCarplaceHomepageContent,
|
||||||
resolveMarketplaceHomepageSections,
|
resolveCarplaceHomepageSections,
|
||||||
type MarketplaceHomepageConfig,
|
type CarplaceHomepageConfig,
|
||||||
type MarketplaceHomepageContent,
|
type CarplaceHomepageContent,
|
||||||
type MarketplaceHomepageSectionType,
|
type CarplaceHomepageSectionType,
|
||||||
type MarketplaceLanguage,
|
type CarplaceLanguage,
|
||||||
} from '@rentaldrivego/types'
|
} from '@rentaldrivego/types'
|
||||||
import { useAdminI18n } from '@/components/I18nProvider'
|
import { useAdminI18n } from '@/components/I18nProvider'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
id: string
|
id: string
|
||||||
@@ -29,22 +28,22 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||||
SUSPENDED: 'text-red-400 bg-red-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',
|
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||||
|
|
||||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
function cloneHomepageContent(content: CarplaceHomepageConfig) {
|
||||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
|
||||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
|
||||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
|
||||||
})
|
})
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
|
||||||
'hero',
|
'hero',
|
||||||
'surface',
|
'surface',
|
||||||
'pillars',
|
'pillars',
|
||||||
@@ -59,12 +58,12 @@ export default function AdminSiteConfigPage() {
|
|||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Site configuration',
|
title: 'Site configuration',
|
||||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
description: 'Edit the main Carplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||||
homepageTitle: 'Main website homepage',
|
homepageTitle: 'Main website homepage',
|
||||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
homepageDescription: 'This controls the Carplace homepage shown on the main RentalDriveGo website.',
|
||||||
saveHomepage: 'Save homepage',
|
saveHomepage: 'Save homepage',
|
||||||
savingHomepage: 'Saving…',
|
savingHomepage: 'Saving…',
|
||||||
homepageSaved: 'Marketplace homepage saved.',
|
homepageSaved: 'Carplace homepage saved.',
|
||||||
search: 'Search by company or slug…',
|
search: 'Search by company or slug…',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
empty: 'No companies found',
|
empty: 'No companies found',
|
||||||
@@ -87,7 +86,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
previewTitle: 'Live preview',
|
previewTitle: 'Live preview',
|
||||||
previewDescription: 'Draft changes appear here before you save them.',
|
previewDescription: 'Draft changes appear here before you save them.',
|
||||||
homepageSections: 'Homepage sections',
|
homepageSections: 'Homepage sections',
|
||||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
homepageSectionsDescription: 'Add or remove blocks from the main Carplace homepage.',
|
||||||
addSection: 'Add',
|
addSection: 'Add',
|
||||||
removeSection: 'Remove',
|
removeSection: 'Remove',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
@@ -95,12 +94,12 @@ export default function AdminSiteConfigPage() {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Configuration du site',
|
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.',
|
description: 'Modifiez ici la page d’accueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||||
homepageTitle: 'Homepage du site principal',
|
homepageTitle: 'Page d’accueil du site principal',
|
||||||
homepageDescription: 'Cette section contrôle la homepage marketplace affichée sur le site principal RentalDriveGo.',
|
homepageDescription: 'Cette section contrôle la page d’accueil Carplace affichée sur le site principal de RentalDriveGo.',
|
||||||
saveHomepage: 'Enregistrer la homepage',
|
saveHomepage: 'Enregistrer la page d’accueil',
|
||||||
savingHomepage: 'Enregistrement…',
|
savingHomepage: 'Enregistrement…',
|
||||||
homepageSaved: 'Homepage marketplace enregistrée.',
|
homepageSaved: 'Page d’accueil Carplace enregistrée.',
|
||||||
search: 'Rechercher par entreprise ou slug…',
|
search: 'Rechercher par entreprise ou slug…',
|
||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
empty: 'Aucune entreprise trouvée',
|
empty: 'Aucune entreprise trouvée',
|
||||||
@@ -112,8 +111,8 @@ export default function AdminSiteConfigPage() {
|
|||||||
manageCompany: 'Ouvrir l’entreprise',
|
manageCompany: 'Ouvrir l’entreprise',
|
||||||
companiesTitle: 'Sites de marque des entreprises',
|
companiesTitle: 'Sites de marque des entreprises',
|
||||||
languageLabel: 'Langue',
|
languageLabel: 'Langue',
|
||||||
hero: 'Hero',
|
hero: 'Bloc principal',
|
||||||
surface: 'Bloc marketplace',
|
surface: 'Bloc Carplace',
|
||||||
companySection: 'Bloc opérateur',
|
companySection: 'Bloc opérateur',
|
||||||
renterSection: 'Bloc client',
|
renterSection: 'Bloc client',
|
||||||
valueSection: 'Blocs de valeur',
|
valueSection: 'Blocs de valeur',
|
||||||
@@ -122,8 +121,8 @@ export default function AdminSiteConfigPage() {
|
|||||||
closingSection: 'Appel à l’action final',
|
closingSection: 'Appel à l’action final',
|
||||||
previewTitle: 'Aperçu en direct',
|
previewTitle: 'Aperçu en direct',
|
||||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||||
homepageSections: 'Sections de la homepage',
|
homepageSections: 'Sections de la page d’accueil',
|
||||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la homepage marketplace principale.',
|
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil Carplace principale.',
|
||||||
addSection: 'Ajouter',
|
addSection: 'Ajouter',
|
||||||
removeSection: 'Retirer',
|
removeSection: 'Retirer',
|
||||||
expand: 'Ouvrir',
|
expand: 'Ouvrir',
|
||||||
@@ -172,8 +171,8 @@ export default function AdminSiteConfigPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
|
||||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
|
||||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||||
@@ -184,27 +183,26 @@ export default function AdminSiteConfigPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const token = localStorage.getItem('admin_token')
|
|
||||||
try {
|
try {
|
||||||
const [companiesRes, homepageRes] = await Promise.all([
|
const [companiesRes, homepageRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/admin/companies`, {
|
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const companiesJson = await companiesRes.json()
|
const companiesJson = await companiesRes.json()
|
||||||
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
||||||
setCompanies(companiesJson.data ?? [])
|
setCompanies(companiesJson.data?.data ?? [])
|
||||||
setFiltered(companiesJson.data ?? [])
|
setFiltered(companiesJson.data?.data ?? [])
|
||||||
|
|
||||||
const homepageJson = await homepageRes.json()
|
const homepageJson = await homepageRes.json()
|
||||||
if (homepageRes.ok && homepageJson?.data) {
|
if (homepageRes.ok && homepageJson?.data) {
|
||||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -227,7 +225,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
)
|
)
|
||||||
}, [search, companies])
|
}, [search, companies])
|
||||||
|
|
||||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
|
||||||
setHomepage((current) => ({
|
setHomepage((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[homepageLanguage]: {
|
[homepageLanguage]: {
|
||||||
@@ -259,16 +257,16 @@ export default function AdminSiteConfigPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveSections() {
|
function getActiveSections() {
|
||||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
return resolveCarplaceHomepageSections(activeContent.sections)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
function addHomepageSection(section: CarplaceHomepageSectionType) {
|
||||||
const sections = getActiveSections()
|
const sections = getActiveSections()
|
||||||
if (sections.includes(section)) return
|
if (sections.includes(section)) return
|
||||||
updateHomepageContent({ sections: [...sections, section] })
|
updateHomepageContent({ sections: [...sections, section] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
function removeHomepageSection(section: CarplaceHomepageSectionType) {
|
||||||
const sections = getActiveSections().filter((item) => item !== section)
|
const sections = getActiveSections().filter((item) => item !== section)
|
||||||
if (sections.length === 0) return
|
if (sections.length === 0) return
|
||||||
updateHomepageContent({ sections })
|
updateHomepageContent({ sections })
|
||||||
@@ -277,20 +275,18 @@ export default function AdminSiteConfigPage() {
|
|||||||
async function saveHomepage() {
|
async function saveHomepage() {
|
||||||
setHomepageSaving(true)
|
setHomepageSaving(true)
|
||||||
setHomepageMessage(null)
|
setHomepageMessage(null)
|
||||||
const token = localStorage.getItem('admin_token')
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ homepage }),
|
body: JSON.stringify({ homepage }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
setHomepage(cloneHomepageContent(json.data as CarplaceHomepageConfig))
|
||||||
setHomepageMessage(copy.homepageSaved)
|
setHomepageMessage(copy.homepageSaved)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -302,13 +298,15 @@ export default function AdminSiteConfigPage() {
|
|||||||
const activeContent = homepage[homepageLanguage]
|
const activeContent = homepage[homepageLanguage]
|
||||||
const activeSections = getActiveSections()
|
const activeSections = getActiveSections()
|
||||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
|
||||||
hero: copy.hero,
|
hero: copy.hero,
|
||||||
surface: copy.surface,
|
surface: copy.surface,
|
||||||
pillars: copy.valueSection,
|
pillars: copy.valueSection,
|
||||||
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
||||||
features: copy.featureSection,
|
features: copy.featureSection,
|
||||||
|
howitworks: language === 'fr' ? 'Fonctionnement' : language === 'ar' ? 'كيفية العمل' : 'How it works',
|
||||||
steps: copy.stepsSection,
|
steps: copy.stepsSection,
|
||||||
|
testimonials: language === 'fr' ? 'Témoignages' : language === 'ar' ? 'الشهادات' : 'Testimonials',
|
||||||
closing: copy.closingSection,
|
closing: copy.closingSection,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +337,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
{(['en', 'fr', 'ar'] as CarplaceLanguage[]).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -416,7 +414,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
|
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<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>
|
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
|
<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">
|
<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">
|
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||||
<div>
|
<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>
|
<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">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{activeSections.includes('surface') ? (
|
{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">
|
<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}
|
{activeContent.surfaceLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
<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}
|
</section> : null}
|
||||||
|
|
||||||
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
|
{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">
|
<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}
|
{activeContent.surfaceLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
<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) => (
|
{activeContent.pillars.map((pillar, index) => (
|
||||||
<article
|
<article
|
||||||
key={`${pillar.title}-${index}`}
|
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>
|
<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>
|
<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">
|
{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">
|
<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>
|
<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>
|
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
|
<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>
|
<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-stone-950">{activeContent.renterTitle}</h5>
|
<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>
|
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
|
||||||
</article>
|
</article>
|
||||||
</section> : null}
|
</section> : null}
|
||||||
@@ -510,7 +508,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{activeContent.features.map((feature, index) => (
|
{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">
|
<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}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
|
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
|
||||||
@@ -521,13 +519,13 @@ export default function AdminSiteConfigPage() {
|
|||||||
) : <div />}
|
) : <div />}
|
||||||
{activeSections.includes('steps') ? (
|
{activeSections.includes('steps') ? (
|
||||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
<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>
|
<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-stone-950">{activeContent.stepsTitle}</h5>
|
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.stepsTitle}</h5>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{activeContent.steps.map((step) => (
|
{activeContent.steps.map((step) => (
|
||||||
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
|
<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>
|
<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>
|
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -536,13 +534,13 @@ export default function AdminSiteConfigPage() {
|
|||||||
) : <div />}
|
) : <div />}
|
||||||
</section> : null}
|
</section> : null}
|
||||||
|
|
||||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-stone-950 px-6 py-7 text-white">
|
{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-amber-300">{activeContent.readyKicker}</p>
|
<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>
|
<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>
|
<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">
|
<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 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>
|
</div>
|
||||||
</section> : null}
|
</section> : null}
|
||||||
</div>
|
</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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import PublicShell from '@/components/PublicShell'
|
import PublicShell from '@/components/PublicShell'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
export default function AdminForgotPasswordPage() {
|
export default function AdminForgotPasswordPage() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -17,9 +16,10 @@ export default function AdminForgotPasswordPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/forgot-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error()
|
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">
|
<main className="flex flex-1 items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<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>
|
<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>
|
<p className="mt-1 text-sm text-zinc-400">Enter your admin email to receive a reset link.</p>
|
||||||
</div>
|
</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 className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||||
)}
|
)}
|
||||||
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="admin@rentaldrivego.com"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Sending…' : 'Send reset link'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+112
-13
@@ -11,7 +11,9 @@ html.dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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%, #fafafa 28%, #f5f5f5 58%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
@@ -19,17 +21,114 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.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, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .panel {
|
||||||
|
border-color: rgb(30 64 175 / 0.40);
|
||||||
|
background-color: rgb(23 37 84 / 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.light body {
|
.light body {
|
||||||
background-color: rgb(248 250 252);
|
color: rgb(28 25 23);
|
||||||
color: rgb(15 23 42);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dark mode: zinc classes → deep navy blue ─────────────── */
|
||||||
|
.bg-zinc-950 {
|
||||||
|
background-color: #172554;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-zinc-950\/90 {
|
||||||
|
background-color: rgb(23 37 84 / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-zinc-900 {
|
||||||
|
background-color: rgb(23 37 84 / 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-zinc-800,
|
||||||
|
.bg-zinc-800\/50 {
|
||||||
|
background-color: rgb(30 58 138 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-zinc-800,
|
||||||
|
.border-zinc-700 {
|
||||||
|
border-color: rgb(30 64 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-zinc-100,
|
||||||
|
.text-zinc-200 {
|
||||||
|
color: rgb(241 245 249);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-zinc-500 {
|
||||||
|
color: rgb(168 162 158);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-zinc-400,
|
||||||
|
.text-zinc-300 {
|
||||||
|
color: rgb(120 113 108);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-zinc-800:hover,
|
||||||
|
.hover\:bg-zinc-800\/50:hover {
|
||||||
|
background-color: rgb(30 64 175 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.light .bg-zinc-950 {
|
||||||
background-color: rgb(248 250 252);
|
background-color: rgb(255 253 248);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .bg-zinc-950\/90 {
|
.light .bg-zinc-950\/90 {
|
||||||
@@ -37,39 +136,39 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.light .bg-zinc-900 {
|
.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,
|
||||||
.light .bg-zinc-800\/50 {
|
.light .bg-zinc-800\/50 {
|
||||||
background-color: rgb(241 245 249);
|
background-color: rgb(245 245 244);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .border-zinc-800,
|
.light .border-zinc-800,
|
||||||
.light .border-zinc-700 {
|
.light .border-zinc-700 {
|
||||||
border-color: rgb(226 232 240);
|
border-color: rgb(231 229 228);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .text-zinc-100,
|
.light .text-zinc-100,
|
||||||
.light .text-zinc-200 {
|
.light .text-zinc-200 {
|
||||||
color: rgb(15 23 42);
|
color: rgb(28 25 23);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .text-zinc-500 {
|
.light .text-zinc-500 {
|
||||||
color: rgb(100 116 139);
|
color: rgb(120 113 108);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .text-zinc-400,
|
.light .text-zinc-400,
|
||||||
.light .text-zinc-300 {
|
.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:hover,
|
||||||
.light .hover\:bg-zinc-800\/50: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 {
|
.light .hover\:text-zinc-200:hover {
|
||||||
color: rgb(15 23 42);
|
color: rgb(28 25 23);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import './globals.css'
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'RentalDriveGo Admin',
|
title: 'RentalDriveGo Admin',
|
||||||
description: 'Platform administration for RentalDriveGo.',
|
description: 'RentalDriveGo platform administration.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark" suppressHydrationWarning>
|
<html lang="en" className="light" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__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>
|
</head>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { headers } from 'next/headers'
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default async function AdminLoginPage() {
|
||||||
const requestHeaders = headers()
|
const requestHeaders = await headers()
|
||||||
const dashboardUrl = resolveServerAppUrl(
|
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('host'),
|
||||||
requestHeaders.get('x-forwarded-proto'),
|
requestHeaders.get('x-forwarded-proto'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import Link from 'next/link'
|
|||||||
import { useState, Suspense } from 'react'
|
import { useState, Suspense } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import PublicShell from '@/components/PublicShell'
|
import PublicShell from '@/components/PublicShell'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
export default function AdminResetPasswordPage() {
|
export default function AdminResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +20,9 @@ function AdminResetPasswordContent() {
|
|||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -33,9 +34,10 @@ function AdminResetPasswordContent() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/reset-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ token, password }),
|
body: JSON.stringify({ token, password }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@@ -78,7 +80,7 @@ function AdminResetPasswordContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/login')}
|
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
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
@@ -94,33 +96,71 @@ 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 className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||||
)}
|
)}
|
||||||
<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
|
<div className="relative">
|
||||||
type="password"
|
<input
|
||||||
required
|
type={showPassword ? 'text' : 'password'}
|
||||||
minLength={8}
|
required
|
||||||
value={password}
|
minLength={8}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
value={password}
|
||||||
placeholder="••••••••"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
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"
|
placeholder="••••••••"
|
||||||
/>
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<div className="relative">
|
||||||
type="password"
|
<input
|
||||||
required
|
type={showConfirm ? 'text' : 'password'}
|
||||||
minLength={8}
|
required
|
||||||
value={confirm}
|
minLength={8}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
value={confirm}
|
||||||
placeholder="••••••••"
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
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"
|
placeholder="••••••••"
|
||||||
/>
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirm ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Resetting…' : 'Reset password'}
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +181,7 @@ function AdminResetShell({ children }: { children: React.ReactNode }) {
|
|||||||
<main className="flex flex-1 items-center justify-center px-4">
|
<main className="flex flex-1 items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<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>
|
<h1 className="mt-3 text-3xl font-black tracking-tight">Reset password</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel p-8">{children}</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'
|
'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 AdminLanguage = 'en' | 'fr' | 'ar'
|
||||||
export type AdminTheme = 'light' | 'dark'
|
export type AdminTheme = 'light' | 'dark'
|
||||||
@@ -28,6 +28,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
auditLogs: 'Audit Logs',
|
auditLogs: 'Audit Logs',
|
||||||
adminUsers: 'Admin Users',
|
adminUsers: 'Admin Users',
|
||||||
billing: 'Billing',
|
billing: 'Billing',
|
||||||
|
pricing: 'Pricing',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
menuManagement: 'Menu Management',
|
||||||
},
|
},
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
@@ -48,6 +51,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
auditLogs: "Journaux d'audit",
|
auditLogs: "Journaux d'audit",
|
||||||
adminUsers: 'Utilisateurs admin',
|
adminUsers: 'Utilisateurs admin',
|
||||||
billing: 'Facturation',
|
billing: 'Facturation',
|
||||||
|
pricing: 'Tarification',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
menuManagement: 'Gestion du menu',
|
||||||
},
|
},
|
||||||
logout: 'Déconnexion',
|
logout: 'Déconnexion',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
@@ -68,6 +74,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
auditLogs: 'سجلات التدقيق',
|
auditLogs: 'سجلات التدقيق',
|
||||||
adminUsers: 'مستخدمو الإدارة',
|
adminUsers: 'مستخدمو الإدارة',
|
||||||
billing: 'الفوترة',
|
billing: 'الفوترة',
|
||||||
|
pricing: 'الأسعار',
|
||||||
|
notifications: 'الإشعارات',
|
||||||
|
menuManagement: 'إدارة القوائم',
|
||||||
},
|
},
|
||||||
logout: 'تسجيل الخروج',
|
logout: 'تسجيل الخروج',
|
||||||
language: 'اللغة',
|
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 = {
|
type AdminI18nContext = {
|
||||||
language: AdminLanguage
|
language: AdminLanguage
|
||||||
setLanguage: (value: AdminLanguage) => void
|
setLanguage: (value: AdminLanguage) => void
|
||||||
@@ -93,11 +110,14 @@ const Context = createContext<AdminI18nContext | null>(null)
|
|||||||
|
|
||||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
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(() => {
|
useEffect(() => {
|
||||||
const stored = window.localStorage.getItem('admin-language')
|
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') {
|
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||||
setLanguage(stored)
|
setLanguage(stored)
|
||||||
}
|
}
|
||||||
@@ -115,6 +135,10 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.lang = language
|
document.documentElement.lang = language
|
||||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||||
|
if (skipFirstLangWrite.current) {
|
||||||
|
skipFirstLangWrite.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
window.localStorage.setItem('admin-language', language)
|
window.localStorage.setItem('admin-language', language)
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
@@ -123,6 +147,8 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
document.documentElement.classList.add(theme)
|
document.documentElement.classList.add(theme)
|
||||||
document.documentElement.style.colorScheme = theme
|
document.documentElement.style.colorScheme = theme
|
||||||
document.body.dataset.theme = 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)
|
window.localStorage.setItem('admin-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
@@ -140,67 +166,146 @@ export function useAdminI18n() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminLanguageSwitcher() {
|
export function AdminLanguageSwitcher() {
|
||||||
const { language, setLanguage, dict } = useAdminI18n()
|
const { language, setLanguage } = useAdminI18n()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
const [embedded, setEmbedded] = useState(false)
|
const [embedded, setEmbedded] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEmbedded(window.self !== window.top)
|
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
|
if (embedded) return null
|
||||||
|
|
||||||
|
const current = languageMeta[language]
|
||||||
|
|
||||||
return (
|
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">
|
<div ref={ref} className="relative flex-1">
|
||||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
|
<button
|
||||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
type="button"
|
||||||
const active = value === language
|
onClick={() => setOpen((o) => !o)}
|
||||||
return (
|
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]"
|
||||||
<button
|
>
|
||||||
key={value}
|
<span className="flex items-center gap-1.5">
|
||||||
type="button"
|
<span aria-hidden="true">{current.flag}</span>
|
||||||
onClick={() => setLanguage(value)}
|
<span>{current.shortLabel}</span>
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
</span>
|
||||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
<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>
|
||||||
{value.toUpperCase()}
|
</button>
|
||||||
</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); 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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{meta.flag}</span>
|
||||||
|
<span>{meta.shortLabel}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminThemeSwitcher() {
|
export function AdminThemeSwitcher() {
|
||||||
const { theme, setTheme, dict } = useAdminI18n()
|
const { theme, setTheme, dict } = useAdminI18n()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
const [embedded, setEmbedded] = useState(false)
|
const [embedded, setEmbedded] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEmbedded(window.self !== window.top)
|
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
|
if (embedded) return null
|
||||||
|
|
||||||
return (
|
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">
|
<div ref={ref} className="relative flex-1">
|
||||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
<button
|
||||||
{dict.theme}
|
type="button"
|
||||||
</span>
|
onClick={() => setOpen((o) => !o)}
|
||||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
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]"
|
||||||
const active = value === theme
|
>
|
||||||
return (
|
<span className="flex items-center gap-1.5">
|
||||||
<button
|
{theme === 'light' ? (
|
||||||
key={value}
|
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
type="button"
|
<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" />
|
||||||
onClick={() => setTheme(value)}
|
</svg>
|
||||||
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'
|
<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>
|
||||||
{value === 'light' ? dict.light : dict.dark}
|
)}
|
||||||
</button>
|
<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); 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' ? (
|
||||||
|
<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>
|
</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,33 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type WithChildren = { children?: React.ReactNode }
|
||||||
|
|
||||||
|
function childTypes(node: React.ReactElement<WithChildren>): 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'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import React from "react";
|
||||||
import {
|
|
||||||
AdminLanguageSwitcher,
|
import PublicFooter from '@/components/PublicFooter'
|
||||||
AdminThemeSwitcher,
|
import PublicHeader from '@/components/PublicHeader'
|
||||||
useAdminI18n,
|
|
||||||
} from '@/components/I18nProvider'
|
|
||||||
|
|
||||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
<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">
|
||||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
|
<PublicHeader />
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
<div className="flex flex-1 flex-col">{children}</div>
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<PublicFooter />
|
||||||
<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>
|
</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 =
|
export const ADMIN_API_BASE =
|
||||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
typeof window === 'undefined'
|
||||||
?? process.env.NEXT_PUBLIC_API_URL
|
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
||||||
?? '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> {
|
export async function adminFetch<T>(path: string, _token?: string): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
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 {
|
export function resolveBrowserAppUrl(fallback: string): string {
|
||||||
if (typeof window === 'undefined') return fallback
|
if (typeof window === 'undefined') return fallback
|
||||||
|
const h = window.location.hostname
|
||||||
|
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = new URL(fallback)
|
const target = new URL(fallback)
|
||||||
target.protocol = window.location.protocol
|
target.protocol = window.location.protocol
|
||||||
target.hostname = window.location.hostname
|
target.hostname = h
|
||||||
|
target.port = ''
|
||||||
return target.toString().replace(/\/$/, '')
|
return target.toString().replace(/\/$/, '')
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,13 +15,27 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"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
|
||||||
+30
-11
@@ -3,10 +3,24 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"build": "tsc",
|
||||||
|
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||||
|
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||||
"start": "node dist/index.js",
|
"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": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^3.4.3",
|
"@react-pdf/renderer": "^3.4.3",
|
||||||
@@ -17,22 +31,25 @@
|
|||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^8.5.1",
|
"express-rate-limit": "^8.5.1",
|
||||||
"firebase-admin": "^12.1.0",
|
"firebase-admin": "^10.3.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^3.0.3",
|
"next": "16.2.9",
|
||||||
"nodemailer": "^6.9.16",
|
"node-cron": "^4.5.0",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"resend": "^3.2.0",
|
"resend": "^3.2.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"turbo": "2.10.0",
|
||||||
"twilio": "^5.1.0",
|
"twilio": "^5.1.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0",
|
||||||
|
"zod-to-json-schema": "^3.25.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -45,9 +62,11 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.3.1",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cors, { type CorsOptions } 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 accountAuthRouter from './modules/auth/auth.account.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 billingRouter from './modules/billing/billing.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 carplaceRouter from './modules/carplace/carplace.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:3001',
|
||||||
|
'http://localhost:3002',
|
||||||
|
'http://localhost:4000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'http://127.0.0.1:3001',
|
||||||
|
'http://127.0.0.1:3002',
|
||||||
|
'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
|
||||||
|
|
||||||
|
function isAllowedLocalDevOrigin(origin: string) {
|
||||||
|
if (process.env.NODE_ENV === 'production') return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(origin)
|
||||||
|
return (
|
||||||
|
url.protocol === 'http:' &&
|
||||||
|
['localhost', '127.0.0.1'].includes(url.hostname) &&
|
||||||
|
['3000', '3001', '3002', '4000'].includes(url.port)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCorsOriginAllowed(origin: string | undefined) {
|
||||||
|
if (!origin) return true
|
||||||
|
return corsOrigins.includes(origin) || isAllowedLocalDevOrigin(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const corsOptions: CorsOptions = {
|
||||||
|
origin(origin, callback) {
|
||||||
|
callback(null, isCorsOriginAllowed(origin))
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
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}/carplace/home`, description: 'Carplace home' },
|
||||||
|
{ method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' },
|
||||||
|
{ method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' },
|
||||||
|
{ method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' },
|
||||||
|
{ 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(corsOptions)
|
||||||
|
|
||||||
|
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/account`, authLimiter, accountAuthRouter)
|
||||||
|
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}/carplace`, publicLimiter, carplaceRouter)
|
||||||
|
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}/billing`, apiLimiter, billingRouter)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"marketplaceHomepage": {
|
"carplaceHomepage": {
|
||||||
"en": {
|
"en": {
|
||||||
"sections": [
|
"sections": [
|
||||||
"hero",
|
"hero",
|
||||||
@@ -7,31 +7,33 @@
|
|||||||
"pillars",
|
"pillars",
|
||||||
"audiences",
|
"audiences",
|
||||||
"features",
|
"features",
|
||||||
|
"howitworks",
|
||||||
"steps",
|
"steps",
|
||||||
|
"testimonials",
|
||||||
"closing"
|
"closing"
|
||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
"heroTitle": "Carplace 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 Carplace, and every booking ends on the company’s own branded checkout.",
|
||||||
"startTrial": "Start free trial",
|
"startTrial": "Start free trial",
|
||||||
"exploreVehicles": "Explore vehicles",
|
"exploreVehicles": "Explore vehicles",
|
||||||
"surfaceLabel": "Marketplace surface",
|
"surfaceLabel": "Carplace surface",
|
||||||
"surfaceTitle": "Designed for two audiences at once.",
|
"surfaceTitle": "Designed for two audiences at once.",
|
||||||
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
|
"surfaceBody": "Operators need control, renters need confidence. The Carplace should show both without feeling like a template.",
|
||||||
"liveLabel": "Live network",
|
"liveLabel": "Live network",
|
||||||
"trustedFleets": "Trusted fleets",
|
"trustedFleets": "Trusted fleets",
|
||||||
"brandedFlows": "Branded booking flows",
|
"brandedFlows": "Branded booking flows",
|
||||||
"multiTenant": "Multi-tenant operations",
|
"multiTenant": "Multi-tenant operations",
|
||||||
"companyKicker": "Operator workflow",
|
"companyKicker": "Operator workflow",
|
||||||
"companyTitle": "Control inventory, offers, billing, and staff from one command layer.",
|
"companyTitle": "Manage inventory, offers, billing, and staff from one control layer.",
|
||||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||||
"renterKicker": "Renter experience",
|
"renterKicker": "Renter experience",
|
||||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.",
|
"renterBody": "The Carplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||||
"pillars": [
|
"pillars": [
|
||||||
{
|
{
|
||||||
"title": "Unified publishing",
|
"title": "Unified publishing",
|
||||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into Carplace discovery and branded booking pages."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Qualified discovery",
|
"title": "Qualified discovery",
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
"metrics": [
|
"metrics": [
|
||||||
{
|
{
|
||||||
"value": "01",
|
"value": "01",
|
||||||
"label": "Shared marketplace visibility"
|
"label": "Shared Carplace visibility"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "02",
|
"value": "02",
|
||||||
@@ -59,21 +61,40 @@
|
|||||||
"featureLabel": "What the product covers",
|
"featureLabel": "What the product covers",
|
||||||
"features": [
|
"features": [
|
||||||
"Fleet management with multi-photo uploads",
|
"Fleet management with multi-photo uploads",
|
||||||
"Marketplace offers and redirect booking flow",
|
"Carplace offers and redirect booking flow",
|
||||||
"Branded public booking site per company",
|
"Branded public booking site per company",
|
||||||
"Customer CRM, analytics, and billing controls"
|
"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 30-day free trial."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "2",
|
||||||
|
"title": "Add Your Fleet",
|
||||||
|
"description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "3",
|
||||||
|
"title": "Start Selling",
|
||||||
|
"description": "Renters discover your fleet, and bookings flow directly to your branded checkout."
|
||||||
|
}
|
||||||
|
],
|
||||||
"stepsTitle": "How companies launch",
|
"stepsTitle": "How companies launch",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"step": "1",
|
"step": "1",
|
||||||
"title": "Create your company workspace",
|
"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 30-day trial, and verify the owner account."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "2",
|
"step": "2",
|
||||||
"title": "Publish vehicles and offers",
|
"title": "Publish vehicles and offers",
|
||||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
"body": "Upload photos once and control what appears on the Carplace and branded site."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "3",
|
"step": "3",
|
||||||
@@ -83,8 +104,8 @@
|
|||||||
],
|
],
|
||||||
"stepLabel": "Step",
|
"stepLabel": "Step",
|
||||||
"readyKicker": "Ready to launch",
|
"readyKicker": "Ready to launch",
|
||||||
"readyTitle": "A marketplace homepage should sell the system in seconds.",
|
"readyTitle": "The Carplace homepage should explain the product in seconds.",
|
||||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.",
|
"readyBody": "Use the Carplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||||
"viewPricing": "View pricing",
|
"viewPricing": "View pricing",
|
||||||
"createWorkspace": "Create workspace"
|
"createWorkspace": "Create workspace"
|
||||||
},
|
},
|
||||||
@@ -95,31 +116,33 @@
|
|||||||
"pillars",
|
"pillars",
|
||||||
"audiences",
|
"audiences",
|
||||||
"features",
|
"features",
|
||||||
|
"howitworks",
|
||||||
"steps",
|
"steps",
|
||||||
|
"testimonials",
|
||||||
"closing"
|
"closing"
|
||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
"heroTitle": "Une vitrine marketplace plus nette et plus forte.",
|
"heroTitle": "Une vitrine Carplace 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 termine sur le paiement de marque de la société.",
|
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||||
"startTrial": "Commencer l’essai gratuit",
|
"startTrial": "Commencer l’essai gratuit",
|
||||||
"exploreVehicles": "Explorer les véhicules",
|
"exploreVehicles": "Explorer les véhicules",
|
||||||
"surfaceLabel": "Surface marketplace",
|
"surfaceLabel": "Vitrine Carplace",
|
||||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
"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.",
|
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La Carplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||||
"liveLabel": "Réseau actif",
|
"liveLabel": "Réseau actif",
|
||||||
"trustedFleets": "Flottes fiables",
|
"trustedFleets": "Flottes fiables",
|
||||||
"brandedFlows": "Parcours de marque",
|
"brandedFlows": "Parcours de marque",
|
||||||
"multiTenant": "Opérations multi-tenant",
|
"multiTenant": "Opérations multi-tenant",
|
||||||
"companyKicker": "Flux opérateur",
|
"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.",
|
"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",
|
"renterKicker": "Expérience client",
|
||||||
"renterTitle": "Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.",
|
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
||||||
"renterBody": "La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
"renterBody": "La Carplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||||
"pillars": [
|
"pillars": [
|
||||||
{
|
{
|
||||||
"title": "Publication unifiée",
|
"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 Carplace et les pages de réservation de marque."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Découverte qualifiée",
|
"title": "Découverte qualifiée",
|
||||||
@@ -127,13 +150,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Revenus en direct",
|
"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": [
|
"metrics": [
|
||||||
{
|
{
|
||||||
"value": "01",
|
"value": "01",
|
||||||
"label": "Visibilité marketplace partagée"
|
"label": "Visibilité Carplace partagée"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "02",
|
"value": "02",
|
||||||
@@ -141,27 +164,46 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "03",
|
"value": "03",
|
||||||
"label": "Paiements détenus par la société"
|
"label": "Paiements gérés par l’entreprise"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"featureLabel": "Ce que couvre le produit",
|
"featureLabel": "Ce que couvre le produit",
|
||||||
"features": [
|
"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",
|
"Offres Carplace et redirection vers la réservation",
|
||||||
"Site public de réservation par entreprise",
|
"Site public de réservation par entreprise",
|
||||||
"CRM client, analytics et contrôle de facturation"
|
"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 30 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 Carplace."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"stepsTitle": "Comment les entreprises démarrent",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"step": "1",
|
"step": "1",
|
||||||
"title": "Créez votre espace entreprise",
|
"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 30 jours et vérifiez le compte propriétaire."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "2",
|
"step": "2",
|
||||||
"title": "Publiez véhicules et offres",
|
"title": "Publiez véhicules et offres",
|
||||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la Carplace et le site de marque."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "3",
|
"step": "3",
|
||||||
@@ -171,8 +213,8 @@
|
|||||||
],
|
],
|
||||||
"stepLabel": "Étape",
|
"stepLabel": "Étape",
|
||||||
"readyKicker": "Prêt à démarrer",
|
"readyKicker": "Prêt à démarrer",
|
||||||
"readyTitle": "Une homepage marketplace doit vendre le système en quelques secondes.",
|
"readyTitle": "La page d’accueil Carplace doit présenter le produit 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.",
|
"readyBody": "Utilisez la Carplace 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",
|
"viewPricing": "Voir les tarifs",
|
||||||
"createWorkspace": "Créer l’espace"
|
"createWorkspace": "Créer l’espace"
|
||||||
},
|
},
|
||||||
@@ -183,12 +225,14 @@
|
|||||||
"pillars",
|
"pillars",
|
||||||
"audiences",
|
"audiences",
|
||||||
"features",
|
"features",
|
||||||
|
"howitworks",
|
||||||
"steps",
|
"steps",
|
||||||
|
"testimonials",
|
||||||
"closing"
|
"closing"
|
||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
"heroTitle": "واجهة سوق أوضح وأقوى.",
|
"heroTitle": "واجهة سوق أكثر وضوحاً وتأثيراً.",
|
||||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.",
|
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.",
|
||||||
"startTrial": "ابدأ التجربة المجانية",
|
"startTrial": "ابدأ التجربة المجانية",
|
||||||
"exploreVehicles": "استكشف السيارات",
|
"exploreVehicles": "استكشف السيارات",
|
||||||
"surfaceLabel": "واجهة السوق",
|
"surfaceLabel": "واجهة السوق",
|
||||||
@@ -198,24 +242,24 @@
|
|||||||
"trustedFleets": "أساطيل موثوقة",
|
"trustedFleets": "أساطيل موثوقة",
|
||||||
"brandedFlows": "مسارات حجز مخصصة",
|
"brandedFlows": "مسارات حجز مخصصة",
|
||||||
"multiTenant": "عمليات متعددة الشركات",
|
"multiTenant": "عمليات متعددة الشركات",
|
||||||
"companyKicker": "تدفق المشغل",
|
"companyKicker": "سير عمل المشغل",
|
||||||
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
||||||
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
||||||
"renterKicker": "تجربة المستأجر",
|
"renterKicker": "تجربة المستأجر",
|
||||||
"renterTitle": "دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.",
|
"renterTitle": "دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.",
|
||||||
"renterBody": "السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.",
|
"renterBody": "هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.",
|
||||||
"pillars": [
|
"pillars": [
|
||||||
{
|
{
|
||||||
"title": "نشر موحد",
|
"title": "نشر موحد",
|
||||||
"body": "صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
"body": "صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "اكتشاف مؤهل",
|
"title": "اكتشاف مؤهل",
|
||||||
"body": "العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة."
|
"body": "تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "مسار إيراد مباشر",
|
"title": "مسار إيراد مباشر",
|
||||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه."
|
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metrics": [
|
"metrics": [
|
||||||
@@ -232,19 +276,38 @@
|
|||||||
"label": "مدفوعات مملوكة للشركة"
|
"label": "مدفوعات مملوكة للشركة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"featureLabel": "ما الذي يغطيه المنتج",
|
"featureLabel": "ما الذي يقدمه المنتج",
|
||||||
"features": [
|
"features": [
|
||||||
"إدارة الأسطول مع رفع عدة صور",
|
"إدارة الأسطول مع رفع عدة صور",
|
||||||
"عروض السوق والتحويل إلى مسار الحجز",
|
"عروض السوق والتحويل إلى مسار الحجز",
|
||||||
"موقع حجز عام مخصص لكل شركة",
|
"موقع حجز عام مخصص لكل شركة",
|
||||||
"إدارة العملاء والتحليلات والفوترة"
|
"إدارة العملاء والتحليلات والفوترة"
|
||||||
],
|
],
|
||||||
|
"howitworksKicker": "البدء",
|
||||||
|
"howitworksTitle": "كيف يعمل",
|
||||||
|
"howitworksSteps": [
|
||||||
|
{
|
||||||
|
"number": "1",
|
||||||
|
"title": "إنشاء حساب",
|
||||||
|
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "2",
|
||||||
|
"title": "أضف أسطولك",
|
||||||
|
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "3",
|
||||||
|
"title": "ابدأ البيع",
|
||||||
|
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
|
||||||
|
}
|
||||||
|
],
|
||||||
"stepsTitle": "كيف تبدأ الشركات",
|
"stepsTitle": "كيف تبدأ الشركات",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"step": "1",
|
"step": "1",
|
||||||
"title": "أنشئ مساحة شركتك",
|
"title": "أنشئ مساحة عمل شركتك",
|
||||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك."
|
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "2",
|
"step": "2",
|
||||||
@@ -260,7 +323,7 @@
|
|||||||
"stepLabel": "الخطوة",
|
"stepLabel": "الخطوة",
|
||||||
"readyKicker": "جاهز للانطلاق",
|
"readyKicker": "جاهز للانطلاق",
|
||||||
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
||||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.",
|
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.",
|
||||||
"viewPricing": "عرض الأسعار",
|
"viewPricing": "عرض الأسعار",
|
||||||
"createWorkspace": "إنشاء المساحة"
|
"createWorkspace": "إنشاء المساحة"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const res = handle({ code })
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(503)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'database_schema_mismatch',
|
||||||
|
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||||
|
statusCode: 503,
|
||||||
|
requestId: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,57 @@
|
|||||||
|
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.code === 'P2021' || err.code === 'P2022') {
|
||||||
|
console.error('[API Error] Database schema mismatch', { requestId: req.requestId, err })
|
||||||
|
return res.status(503).json(withRequestId(req, {
|
||||||
|
error: 'database_schema_mismatch',
|
||||||
|
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||||
|
statusCode: 503,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 http from 'http'
|
||||||
import { Server as SocketIOServer } from 'socket.io'
|
import { Server as SocketIOServer } from 'socket.io'
|
||||||
|
import type { Socket } from 'socket.io'
|
||||||
import cron from 'node-cron'
|
import cron from 'node-cron'
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { redis } from './lib/redis'
|
import { redis } from './lib/redis'
|
||||||
import { prisma } from './lib/prisma'
|
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 ───────────────────────────────────────────────────
|
const app = createApp()
|
||||||
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 server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
|
assertStorageConfiguration()
|
||||||
// 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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ─── Socket.io ────────────────────────────────────────────────
|
// ─── Socket.io ────────────────────────────────────────────────
|
||||||
const io = new SocketIOServer(server, {
|
const io = new SocketIOServer(server, {
|
||||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const redisMessageSchema = z.object({
|
|
||||||
type: z.string(),
|
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
|
||||||
payload: z.unknown(),
|
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
|
// Authenticate socket connections via JWT before joining user rooms
|
||||||
io.use((socket, next) => {
|
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
|
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||||
try {
|
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
|
;(socket as any).authenticatedUserId = payload.sub
|
||||||
next()
|
next()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -112,140 +79,13 @@ subscriber.psubscribe('notifications:*', (err) => {
|
|||||||
subscriber.on('pmessage', (_pattern, channel, message) => {
|
subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(message)
|
const parsed = JSON.parse(message)
|
||||||
const validated = redisMessageSchema.parse(parsed)
|
|
||||||
const userId = channel.replace('notifications:', '')
|
const userId = channel.replace('notifications:', '')
|
||||||
io.to(`user:${userId}`).emit('notification', validated)
|
io.to(`user:${userId}`).emit('notification', parsed)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Redis] Invalid notification message:', 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 ───────────────────────────────────────────
|
// ─── Scheduled jobs ───────────────────────────────────────────
|
||||||
|
|
||||||
// Daily: flag expiring/expired licenses
|
// 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)
|
// Daily: send trial-ending reminders (3 days before trial end)
|
||||||
cron.schedule('0 9 * * *', async () => {
|
cron.schedule('0 9 * * *', async () => {
|
||||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
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) {
|
for (const sub of subscriptions) {
|
||||||
const owner = sub.company.employees[0]
|
const owner = sub.company.employees[0]
|
||||||
if (owner) {
|
if (owner) {
|
||||||
await prisma.notification.create({
|
await sendNotification({
|
||||||
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' },
|
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 ────────────────────────────────────────────────────
|
// ─── Start ────────────────────────────────────────────────────
|
||||||
const PORT = process.env.API_PORT ?? 4000
|
const PORT = Number(process.env.API_PORT ?? 4000)
|
||||||
server.listen(PORT, () => {
|
const HOST = process.env.API_HOST ?? '0.0.0.0'
|
||||||
console.log(`[API] Server running on port ${PORT}`)
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[API] Server running on ${HOST}:${PORT}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
export { app, io }
|
export { app, io }
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
carplaceReservationEmail,
|
||||||
|
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 Carplace reservation HTML when present', () => {
|
||||||
|
const html = carplaceReservationEmail.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),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Carplace reservation request ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export const carplaceReservationEmail = {
|
||||||
|
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 }
|
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 path from 'path'
|
||||||
import crypto from 'crypto'
|
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 {
|
function getApiBase(): string {
|
||||||
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
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(
|
export async function uploadImage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
folder: string,
|
folder: string,
|
||||||
publicId?: string
|
publicId?: string,
|
||||||
|
visibility: StorageVisibility = inferVisibility(folder),
|
||||||
): Promise<string> {
|
): 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 })
|
fs.mkdirSync(folderPath, { recursive: true })
|
||||||
|
|
||||||
const filename = publicId
|
const filename = publicId
|
||||||
@@ -25,11 +118,25 @@ export async function uploadImage(
|
|||||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
export function resolveStoredFilePath(imageUrl: string): string | null {
|
||||||
const marker = '/storage/'
|
const relative = getStorageRelativePath(imageUrl)
|
||||||
const idx = imageUrl.indexOf(marker)
|
if (!relative) return null
|
||||||
if (idx === -1) return
|
|
||||||
const relative = imageUrl.slice(idx + marker.length)
|
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
|
||||||
const filePath = path.join(STORAGE_ROOT, relative)
|
for (const root of roots) {
|
||||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
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 rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||||
import type { Request } from 'express'
|
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
|
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
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({
|
export const authLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 20,
|
max: 20,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skipSuccessfulRequests: false,
|
skipSuccessfulRequests: true,
|
||||||
keyGenerator: (req) => getClientIpKey(req),
|
keyGenerator: (req) => getClientIpKey(req),
|
||||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
||||||
})
|
})
|
||||||
@@ -23,14 +74,15 @@ export const apiLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
keyGenerator: (req) => {
|
keyGenerator: (req) => {
|
||||||
const ip = getClientIpKey(req)
|
const ip = getClientIpKey(req)
|
||||||
|
const actorKey = getAuthenticatedActorKey(req)
|
||||||
const companyId = (req as any).companyId ?? ''
|
const companyId = (req as any).companyId ?? ''
|
||||||
const renterId = (req as any).renterId ?? ''
|
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 },
|
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Limiter for public marketplace and site endpoints (no auth)
|
// Limiter for public carplace and site endpoints (no auth)
|
||||||
export const publicLimiter = rateLimit({
|
export const publicLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 60,
|
max: 60,
|
||||||
@@ -46,6 +98,29 @@ export const adminLimiter = rateLimit({
|
|||||||
max: 100,
|
max: 100,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
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 },
|
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 },
|
||||||
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user