fix architecture and write new tests
This commit is contained in:
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego
|
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
|
||||||
@@ -15,27 +15,27 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
|||||||
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
|
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
|
||||||
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||||
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||||
JWT_SECRET=dev-secret
|
JWT_SECRET=placeholder
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
ADMIN_SEED_EMAIL=rentaldrivego@gmail.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:4000
|
CORS_ORIGINS=http://localhost:3000,http://localhost:4000
|
||||||
|
|
||||||
# Email - disable Resend (placeholder), use SMTP instead
|
# Email - disable Resend (placeholder), use SMTP instead
|
||||||
RESEND_API_KEY=re_...
|
RESEND_API_KEY=placeholder
|
||||||
EMAIL_FROM=rentaldrivego@gmail.com
|
EMAIL_FROM=rentaldrivego@gmail.com
|
||||||
EMAIL_FROM_NAME=RentalDriveGo
|
EMAIL_FROM_NAME=RentalDriveGo
|
||||||
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=placeholder
|
||||||
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
|
||||||
|
|||||||
@@ -14,20 +14,20 @@ REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
|||||||
# APP_VERSION=latest
|
# APP_VERSION=latest
|
||||||
REGISTRY_HOST=registry.rentaldrivego.ma
|
REGISTRY_HOST=registry.rentaldrivego.ma
|
||||||
REGISTRY_USER=rentaldrivego_registry
|
REGISTRY_USER=rentaldrivego_registry
|
||||||
REGISTRY_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
REGISTRY_PASSWORD=placeholder
|
||||||
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
||||||
REGISTRY_HTTP_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
REGISTRY_HTTP_SECRET=placeholder
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||||
DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego
|
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||||
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
||||||
DATABASE_URL_FROM_POSTGRES=true
|
DATABASE_URL_FROM_POSTGRES=true
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=rentaldrivego
|
POSTGRES_DB=rentaldrivego
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=24DY&1u5FLCkNMeiO_p
|
POSTGRES_PASSWORD=placeholder
|
||||||
|
|
||||||
|
|
||||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -62,7 +62,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin
|
|||||||
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
||||||
|
|
||||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
JWT_SECRET=placeholder
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -78,7 +78,7 @@ 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=placeholder
|
||||||
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
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ POSTGRES_HOST=postgres
|
|||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=rentaldrivego
|
POSTGRES_DB=rentaldrivego
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=placeholder
|
||||||
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
|
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
@@ -38,7 +38,7 @@ ADMIN_URL=https://example.com/admin
|
|||||||
CORS_ORIGINS=https://example.com,https://www.example.com
|
CORS_ORIGINS=https://example.com,https://www.example.com
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
JWT_SECRET=replace-with-a-long-random-secret
|
JWT_SECRET=placeholder
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -57,7 +57,7 @@ MAIL_HOST=smtp.gmail.com
|
|||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_SCHEME=smtp
|
MAIL_SCHEME=smtp
|
||||||
MAIL_USERNAME=your-smtp-user@example.com
|
MAIL_USERNAME=your-smtp-user@example.com
|
||||||
MAIL_PASSWORD=replace-with-an-app-password
|
MAIL_PASSWORD=placeholder
|
||||||
MAIL_FROM_ADDRESS=noreply@example.com
|
MAIL_FROM_ADDRESS=noreply@example.com
|
||||||
MAIL_FROM_NAME=RentalDriveGo
|
MAIL_FROM_NAME=RentalDriveGo
|
||||||
MAIL_REPLY_TO_ADDRESS=support@example.com
|
MAIL_REPLY_TO_ADDRESS=support@example.com
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+11
-11
@@ -4,7 +4,7 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ─── Database ──────────────────────────────────────────────────
|
# ─── Database ──────────────────────────────────────────────────
|
||||||
DATABASE_URL="postgresql://postgres:24DY&1u5FLCkNMeiO_p@localhost:5432/rentaldrivego"
|
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||||
|
|
||||||
# ─── API ───────────────────────────────────────────────────────
|
# ─── API ───────────────────────────────────────────────────────
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
@@ -12,13 +12,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=placeholder
|
||||||
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 +28,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 +43,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=rentaldrivego@gmail.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
|
||||||
@@ -84,7 +84,7 @@ 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.com
|
||||||
ADMIN_SEED_PASSWORD=changeme123
|
ADMIN_SEED_PASSWORD=placeholder
|
||||||
ADMIN_SEED_FIRST_NAME=Super
|
ADMIN_SEED_FIRST_NAME=Super
|
||||||
ADMIN_SEED_LAST_NAME=Admin
|
ADMIN_SEED_LAST_NAME=Admin
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -37,12 +37,21 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
||||||
|
&& groupadd --system --gid 10001 app \
|
||||||
|
&& useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \
|
||||||
|
&& mkdir -p /var/lib/rentaldrivego/storage/public /var/lib/rentaldrivego/storage/private /tmp/rentaldrivego \
|
||||||
|
&& chown -R app:app /app /var/lib/rentaldrivego /tmp/rentaldrivego
|
||||||
|
|
||||||
COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
COPY --from=builder --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 --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"]
|
||||||
|
|
||||||
EXPOSE 3000 3001 3002 4000
|
EXPOSE 3000 3001 3002 4000
|
||||||
|
|
||||||
|
|||||||
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/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,4 +1,27 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
|
const securityHeaders = [
|
||||||
|
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob: https:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' https: wss:",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join('; '),
|
||||||
|
},
|
||||||
|
]
|
||||||
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'
|
const ADMIN_BASE_PATH = '/admin'
|
||||||
@@ -43,6 +66,14 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
transpilePackages: ['@rentaldrivego/types'],
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: securityHeaders,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+12
-9
@@ -10,25 +10,28 @@
|
|||||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||||
"start": "next start -H 0.0.0.0 -p 3002",
|
"start": "next start -H 0.0.0.0 -p 3002",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rentaldrivego/types": "*",
|
"@rentaldrivego/types": "*",
|
||||||
"next": "14.2.3",
|
"autoprefixer": "^10.4.19",
|
||||||
|
"dayjs": "^1.11.11",
|
||||||
|
"lucide-react": "^0.376.0",
|
||||||
|
"next": "^16.2.7",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"autoprefixer": "^10.4.19",
|
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"zod": "^3.23.0",
|
|
||||||
"dayjs": "^1.11.11",
|
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"lucide-react": "^0.376.0"
|
"tailwindcss": "^3.4.3",
|
||||||
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.0",
|
"@types/node": "^20.12.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,10 @@ export default function AuthRedirectPage() {
|
|||||||
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 = resolveAdminNextPath(params.get('next'))
|
||||||
|
|
||||||
if (token) {
|
// Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie.
|
||||||
localStorage.setItem('admin_token', token)
|
|
||||||
// Clear the token from the URL
|
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
}
|
|
||||||
|
|
||||||
window.location.replace(next)
|
window.location.replace(next)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -33,13 +33,11 @@ export default function AdminUsersPage() {
|
|||||||
const [form, setForm] = useState(EMPTY_FORM)
|
const [form, setForm] = useState(EMPTY_FORM)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
|
||||||
|
|
||||||
async function fetchAdmins() {
|
async function fetchAdmins() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_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')
|
||||||
@@ -97,7 +95,8 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
||||||
method: isEditing ? 'PATCH' : 'POST',
|
method: isEditing ? 'PATCH' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ 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(`${ADMIN_API_BASE}/admin/audit-logs`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|||||||
@@ -467,14 +467,13 @@ export default function AdminBillingPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const token = localStorage.getItem('admin_token')
|
|
||||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
if (res.headers.get('content-type')?.includes('application/pdf')) {
|
if (res.headers.get('content-type')?.includes('application/pdf')) {
|
||||||
|
|||||||
@@ -207,10 +207,6 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
|
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
|
||||||
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
|
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
|
||||||
|
|
||||||
function getToken() {
|
|
||||||
return localStorage.getItem('admin_token') ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDateInput(value: string | null | undefined) {
|
function toDateInput(value: string | null | undefined) {
|
||||||
return value ? new Date(value).toISOString().slice(0, 10) : ''
|
return value ? new Date(value).toISOString().slice(0, 10) : ''
|
||||||
}
|
}
|
||||||
@@ -324,11 +320,10 @@ export default function AdminCompanyDetailPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const headers = { Authorization: `Bearer ${getToken()}` }
|
|
||||||
try {
|
try {
|
||||||
const [cRes, aRes] = await Promise.all([
|
const [cRes, aRes] = await Promise.all([
|
||||||
fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }),
|
fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { cache: 'no-store', credentials: 'include' }),
|
||||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }),
|
fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { cache: 'no-store', credentials: 'include' }),
|
||||||
])
|
])
|
||||||
const cJson = await cRes.json()
|
const cJson = await cRes.json()
|
||||||
const aJson = await aRes.json()
|
const aJson = await aRes.json()
|
||||||
@@ -339,7 +334,7 @@ export default function AdminCompanyDetailPage() {
|
|||||||
|
|
||||||
const subId = cJson.data?.subscription?.id
|
const subId = cJson.data?.subscription?.id
|
||||||
if (subId) {
|
if (subId) {
|
||||||
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' })
|
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { cache: 'no-store', credentials: 'include' })
|
||||||
const eJson = await eRes.json()
|
const eJson = await eRes.json()
|
||||||
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
|
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
|
||||||
}
|
}
|
||||||
@@ -365,7 +360,8 @@ export default function AdminCompanyDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
company: {
|
company: {
|
||||||
name: form.company.name,
|
name: form.company.name,
|
||||||
@@ -469,7 +465,8 @@ export default function AdminCompanyDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ reason: subOverrideReason }),
|
body: JSON.stringify({ reason: subOverrideReason }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@@ -490,7 +487,8 @@ export default function AdminCompanyDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_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', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@@ -508,7 +506,7 @@ export default function AdminCompanyDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Delete failed')
|
if (!res.ok) throw new Error(json?.message ?? 'Delete failed')
|
||||||
|
|||||||
@@ -84,10 +84,9 @@ 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(`${ADMIN_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()
|
||||||
@@ -111,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(`${ADMIN_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')
|
||||||
|
|||||||
@@ -25,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 }) {
|
||||||
@@ -57,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(`${ADMIN_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 {
|
||||||
@@ -126,7 +125,7 @@ export default function ContainersPage() {
|
|||||||
|
|
||||||
const fetchContainers = useCallback(async () => {
|
const fetchContainers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_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.')
|
||||||
@@ -151,7 +150,7 @@ export default function ContainersPage() {
|
|||||||
setProvisioning(true)
|
setProvisioning(true)
|
||||||
setProvisionResults(null)
|
setProvisionResults(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_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 ?? [])
|
||||||
@@ -172,7 +171,7 @@ export default function ContainersPage() {
|
|||||||
action === 'remove'
|
action === 'remove'
|
||||||
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
||||||
: `${ADMIN_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.`)
|
||||||
|
|||||||
@@ -38,16 +38,30 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
|||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('admin_token')
|
let cancelled = false
|
||||||
if (!token) {
|
|
||||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
fetch(`${process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1'}/admin/auth/me`, {
|
||||||
} else {
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (response.ok) {
|
||||||
setReady(true)
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,20 +86,15 @@ const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
|||||||
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
|
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
'mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-950 outline-none focus:border-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
|
'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-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
|
const LABEL = 'text-xs font-medium uppercase tracking-wider text-zinc-500'
|
||||||
|
|
||||||
function getToken() {
|
|
||||||
return typeof window === 'undefined' ? '' : localStorage.getItem('admin_token') ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...(options?.headers ?? {}),
|
...(options?.headers ?? {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -148,13 +143,13 @@ function formFromItem(item: MenuItem): FormState {
|
|||||||
function chip(text: string, tone = 'default') {
|
function chip(text: string, tone = 'default') {
|
||||||
const toneClass =
|
const toneClass =
|
||||||
tone === 'success'
|
tone === 'success'
|
||||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300'
|
? 'bg-emerald-950/40 text-emerald-400'
|
||||||
: tone === 'warn'
|
: tone === 'warn'
|
||||||
? 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-300'
|
? 'bg-orange-950/40 text-orange-400'
|
||||||
: 'border-stone-200 bg-stone-50 text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
|
: 'bg-zinc-800 text-zinc-300'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -347,8 +342,8 @@ export default function MenuManagementPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10">
|
<div className="shell py-8">
|
||||||
<div className="rounded-3xl border border-stone-200/80 bg-white/80 p-8 text-sm text-stone-500 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/70 dark:text-slate-400">
|
<div className="panel p-8 text-sm text-zinc-500">
|
||||||
Loading menu management…
|
Loading menu management…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,20 +351,20 @@ export default function MenuManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 px-8 py-10 text-stone-900 dark:text-white">
|
<div className="shell py-8 space-y-6 text-zinc-100">
|
||||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-8 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
<section className="panel p-8">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-300">Platform Navigation</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-blue-950 dark:text-white">Menu Management</h1>
|
<h1 className="mt-1 text-3xl font-black">Menu Management</h1>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-stone-500 dark:text-slate-400">
|
<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.
|
Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startCreate}
|
onClick={startCreate}
|
||||||
className="rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-500"
|
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
|
Create menu item
|
||||||
</button>
|
</button>
|
||||||
@@ -378,62 +373,62 @@ export default function MenuManagementPage() {
|
|||||||
|
|
||||||
{(error || success) && (
|
{(error || success) && (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
{error && <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-300">{error}</div>}
|
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||||
{success && <div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-900/20 dark:text-emerald-300">{success}</div>}
|
{success && <div className="panel p-4 text-sm text-emerald-400">{success}</div>}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
|
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
|
||||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
<section className="panel overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Menu items</h2>
|
<h2 className="px-5 pt-5 text-xl font-semibold text-zinc-100">Menu items</h2>
|
||||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{items.length} configured items</p>
|
<p className="px-5 pb-1 pt-1 text-sm text-zinc-400">{items.length} configured items</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-left text-sm">
|
<table className="min-w-full text-left text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stone-200 dark:border-blue-900">
|
<tr className="border-b border-zinc-800">
|
||||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Item</th>
|
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Item</th>
|
||||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Type</th>
|
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</th>
|
||||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Assignments</th>
|
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Assignments</th>
|
||||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Status</th>
|
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Actions</th>
|
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-zinc-800/60">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<tr key={item.id} className="border-b border-stone-100 align-top last:border-b-0 dark:border-blue-900/60">
|
<tr key={item.id} className="align-top transition-colors hover:bg-zinc-800/30">
|
||||||
<td className="px-3 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold text-blue-950 dark:text-white">{item.label}</span>
|
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||||
{item.isRequired && chip('Required', 'warn')}
|
{item.isRequired && chip('Required', 'warn')}
|
||||||
{item.systemKey && chip(item.systemKey)}
|
{item.systemKey && chip(item.systemKey)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-stone-500 dark:text-slate-400">
|
<p className="text-xs text-zinc-500">
|
||||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||||
{item.parent ? ` • child of ${item.parent.label}` : ''}
|
{item.parent ? ` • child of ${item.parent.label}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4 text-xs text-stone-600 dark:text-slate-300">{item.itemType}</td>
|
<td className="px-5 py-4 text-xs text-zinc-300">{item.itemType}</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{item.subscriptionAssignments.length > 0
|
{item.subscriptionAssignments.length > 0
|
||||||
? item.subscriptionAssignments.map((assignment) => (
|
? item.subscriptionAssignments.map((assignment) => (
|
||||||
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
|
<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}
|
{assignment.plan}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
: <span className="text-xs text-stone-400 dark:text-slate-500">No plan assignments</span>}
|
: <span className="text-xs text-zinc-500">No plan assignments</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{item.companyAssignments.slice(0, 3).map((assignment) => (
|
{item.companyAssignments.slice(0, 3).map((assignment) => (
|
||||||
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
|
<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}
|
{assignment.company.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -441,25 +436,25 @@ export default function MenuManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{item.roleVisibilities.map((entry) => (
|
{item.roleVisibilities.map((entry) => (
|
||||||
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
|
<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}
|
{entry.role}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-5 py-4">
|
||||||
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
|
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button type="button" onClick={() => startEdit(item)} className="rounded-full border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:border-orange-400 hover:text-orange-600 dark:border-blue-800 dark:text-slate-300">
|
<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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => toggleStatus(item)} className="rounded-full border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:border-orange-400 hover:text-orange-600 dark:border-blue-800 dark:text-slate-300">
|
<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'}
|
{item.isActive ? 'Disable' : 'Enable'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => removeItem(item)} className="rounded-full border border-red-200 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-900/60 dark:text-red-300 dark:hover:bg-red-900/20">
|
<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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,16 +466,16 @@ export default function MenuManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
<section className="panel p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
<h2 className="text-xl font-semibold text-zinc-100">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
||||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
|
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-600 hover:text-orange-500">
|
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-400 hover:text-orange-300">
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -537,18 +532,18 @@ export default function MenuManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
<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)} />
|
<input type="checkbox" checked={form.isActive} onChange={(e) => updateForm('isActive', e.target.checked)} />
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
<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)} />
|
<input type="checkbox" checked={form.openInNewTab} onChange={(e) => updateForm('openInNewTab', e.target.checked)} />
|
||||||
New tab
|
New tab
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
<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)} />
|
<input type="checkbox" checked={form.isRequired} onChange={(e) => updateForm('isRequired', e.target.checked)} />
|
||||||
Required system item
|
Required system item
|
||||||
</label>
|
</label>
|
||||||
@@ -557,7 +552,7 @@ export default function MenuManagementPage() {
|
|||||||
<p className={LABEL}>Role visibility</p>
|
<p className={LABEL}>Role visibility</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{ROLES.map((role) => (
|
{ROLES.map((role) => (
|
||||||
<label key={role} className="flex items-center gap-2 rounded-full border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
<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)} />
|
<input type="checkbox" checked={form.roles.includes(role)} onChange={() => toggleArrayValue('roles', role)} />
|
||||||
{role}
|
{role}
|
||||||
</label>
|
</label>
|
||||||
@@ -569,7 +564,7 @@ export default function MenuManagementPage() {
|
|||||||
<p className={LABEL}>Subscription plans</p>
|
<p className={LABEL}>Subscription plans</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{PLANS.map((plan) => (
|
{PLANS.map((plan) => (
|
||||||
<label key={plan} className="flex items-center gap-2 rounded-full border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
<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)} />
|
<input type="checkbox" checked={form.plans.includes(plan)} onChange={() => toggleArrayValue('plans', plan)} />
|
||||||
{plan}
|
{plan}
|
||||||
</label>
|
</label>
|
||||||
@@ -579,14 +574,14 @@ export default function MenuManagementPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className={LABEL}>Company-specific assignments</p>
|
<p className={LABEL}>Company-specific assignments</p>
|
||||||
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-stone-200 p-3 dark:border-blue-800">
|
<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) => (
|
{companies.map((company) => (
|
||||||
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm hover:bg-stone-50 dark:hover:bg-[#07101e]">
|
<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">
|
<span className="flex items-center gap-2">
|
||||||
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
|
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
|
||||||
<span>{company.name}</span>
|
<span>{company.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-stone-400 dark:text-slate-500">
|
<span className="text-xs text-zinc-500">
|
||||||
{company.subscription?.plan ?? 'No plan'} • {company.status}
|
{company.subscription?.plan ?? 'No plan'} • {company.status}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -597,7 +592,7 @@ export default function MenuManagementPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
|
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'}
|
{saving ? 'Saving…' : editingId ? 'Update menu item' : 'Create menu item'}
|
||||||
</button>
|
</button>
|
||||||
@@ -606,11 +601,11 @@ export default function MenuManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
|
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
|
||||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
<section className="panel p-6">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Preview company menu</h2>
|
<h2 className="text-xl font-semibold text-zinc-100">Preview company menu</h2>
|
||||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
|
<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.
|
Test the generated menu for a company and role before troubleshooting or saving follow-up changes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -632,7 +627,7 @@ export default function MenuManagementPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={runPreview}
|
onClick={runPreview}
|
||||||
disabled={previewing || !previewCompanyId}
|
disabled={previewing || !previewCompanyId}
|
||||||
className="rounded-full bg-blue-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-blue-900 disabled:opacity-60 dark:bg-orange-600 dark:hover:bg-orange-500"
|
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'}
|
{previewing ? 'Previewing…' : 'Run preview'}
|
||||||
</button>
|
</button>
|
||||||
@@ -640,25 +635,25 @@ export default function MenuManagementPage() {
|
|||||||
|
|
||||||
{preview && (
|
{preview && (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<div className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
|
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||||
<p className="text-sm font-semibold text-blue-950 dark:text-white">{preview.company.name}</p>
|
<p className="text-sm font-semibold text-zinc-100">{preview.company.name}</p>
|
||||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status}
|
Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{preview.items.map((item) => (
|
{preview.items.map((item) => (
|
||||||
<div key={item.id} className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
|
<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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold text-blue-950 dark:text-white">{item.label}</span>
|
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||||
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
|
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
|
||||||
{chip(item.source === 'none' ? 'unassigned' : item.source)}
|
{chip(item.source === 'none' ? 'unassigned' : item.source)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 space-y-1 text-sm text-stone-600 dark:text-slate-300">
|
<ul className="mt-3 space-y-1 text-sm text-zinc-300">
|
||||||
{item.reasons.map((reason, index) => (
|
{item.reasons.map((reason, index) => (
|
||||||
<li key={`${item.id}-${index}`}>{reason}</li>
|
<li key={`${item.id}-${index}`}>{reason}</li>
|
||||||
))}
|
))}
|
||||||
@@ -670,22 +665,22 @@ export default function MenuManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
<section className="panel p-6">
|
||||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Recent menu audit activity</h2>
|
<h2 className="text-xl font-semibold text-zinc-100">Recent menu audit activity</h2>
|
||||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
Recent platform-side changes to menu items and assignments.
|
Recent platform-side changes to menu items and assignments.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{auditLogs.map((entry) => (
|
{auditLogs.map((entry) => (
|
||||||
<div key={entry.id} className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-sm font-semibold text-blue-950 dark:text-white">{entry.action}</p>
|
<p className="text-sm font-semibold text-zinc-100">{entry.action}</p>
|
||||||
<span className="text-xs text-stone-400 dark:text-slate-500">
|
<span className="text-xs text-zinc-500">
|
||||||
{new Date(entry.createdAt).toLocaleString()}
|
{new Date(entry.createdAt).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
{entry.adminUser
|
{entry.adminUser
|
||||||
? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}`
|
? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}`
|
||||||
: 'Unknown admin'}
|
: 'Unknown admin'}
|
||||||
|
|||||||
@@ -56,14 +56,13 @@ export default function AdminNotificationsPage() {
|
|||||||
function load(p: number) {
|
function load(p: number) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const token = localStorage.getItem('admin_token') ?? ''
|
|
||||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||||
if (filterChannel) params.set('channel', filterChannel)
|
if (filterChannel) params.set('channel', filterChannel)
|
||||||
if (filterStatus) params.set('status', filterStatus)
|
if (filterStatus) params.set('status', filterStatus)
|
||||||
if (filterCompany) params.set('companyId', filterCompany)
|
if (filterCompany) params.set('companyId', filterCompany)
|
||||||
|
|
||||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ export default function AdminDashboardPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('admin_token') ?? ''
|
|
||||||
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ function emptyPlanFeatureForm(plan = PLANS[0]): PlanFeatureForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
function authHeaders(): Record<string, string> {
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null
|
return {}
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortFeatures(list: PlanFeature[]) {
|
function sortFeatures(list: PlanFeature[]) {
|
||||||
@@ -315,7 +314,7 @@ function PlanFeaturesSection() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders() })
|
fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders(), credentials: 'include' })
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
const json = await r.json()
|
const json = await r.json()
|
||||||
if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features')
|
if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features')
|
||||||
@@ -377,6 +376,7 @@ function PlanFeaturesSection() {
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: isEdit ? 'PATCH' : 'POST',
|
method: isEdit ? 'PATCH' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(result.payload),
|
body: JSON.stringify(result.payload),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@@ -398,6 +398,7 @@ function PlanFeaturesSection() {
|
|||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/features/${id}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/features/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Delete failed')
|
if (!res.ok) throw new Error('Delete failed')
|
||||||
setFeatures((prev) => prev.filter((feature) => feature.id !== id))
|
setFeatures((prev) => prev.filter((feature) => feature.id !== id))
|
||||||
@@ -712,7 +713,7 @@ function PromotionsSection() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders() })
|
fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders(), credentials: 'include' })
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
const json = await r.json()
|
const json = await r.json()
|
||||||
if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions')
|
if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions')
|
||||||
@@ -795,6 +796,7 @@ function PromotionsSection() {
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: isEdit ? 'PATCH' : 'POST',
|
method: isEdit ? 'PATCH' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(result.payload),
|
body: JSON.stringify(result.payload),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@@ -816,6 +818,7 @@ function PromotionsSection() {
|
|||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ isActive: !promo.isActive }),
|
body: JSON.stringify({ isActive: !promo.isActive }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@@ -829,6 +832,7 @@ function PromotionsSection() {
|
|||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${id}`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Delete failed')
|
if (!res.ok) throw new Error('Delete failed')
|
||||||
setPromotions((prev) => prev.filter((p) => p.id !== id))
|
setPromotions((prev) => prev.filter((p) => p.id !== id))
|
||||||
@@ -1005,6 +1009,7 @@ export default function PricingPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -1037,6 +1042,7 @@ export default function PricingPage() {
|
|||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ entries }),
|
body: JSON.stringify({ entries }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
|
|||||||
@@ -70,13 +70,11 @@ 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(`${ADMIN_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')
|
||||||
@@ -105,7 +103,7 @@ export default function AdminRentersPage() {
|
|||||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
const endpoint = isBlocked ? 'unblock' : 'block'
|
||||||
const res = await fetch(`${ADMIN_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()
|
||||||
|
|||||||
@@ -183,15 +183,14 @@ 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(`${ADMIN_API_BASE}/admin/companies`, {
|
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -276,15 +275,13 @@ 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(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-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()
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ export default function AdminForgotPasswordPage() {
|
|||||||
const res = await fetch(`${ADMIN_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()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ 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:3000/dashboard',
|
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||||
requestHeaders.get('host'),
|
requestHeaders.get('host'),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function AdminResetPasswordContent() {
|
|||||||
const res = await fetch(`${ADMIN_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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React, { isValidElement } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/components/PublicHeader', () => ({ default: function MockAdminHeader() { return React.createElement('admin-header') } }))
|
||||||
|
vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter() { return React.createElement('admin-footer') } }))
|
||||||
|
|
||||||
|
import PublicShell from './PublicShell'
|
||||||
|
|
||||||
|
function childTypes(node: React.ReactElement): string[] {
|
||||||
|
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||||
|
const type = child.type as any
|
||||||
|
if (typeof type === 'string') return type
|
||||||
|
return type.displayName ?? type.name ?? 'anonymous'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin PublicShell', () => {
|
||||||
|
it('always renders the admin public chrome around children', () => {
|
||||||
|
const shell = PublicShell({ children: React.createElement('section', { id: 'login' }) })
|
||||||
|
|
||||||
|
expect(childTypes(shell)).toEqual(['MockAdminHeader', 'div', 'MockAdminFooter'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a flex column root so footer placement stays stable', () => {
|
||||||
|
const shell = PublicShell({ children: React.createElement('section') })
|
||||||
|
|
||||||
|
expect(shell.props.className).toContain('flex')
|
||||||
|
expect(shell.props.className).toContain('min-h-screen')
|
||||||
|
expect(shell.props.className).toContain('flex-col')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,10 +3,10 @@ export const ADMIN_API_BASE =
|
|||||||
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
||||||
: (process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1')
|
: (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(`${ADMIN_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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/rentaldrivego_test
|
DATABASE_URL=postgresql://user:replace-with-password@host:5432/db
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
JWT_SECRET=test-secret
|
JWT_SECRET=replace-with-64-byte-random-secret
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
NODE_ENV=test
|
NODE_ENV=test
|
||||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||||
|
|||||||
@@ -16,7 +16,11 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"pretest:integration": "npm run build --workspace @rentaldrivego/types",
|
"pretest:integration": "npm run build --workspace @rentaldrivego/types",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
"test:integration:watch": "vitest --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",
|
||||||
@@ -32,9 +36,9 @@
|
|||||||
"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",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^8.0.10",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
+41
-11
@@ -4,8 +4,9 @@ import helmet from 'helmet'
|
|||||||
import morgan from 'morgan'
|
import morgan from 'morgan'
|
||||||
import swaggerUi from 'swagger-ui-express'
|
import swaggerUi from 'swagger-ui-express'
|
||||||
import { openApiDocument } from './swagger/openapi'
|
import { openApiDocument } from './swagger/openapi'
|
||||||
import { getLegacyStorageRoots, getStorageRoot } from './lib/storage'
|
import { getPublicStorageRoot } from './lib/storage'
|
||||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||||
|
import { requestIdMiddleware } from './middleware/requestId'
|
||||||
|
|
||||||
// ─── Module routes ────────────────────────────────────────────
|
// ─── Module routes ────────────────────────────────────────────
|
||||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||||
@@ -78,6 +79,15 @@ export function createApp() {
|
|||||||
app.set('trust proxy', 1)
|
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)
|
app.use(corsMiddleware)
|
||||||
|
|
||||||
// Customer identity documents must never be anonymously retrievable from the
|
// Customer identity documents must never be anonymously retrievable from the
|
||||||
@@ -95,24 +105,43 @@ export function createApp() {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Serve uploaded assets from the configured storage root, with a legacy fallback
|
// Serve only explicitly public uploaded assets. Private documents are resolved
|
||||||
// for older files that were written under the previous API-local path.
|
// through authenticated API routes such as /customers/:id/license-image.
|
||||||
app.use('/storage', express.static(getStorageRoot()))
|
app.use('/storage', express.static(getPublicStorageRoot()))
|
||||||
for (const legacyRoot of getLegacyStorageRoots()) {
|
|
||||||
app.use('/storage', express.static(legacyRoot))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swagger UI — mounted before helmet so its assets are not blocked by CSP
|
// 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.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
|
||||||
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
|
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
|
||||||
|
|
||||||
// Webhook must use raw body BEFORE express.json()
|
// Webhooks must use raw body BEFORE express.json(); signature verification
|
||||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
// 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
|
// Let /storage responses manage CORP explicitly so missing files still return
|
||||||
// a normal cross-origin 404 instead of being blocked by Helmet's default
|
// a normal cross-origin 404 instead of being blocked by Helmet's default
|
||||||
// same-origin policy.
|
// same-origin policy. Keep CSP explicit instead of letting browser security
|
||||||
app.use(helmet({ crossOriginResourcePolicy: false }))
|
// 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'))
|
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
|
||||||
app.use(express.json({ limit: '10mb' }))
|
app.use(express.json({ limit: '10mb' }))
|
||||||
|
|
||||||
@@ -121,6 +150,7 @@ export function createApp() {
|
|||||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||||
|
|
||||||
|
app.use(`${v1}/admin/auth`, authLimiter)
|
||||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||||
|
|
||||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Response } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
|
||||||
|
import { errorMiddleware } from './errorMiddleware'
|
||||||
|
|
||||||
|
function createResponseStub() {
|
||||||
|
const res = {
|
||||||
|
status: vi.fn(),
|
||||||
|
json: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status.mockReturnValue(res)
|
||||||
|
res.json.mockReturnValue(res)
|
||||||
|
|
||||||
|
return res as unknown as Response & typeof res
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(error: unknown) {
|
||||||
|
const res = createResponseStub()
|
||||||
|
errorMiddleware(error, {} as any, res, vi.fn())
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppError subclasses', () => {
|
||||||
|
it.each([
|
||||||
|
[new ValidationError('Bad payload'), 400, 'validation_error'],
|
||||||
|
[new UnauthorizedError(), 401, 'unauthorized'],
|
||||||
|
[new ForbiddenError(), 403, 'forbidden'],
|
||||||
|
[new NotFoundError(), 404, 'not_found'],
|
||||||
|
[new ConflictError(), 409, 'conflict'],
|
||||||
|
])('sets stable status and error code for %s', (error, statusCode, code) => {
|
||||||
|
expect(error).toBeInstanceOf(AppError)
|
||||||
|
expect(error.statusCode).toBe(statusCode)
|
||||||
|
expect(error.error).toBe(code)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('errorMiddleware', () => {
|
||||||
|
it('normalizes Zod errors into validation responses', () => {
|
||||||
|
const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
|
||||||
|
const res = handle(result.success ? new Error('unexpected') : result.error)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
error: 'validation_error',
|
||||||
|
message: 'Invalid request body',
|
||||||
|
statusCode: 400,
|
||||||
|
issues: expect.any(Array),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes Prisma not found errors', () => {
|
||||||
|
const res = handle({ code: 'P2025' })
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'not_found',
|
||||||
|
message: 'Resource not found',
|
||||||
|
statusCode: 404,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes Prisma unique constraint errors', () => {
|
||||||
|
const res = handle({ code: 'P2002' })
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(409)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'conflict',
|
||||||
|
message: 'A resource with this value already exists',
|
||||||
|
statusCode: 409,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves AppError metadata in the response body', () => {
|
||||||
|
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(402)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'payment_required',
|
||||||
|
message: 'Plan required',
|
||||||
|
statusCode: 402,
|
||||||
|
requiredPlan: 'PRO',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a 500 internal error response', () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const res = handle(new Error('boom'))
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined })
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,29 +1,48 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import { AppError } from './index'
|
import { AppError } from './index'
|
||||||
|
|
||||||
export function errorMiddleware(err: any, _req: Request, res: Response, _next: NextFunction) {
|
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') {
|
if (err.name === 'ZodError') {
|
||||||
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
|
return res.status(400).json(withRequestId(req, {
|
||||||
|
error: 'validation_error',
|
||||||
|
message: 'Invalid request body',
|
||||||
|
issues: err.issues,
|
||||||
|
statusCode: 400,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err.code === 'P2025') {
|
if (err.code === 'P2025') {
|
||||||
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
|
return res.status(404).json(withRequestId(req, { error: 'not_found', message: 'Resource not found', statusCode: 404 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err.code === 'P2002') {
|
if (err.code === 'P2002') {
|
||||||
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
|
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
if (err.statusCode >= 500) console.error('[API Error]', err)
|
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
||||||
return res.status(err.statusCode).json({ error: err.error, message: err.message, statusCode: err.statusCode, ...err.data })
|
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 = err.statusCode ?? 500
|
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500
|
||||||
const message = err.message ?? 'Internal server error'
|
const safeStatusCode = statusCode >= 400 && statusCode < 600 ? statusCode : 500
|
||||||
const code = err.code ?? 'internal_error'
|
|
||||||
|
|
||||||
if (statusCode >= 500) console.error('[API Error]', err)
|
if (safeStatusCode >= 500) {
|
||||||
|
console.error('[API Error]', { requestId: req.requestId, err })
|
||||||
res.status(statusCode).json({ error: code, message, statusCode })
|
}
|
||||||
|
|
||||||
|
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,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import path from 'path'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import { ValidationError } from '../errors'
|
import { ValidationError } from '../errors'
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
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.
|
* Shared multer instance used by all upload endpoints.
|
||||||
@@ -9,26 +16,68 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
|||||||
*/
|
*/
|
||||||
export const imageUpload = multer({
|
export const imageUpload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: MAX_FILE_SIZE },
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MIME type.
|
* Asserts that a file was provided and is an image by content, not by client claims.
|
||||||
* Call inside the route handler after the multer middleware runs.
|
|
||||||
*/
|
*/
|
||||||
export function assertImageFile(
|
export function assertImageFile(
|
||||||
file: Express.Multer.File | undefined,
|
file: Express.Multer.File | undefined,
|
||||||
fieldLabel = 'file',
|
fieldLabel = 'file',
|
||||||
): asserts file is Express.Multer.File {
|
): asserts file is Express.Multer.File {
|
||||||
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
|
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
|
||||||
if (!file.mimetype.startsWith('image/')) throw new ValidationError('Only image uploads are supported')
|
assertSafeImageContent(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that at least one file was provided and all files are images.
|
* Asserts that at least one file was provided and all files are image content.
|
||||||
*/
|
*/
|
||||||
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
|
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`)
|
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
|
||||||
const nonImage = files.find((f) => !f.mimetype.startsWith('image/'))
|
for (const file of files) assertSafeImageContent(file)
|
||||||
if (nonImage) throw new ValidationError(`All uploaded files must be images — "${nonImage.originalname}" is not`)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-3
@@ -1,11 +1,13 @@
|
|||||||
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 { redis } from './lib/redis'
|
import { redis } from './lib/redis'
|
||||||
import { prisma } from './lib/prisma'
|
import { prisma } from './lib/prisma'
|
||||||
import { assertStorageConfiguration } from './lib/storage'
|
import { assertStorageConfiguration } from './lib/storage'
|
||||||
import { createApp, corsOrigins } from './app'
|
import { createApp, corsOrigins } from './app'
|
||||||
|
import { verifyAnyActorToken } from './security/tokens'
|
||||||
|
import { getSessionCookieName } from './security/sessionCookies'
|
||||||
import { sendNotification } from './services/notificationService'
|
import { sendNotification } from './services/notificationService'
|
||||||
import {
|
import {
|
||||||
runTrialExpirationJob,
|
runTrialExpirationJob,
|
||||||
@@ -24,12 +26,37 @@ const io = new SocketIOServer(server, {
|
|||||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
|
||||||
|
if (!cookieHeader) return null
|
||||||
|
|
||||||
|
for (const chunk of cookieHeader.split(';')) {
|
||||||
|
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||||
|
if (rawName === name) return decodeURIComponent(rawValue.join('='))
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocketSessionToken(socket: Socket): string | undefined {
|
||||||
|
const explicitToken = socket.handshake.auth?.token
|
||||||
|
if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim()
|
||||||
|
|
||||||
|
const cookieHeader = socket.request.headers.cookie
|
||||||
|
return (
|
||||||
|
readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ??
|
||||||
|
readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ??
|
||||||
|
readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ??
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate socket connections via JWT before joining user rooms
|
// 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 {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
marketplaceReservationEmail,
|
||||||
|
resetPasswordEmail,
|
||||||
|
signupEmail,
|
||||||
|
} from './emailTranslations'
|
||||||
|
|
||||||
|
describe('emailTranslations', () => {
|
||||||
|
const trialEnd = new Date('2026-07-15T12:00:00.000Z')
|
||||||
|
|
||||||
|
it('renders signup subjects in every supported locale', () => {
|
||||||
|
expect(signupEmail.subject('en')).toContain('workspace')
|
||||||
|
expect(signupEmail.subject('fr')).toContain('espace')
|
||||||
|
expect(signupEmail.subject('ar')).toContain('جاهزة')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders localized signup text with billing and provider details', () => {
|
||||||
|
const text = signupEmail.text({
|
||||||
|
firstName: 'Aya',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
plan: 'PRO',
|
||||||
|
billingPeriod: 'ANNUAL',
|
||||||
|
currency: 'MAD',
|
||||||
|
paymentProvider: 'AmanPay',
|
||||||
|
trialEnd,
|
||||||
|
}, 'fr')
|
||||||
|
|
||||||
|
expect(text).toContain('Bonjour Aya')
|
||||||
|
expect(text).toContain('Atlas Cars')
|
||||||
|
expect(text).toContain('Forfait : PRO (annuel)')
|
||||||
|
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
|
||||||
|
const html = resetPasswordEmail.html('https://example.test/reset/token', 'Mina', 45, 'ar')
|
||||||
|
|
||||||
|
expect(html).toContain('dir="rtl"')
|
||||||
|
expect(html).toContain('https://example.test/reset/token')
|
||||||
|
expect(html).toContain('45')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes optional contact phone in marketplace reservation HTML when present', () => {
|
||||||
|
const html = marketplaceReservationEmail.html({
|
||||||
|
firstName: 'Yassine',
|
||||||
|
vehicleYear: 2024,
|
||||||
|
vehicleMake: 'Dacia',
|
||||||
|
vehicleModel: 'Duster',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
startDate: new Date('2026-08-01T00:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-08-05T00:00:00.000Z'),
|
||||||
|
totalDays: 4,
|
||||||
|
rateDisplay: '400.00',
|
||||||
|
totalDisplay: '1600.00',
|
||||||
|
email: 'yassine@example.test',
|
||||||
|
phone: '+212600000000',
|
||||||
|
}, 'en')
|
||||||
|
|
||||||
|
expect(html).toContain('2024 Dacia Duster')
|
||||||
|
expect(html).toContain('Atlas Cars')
|
||||||
|
expect(html).toContain('yassine@example.test or +212600000000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats dates with the locale-specific formatter', () => {
|
||||||
|
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'en')).toContain('2026')
|
||||||
|
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'fr')).toContain('2026')
|
||||||
|
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toMatch(/2026|٢٠٢٦/)
|
||||||
|
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toContain('فبراير')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,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,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,12 +10,22 @@ const LEGACY_STORAGE_ROOTS = [
|
|||||||
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
|
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type StorageVisibility = 'public' | 'private'
|
||||||
|
|
||||||
export function getStorageRoot(): string {
|
export function getStorageRoot(): string {
|
||||||
return process.env.FILE_STORAGE_ROOT
|
return process.env.FILE_STORAGE_ROOT
|
||||||
? path.resolve(process.env.FILE_STORAGE_ROOT)
|
? path.resolve(process.env.FILE_STORAGE_ROOT)
|
||||||
: DEFAULT_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[] {
|
export function getLegacyStorageRoots(): string[] {
|
||||||
return LEGACY_STORAGE_ROOTS
|
return LEGACY_STORAGE_ROOTS
|
||||||
}
|
}
|
||||||
@@ -45,10 +55,22 @@ export function assertStorageConfiguration(): string {
|
|||||||
return storageRoot
|
return storageRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureStorageRoot(): string {
|
function ensureStorageRoot(visibility: StorageVisibility): string {
|
||||||
const storageRoot = assertStorageConfiguration()
|
assertStorageConfiguration()
|
||||||
fs.mkdirSync(storageRoot, { recursive: true })
|
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
|
||||||
return storageRoot
|
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 {
|
||||||
@@ -77,10 +99,14 @@ export function getProtectedCustomerLicenseImageUrl(customerId: string): string
|
|||||||
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 storageRoot = ensureStorageRoot()
|
const storageRoot = ensureStorageRoot(visibility)
|
||||||
const folderPath = path.join(storageRoot, folder)
|
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
|
||||||
@@ -96,9 +122,10 @@ export function resolveStoredFilePath(imageUrl: string): string | null {
|
|||||||
const relative = getStorageRelativePath(imageUrl)
|
const relative = getStorageRelativePath(imageUrl)
|
||||||
if (!relative) return null
|
if (!relative) return null
|
||||||
|
|
||||||
const roots = [assertStorageConfiguration(), ...getLegacyStorageRoots()]
|
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const filePath = path.join(root, relative)
|
const filePath = path.join(root, relative)
|
||||||
|
if (!isWithinPath(filePath, root)) continue
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
return filePath
|
return 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 |
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,19 @@ export function getCookie(req: Request, name: string): string | null {
|
|||||||
return null
|
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>) {
|
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||||
return res.status(403).json({ error, message, statusCode: 403, ...extra })
|
return res.status(403).json({ error, message, statusCode: 403, ...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,5 +1,54 @@
|
|||||||
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 ?? '')
|
||||||
@@ -25,9 +74,10 @@ 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 },
|
||||||
})
|
})
|
||||||
@@ -48,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 },
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const incoming = req.headers['x-request-id']
|
||||||
|
const requestId = Array.isArray(incoming) ? incoming[0] : incoming
|
||||||
|
req.requestId = requestId && requestId.length <= 128 ? requestId : `req_${crypto.randomUUID()}`
|
||||||
|
res.setHeader('X-Request-Id', req.requestId)
|
||||||
|
next()
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
vi.mock('jsonwebtoken', () => ({
|
||||||
|
default: { verify: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
adminUser: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
|
||||||
|
|
||||||
|
function responseStub() {
|
||||||
|
const res = { status: vi.fn(), json: vi.fn() }
|
||||||
|
res.status.mockReturnValue(res)
|
||||||
|
res.json.mockReturnValue(res)
|
||||||
|
return res as unknown as Response & typeof res
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireAdminAuth middleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.JWT_SECRET = 'test-secret'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects requests without an admin bearer token', async () => {
|
||||||
|
const req = { headers: {} } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireAdminAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid token signatures', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||||
|
const req = { headers: { authorization: 'Bearer bad' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireAdminAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||||
|
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin token types', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireAdminAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||||
|
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects inactive admin accounts', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||||
|
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: false } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireAdminAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches the admin record for valid admin tokens', async () => {
|
||||||
|
const admin = { id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true }
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||||
|
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireAdminAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(prisma.adminUser.findUnique).toHaveBeenCalledWith({ where: { id: 'admin_1' } })
|
||||||
|
expect(req.admin).toEqual(admin)
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks non-enrolled admins from privileged routes', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||||
|
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireAdminAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('requireAdminRole middleware', () => {
|
||||||
|
it('requires requireAdminAuth to run first', () => {
|
||||||
|
const req = {} as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireAdminRole('SUPPORT' as any)(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks admins below the required rank', () => {
|
||||||
|
const req = { admin: { role: 'VIEWER' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireAdminRole('FINANCE' as any)(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'forbidden',
|
||||||
|
message: 'This action requires the FINANCE role or higher',
|
||||||
|
statusCode: 403,
|
||||||
|
})
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows admins at or above the required rank', () => {
|
||||||
|
const req = { admin: { role: 'ADMIN' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireAdminRole('SUPPORT' as any)(req, res, next)
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
expect(res.status).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { AdminRole } from '@rentaldrivego/database'
|
import { AdminRole } from '@rentaldrivego/database'
|
||||||
import { sendUnauthorized, sendForbidden } from './authHelpers'
|
import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
|
||||||
|
import { verifyActorToken } from '../security/tokens'
|
||||||
|
import { getSessionCookieName } from '../security/sessionCookies'
|
||||||
|
|
||||||
const ROLE_RANK: Record<AdminRole, number> = {
|
const ROLE_RANK: Record<AdminRole, number> = {
|
||||||
SUPER_ADMIN: 5,
|
SUPER_ADMIN: 5,
|
||||||
@@ -12,35 +13,48 @@ const ROLE_RANK: Record<AdminRole, number> = {
|
|||||||
VIEWER: 1,
|
VIEWER: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
|
||||||
|
'/auth/me',
|
||||||
|
'/auth/logout',
|
||||||
|
'/auth/2fa/setup',
|
||||||
|
'/auth/2fa/verify',
|
||||||
|
])
|
||||||
|
|
||||||
|
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
|
||||||
|
|
||||||
|
function is2faEnrollmentExempt(req: Request) {
|
||||||
|
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requires a valid admin Bearer token.
|
* Requires a valid admin session token.
|
||||||
*
|
*
|
||||||
* Guarantees on success:
|
* Guarantees on success:
|
||||||
* req.admin — the full AdminUser record
|
* req.admin — the full AdminUser record
|
||||||
*/
|
*/
|
||||||
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
|
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
const authHeader = req.headers.authorization
|
const token = getAuthToken(req, getSessionCookieName('admin'))
|
||||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
|
||||||
|
|
||||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||||
|
|
||||||
let payload: { sub: string; type: string }
|
let payload: { sub: string; type: string; last2faAt?: number }
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
payload = verifyActorToken(token, 'admin')
|
||||||
} catch {
|
} catch {
|
||||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
|
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type !== 'admin') {
|
|
||||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||||
if (!admin || !admin.isActive) {
|
if (!admin || !admin.isActive) {
|
||||||
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
|
||||||
|
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
|
||||||
|
}
|
||||||
|
|
||||||
req.admin = admin
|
req.admin = admin
|
||||||
|
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,3 +77,18 @@ export function requireAdminRole(minimumRole: AdminRole) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const admin = req.admin
|
||||||
|
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||||
|
if (!admin.totpEnabled) {
|
||||||
|
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
|
||||||
|
}
|
||||||
|
|
||||||
|
const last2faAt = req.adminAuthLast2faAt
|
||||||
|
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
|
||||||
|
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
import { generateCompanyApiKey } from '../security/apiKeys'
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
companyApiKey: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { requireApiKey } from './requireApiKey'
|
||||||
|
|
||||||
|
function createResponseStub() {
|
||||||
|
const res = {
|
||||||
|
status: vi.fn(),
|
||||||
|
json: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status.mockReturnValue(res)
|
||||||
|
res.json.mockReturnValue(res)
|
||||||
|
|
||||||
|
return res as unknown as Response & typeof res
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireApiKey middleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects requests with no x-api-key header', async () => {
|
||||||
|
const req = { headers: {} } as Request
|
||||||
|
const res = createResponseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireApiKey(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'missing_api_key',
|
||||||
|
message: 'API key required in x-api-key header',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed API keys before database lookup', async () => {
|
||||||
|
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
|
||||||
|
const res = createResponseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireApiKey(req, res, next)
|
||||||
|
|
||||||
|
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'invalid_api_key',
|
||||||
|
message: 'Invalid API key',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown API key prefixes', async () => {
|
||||||
|
const generated = generateCompanyApiKey()
|
||||||
|
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
|
||||||
|
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||||
|
const res = createResponseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireApiKey(req, res, next)
|
||||||
|
|
||||||
|
expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { prefix: generated.prefix },
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'invalid_api_key',
|
||||||
|
message: 'Invalid API key',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects revoked API keys', async () => {
|
||||||
|
const generated = generateCompanyApiKey()
|
||||||
|
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||||
|
id: 'key_1',
|
||||||
|
companyId: 'company_1',
|
||||||
|
prefix: generated.prefix,
|
||||||
|
keyHash: generated.keyHash,
|
||||||
|
revokedAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
company: { id: 'company_1', name: 'Atlas Cars' },
|
||||||
|
})
|
||||||
|
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||||
|
const res = createResponseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireApiKey(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects API keys whose secret does not match the stored hash', async () => {
|
||||||
|
const generated = generateCompanyApiKey()
|
||||||
|
const other = generateCompanyApiKey()
|
||||||
|
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||||
|
id: 'key_1',
|
||||||
|
companyId: 'company_1',
|
||||||
|
prefix: generated.prefix,
|
||||||
|
keyHash: other.keyHash,
|
||||||
|
revokedAt: null,
|
||||||
|
company: { id: 'company_1', name: 'Atlas Cars' },
|
||||||
|
})
|
||||||
|
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||||
|
const res = createResponseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireApiKey(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
|
||||||
|
const generated = generateCompanyApiKey()
|
||||||
|
const company = { id: 'company_1', name: 'Atlas Cars' }
|
||||||
|
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||||
|
id: 'key_1',
|
||||||
|
companyId: company.id,
|
||||||
|
prefix: generated.prefix,
|
||||||
|
keyHash: generated.keyHash,
|
||||||
|
revokedAt: null,
|
||||||
|
company,
|
||||||
|
})
|
||||||
|
vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
|
||||||
|
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||||
|
const res = createResponseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireApiKey(req, res, next)
|
||||||
|
|
||||||
|
expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'key_1' },
|
||||||
|
data: { lastUsedAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
expect(req.company).toEqual(company)
|
||||||
|
expect(req.companyId).toBe('company_1')
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
expect(res.status).not.toHaveBeenCalled()
|
||||||
|
expect(res.json).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { getApiKeyPrefix, hashApiKey, timingSafeEqualHex } from '../security/apiKeys'
|
||||||
|
|
||||||
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||||
const apiKey = req.headers['x-api-key'] as string | undefined
|
const apiKey = req.headers['x-api-key'] as string | undefined
|
||||||
@@ -8,12 +9,27 @@ export async function requireApiKey(req: Request, res: Response, next: NextFunct
|
|||||||
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
|
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const company = await prisma.company.findUnique({ where: { apiKey } })
|
const prefix = getApiKeyPrefix(apiKey)
|
||||||
if (!company) {
|
if (!prefix) {
|
||||||
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
req.company = company
|
const keyRecord = await (prisma as any).companyApiKey.findUnique({
|
||||||
req.companyId = company.id
|
where: { prefix },
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const hashed = hashApiKey(apiKey)
|
||||||
|
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
|
||||||
|
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await (prisma as any).companyApiKey.update({
|
||||||
|
where: { id: keyRecord.id },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
req.company = keyRecord.company
|
||||||
|
req.companyId = keyRecord.companyId
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
vi.mock('jsonwebtoken', () => ({
|
||||||
|
default: { verify: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
employee: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { requireCompanyAuth, requireCompanyDocumentAuth } from './requireCompanyAuth'
|
||||||
|
|
||||||
|
function responseStub() {
|
||||||
|
const res = { status: vi.fn(), json: vi.fn() }
|
||||||
|
res.status.mockReturnValue(res)
|
||||||
|
res.json.mockReturnValue(res)
|
||||||
|
return res as unknown as Response & typeof res
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireCompanyAuth middleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.JWT_SECRET = 'test-secret'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing bearer tokens', async () => {
|
||||||
|
const req = { headers: {} } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireCompanyAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||||
|
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid tokens', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||||
|
const req = { headers: { authorization: 'Bearer bad' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireCompanyAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-employee token types', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireCompanyAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||||
|
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects inactive or missing employees', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||||
|
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireCompanyAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches employee and company context for active employees', async () => {
|
||||||
|
const employee = { id: 'emp_1', companyId: 'company_1', isActive: true, company: { id: 'company_1', name: 'Atlas' } }
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||||
|
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireCompanyAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(prisma.employee.findUnique).toHaveBeenCalledWith({ where: { id: 'emp_1' }, include: { company: true } })
|
||||||
|
expect(req.employee).toEqual(employee)
|
||||||
|
expect(req.company).toEqual(employee.company)
|
||||||
|
expect(req.companyId).toBe('company_1')
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts employee_session cookies for document routes', async () => {
|
||||||
|
const employee = { id: 'emp_2', companyId: 'company_2', isActive: true, company: { id: 'company_2' } }
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_2', type: 'employee' } as any)
|
||||||
|
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
|
||||||
|
const req = { headers: { cookie: 'employee_session=cookie-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireCompanyDocumentAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
issuer: 'rentaldrivego-api',
|
||||||
|
audience: 'employee',
|
||||||
|
})
|
||||||
|
expect(req.companyId).toBe('company_2')
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { getCookie, sendUnauthorized } from './authHelpers'
|
import { getAuthToken, sendUnauthorized } from './authHelpers'
|
||||||
|
import { verifyActorToken } from '../security/tokens'
|
||||||
|
import { getSessionCookieName } from '../security/sessionCookies'
|
||||||
|
import { actorLimiter } from './rateLimiter'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
|
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
|
||||||
@@ -11,25 +13,18 @@ import { getCookie, sendUnauthorized } from './authHelpers'
|
|||||||
* req.company — the employee's company
|
* req.company — the employee's company
|
||||||
* req.companyId — string shorthand for req.company.id
|
* req.companyId — string shorthand for req.company.id
|
||||||
*/
|
*/
|
||||||
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
|
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction) {
|
||||||
const authHeader = req.headers.authorization
|
const token = getAuthToken(req, getSessionCookieName('employee'))
|
||||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
|
||||||
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
|
|
||||||
const token = bearerToken ?? cookieToken
|
|
||||||
|
|
||||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||||
|
|
||||||
let payload: { sub: string; type: string }
|
let payload: { sub: string; type: string }
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
payload = verifyActorToken(token, 'employee')
|
||||||
} catch {
|
} catch {
|
||||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
|
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type !== 'employee') {
|
|
||||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
|
|
||||||
}
|
|
||||||
|
|
||||||
const employee = await prisma.employee.findUnique({
|
const employee = await prisma.employee.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
@@ -42,7 +37,7 @@ async function authenticateCompanyRequest(req: Request, res: Response, next: Nex
|
|||||||
req.employee = employee
|
req.employee = employee
|
||||||
req.company = employee.company
|
req.company = employee.company
|
||||||
req.companyId = employee.companyId
|
req.companyId = employee.companyId
|
||||||
next()
|
return actorLimiter(req, res, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
@@ -50,5 +45,5 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
|
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
return authenticateCompanyRequest(req, res, next, true)
|
return authenticateCompanyRequest(req, res, next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
import { sendForbidden, sendUnauthorized } from './authHelpers'
|
||||||
|
import type { EmployeeRole } from '@rentaldrivego/database'
|
||||||
|
import { CompanyPolicy, type CompanyPolicyAction } from '../security/policies/companyPolicy'
|
||||||
|
|
||||||
|
export function requireCompanyPolicy(action: CompanyPolicyAction) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const employee = req.employee
|
||||||
|
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||||
|
|
||||||
|
const allowedRoles: readonly EmployeeRole[] = CompanyPolicy[action]
|
||||||
|
if (!allowedRoles.includes(employee.role)) {
|
||||||
|
return sendForbidden(res, 'forbidden', 'You do not have permission to perform this action', {
|
||||||
|
action,
|
||||||
|
allowedRoles,
|
||||||
|
yourRole: employee.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
vi.mock('jsonwebtoken', () => ({
|
||||||
|
default: { verify: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
renter: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { optionalRenterAuth, requireRenterAuth } from './requireRenterAuth'
|
||||||
|
|
||||||
|
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('requireRenterAuth middleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.JWT_SECRET = 'test-secret'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing bearer tokens', async () => {
|
||||||
|
const req = { headers: {} } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireRenterAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid tokens and wrong token types', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireRenterAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
|
||||||
|
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects inactive renters', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||||
|
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: false } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireRenterAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches renterId for active renters', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||||
|
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: true } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireRenterAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(req.renterId).toBe('renter_1')
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('optional auth never blocks missing or invalid tokens', async () => {
|
||||||
|
const noTokenReq = { headers: {} } as Request
|
||||||
|
const invalidReq = { headers: { authorization: 'Bearer bad' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||||
|
|
||||||
|
await optionalRenterAuth(noTokenReq, res, next)
|
||||||
|
await optionalRenterAuth(invalidReq, res, next)
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('optional auth attaches renterId only for renter tokens', async () => {
|
||||||
|
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_2', type: 'renter' } as any)
|
||||||
|
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await optionalRenterAuth(req, res, next)
|
||||||
|
|
||||||
|
expect(req.renterId).toBe('renter_2')
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { sendUnauthorized } from './authHelpers'
|
import { getAuthToken, sendUnauthorized } from './authHelpers'
|
||||||
|
import { verifyActorToken } from '../security/tokens'
|
||||||
|
import { getSessionCookieName } from '../security/sessionCookies'
|
||||||
|
import { actorLimiter } from './rateLimiter'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requires a valid renter Bearer token.
|
* Requires a valid renter Bearer token.
|
||||||
@@ -10,29 +12,24 @@ import { sendUnauthorized } from './authHelpers'
|
|||||||
* req.renterId — the authenticated renter's id
|
* req.renterId — the authenticated renter's id
|
||||||
*/
|
*/
|
||||||
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
|
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
const authHeader = req.headers.authorization
|
const token = getAuthToken(req, getSessionCookieName('renter'))
|
||||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
|
||||||
|
|
||||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
|
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
|
||||||
|
|
||||||
let payload: { sub: string; type: string }
|
let payload: { sub: string; type: string }
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
payload = verifyActorToken(token, 'renter')
|
||||||
} catch {
|
} catch {
|
||||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
|
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type !== 'renter') {
|
|
||||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
|
||||||
}
|
|
||||||
|
|
||||||
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
||||||
if (!renter || !renter.isActive) {
|
if (!renter || !renter.isActive) {
|
||||||
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
|
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
|
||||||
}
|
}
|
||||||
|
|
||||||
req.renterId = renter.id
|
req.renterId = renter.id
|
||||||
next()
|
return actorLimiter(req, res, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,13 +40,12 @@ export async function requireRenterAuth(req: Request, res: Response, next: NextF
|
|||||||
* req.renterId — the authenticated renter's id (if token is valid)
|
* req.renterId — the authenticated renter's id (if token is valid)
|
||||||
*/
|
*/
|
||||||
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
|
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
|
||||||
const authHeader = req.headers.authorization
|
const token = getAuthToken(req, getSessionCookieName('renter'))
|
||||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
|
||||||
if (!token) return next()
|
if (!token) return next()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
const payload = verifyActorToken(token, 'renter')
|
||||||
if (payload.type === 'renter') req.renterId = payload.sub
|
req.renterId = payload.sub
|
||||||
} catch {
|
} catch {
|
||||||
// Optional — silently ignore invalid tokens
|
// Optional — silently ignore invalid tokens
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
import { requireRole } from './requireRole'
|
||||||
|
|
||||||
|
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('requireRole middleware', () => {
|
||||||
|
it('rejects requests when company auth did not attach an employee', () => {
|
||||||
|
const req = {} as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireRole('AGENT' as any)(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects employees below the required role and includes role context', () => {
|
||||||
|
const req = { employee: { role: 'AGENT' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireRole('MANAGER' as any)(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'forbidden',
|
||||||
|
message: 'This action requires the MANAGER role or higher',
|
||||||
|
statusCode: 403,
|
||||||
|
requiredRole: 'MANAGER',
|
||||||
|
yourRole: 'AGENT',
|
||||||
|
})
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows employees at or above the required role', () => {
|
||||||
|
const req = { employee: { role: 'OWNER' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireRole('MANAGER' as any)(req, res, next)
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
expect(res.status).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
import { requireSubscription } from './requireSubscription'
|
||||||
|
|
||||||
|
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('requireSubscription middleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires tenant/company context first', () => {
|
||||||
|
const req = {} as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireSubscription(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks suspended companies with a billing URL', () => {
|
||||||
|
const req = { company: { status: 'SUSPENDED' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireSubscription(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(402)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'subscription_suspended',
|
||||||
|
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||||
|
statusCode: 402,
|
||||||
|
billingUrl: 'https://dashboard.example.test/billing',
|
||||||
|
})
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks pending companies with setup guidance', () => {
|
||||||
|
const req = { company: { status: 'PENDING' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireSubscription(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(402)
|
||||||
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
error: 'subscription_pending',
|
||||||
|
message: 'Your account is pending activation. Please complete your subscription setup.',
|
||||||
|
}))
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows active companies through', () => {
|
||||||
|
const req = { company: { status: 'ACTIVE' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
requireSubscription(req, res, next)
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
expect(res.status).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
company: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { requireTenant } from './requireTenant'
|
||||||
|
|
||||||
|
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('requireTenant middleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast when company auth did not set companyId', async () => {
|
||||||
|
const req = {} as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireTenant(req, res, next)
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'unauthenticated',
|
||||||
|
message: 'Tenant context missing — requireCompanyAuth must run first',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
expect(prisma.company.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects company ids that no longer exist', async () => {
|
||||||
|
vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
|
||||||
|
const req = { companyId: 'company_missing' } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireTenant(req, res, next)
|
||||||
|
|
||||||
|
expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { id: 'company_missing' } })
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'company_not_found', message: 'Company not found', statusCode: 401 })
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches the fresh company record and continues', async () => {
|
||||||
|
const company = { id: 'company_1', status: 'ACTIVE' }
|
||||||
|
vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
|
||||||
|
const req = { companyId: 'company_1' } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireTenant(req, res, next)
|
||||||
|
|
||||||
|
expect(req.company).toEqual(company)
|
||||||
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
billingAccountUpdateSchema,
|
||||||
|
billingCreditNoteSchema,
|
||||||
|
billingRefundSchema,
|
||||||
|
createBillingInvoiceSchema,
|
||||||
|
payBillingInvoiceSchema,
|
||||||
|
} from './admin.schemas'
|
||||||
|
|
||||||
|
describe('admin billing schemas', () => {
|
||||||
|
it('accepts complete draft invoice payloads and preserves nullable billing fields', () => {
|
||||||
|
const parsed = createBillingInvoiceSchema.parse({
|
||||||
|
subscriptionId: null,
|
||||||
|
invoiceType: 'MANUAL',
|
||||||
|
currency: 'MAD',
|
||||||
|
dueAt: '2026-08-10',
|
||||||
|
isSubscriptionBlocking: false,
|
||||||
|
adminReason: null,
|
||||||
|
lineItems: [{
|
||||||
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
|
description: 'Manual correction',
|
||||||
|
quantity: 2,
|
||||||
|
unitAmount: 1500,
|
||||||
|
periodStart: null,
|
||||||
|
periodEnd: '2026-08-31T00:00:00.000Z',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parsed.lineItems[0]).toMatchObject({ quantity: 2, unitAmount: 1500, periodStart: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invoices without line items because empty invoices are bookkeeping cosplay', () => {
|
||||||
|
expect(() => createBillingInvoiceSchema.parse({ invoiceType: 'MANUAL', lineItems: [] })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bounds billing account net terms and validates email formatting', () => {
|
||||||
|
expect(billingAccountUpdateSchema.parse({
|
||||||
|
legalName: 'Atlas Cars LLC',
|
||||||
|
billingEmail: 'billing@example.test',
|
||||||
|
invoiceTerms: 'NET_30',
|
||||||
|
netTermsDays: 30,
|
||||||
|
})).toMatchObject({ netTermsDays: 30 })
|
||||||
|
|
||||||
|
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires positive money movements for payments, credit notes, and refunds', () => {
|
||||||
|
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
|
||||||
|
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
|
||||||
|
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
|
||||||
|
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -315,7 +315,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const legacy of unsynced) {
|
for (const legacy of unsynced) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx: any) => {
|
||||||
const latest = await tx.subscriptionInvoice.findUnique({
|
const latest = await tx.subscriptionInvoice.findUnique({
|
||||||
where: { id: legacy.id },
|
where: { id: legacy.id },
|
||||||
include: { subscription: true, attempts: true },
|
include: { subscription: true, attempts: true },
|
||||||
@@ -434,37 +434,39 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const invoiceAggItems = invoiceAgg as any[]
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
billingAccountCount: total,
|
billingAccountCount: total,
|
||||||
openInvoiceCount: invoiceAgg
|
openInvoiceCount: invoiceAggItems
|
||||||
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
|
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
|
||||||
.reduce((sum, item) => sum + item._count._all, 0),
|
.reduce((sum, item) => sum + item._count._all, 0),
|
||||||
pastDueInvoiceCount: invoiceAgg
|
pastDueInvoiceCount: invoiceAggItems
|
||||||
.filter((item) => item.status === 'PAST_DUE')
|
.filter((item) => item.status === 'PAST_DUE')
|
||||||
.reduce((sum, item) => sum + item._count._all, 0),
|
.reduce((sum, item) => sum + item._count._all, 0),
|
||||||
paidInvoiceCount: invoiceAgg
|
paidInvoiceCount: invoiceAggItems
|
||||||
.filter((item) => item.status === 'PAID')
|
.filter((item) => item.status === 'PAID')
|
||||||
.reduce((sum, item) => sum + item._count._all, 0),
|
.reduce((sum, item) => sum + item._count._all, 0),
|
||||||
accountsReceivableTotal: invoiceAgg
|
accountsReceivableTotal: invoiceAggItems
|
||||||
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
|
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
|
||||||
.reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0),
|
.reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0),
|
||||||
recognizedRevenueTotal: invoiceAgg
|
recognizedRevenueTotal: invoiceAggItems
|
||||||
.filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status))
|
.filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status))
|
||||||
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
|
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = accounts.map((account) => {
|
const data = (accounts as any[]).map((account) => {
|
||||||
const openBalance = account.invoices
|
const openBalance = (account.invoices as any[])
|
||||||
.filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
|
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
|
||||||
.reduce((sum, invoice) => sum + invoice.amountDue, 0)
|
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
|
||||||
const paidBalance = account.invoices
|
const paidBalance = (account.invoices as any[])
|
||||||
.filter((invoice) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
|
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
|
||||||
.reduce((sum, invoice) => sum + invoice.amountPaid, 0)
|
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
|
||||||
return {
|
return {
|
||||||
...account,
|
...account,
|
||||||
openBalance,
|
openBalance,
|
||||||
paidBalance,
|
paidBalance,
|
||||||
creditBalance: account.creditBalances.reduce((sum, item) => sum + item.balanceAmount, 0),
|
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -630,7 +632,7 @@ export async function createDraftInvoice(
|
|||||||
) {
|
) {
|
||||||
if (!data.lineItems.length) throw new ValidationError('Invoice requires at least one line item')
|
if (!data.lineItems.length) throw new ValidationError('Invoice requires at least one line item')
|
||||||
|
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const account = await tx.billingAccount.findUniqueOrThrow({
|
const account = await tx.billingAccount.findUniqueOrThrow({
|
||||||
where: { id: billingAccountId },
|
where: { id: billingAccountId },
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
@@ -696,7 +698,7 @@ export async function createDraftInvoice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
|
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUnique({
|
const current = await tx.billingInvoice.findUnique({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
include: {
|
include: {
|
||||||
@@ -891,7 +893,7 @@ export async function payInvoice(
|
|||||||
adminId: string,
|
adminId: string,
|
||||||
ip?: string,
|
ip?: string,
|
||||||
) {
|
) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUniqueOrThrow({
|
const current = await tx.billingInvoice.findUniqueOrThrow({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
include: {
|
include: {
|
||||||
@@ -994,7 +996,7 @@ export async function retryInvoicePayment(
|
|||||||
adminId: string,
|
adminId: string,
|
||||||
ip?: string,
|
ip?: string,
|
||||||
) {
|
) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUniqueOrThrow({
|
const current = await tx.billingInvoice.findUniqueOrThrow({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
include: { billingAccount: true },
|
include: { billingAccount: true },
|
||||||
@@ -1069,7 +1071,7 @@ export async function retryInvoicePayment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function voidInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
|
export async function voidInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
|
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
|
||||||
if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
|
if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
|
||||||
throw new ValidationError('Only unpaid invoices can be voided')
|
throw new ValidationError('Only unpaid invoices can be voided')
|
||||||
@@ -1122,7 +1124,7 @@ export async function voidInvoice(invoiceId: string, reason: string, adminId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function markInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
|
export async function markInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
|
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
|
||||||
if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) {
|
if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) {
|
||||||
throw new ValidationError('Only open invoices can be marked uncollectible')
|
throw new ValidationError('Only open invoices can be marked uncollectible')
|
||||||
@@ -1177,7 +1179,7 @@ export async function issueCreditNote(
|
|||||||
adminId: string,
|
adminId: string,
|
||||||
ip?: string,
|
ip?: string,
|
||||||
) {
|
) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUniqueOrThrow({
|
const current = await tx.billingInvoice.findUniqueOrThrow({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
include: { creditNotes: true },
|
include: { creditNotes: true },
|
||||||
@@ -1259,7 +1261,7 @@ export async function issueRefund(
|
|||||||
adminId: string,
|
adminId: string,
|
||||||
ip?: string,
|
ip?: string,
|
||||||
) {
|
) {
|
||||||
const invoice = await prisma.$transaction(async (tx) => {
|
const invoice = await prisma.$transaction(async (tx: any) => {
|
||||||
const current = await tx.billingInvoice.findUniqueOrThrow({
|
const current = await tx.billingInvoice.findUniqueOrThrow({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
include: {
|
include: {
|
||||||
@@ -1354,7 +1356,7 @@ export async function getInvoicePdf(invoiceId: string) {
|
|||||||
if (!invoice) throw new NotFoundError('Invoice not found')
|
if (!invoice) throw new NotFoundError('Invoice not found')
|
||||||
if (!invoice.invoiceNumber) throw new ValidationError('Invoice must be finalized before a PDF can be generated')
|
if (!invoice.invoiceNumber) throw new ValidationError('Invoice must be finalized before a PDF can be generated')
|
||||||
|
|
||||||
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
|
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
|
||||||
const pdfBuffer = await generateInvoicePdf({
|
const pdfBuffer = await generateInvoicePdf({
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
|
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
|
||||||
@@ -1380,7 +1382,7 @@ export async function getInvoicePdf(invoiceId: string) {
|
|||||||
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
|
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
|
||||||
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
|
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
|
||||||
paidAt: invoice.paidAt?.toISOString(),
|
paidAt: invoice.paidAt?.toISOString(),
|
||||||
lineItems: invoice.lineItems.map((item) => ({
|
lineItems: invoice.lineItems.map((item: any) => ({
|
||||||
description: item.description,
|
description: item.description,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
menuItemSchema,
|
||||||
|
menuPlanAssignmentsSchema,
|
||||||
|
menuCompanyAssignmentsSchema,
|
||||||
|
menuPreviewSchema,
|
||||||
|
promotionCreateSchema,
|
||||||
|
promotionUpdateSchema,
|
||||||
|
} from './admin.schemas'
|
||||||
|
|
||||||
|
describe('admin menu and promotion schemas', () => {
|
||||||
|
it('trims labels and applies safe menu item defaults', () => {
|
||||||
|
expect(menuItemSchema.parse({ label: ' Fleet ', itemType: 'INTERNAL_PAGE' })).toMatchObject({
|
||||||
|
label: 'Fleet',
|
||||||
|
itemType: 'INTERNAL_PAGE',
|
||||||
|
displayOrder: 0,
|
||||||
|
openInNewTab: false,
|
||||||
|
isRequired: false,
|
||||||
|
isActive: true,
|
||||||
|
roles: [],
|
||||||
|
subscriptionPlans: [],
|
||||||
|
companyAssignments: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative menu ordering and invalid role previews', () => {
|
||||||
|
expect(() => menuItemSchema.parse({ label: 'Fleet', itemType: 'INTERNAL_PAGE', displayOrder: -1 })).toThrow()
|
||||||
|
expect(() => menuPreviewSchema.parse({ companyId: 'company_1', role: 'SUPER_ADMIN' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires at least one plan or company assignment', () => {
|
||||||
|
expect(() => menuPlanAssignmentsSchema.parse({ assignments: [] })).toThrow()
|
||||||
|
expect(() => menuCompanyAssignmentsSchema.parse({ assignments: [] })).toThrow()
|
||||||
|
expect(menuPlanAssignmentsSchema.parse({ assignments: [{ plan: 'PRO' }] })).toEqual({ assignments: [{ plan: 'PRO' }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts only uppercase promotion codes and preserves update partiality', () => {
|
||||||
|
const valid = {
|
||||||
|
code: 'SUMMER_26',
|
||||||
|
name: 'Summer 2026',
|
||||||
|
discountType: 'PERCENTAGE',
|
||||||
|
discountValue: 20,
|
||||||
|
plans: ['STARTER', 'GROWTH'],
|
||||||
|
periods: ['MONTHLY'],
|
||||||
|
validFrom: '2026-07-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(promotionCreateSchema.parse(valid)).toMatchObject({ ...valid, isActive: true })
|
||||||
|
expect(() => promotionCreateSchema.parse({ ...valid, code: 'summer' })).toThrow()
|
||||||
|
expect(promotionUpdateSchema.parse({ name: 'Updated name' })).toEqual({ name: 'Updated name' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { presentAdminSession, presentAdminUser, presentPaginated } from './admin.presenter'
|
||||||
|
|
||||||
|
describe('admin.presenter', () => {
|
||||||
|
it('removes admin secrets from user responses', () => {
|
||||||
|
const result = presentAdminUser({
|
||||||
|
id: 'admin_1',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
totpSecret: 'secret',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps sessions without leaking credentials', () => {
|
||||||
|
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
|
||||||
|
token: 'jwt-token',
|
||||||
|
admin: { id: 'admin_1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes pagination metadata and preserves extra aggregate fields', () => {
|
||||||
|
expect(presentPaginated([{ id: 'row_1' }], 41, 2, 20, { active: 11 })).toEqual({
|
||||||
|
data: [{ id: 'row_1' }],
|
||||||
|
total: 41,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 20,
|
||||||
|
totalPages: 3,
|
||||||
|
active: 11,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
adminUser: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn() },
|
||||||
|
auditLog: { create: vi.fn() },
|
||||||
|
company: { findMany: vi.fn(), count: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||||
|
billingAccount: { findMany: vi.fn(), count: vi.fn() },
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import * as repo from './admin.repo'
|
||||||
|
|
||||||
|
describe('admin.repo edge queries', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('clears reset token metadata when updating an admin password', async () => {
|
||||||
|
await repo.updateAdminPassword('admin_1', 'hash_1')
|
||||||
|
|
||||||
|
expect(prisma.adminUser.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'admin_1' },
|
||||||
|
data: {
|
||||||
|
passwordHash: 'hash_1',
|
||||||
|
passwordResetToken: null,
|
||||||
|
passwordResetExpiresAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters company list by search, status and plan with pagination', async () => {
|
||||||
|
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
|
||||||
|
vi.mocked(prisma.company.count).mockResolvedValue(0 as never)
|
||||||
|
|
||||||
|
await repo.listCompaniesPage({ q: 'atlas', status: 'ACTIVE', plan: 'PRO', page: 3, pageSize: 25 })
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: 'atlas', mode: 'insensitive' } },
|
||||||
|
{ email: { contains: 'atlas', mode: 'insensitive' } },
|
||||||
|
{ slug: { contains: 'atlas', mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
subscription: { plan: 'PRO' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(prisma.company.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where,
|
||||||
|
skip: 50,
|
||||||
|
take: 25,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}))
|
||||||
|
expect(prisma.company.count).toHaveBeenCalledWith({ where })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks up reset tokens only when they have not expired', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
|
||||||
|
|
||||||
|
await repo.findAdminByResetToken('reset-token')
|
||||||
|
|
||||||
|
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
passwordResetToken: 'reset-token',
|
||||||
|
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -58,6 +58,28 @@ export function enableAdminTotp(id: string) {
|
|||||||
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
|
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
|
||||||
|
await tx.adminRecoveryCode.createMany({
|
||||||
|
data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUnusedAdminRecoveryCodes(adminUserId: string) {
|
||||||
|
return prisma.adminRecoveryCode.findMany({
|
||||||
|
where: { adminUserId, usedAt: null },
|
||||||
|
select: { id: true, codeHash: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAdminRecoveryCodeUsed(id: string) {
|
||||||
|
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
|
||||||
|
}
|
||||||
|
|
||||||
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
|
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
|
||||||
return prisma.adminUser.update({
|
return prisma.adminUser.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -136,7 +158,7 @@ export async function applyCompanyUpdate(
|
|||||||
brand?: { paymentMethodsEnabled?: any[] | null } | null
|
brand?: { paymentMethodsEnabled?: any[] | null } | null
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx: any) => {
|
||||||
if (body.company) {
|
if (body.company) {
|
||||||
const companyData = { ...body.company }
|
const companyData = { ...body.company }
|
||||||
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
|
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdminAuth'
|
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
|
||||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||||
import { ok, created } from '../../http/respond'
|
import { ok, created } from '../../http/respond'
|
||||||
|
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
|
||||||
import * as service from './admin.service'
|
import * as service from './admin.service'
|
||||||
import * as subService from '../subscriptions/subscription.service'
|
import * as subService from '../subscriptions/subscription.service'
|
||||||
import * as menuService from '../menu/menu.service'
|
import * as menuService from '../menu/menu.service'
|
||||||
@@ -29,6 +30,10 @@ const adminExtendTrialSchema = z.object({
|
|||||||
extraDays: z.number().int().positive(),
|
extraDays: z.number().int().positive(),
|
||||||
reason: z.string().min(1).max(500),
|
reason: z.string().min(1).max(500),
|
||||||
})
|
})
|
||||||
|
const adminImpersonationSchema = z.object({
|
||||||
|
reason: z.string().min(5).max(500),
|
||||||
|
durationMinutes: z.number().int().min(1).max(30).default(15),
|
||||||
|
})
|
||||||
const subIdParamSchema = z.object({ subscriptionId: z.string() })
|
const subIdParamSchema = z.object({ subscriptionId: z.string() })
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -37,15 +42,21 @@ const router = Router()
|
|||||||
|
|
||||||
router.post('/auth/login', async (req, res, next) => {
|
router.post('/auth/login', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { email, password, totpCode } = parseBody(loginSchema, req)
|
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
|
||||||
const result = await service.login(email, password, totpCode)
|
const result = await service.login(email, password, totpCode, recoveryCode)
|
||||||
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||||
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
||||||
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
||||||
|
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
||||||
ok(res, result)
|
ok(res, result)
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/auth/logout', (_req, res) => {
|
||||||
|
clearSessionCookie(res, 'admin')
|
||||||
|
ok(res, { success: true })
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/auth/forgot-password', async (req, res, next) => {
|
router.post('/auth/forgot-password', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { email } = parseBody(forgotPasswordSchema, req)
|
const { email } = parseBody(forgotPasswordSchema, req)
|
||||||
@@ -78,9 +89,16 @@ router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
|
|||||||
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
|
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { code } = parseBody(totpVerifySchema, req)
|
const { code } = parseBody(totpVerifySchema, req)
|
||||||
const valid = await service.verifyTotp(req.admin.id, code)
|
const result = await service.verifyTotp(req.admin.id, code)
|
||||||
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
|
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
|
||||||
ok(res, { success: true })
|
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
||||||
|
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
ok(res, await service.regenerateRecoveryCodes(req.admin.id))
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -115,7 +133,7 @@ router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPOR
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.deleteCompany(id, req.admin.id, req.ip)
|
await service.deleteCompany(id, req.admin.id, req.ip)
|
||||||
@@ -123,10 +141,11 @@ router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), asy
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip))
|
const { reason, durationMinutes } = parseBody(adminImpersonationSchema, req)
|
||||||
|
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip, reason, durationMinutes))
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -272,20 +291,20 @@ router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
|
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
|
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
const { role } = parseBody(adminRoleSchema, req)
|
const { role } = parseBody(adminRoleSchema, req)
|
||||||
@@ -294,7 +313,7 @@ router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
const { permissions } = parseBody(adminPermissionsSchema, req)
|
const { permissions } = parseBody(adminPermissionsSchema, req)
|
||||||
@@ -370,7 +389,7 @@ router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAd
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
||||||
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
|
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
|
||||||
@@ -407,7 +426,7 @@ router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requi
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
||||||
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
|
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
|
||||||
@@ -422,7 +441,7 @@ router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { entries } = parseBody(pricingUpdateSchema, req)
|
const { entries } = parseBody(pricingUpdateSchema, req)
|
||||||
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
|
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
|
||||||
@@ -435,14 +454,14 @@ router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), a
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const data = parseBody(planFeatureCreateSchema, req)
|
const data = parseBody(planFeatureCreateSchema, req)
|
||||||
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
|
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { featureId } = parseParams(planFeatureIdParamSchema, req)
|
const { featureId } = parseParams(planFeatureIdParamSchema, req)
|
||||||
const data = parseBody(planFeatureUpdateSchema, req)
|
const data = parseBody(planFeatureUpdateSchema, req)
|
||||||
@@ -450,7 +469,7 @@ router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole(
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { featureId } = parseParams(planFeatureIdParamSchema, req)
|
const { featureId } = parseParams(planFeatureIdParamSchema, req)
|
||||||
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
|
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
|
||||||
@@ -466,14 +485,14 @@ router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'),
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const data = parseBody(promotionCreateSchema, req)
|
const data = parseBody(promotionCreateSchema, req)
|
||||||
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
|
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { promotionId } = parseParams(promotionIdParamSchema, req)
|
const { promotionId } = parseParams(promotionIdParamSchema, req)
|
||||||
const data = parseBody(promotionUpdateSchema, req)
|
const data = parseBody(promotionUpdateSchema, req)
|
||||||
@@ -481,7 +500,7 @@ router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminR
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { promotionId } = parseParams(promotionIdParamSchema, req)
|
const { promotionId } = parseParams(promotionIdParamSchema, req)
|
||||||
await service.deletePromotion(promotionId, req.admin.id, req.ip)
|
await service.deletePromotion(promotionId, req.admin.id, req.ip)
|
||||||
@@ -498,7 +517,7 @@ router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdm
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||||
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
|
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
|
||||||
@@ -506,7 +525,7 @@ router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, req
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||||
@@ -514,7 +533,7 @@ router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAu
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||||
@@ -522,7 +541,7 @@ router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireA
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||||
@@ -530,7 +549,7 @@ router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requi
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
const { subscriptionId } = parseParams(subIdParamSchema, req)
|
||||||
const { reason } = parseBody(adminSubOverrideSchema, req)
|
const { reason } = parseBody(adminSubOverrideSchema, req)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const loginSchema = z.object({
|
|||||||
email: z.string().email().max(255).trim().toLowerCase(),
|
email: z.string().email().max(255).trim().toLowerCase(),
|
||||||
password: z.string().max(128),
|
password: z.string().max(128),
|
||||||
totpCode: z.string().length(6).optional(),
|
totpCode: z.string().length(6).optional(),
|
||||||
|
recoveryCode: z.string().min(8).max(32).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const forgotPasswordSchema = z.object({
|
export const forgotPasswordSchema = z.object({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { authenticator } from 'otplib'
|
import { authenticator } from 'otplib'
|
||||||
|
import { signActorToken } from '../../security/tokens'
|
||||||
import qrcode from 'qrcode'
|
import qrcode from 'qrcode'
|
||||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
@@ -10,9 +10,50 @@ import * as repo from './admin.repo'
|
|||||||
import * as billingService from './admin.billing.service'
|
import * as billingService from './admin.billing.service'
|
||||||
|
|
||||||
const ADMIN_RESET_TTL_MINUTES = 60
|
const ADMIN_RESET_TTL_MINUTES = 60
|
||||||
|
const ADMIN_RECOVERY_CODE_COUNT = 10
|
||||||
|
|
||||||
function signAdminToken(adminId: string) {
|
|
||||||
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
|
function generateRecoveryCode() {
|
||||||
|
const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12)
|
||||||
|
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueAdminRecoveryCodes(adminId: string) {
|
||||||
|
const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode)
|
||||||
|
const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12)))
|
||||||
|
await repo.replaceAdminRecoveryCodes(adminId, hashes)
|
||||||
|
await repo.createAuditLog({
|
||||||
|
adminUserId: adminId,
|
||||||
|
action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED',
|
||||||
|
resource: 'AdminUser',
|
||||||
|
resourceId: adminId,
|
||||||
|
})
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeAdminRecoveryCode(adminId: string, code: string) {
|
||||||
|
const normalized = code.trim().toUpperCase()
|
||||||
|
if (!normalized) return false
|
||||||
|
|
||||||
|
const codes = await repo.listUnusedAdminRecoveryCodes(adminId)
|
||||||
|
for (const candidate of codes) {
|
||||||
|
if (await bcrypt.compare(normalized, candidate.codeHash)) {
|
||||||
|
await repo.markAdminRecoveryCodeUsed(candidate.id)
|
||||||
|
await repo.createAuditLog({
|
||||||
|
adminUserId: adminId,
|
||||||
|
action: 'ADMIN_2FA_RECOVERY_CODE_USED',
|
||||||
|
resource: 'AdminUser',
|
||||||
|
resourceId: adminId,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function signAdminToken(adminId: string, last2faAt?: number) {
|
||||||
|
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAuditJson<T>(value: T) {
|
function toAuditJson<T>(value: T) {
|
||||||
@@ -31,7 +72,7 @@ function ensureAdminBasePath(baseUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string, totpCode?: string) {
|
export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
|
||||||
const admin = await repo.findAdminByEmail(email)
|
const admin = await repo.findAdminByEmail(email)
|
||||||
if (!admin || !admin.isActive) return null
|
if (!admin || !admin.isActive) return null
|
||||||
|
|
||||||
@@ -39,8 +80,16 @@ export async function login(email: string, password: string, totpCode?: string)
|
|||||||
if (!valid) return null
|
if (!valid) return null
|
||||||
|
|
||||||
if (admin.totpEnabled) {
|
if (admin.totpEnabled) {
|
||||||
if (!totpCode) return { totpRequired: true } as const
|
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
|
||||||
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
|
|
||||||
|
const validTotp = totpCode
|
||||||
|
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
||||||
|
: false
|
||||||
|
const validRecoveryCode = !validTotp && recoveryCode
|
||||||
|
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
||||||
|
: false
|
||||||
|
|
||||||
|
if (!validTotp && !validRecoveryCode) {
|
||||||
return { invalidTotp: true } as const
|
return { invalidTotp: true } as const
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +102,7 @@ export async function login(email: string, password: string, totpCode?: string)
|
|||||||
resourceId: admin.id,
|
resourceId: admin.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
return presenter.presentAdminSession(admin, signAdminToken(admin.id))
|
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupTotp(adminId: string, email: string) {
|
export async function setupTotp(adminId: string, email: string) {
|
||||||
@@ -69,8 +118,24 @@ export async function verifyTotp(adminId: string, code: string) {
|
|||||||
if (!admin.totpSecret) return false
|
if (!admin.totpSecret) return false
|
||||||
|
|
||||||
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
|
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
|
||||||
if (valid) await repo.enableAdminTotp(adminId)
|
if (!valid) return false
|
||||||
return valid
|
|
||||||
|
await repo.enableAdminTotp(adminId)
|
||||||
|
await repo.createAuditLog({
|
||||||
|
adminUserId: adminId,
|
||||||
|
action: 'ADMIN_2FA_VERIFIED',
|
||||||
|
resource: 'AdminUser',
|
||||||
|
resourceId: adminId,
|
||||||
|
})
|
||||||
|
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
|
||||||
|
return {
|
||||||
|
...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())),
|
||||||
|
recoveryCodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateRecoveryCodes(adminId: string) {
|
||||||
|
return { recoveryCodes: await issueAdminRecoveryCodes(adminId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forgotPassword(email: string) {
|
export async function forgotPassword(email: string) {
|
||||||
@@ -155,18 +220,12 @@ export async function deleteCompany(id: string, adminId: string, ip?: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function impersonateCompany(id: string, adminId: string, ip?: string) {
|
export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) {
|
||||||
const company = await repo.getCompanyForImpersonation(id)
|
const company = await repo.getCompanyForImpersonation(id)
|
||||||
const token = jwt.sign(
|
const ttlMinutes = Math.min(Math.max(durationMinutes, 1), 30)
|
||||||
{
|
const employeeId = company.employees[0]?.id
|
||||||
sub: company.employees[0]?.id,
|
if (!employeeId) throw new Error('Company has no employee account to impersonate')
|
||||||
companyId: company.id,
|
const token = signActorToken(employeeId, 'employee', { expiresIn: `${ttlMinutes}m` as any })
|
||||||
isImpersonation: true,
|
|
||||||
type: 'employee',
|
|
||||||
},
|
|
||||||
process.env.JWT_SECRET!,
|
|
||||||
{ expiresIn: '30m' },
|
|
||||||
)
|
|
||||||
|
|
||||||
await repo.createAuditLog({
|
await repo.createAuditLog({
|
||||||
adminUserId: adminId,
|
adminUserId: adminId,
|
||||||
@@ -174,10 +233,13 @@ export async function impersonateCompany(id: string, adminId: string, ip?: strin
|
|||||||
resource: 'Company',
|
resource: 'Company',
|
||||||
resourceId: id,
|
resourceId: id,
|
||||||
companyId: id,
|
companyId: id,
|
||||||
|
note: reason,
|
||||||
|
before: { originalAdminId: adminId },
|
||||||
|
after: { targetCompanyId: id, durationMinutes: ttlMinutes },
|
||||||
ipAddress: ip,
|
ipAddress: ip,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { token, expiresIn: 1800 }
|
return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
|
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
|
||||||
@@ -205,7 +267,7 @@ export async function getAuditLogs(query: { adminId?: string; action?: string; c
|
|||||||
|
|
||||||
export async function listAdmins() {
|
export async function listAdmins() {
|
||||||
const admins = await repo.listAdmins()
|
const admins = await repo.listAdmins()
|
||||||
return admins.map((admin) => presenter.presentAdminUser(admin))
|
return admins.map((admin: any) => presenter.presentAdminUser(admin))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
|
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { reportQuerySchema, summaryQuerySchema } from './analytics.schemas'
|
||||||
|
|
||||||
|
describe('analytics schema contracts', () => {
|
||||||
|
it('defaults summary period to the 30 day window', () => {
|
||||||
|
expect(summaryQuerySchema.parse({})).toEqual({ period: '30d' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults reports to JSON while preserving explicit date range filters', () => {
|
||||||
|
expect(reportQuerySchema.parse({ from: '2026-01-01', to: '2026-01-31' })).toEqual({
|
||||||
|
from: '2026-01-01',
|
||||||
|
to: '2026-01-31',
|
||||||
|
format: 'JSON',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes CSV format and period through without lowercasing surprises', () => {
|
||||||
|
expect(reportQuerySchema.parse({ format: 'CSV', period: 'quarter' })).toEqual({
|
||||||
|
format: 'CSV',
|
||||||
|
period: 'quarter',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
reservation: {
|
||||||
|
count: vi.fn(),
|
||||||
|
aggregate: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
groupBy: vi.fn(),
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
accountingSettings: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/financialReportService', () => ({
|
||||||
|
generateFinancialReport: vi.fn(),
|
||||||
|
toCsv: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
|
||||||
|
import { getDashboard, getReport, getSources, getSummary } from './analytics.service'
|
||||||
|
|
||||||
|
describe('analytics.service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summarizes reservations, available vehicles, revenue, and customers for a day window', async () => {
|
||||||
|
vi.mocked(prisma.reservation.count).mockResolvedValue(7 as never)
|
||||||
|
vi.mocked(prisma.vehicle.count).mockResolvedValue(12 as never)
|
||||||
|
vi.mocked(prisma.reservation.aggregate).mockResolvedValue({ _sum: { totalAmount: 4800 } } as never)
|
||||||
|
vi.mocked(prisma.customer.count).mockResolvedValue(42 as never)
|
||||||
|
|
||||||
|
const result = await getSummary('company_1', '14d')
|
||||||
|
|
||||||
|
expect(prisma.reservation.count).toHaveBeenCalledWith({
|
||||||
|
where: { companyId: 'company_1', createdAt: { gte: new Date('2026-05-25T12:00:00.000Z') } },
|
||||||
|
})
|
||||||
|
expect(prisma.vehicle.count).toHaveBeenCalledWith({ where: { companyId: 'company_1', status: 'AVAILABLE' } })
|
||||||
|
expect(result).toEqual({
|
||||||
|
totalReservations: 7,
|
||||||
|
activeVehicles: 12,
|
||||||
|
totalRevenue: 4800,
|
||||||
|
totalCustomers: 42,
|
||||||
|
period: '14d',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds dashboard KPIs, percentage deltas, recent reservation cards, source breakdown, and subscription summary', async () => {
|
||||||
|
vi.mocked(prisma.reservation.count)
|
||||||
|
.mockResolvedValueOnce(10 as never)
|
||||||
|
.mockResolvedValueOnce(5 as never)
|
||||||
|
vi.mocked(prisma.vehicle.count)
|
||||||
|
.mockResolvedValueOnce(4 as never)
|
||||||
|
.mockResolvedValueOnce(0 as never)
|
||||||
|
vi.mocked(prisma.customer.count)
|
||||||
|
.mockResolvedValueOnce(20 as never)
|
||||||
|
.mockResolvedValueOnce(25 as never)
|
||||||
|
vi.mocked(prisma.reservation.aggregate)
|
||||||
|
.mockResolvedValueOnce({ _sum: { totalAmount: 9000 } } as never)
|
||||||
|
.mockResolvedValueOnce({ _sum: { totalAmount: 3000 } } as never)
|
||||||
|
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'reservation_abc12345',
|
||||||
|
contractNumber: null,
|
||||||
|
customer: { firstName: 'Nora', lastName: 'Driver' },
|
||||||
|
vehicle: { make: 'Dacia', model: 'Duster' },
|
||||||
|
startDate: new Date('2026-06-10T00:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-06-12T00:00:00.000Z'),
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
totalAmount: 1200,
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
|
||||||
|
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||||
|
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
|
||||||
|
] as never)
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
status: 'ACTIVE',
|
||||||
|
plan: 'PRO',
|
||||||
|
trialEndAt: new Date('2026-06-30T00:00:00.000Z'),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await getDashboard('company_1')
|
||||||
|
|
||||||
|
expect(result.kpis).toEqual({
|
||||||
|
totalBookings: 10,
|
||||||
|
activeVehicles: 4,
|
||||||
|
monthlyRevenue: 9000,
|
||||||
|
totalCustomers: 20,
|
||||||
|
bookingsChange: 100,
|
||||||
|
vehiclesChange: 100,
|
||||||
|
revenueChange: 200,
|
||||||
|
customersChange: -20,
|
||||||
|
})
|
||||||
|
expect(result.recentReservations).toEqual([expect.objectContaining({
|
||||||
|
id: 'reservation_abc12345',
|
||||||
|
bookingRef: 'ABC12345',
|
||||||
|
customerName: 'Nora Driver',
|
||||||
|
vehicleName: 'Dacia Duster',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
totalAmount: 1200,
|
||||||
|
})])
|
||||||
|
expect(result.sourceBreakdown).toEqual([
|
||||||
|
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
|
||||||
|
{ source: 'DIRECT', count: 2, revenue: 0 },
|
||||||
|
])
|
||||||
|
expect(result.subscription).toEqual({
|
||||||
|
status: 'ACTIVE',
|
||||||
|
planName: 'PRO',
|
||||||
|
trialEndsAt: new Date('2026-06-30T00:00:00.000Z'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns source groups straight from reservation grouping', async () => {
|
||||||
|
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([{ source: 'DIRECT', _count: { id: 2 } }] as never)
|
||||||
|
|
||||||
|
await expect(getSources('company_1')).resolves.toEqual([{ source: 'DIRECT', _count: { id: 2 } }])
|
||||||
|
expect(prisma.reservation.groupBy).toHaveBeenCalledWith({ by: ['source'], where: { companyId: 'company_1' }, _count: { id: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses explicit report dates and emits CSV only when requested', async () => {
|
||||||
|
vi.mocked(prisma.accountingSettings.findUnique).mockResolvedValue({ reportingPeriod: 'ANNUAL' } as never)
|
||||||
|
vi.mocked(generateFinancialReport).mockResolvedValue({ rows: [{ label: 'Revenue', amount: 1000 }] } as never)
|
||||||
|
vi.mocked(toCsv).mockReturnValue('label,amount\nRevenue,1000')
|
||||||
|
|
||||||
|
const result = await getReport('company_1', {
|
||||||
|
from: '2026-05-01',
|
||||||
|
to: '2026-05-31',
|
||||||
|
format: 'CSV',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(generateFinancialReport).toHaveBeenCalledWith('company_1', new Date('2026-05-01'), new Date('2026-05-31'))
|
||||||
|
expect(toCsv).toHaveBeenCalledWith([{ label: 'Revenue', amount: 1000 }])
|
||||||
|
expect(result.csv).toBe('label,amount\nRevenue,1000')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||||
|
|
||||||
|
import { createCompanySignup } from './auth.company.repo'
|
||||||
|
|
||||||
|
function makeDb() {
|
||||||
|
return {
|
||||||
|
company: { create: vi.fn().mockResolvedValue({ id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' }) },
|
||||||
|
brandSettings: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
contractSettings: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
subscription: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
employee: { create: vi.fn().mockResolvedValue({ id: 'employee_1', email: 'owner@example.com' }) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseInput = {
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
legalName: 'Atlas Cars SARL',
|
||||||
|
slug: 'atlas-cars',
|
||||||
|
ownerEmail: 'owner@example.com',
|
||||||
|
companyEmail: 'contact@example.com',
|
||||||
|
companyPhone: '+212600000000',
|
||||||
|
streetAddress: '1 Fleet Street',
|
||||||
|
city: 'Casablanca',
|
||||||
|
country: 'Morocco',
|
||||||
|
zipCode: '20000',
|
||||||
|
legalForm: 'SARL',
|
||||||
|
managerName: 'Aya Benali',
|
||||||
|
iceNumber: 'ICE123',
|
||||||
|
taxId: 'TAX123',
|
||||||
|
operatingLicenseNumber: 'LIC123',
|
||||||
|
operatingLicenseIssuedAt: '2026-01-01',
|
||||||
|
operatingLicenseIssuedBy: 'Transport Authority',
|
||||||
|
fax: '',
|
||||||
|
yearsActive: '5',
|
||||||
|
representativeName: '',
|
||||||
|
representativeTitle: '',
|
||||||
|
responsibleName: 'Omar Alaoui',
|
||||||
|
responsibleRole: 'Manager',
|
||||||
|
responsibleIdentityNumber: 'ID123',
|
||||||
|
responsibleQualification: '',
|
||||||
|
responsiblePhone: '+212611111111',
|
||||||
|
responsibleEmail: 'responsible@example.com',
|
||||||
|
currency: 'MAD' as const,
|
||||||
|
registrationNumber: 'REG123',
|
||||||
|
plan: 'GROWTH' as const,
|
||||||
|
billingPeriod: 'ANNUAL' as const,
|
||||||
|
preferredLanguage: 'fr' as const,
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
now: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
trialEndAt: new Date('2026-08-30T00:00:00.000Z'),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth.company.repo.createCompanySignup', () => {
|
||||||
|
it('creates the company, tenant settings, trial subscription, and active owner in one transaction client', async () => {
|
||||||
|
const db = makeDb()
|
||||||
|
|
||||||
|
await expect(createCompanySignup(db as never, baseInput)).resolves.toEqual({
|
||||||
|
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||||
|
employee: { id: 'employee_1', email: 'owner@example.com' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(db.company.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||||
|
name: 'Atlas Cars',
|
||||||
|
slug: 'atlas-cars',
|
||||||
|
email: 'contact@example.com',
|
||||||
|
status: 'TRIALING',
|
||||||
|
address: expect.objectContaining({
|
||||||
|
legalName: 'Atlas Cars SARL',
|
||||||
|
companyEmail: 'contact@example.com',
|
||||||
|
fax: null,
|
||||||
|
representativeName: null,
|
||||||
|
responsibleEmail: 'responsible@example.com',
|
||||||
|
}),
|
||||||
|
}) })
|
||||||
|
|
||||||
|
expect(db.brandSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||||
|
companyId: 'company_1',
|
||||||
|
displayName: 'Atlas Cars',
|
||||||
|
subdomain: 'atlas-cars',
|
||||||
|
defaultLocale: 'fr',
|
||||||
|
defaultCurrency: 'MAD',
|
||||||
|
}) })
|
||||||
|
|
||||||
|
expect(db.contractSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||||
|
companyId: 'company_1',
|
||||||
|
registrationNumber: 'REG123',
|
||||||
|
taxId: 'TAX123',
|
||||||
|
}) })
|
||||||
|
|
||||||
|
expect(db.subscription.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||||
|
companyId: 'company_1',
|
||||||
|
plan: 'GROWTH',
|
||||||
|
billingPeriod: 'ANNUAL',
|
||||||
|
status: 'TRIALING',
|
||||||
|
trialStartAt: baseInput.now,
|
||||||
|
trialEndAt: baseInput.trialEndAt,
|
||||||
|
}) })
|
||||||
|
|
||||||
|
expect(db.employee.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||||
|
companyId: 'company_1',
|
||||||
|
clerkUserId: 'local_owner_company_1',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
role: 'OWNER',
|
||||||
|
preferredLanguage: 'fr',
|
||||||
|
isActive: true,
|
||||||
|
}) })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AppError } from '../../http/errors'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import * as repo from './auth.company.repo'
|
||||||
|
import * as service from './auth.company.service'
|
||||||
|
import { sendNotification } from '../../services/notificationService'
|
||||||
|
|
||||||
|
vi.mock('bcryptjs', () => ({ default: { hash: vi.fn().mockResolvedValue('hashed-password') } }))
|
||||||
|
vi.mock('../../lib/prisma', () => ({ prisma: { $transaction: vi.fn() } }))
|
||||||
|
vi.mock('./auth.company.repo', () => ({
|
||||||
|
findCompanyBySlug: vi.fn(),
|
||||||
|
findCompanyByEmail: vi.fn(),
|
||||||
|
findEmployeeByEmail: vi.fn(),
|
||||||
|
createCompanySignup: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn() }))
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'super-secret',
|
||||||
|
companyName: 'Atlas & Desert Cars!!!',
|
||||||
|
legalName: 'Atlas Cars SARL',
|
||||||
|
legalForm: 'SARL',
|
||||||
|
registrationNumber: 'REG123',
|
||||||
|
iceNumber: 'ICE123',
|
||||||
|
taxId: 'TAX123',
|
||||||
|
operatingLicenseNumber: 'LIC123',
|
||||||
|
operatingLicenseIssuedAt: '2026-01-01',
|
||||||
|
operatingLicenseIssuedBy: 'Transport Authority',
|
||||||
|
streetAddress: '1 Fleet Street',
|
||||||
|
city: 'Casablanca',
|
||||||
|
country: 'Morocco',
|
||||||
|
zipCode: '20000',
|
||||||
|
companyPhone: '+212600000000',
|
||||||
|
companyEmail: 'contact@example.com',
|
||||||
|
yearsActive: '5',
|
||||||
|
responsibleName: 'Omar Alaoui',
|
||||||
|
responsibleRole: 'Manager',
|
||||||
|
responsibleIdentityNumber: 'ID123',
|
||||||
|
responsiblePhone: '+212611111111',
|
||||||
|
responsibleEmail: 'responsible@example.com',
|
||||||
|
preferredLanguage: 'fr' as const,
|
||||||
|
plan: 'PRO' as const,
|
||||||
|
billingPeriod: 'MONTHLY' as const,
|
||||||
|
currency: 'MAD' as const,
|
||||||
|
paymentProvider: 'PAYPAL' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth.company.service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(repo.findCompanyByEmail).mockResolvedValue(null)
|
||||||
|
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue(null)
|
||||||
|
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(null)
|
||||||
|
vi.mocked(repo.createCompanySignup).mockResolvedValue({
|
||||||
|
company: { id: 'company_1', name: 'Atlas & Desert Cars!!!', slug: 'atlas-desert-cars' },
|
||||||
|
employee: { id: 'employee_1' },
|
||||||
|
} as never)
|
||||||
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn({ tx: true }))
|
||||||
|
vi.mocked(sendNotification).mockResolvedValue([{ channel: 'EMAIL', success: true }] as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate company contact email before hashing or transaction work', async () => {
|
||||||
|
vi.mocked(repo.findCompanyByEmail).mockResolvedValue({ id: 'company_existing' } as never)
|
||||||
|
|
||||||
|
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'company_email_taken' })
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate owner email before creating tenant data', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue({ id: 'employee_existing' } as never)
|
||||||
|
|
||||||
|
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'owner_email_taken' })
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates a stable slug, creates the tenant signup, sends notification, and presents the workspace result', async () => {
|
||||||
|
const result = await service.signup(body)
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
companyId: 'company_1',
|
||||||
|
companyName: 'Atlas & Desert Cars!!!',
|
||||||
|
slug: 'atlas-desert-cars',
|
||||||
|
nextStep: 'workspace_created',
|
||||||
|
emailDelivery: { channel: 'EMAIL', success: true },
|
||||||
|
}))
|
||||||
|
expect(result.trialEndsAt).toEqual(expect.any(String))
|
||||||
|
expect(repo.findCompanyBySlug).toHaveBeenCalledWith('atlas-desert-cars')
|
||||||
|
expect(repo.createCompanySignup).toHaveBeenCalledWith({ tx: true }, expect.objectContaining({
|
||||||
|
slug: 'atlas-desert-cars',
|
||||||
|
ownerEmail: 'owner@example.com',
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
managerName: 'Aya Benali',
|
||||||
|
trialEndAt: expect.any(Date),
|
||||||
|
}))
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'ACCOUNT_CREATED',
|
||||||
|
companyId: 'company_1',
|
||||||
|
employeeId: 'employee_1',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
channels: ['EMAIL', 'IN_APP'],
|
||||||
|
locale: 'fr',
|
||||||
|
templateVariables: expect.objectContaining({
|
||||||
|
firstName: 'Aya',
|
||||||
|
companyName: 'Atlas & Desert Cars!!!',
|
||||||
|
paymentProvider: 'PAYPAL',
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('increments the slug when the clean base slug is already taken', async () => {
|
||||||
|
vi.mocked(repo.findCompanyBySlug)
|
||||||
|
.mockResolvedValueOnce({ id: 'existing' } as never)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
await service.signup(body)
|
||||||
|
|
||||||
|
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(1, 'atlas-desert-cars')
|
||||||
|
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(2, 'atlas-desert-cars-2')
|
||||||
|
expect(repo.createCompanySignup).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ slug: 'atlas-desert-cars-2' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps signup successful when notification delivery fails', async () => {
|
||||||
|
vi.mocked(sendNotification).mockRejectedValue(new Error('smtp down'))
|
||||||
|
|
||||||
|
await expect(service.signup(body)).resolves.toMatchObject({
|
||||||
|
companyId: 'company_1',
|
||||||
|
emailDelivery: { attempted: false, success: false, error: null },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes removed legacy auth endpoints as explicit gone errors', () => {
|
||||||
|
expect(() => service.completeSignupDisabled()).toThrow(AppError)
|
||||||
|
expect(() => service.verifyEmailDisabled()).toThrow(AppError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -45,7 +45,7 @@ export async function signup(body: CompanySignupInput) {
|
|||||||
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
|
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
|
||||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||||
|
|
||||||
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
|
const result = await prisma.$transaction((tx: any) => repo.createCompanySignup(tx, {
|
||||||
companyName: body.companyName,
|
companyName: body.companyName,
|
||||||
legalName: body.legalName,
|
legalName: body.legalName,
|
||||||
slug,
|
slug,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
import * as repo from './auth.employee.repo'
|
||||||
|
|
||||||
|
describe('auth.employee.repo query boundaries', () => {
|
||||||
|
it('loads employee sessions with company context by id', async () => {
|
||||||
|
await repo.findEmployeeWithCompanyById('employee_1')
|
||||||
|
|
||||||
|
expect(prismaMock.employee.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'employee_1' },
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks up employee login emails case-insensitively and includes company context', async () => {
|
||||||
|
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
|
||||||
|
|
||||||
|
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only sends forgot-password emails to active employees', async () => {
|
||||||
|
await repo.findActiveEmployeeByEmail('agent@example.test')
|
||||||
|
|
||||||
|
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
email: { equals: 'agent@example.test', mode: 'insensitive' },
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||||
|
|
||||||
|
await repo.findEmployeeByResetToken('reset_123')
|
||||||
|
|
||||||
|
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
passwordResetToken: 'reset_123',
|
||||||
|
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears stored reset token fields when password changes', async () => {
|
||||||
|
await repo.resetPassword('employee_1', 'hash_new')
|
||||||
|
|
||||||
|
expect(prismaMock.employee.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'employee_1' },
|
||||||
|
data: {
|
||||||
|
passwordHash: 'hash_new',
|
||||||
|
passwordResetToken: null,
|
||||||
|
passwordResetExpiresAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||||
import { parseBody } from '../../http/validate'
|
import { parseBody } from '../../http/validate'
|
||||||
import { ok } from '../../http/respond'
|
import { ok } from '../../http/respond'
|
||||||
|
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
|
||||||
import { getEmployeeMenu } from '../menu/menu.service'
|
import { getEmployeeMenu } from '../menu/menu.service'
|
||||||
import {
|
import {
|
||||||
employeeForgotPasswordSchema,
|
employeeForgotPasswordSchema,
|
||||||
@@ -28,10 +29,17 @@ router.get('/menu', requireCompanyAuth, async (req, res, next) => {
|
|||||||
router.post('/login', async (req, res, next) => {
|
router.post('/login', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(employeeLoginSchema, req)
|
const body = parseBody(employeeLoginSchema, req)
|
||||||
ok(res, await service.login(body))
|
const result = await service.login(body)
|
||||||
|
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
|
||||||
|
ok(res, result)
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/logout', (_req, res) => {
|
||||||
|
clearSessionCookie(res, 'employee')
|
||||||
|
ok(res, { success: true })
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/forgot-password', async (req, res, next) => {
|
router.post('/forgot-password', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { email } = parseBody(employeeForgotPasswordSchema, req)
|
const { email } = parseBody(employeeForgotPasswordSchema, req)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('bcryptjs', () => ({
|
||||||
|
default: {
|
||||||
|
compare: vi.fn(),
|
||||||
|
hash: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('../../services/notificationService', () => ({ sendTransactionalEmail: vi.fn() }))
|
||||||
|
vi.mock('./auth.employee.repo', () => ({
|
||||||
|
findEmployeeWithCompanyById: vi.fn(),
|
||||||
|
findEmployeeWithCompanyByEmail: vi.fn(),
|
||||||
|
findActiveEmployeeByEmail: vi.fn(),
|
||||||
|
findEmployeeByResetToken: vi.fn(),
|
||||||
|
findEmployeeById: vi.fn(),
|
||||||
|
updatePreferredLanguage: vi.fn(),
|
||||||
|
resetPassword: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { AppError } from '../../http/errors'
|
||||||
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
|
import * as repo from './auth.employee.repo'
|
||||||
|
import * as service from './auth.employee.service'
|
||||||
|
|
||||||
|
const employee = {
|
||||||
|
id: 'employee_1',
|
||||||
|
email: 'agent@example.test',
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Agent',
|
||||||
|
role: 'AGENT',
|
||||||
|
preferredLanguage: 'fr',
|
||||||
|
companyId: 'company_1',
|
||||||
|
isActive: true,
|
||||||
|
passwordHash: 'hash_old',
|
||||||
|
company: { name: 'Atlas Cars', slug: 'atlas' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth.employee.service edge behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.JWT_SECRET = 'test-secret'
|
||||||
|
process.env.JWT_EXPIRY = '8h'
|
||||||
|
delete process.env.DASHBOARD_URL
|
||||||
|
delete process.env.NEXT_PUBLIC_DASHBOARD_URL
|
||||||
|
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
|
||||||
|
vi.mocked(bcrypt.hash).mockResolvedValue('hash_new' as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects inactive employee sessions before presenting tenant data', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeWithCompanyById).mockResolvedValue({ ...employee, isActive: false } as never)
|
||||||
|
|
||||||
|
await expect(service.getMe('employee_1')).rejects.toMatchObject({
|
||||||
|
statusCode: 401,
|
||||||
|
error: 'unauthenticated',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs in active employees with a signed token and company context', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue(employee as never)
|
||||||
|
|
||||||
|
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
|
||||||
|
|
||||||
|
expect(bcrypt.compare).toHaveBeenCalledWith('correct-password', 'hash_old')
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
token: expect.any(String),
|
||||||
|
employee: {
|
||||||
|
id: 'employee_1',
|
||||||
|
companyId: 'company_1',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
companySlug: 'atlas',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes employees without passwords from wrong credentials', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
|
||||||
|
|
||||||
|
await expect(service.login({ email: 'agent@example.test', password: 'anything' })).rejects.toMatchObject({
|
||||||
|
statusCode: 401,
|
||||||
|
error: 'password_not_set',
|
||||||
|
})
|
||||||
|
expect(bcrypt.compare).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends reset links to active employees using the dashboard base path exactly once', async () => {
|
||||||
|
process.env.DASHBOARD_URL = 'https://tenant.example.test/app'
|
||||||
|
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(employee as never)
|
||||||
|
vi.mocked(sendTransactionalEmail).mockResolvedValue({ success: true } as never)
|
||||||
|
|
||||||
|
await expect(service.forgotPassword('agent@example.test')).resolves.toEqual({
|
||||||
|
message: 'If that email is registered, a reset link has been sent.',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
to: 'agent@example.test',
|
||||||
|
subject: expect.any(String),
|
||||||
|
html: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
|
||||||
|
text: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not disclose whether forgot-password emails exist', async () => {
|
||||||
|
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(service.forgotPassword('missing@example.test')).resolves.toEqual({
|
||||||
|
message: 'If that email is registered, a reset link has been sent.',
|
||||||
|
})
|
||||||
|
expect(sendTransactionalEmail).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates employee password from a stored reset token and clears reset state through the repo', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(employee as never)
|
||||||
|
|
||||||
|
await expect(service.resetPassword('stored-token', 'new-secure-password')).resolves.toEqual({
|
||||||
|
message: 'Password updated successfully. You can now sign in.',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bcrypt.hash).toHaveBeenCalledWith('new-secure-password', 12)
|
||||||
|
expect(repo.resetPassword).toHaveBeenCalledWith('employee_1', 'hash_new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid reset tokens before hashing new passwords', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
|
||||||
|
vi.mocked(repo.findEmployeeById).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(service.resetPassword('bad-token', 'new-secure-password')).rejects.toMatchObject({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'invalid_token',
|
||||||
|
})
|
||||||
|
expect(bcrypt.hash).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delegates language preference updates and returns a stable contract', async () => {
|
||||||
|
vi.mocked(repo.updatePreferredLanguage).mockResolvedValue({} as never)
|
||||||
|
|
||||||
|
await expect(service.updateLanguage('employee_1', 'ar')).resolves.toEqual({ language: 'ar' })
|
||||||
|
expect(repo.updatePreferredLanguage).toHaveBeenCalledWith('employee_1', 'ar')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { signActorToken } from '../../security/tokens'
|
||||||
import { AppError } from '../../http/errors'
|
import { AppError } from '../../http/errors'
|
||||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
|
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
|
||||||
@@ -20,11 +21,9 @@ type EmployeePasswordResetPayload = jwt.JwtPayload & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function signEmployeeToken(employeeId: string) {
|
function signEmployeeToken(employeeId: string) {
|
||||||
return jwt.sign(
|
return signActorToken(employeeId, 'employee', {
|
||||||
{ sub: employeeId, type: 'employee' },
|
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
|
||||||
process.env.JWT_SECRET!,
|
})
|
||||||
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
|
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
|
||||||
@@ -42,13 +41,22 @@ function signEmployeePasswordResetToken(employeeId: string, passwordHash: string
|
|||||||
pwdv: getEmployeePasswordResetVersion(passwordHash),
|
pwdv: getEmployeePasswordResetVersion(passwordHash),
|
||||||
},
|
},
|
||||||
process.env.JWT_SECRET!,
|
process.env.JWT_SECRET!,
|
||||||
{ expiresIn: `${RESET_TOKEN_TTL_MINUTES}m` },
|
{
|
||||||
|
algorithm: 'HS256',
|
||||||
|
issuer: 'rentaldrivego-api',
|
||||||
|
audience: 'employee_password_reset',
|
||||||
|
expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
|
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload
|
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
issuer: 'rentaldrivego-api',
|
||||||
|
audience: 'employee_password_reset',
|
||||||
|
}) as jwt.JwtPayload
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof payload.sub !== 'string' ||
|
typeof payload.sub !== 'string' ||
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { presentCompanySignup, presentEmployeeSession, presentRenterProfile } from './auth.presenter'
|
||||||
|
|
||||||
|
describe('auth presenters', () => {
|
||||||
|
it('presents employee sessions without leaking company internals or requiring a token', () => {
|
||||||
|
const employee = {
|
||||||
|
id: 'employee_1',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
role: 'OWNER',
|
||||||
|
preferredLanguage: 'fr',
|
||||||
|
companyId: 'company_1',
|
||||||
|
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(presentEmployeeSession(employee)).toEqual({
|
||||||
|
employee: {
|
||||||
|
id: 'employee_1',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
role: 'OWNER',
|
||||||
|
preferredLanguage: 'fr',
|
||||||
|
companyId: 'company_1',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
companySlug: 'atlas-cars',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds the token only when one is supplied', () => {
|
||||||
|
const employee = {
|
||||||
|
id: 'employee_1',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
role: 'OWNER',
|
||||||
|
preferredLanguage: null,
|
||||||
|
companyId: 'company_1',
|
||||||
|
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(presentEmployeeSession(employee, 'jwt-token')).toEqual(expect.objectContaining({
|
||||||
|
token: 'jwt-token',
|
||||||
|
employee: expect.objectContaining({ id: 'employee_1' }),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('presents company signup with a deterministic next step and fallback email delivery state', () => {
|
||||||
|
expect(presentCompanySignup({
|
||||||
|
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
|
||||||
|
trialEndAt: new Date('2026-09-01T00:00:00.000Z'),
|
||||||
|
})).toEqual({
|
||||||
|
companyId: 'company_1',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
slug: 'atlas-cars',
|
||||||
|
invitationId: null,
|
||||||
|
trialEndsAt: '2026-09-01T00:00:00.000Z',
|
||||||
|
nextStep: 'workspace_created',
|
||||||
|
emailDelivery: { attempted: false, success: false, error: null },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates saved renter companies in renter save order and leaves missing brand data explicit', () => {
|
||||||
|
const renter = {
|
||||||
|
id: 'renter_1',
|
||||||
|
firstName: 'Youssef',
|
||||||
|
lastName: 'Amrani',
|
||||||
|
email: 'youssef@example.com',
|
||||||
|
phone: null,
|
||||||
|
preferredLocale: 'fr',
|
||||||
|
preferredCurrency: 'MAD',
|
||||||
|
emailVerified: true,
|
||||||
|
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_missing' }, { companyId: 'company_1' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = presentRenterProfile(renter, [
|
||||||
|
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
|
||||||
|
{ id: 'company_2', brand: null },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.savedCompanies).toEqual([
|
||||||
|
{ id: 'company_2', brand: null },
|
||||||
|
{ id: 'company_missing', brand: null },
|
||||||
|
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AppError } from '../../http/errors'
|
||||||
|
import * as repo from './auth.renter.repo'
|
||||||
|
import * as service from './auth.renter.service'
|
||||||
|
|
||||||
|
vi.mock('./auth.renter.repo', () => ({
|
||||||
|
findRenterProfile: vi.fn(),
|
||||||
|
findSavedCompanies: vi.fn(),
|
||||||
|
updateRenterProfile: vi.fn(),
|
||||||
|
updateRenterFcmToken: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('auth.renter.service', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('keeps renter self-signup disabled with an explicit product error', () => {
|
||||||
|
expect(() => service.signupDisabled()).toThrow(AppError)
|
||||||
|
try {
|
||||||
|
service.signupDisabled()
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toMatchObject({ statusCode: 403, error: 'renter_signup_disabled' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps renter login disabled with an explicit product error', () => {
|
||||||
|
expect(() => service.loginDisabled()).toThrow(AppError)
|
||||||
|
try {
|
||||||
|
service.loginDisabled()
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toMatchObject({ statusCode: 403, error: 'renter_login_disabled' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads saved company brand data only for company IDs attached to the renter profile', async () => {
|
||||||
|
vi.mocked(repo.findRenterProfile).mockResolvedValue({
|
||||||
|
id: 'renter_1',
|
||||||
|
firstName: 'Youssef',
|
||||||
|
lastName: 'Amrani',
|
||||||
|
email: 'youssef@example.com',
|
||||||
|
phone: null,
|
||||||
|
preferredLocale: 'fr',
|
||||||
|
preferredCurrency: 'MAD',
|
||||||
|
emailVerified: true,
|
||||||
|
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_1' }],
|
||||||
|
} as never)
|
||||||
|
vi.mocked(repo.findSavedCompanies).mockResolvedValue([
|
||||||
|
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: null } },
|
||||||
|
{ id: 'company_2', brand: { displayName: 'Desert Cars', subdomain: 'desert', logoUrl: '/desert.png' } },
|
||||||
|
] as never)
|
||||||
|
|
||||||
|
await expect(service.getMe('renter_1')).resolves.toMatchObject({
|
||||||
|
id: 'renter_1',
|
||||||
|
savedCompanies: [
|
||||||
|
{ id: 'company_2', brand: { displayName: 'Desert Cars' } },
|
||||||
|
{ id: 'company_1', brand: { displayName: 'Atlas' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(repo.findSavedCompanies).toHaveBeenCalledWith(['company_2', 'company_1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delegates profile updates without inventing side effects in the service layer', async () => {
|
||||||
|
vi.mocked(repo.updateRenterProfile).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
|
||||||
|
|
||||||
|
await expect(service.updateMe('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })).resolves.toEqual({
|
||||||
|
id: 'renter_1',
|
||||||
|
firstName: 'Nora',
|
||||||
|
})
|
||||||
|
expect(repo.updateRenterProfile).toHaveBeenCalledWith('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the renter FCM token and returns a stable success contract', async () => {
|
||||||
|
vi.mocked(repo.updateRenterFcmToken).mockResolvedValue({} as never)
|
||||||
|
|
||||||
|
await expect(service.updateFcmToken('renter_1', 'fcm_123')).resolves.toEqual({ success: true })
|
||||||
|
expect(repo.updateRenterFcmToken).toHaveBeenCalledWith('renter_1', 'fcm_123')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,7 +16,7 @@ export function loginDisabled() {
|
|||||||
|
|
||||||
export async function getMe(renterId: string) {
|
export async function getMe(renterId: string) {
|
||||||
const renter = await repo.findRenterProfile(renterId)
|
const renter = await repo.findRenterProfile(renterId)
|
||||||
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany) => savedCompany.companyId))
|
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany: any) => savedCompany.companyId))
|
||||||
return presentRenterProfile(renter, savedCompanies)
|
return presentRenterProfile(renter, savedCompanies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { companySignupSchema } from './auth.company.schemas'
|
||||||
|
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
|
||||||
|
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
|
||||||
|
|
||||||
|
const validCompanySignup = {
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Haddad',
|
||||||
|
email: 'owner@example.test',
|
||||||
|
password: 'securePass123',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
legalName: 'Atlas Cars SARL',
|
||||||
|
legalForm: 'SARL',
|
||||||
|
registrationNumber: 'RC-1',
|
||||||
|
iceNumber: 'ICE-1',
|
||||||
|
taxId: 'TAX-1',
|
||||||
|
operatingLicenseNumber: 'LIC-1',
|
||||||
|
operatingLicenseIssuedAt: '2025-01-01',
|
||||||
|
operatingLicenseIssuedBy: 'Transport Authority',
|
||||||
|
streetAddress: '1 Main Street',
|
||||||
|
city: 'Casablanca',
|
||||||
|
country: 'Morocco',
|
||||||
|
zipCode: '20000',
|
||||||
|
companyPhone: '+212600000000',
|
||||||
|
companyEmail: 'company@example.test',
|
||||||
|
yearsActive: '3',
|
||||||
|
responsibleName: 'Aya Haddad',
|
||||||
|
responsibleRole: 'Owner',
|
||||||
|
responsibleIdentityNumber: 'ID-1',
|
||||||
|
responsiblePhone: '+212600000001',
|
||||||
|
responsibleEmail: 'responsible@example.test',
|
||||||
|
plan: 'GROWTH',
|
||||||
|
billingPeriod: 'MONTHLY',
|
||||||
|
currency: 'MAD',
|
||||||
|
paymentProvider: 'AMANPAY',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('role-specific auth schema contracts', () => {
|
||||||
|
it('defaults company signup language and keeps billing provider constrained', () => {
|
||||||
|
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' })
|
||||||
|
expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow()
|
||||||
|
expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes employee email fields and validates reset password strength', () => {
|
||||||
|
expect(employeeLoginSchema.parse({ email: 'OWNER@EXAMPLE.TEST', password: 'secret' })).toEqual({
|
||||||
|
email: 'owner@example.test',
|
||||||
|
password: 'secret',
|
||||||
|
})
|
||||||
|
expect(employeeForgotPasswordSchema.parse({ email: 'HELP@EXAMPLE.TEST' })).toEqual({ email: 'help@example.test' })
|
||||||
|
expect(employeeResetPasswordSchema.parse({ token: 'token_1', password: 'new-pass-123' })).toEqual({ token: 'token_1', password: 'new-pass-123' })
|
||||||
|
expect(() => employeeResetPasswordSchema.parse({ token: '', password: 'short' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits employee language changes to supported locales', () => {
|
||||||
|
expect(employeeLanguageSchema.parse({ language: 'fr' })).toEqual({ language: 'fr' })
|
||||||
|
expect(() => employeeLanguageSchema.parse({ language: 'es' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes renter profile updates while keeping MAD as the only supported currency', () => {
|
||||||
|
expect(renterUpdateSchema.parse({ firstName: ' Salma ', lastName: ' Idrissi ', preferredLocale: 'ar', preferredCurrency: 'MAD' })).toEqual({
|
||||||
|
firstName: 'Salma',
|
||||||
|
lastName: 'Idrissi',
|
||||||
|
preferredLocale: 'ar',
|
||||||
|
preferredCurrency: 'MAD',
|
||||||
|
})
|
||||||
|
expect(() => renterUpdateSchema.parse({ preferredCurrency: 'EUR' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a renter FCM token payload key', () => {
|
||||||
|
expect(renterFcmTokenSchema.parse({ fcmToken: 'token_abc' })).toEqual({ fcmToken: 'token_abc' })
|
||||||
|
expect(() => renterFcmTokenSchema.parse({ token: 'token_abc' })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { companySignupSchema } from './auth.company.schemas'
|
||||||
|
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
|
||||||
|
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
|
||||||
|
|
||||||
|
describe('auth schemas', () => {
|
||||||
|
const validCompanySignup = {
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
email: 'owner@example.test',
|
||||||
|
password: 'safe-password',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
legalName: 'Atlas Cars SARL',
|
||||||
|
legalForm: 'SARL',
|
||||||
|
registrationNumber: 'RC-1',
|
||||||
|
iceNumber: 'ICE-1',
|
||||||
|
taxId: 'TAX-1',
|
||||||
|
operatingLicenseNumber: 'LIC-1',
|
||||||
|
operatingLicenseIssuedAt: '2024-01-01',
|
||||||
|
operatingLicenseIssuedBy: 'Rabat',
|
||||||
|
streetAddress: '1 Avenue',
|
||||||
|
city: 'Rabat',
|
||||||
|
country: 'Morocco',
|
||||||
|
zipCode: '10000',
|
||||||
|
companyPhone: '+212600000000',
|
||||||
|
companyEmail: 'company@example.test',
|
||||||
|
yearsActive: '3',
|
||||||
|
responsibleName: 'Aya Benali',
|
||||||
|
responsibleRole: 'Owner',
|
||||||
|
responsibleIdentityNumber: 'ID-1',
|
||||||
|
responsiblePhone: '+212600000001',
|
||||||
|
responsibleEmail: 'owner@example.test',
|
||||||
|
plan: 'GROWTH',
|
||||||
|
billingPeriod: 'MONTHLY',
|
||||||
|
currency: 'MAD',
|
||||||
|
paymentProvider: 'AMANPAY',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
it('defaults company signup language and rejects unsupported commercial choices', () => {
|
||||||
|
const parsed = companySignupSchema.parse(validCompanySignup)
|
||||||
|
|
||||||
|
expect(parsed.preferredLanguage).toBe('en')
|
||||||
|
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(false)
|
||||||
|
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
|
||||||
|
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes employee auth fields and rejects weak reset payloads', () => {
|
||||||
|
expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({
|
||||||
|
email: 'agent@example.com',
|
||||||
|
password: 'password',
|
||||||
|
})
|
||||||
|
expect(employeeForgotPasswordSchema.parse({ email: 'Reset@Example.COM' }).email).toBe('reset@example.com')
|
||||||
|
expect(employeeLanguageSchema.safeParse({ language: 'ar' }).success).toBe(true)
|
||||||
|
expect(employeeLanguageSchema.safeParse({ language: 'es' }).success).toBe(false)
|
||||||
|
expect(employeeResetPasswordSchema.safeParse({ token: '', password: 'long-enough' }).success).toBe(false)
|
||||||
|
expect(employeeResetPasswordSchema.safeParse({ token: 'token_1', password: 'short' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims renter profile updates and keeps renter push token required', () => {
|
||||||
|
expect(renterUpdateSchema.parse({ firstName: ' Aya ', lastName: ' Benali ', phone: ' 0600000000 ', preferredLocale: 'fr', preferredCurrency: 'MAD' })).toEqual({
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
phone: '0600000000',
|
||||||
|
preferredLocale: 'fr',
|
||||||
|
preferredCurrency: 'MAD',
|
||||||
|
})
|
||||||
|
expect(renterUpdateSchema.safeParse({ preferredLocale: 'es' }).success).toBe(false)
|
||||||
|
expect(renterUpdateSchema.safeParse({ preferredCurrency: 'EUR' }).success).toBe(false)
|
||||||
|
expect(renterFcmTokenSchema.safeParse({ fcmToken: 'token_1' }).success).toBe(true)
|
||||||
|
expect(renterFcmTokenSchema.safeParse({}).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
company: { findUniqueOrThrow: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
|
||||||
|
brandSettings: { findUnique: vi.fn(), upsert: vi.fn(), findFirst: vi.fn(), updateMany: vi.fn() },
|
||||||
|
contractSettings: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||||
|
insurancePolicy: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
|
||||||
|
pricingRule: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
|
||||||
|
accountingSettings: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import * as repo from './company.repo'
|
||||||
|
|
||||||
|
describe('company.repo edge queries', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('loads a company dashboard envelope with brand, subscription and counts', async () => {
|
||||||
|
await repo.findCompany('company_1')
|
||||||
|
|
||||||
|
expect(prisma.company.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'company_1' },
|
||||||
|
include: {
|
||||||
|
brand: true,
|
||||||
|
subscription: true,
|
||||||
|
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts brand settings with companyId only in the create branch', async () => {
|
||||||
|
await repo.upsertBrand('company_1', { logoUrl: '/new.png' }, { primaryColor: '#111111' })
|
||||||
|
|
||||||
|
expect(prisma.brandSettings.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { companyId: 'company_1' },
|
||||||
|
update: { logoUrl: '/new.png' },
|
||||||
|
create: { companyId: 'company_1', primaryColor: '#111111' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('orders insurance policies by required status, sort order and name', async () => {
|
||||||
|
await repo.findInsurancePolicies('company_1')
|
||||||
|
|
||||||
|
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { companyId: 'company_1' },
|
||||||
|
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates and deletes pricing rules inside the tenant boundary', async () => {
|
||||||
|
await repo.updatePricingRule('rule_1', 'company_1', { isActive: false })
|
||||||
|
await repo.deletePricingRule('rule_1', 'company_1')
|
||||||
|
|
||||||
|
expect(prisma.pricingRule.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'rule_1', companyId: 'company_1' },
|
||||||
|
data: { isActive: false },
|
||||||
|
})
|
||||||
|
expect(prisma.pricingRule.deleteMany).toHaveBeenCalledWith({ where: { id: 'rule_1', companyId: 'company_1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects duplicate public branding outside the current company', async () => {
|
||||||
|
await repo.findBrandBySubdomain('atlas', 'company_1')
|
||||||
|
await repo.findBrandByCustomDomain('cars.example.test', 'company_1')
|
||||||
|
|
||||||
|
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { subdomain: 'atlas', companyId: { not: 'company_1' } },
|
||||||
|
})
|
||||||
|
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { customDomain: 'cars.example.test', companyId: { not: 'company_1' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears custom domain verification metadata in one tenant-scoped mutation', async () => {
|
||||||
|
await repo.clearCustomDomain('company_1')
|
||||||
|
|
||||||
|
expect(prisma.brandSettings.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { companyId: 'company_1' },
|
||||||
|
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { generateCompanyApiKey } from '../../security/apiKeys'
|
||||||
|
|
||||||
export async function findCompany(companyId: string) {
|
export async function findCompany(companyId: string) {
|
||||||
return prisma.company.findUniqueOrThrow({
|
return prisma.company.findUniqueOrThrow({
|
||||||
@@ -98,15 +99,34 @@ export async function upsertAccountingSettings(companyId: string, data: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function findApiKey(companyId: string) {
|
export async function findApiKey(companyId: string) {
|
||||||
return prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { apiKey: true } })
|
const apiKeys = await (prisma as any).companyApiKey.findMany({
|
||||||
|
where: { companyId, revokedAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true, revokedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { apiKeys }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateApiKey(companyId: string) {
|
export async function regenerateApiKey(companyId: string) {
|
||||||
return prisma.company.update({
|
const nextKey = generateCompanyApiKey()
|
||||||
where: { id: companyId },
|
|
||||||
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
|
await prisma.$transaction(async (tx: any) => {
|
||||||
select: { apiKey: true },
|
await (tx as any).companyApiKey.updateMany({
|
||||||
|
where: { companyId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
await (tx as any).companyApiKey.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
name: 'Default API key',
|
||||||
|
prefix: nextKey.prefix,
|
||||||
|
keyHash: nextKey.keyHash,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { apiKey: nextKey.rawKey, prefix: nextKey.prefix, warning: 'This raw API key is shown once. Store it securely.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
|
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
|||||||
import { requireTenant } from '../../middleware/requireTenant'
|
import { requireTenant } from '../../middleware/requireTenant'
|
||||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||||
import { requireRole } from '../../middleware/requireRole'
|
import { requireRole } from '../../middleware/requireRole'
|
||||||
|
import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy'
|
||||||
import { parseBody, parseParams } from '../../http/validate'
|
import { parseBody, parseParams } from '../../http/validate'
|
||||||
import { ok, created, noContent } from '../../http/respond'
|
import { ok, created, noContent } from '../../http/respond'
|
||||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||||
@@ -164,7 +165,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
|
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.deletePricingRule(id, req.companyId)
|
await service.deletePricingRule(id, req.companyId)
|
||||||
@@ -179,7 +180,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/accounting-settings', async (req, res, next) => {
|
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(accountingSettingsSchema, req)
|
const body = parseBody(accountingSettingsSchema, req)
|
||||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||||
@@ -187,14 +188,14 @@ router.patch('/me/accounting-settings', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/me/api-key', async (req, res, next) => {
|
router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const company = await service.getApiKey(req.companyId)
|
const company = await service.getApiKey(req.companyId)
|
||||||
ok(res, company)
|
ok(res, company)
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/api-key/regenerate', async (req, res, next) => {
|
router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const company = await service.regenerateApiKey(req.companyId)
|
const company = await service.regenerateApiKey(req.companyId)
|
||||||
ok(res, company)
|
ok(res, company)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user