Compare commits
346 Commits
main
...
fix_branch
| Author | SHA1 | Date | |
|---|---|---|---|
| 057d41e1c2 | |||
| b99e8d0be4 | |||
| cb8bf63218 | |||
| f00d2a8aea | |||
| c0eb1de7a7 | |||
| 66877d66c2 | |||
| fe68f4c6b0 | |||
| e3f80af47d | |||
| c8d7a3954a | |||
| 79a55143b9 | |||
| d6f5f315e8 | |||
| d26df78c70 | |||
| 63974a0061 | |||
| 72671d146d | |||
| 5f1c427600 | |||
| 653074be3e | |||
| fe8ffbeb9f | |||
| c9915a8315 | |||
| 85ff9d492b | |||
| 10e061d2c1 | |||
| 7e7a76633e | |||
| 51ef1fb319 | |||
| 8cd24bae10 | |||
| 6004fbafa0 | |||
| 4587ab71ac | |||
| a8b957f808 | |||
| 4a6df3dcba | |||
| d616621bb6 | |||
| 8fc88ffc14 | |||
| 53de25120a | |||
| 7b8f81336a | |||
| 5f06256271 | |||
| 10ca76fc1e | |||
| cfc3f371de | |||
| 92f1ff655e | |||
| e51c44f486 | |||
| 5429f582f5 | |||
| 149806a773 | |||
| 626da23c2e | |||
| d6fcb52941 | |||
| 86531c5cb0 | |||
| f2fe4b0bc0 | |||
| 300c9dc694 | |||
| 7bc1dd338a | |||
| dc04ef07b9 | |||
| b944ff554c | |||
| 96d1d2d0d6 | |||
| 9e2fc050be | |||
| a665c942d2 | |||
| 1b3c476dd4 | |||
| 3c520f7a53 | |||
| eeec8f0a82 | |||
| 50c74b9007 | |||
| 5649ab02c7 | |||
| 8046a1d447 | |||
| 2b422ca197 | |||
| f6fcd7ce54 | |||
| bcabd17220 | |||
| 7ecd85e9b7 | |||
| 93fba7a318 | |||
| 8f5ca1df22 | |||
| d13ae2205f | |||
| 8e097db22d | |||
| be49ac5e69 | |||
| abb5d105cf | |||
| fb7b4eefc1 | |||
| 7ce0a5adf3 | |||
| 9c526af996 | |||
| a010ad6811 | |||
| ec03e783e5 | |||
| 9c50a59133 | |||
| aa6afea30b | |||
| 98f0b3ffad | |||
| 1d6ae6a54e | |||
| c77d0a8e89 | |||
| fefaf8108d | |||
| 9e096f0216 | |||
| 7f51825b1f | |||
| da35656839 | |||
| 026e33a228 | |||
| abec82a08f | |||
| f4ea01e9de | |||
| a5b6c559ea | |||
| e1b848042b | |||
| d238e6e806 | |||
| be30d19cdb | |||
| 24c58cf006 | |||
| 2befed486f | |||
| 2436907dc2 | |||
| fbbd5e4c1c | |||
| a45b318d5e | |||
| 1c8b3d5401 | |||
| e7e80103c7 | |||
| b5dfe66373 | |||
| c74789f949 | |||
| 0adebc08e5 | |||
| 342e8ee2fe | |||
| 030ecd8fd8 | |||
| 382738b52a | |||
| 2d5f34fc65 | |||
| 765c1ede8f | |||
| f6f7352c0d | |||
| 48809e8bf0 | |||
| 088fc2db65 | |||
| f4097b0b2f | |||
| 7a7fad65d0 | |||
| a7d0c29144 | |||
| b220807d70 | |||
| 2de00868ad | |||
| 2959285425 | |||
| c27f0909df | |||
| 511ab4d03b | |||
| 00d01bdaf4 | |||
| 68bf9ca610 | |||
| f330efb1ad | |||
| e3f40410e9 | |||
| c21916559f | |||
| 7ff2dbb139 | |||
| ad5f5ebab7 | |||
| a947e48c49 | |||
| 6913c298ad | |||
| b45f84d62b | |||
| 781512399b | |||
| 56c3bcf666 | |||
| 9c11f3660d | |||
| 7c1bd2d0c0 | |||
| 1fd1ddf3f2 | |||
| 0394f0ea2a | |||
| 2993dd5b57 | |||
| e93b988146 | |||
| ea2bd215f6 | |||
| 13b54f07de | |||
| 75a1d39050 | |||
| 0dcbe18c54 | |||
| 8ef61d7005 | |||
| 56984c4ea7 | |||
| 330fc11791 | |||
| 0ddf67c754 | |||
| bcfe518af2 | |||
| 30631504f1 | |||
| 757ad41c9b | |||
| 97d5ecfcf0 | |||
| 63f8df1e38 | |||
| e2b564aea5 | |||
| 184a4bac8b | |||
| 9703de974a | |||
| 5dfd5b1814 | |||
| 13d0512048 | |||
| b0c78ad8a2 | |||
| d8b7b92cb1 | |||
| fb77e91730 | |||
| 9ad92765c1 | |||
| f22e0d45e1 | |||
| a752a399c2 | |||
| e1e2f55c93 | |||
| 85d1aad956 | |||
| 26ab64e473 | |||
| 1e100cf367 | |||
| 3d6607e6c8 | |||
| ae10f5272f | |||
| a8472ddeed | |||
| 6de0690b76 | |||
| 0c9f3f8635 | |||
| 8aab968e09 | |||
| bffda95a7c | |||
| ad88fe0148 | |||
| df83c9876d | |||
| abb3a18345 | |||
| a486f891cf | |||
| 9dd4a3d087 | |||
| 7f86435769 | |||
| e6460de4d0 | |||
| 59b58d02ea | |||
| a48285ecc5 | |||
| 7392b094e0 | |||
| 4d82cf9f1a | |||
| f51d6e4634 | |||
| dce4fdc77e | |||
| dc72970ff1 | |||
| 38ed8045ab | |||
| ab03ee3a4d | |||
| 0f083d8e5d | |||
| e36a59b0bc | |||
| 7962bf9a92 | |||
| 35172ab46b | |||
| d7fb7b7a7b | |||
| 256ff0814e | |||
| c5f614b3e4 | |||
| cd67db99ed | |||
| 81b24080ac | |||
| 3b142ca4c7 | |||
| 572d115003 | |||
| 2739f14e45 | |||
| 4f9107758e | |||
| 8ee9bc0bca | |||
| 6c50458aca | |||
| aca56e7ad9 | |||
| 7da3957ee1 | |||
| d4c460c3cb | |||
| e02c811ffd | |||
| 95accb780d | |||
| ddaccff28c | |||
| 44f59423ec | |||
| af5b7fda7e | |||
| b52c6305b5 | |||
| b5a224dc04 | |||
| 6b97c895e0 | |||
| 37a6ed8a76 | |||
| 6eeba99c45 | |||
| 919f583677 | |||
| f9b793a05f | |||
| 7744d533fd | |||
| ab3ce8e366 | |||
| e5bde57613 | |||
| b22983650d | |||
| 64df4439d3 | |||
| 30c4997656 | |||
| 1c441a164c | |||
| 99ce11eac3 | |||
| f98ac2c4fa | |||
| f1fff38400 | |||
| 80a597bc10 | |||
| 560da1cadf | |||
| a896b1b4e8 | |||
| a5c0512dfa | |||
| 56d936be35 | |||
| 00a1d8e3a8 | |||
| 8d479b050d | |||
| 03f6c26940 | |||
| 52c1ecffe2 | |||
| e0b2239f66 | |||
| e6110d7faa | |||
| 570a58477a | |||
| 994bf3610b | |||
| f985151f0a | |||
| b1ed88f963 | |||
| 2f78937b6f | |||
| 48064fee7a | |||
| 11ed6566b9 | |||
| 72107396a1 | |||
| a9305fa4a2 | |||
| da1841e6b9 | |||
| e9dfbeb591 | |||
| b5a974ee14 | |||
| 29886c3397 | |||
| 8f57ca9d76 | |||
| 0df950f2b1 | |||
| 05d7b1bf8c | |||
| a6a628ce66 | |||
| edf4ef8f84 | |||
| 044f8cc2ee | |||
| f0c5d72523 | |||
| 3f3b9d9e6a | |||
| 1a1c418087 | |||
| 9ab73e8ce6 | |||
| 5ec91230c1 | |||
| 8b3eb588f2 | |||
| 797e01eece | |||
| 2ffbc203e0 | |||
| 3680c5d5f2 | |||
| 0d969ab095 | |||
| 8ed572b3bd | |||
| 914ae839a7 | |||
| 813eb5404c | |||
| c8bde121fc | |||
| 33b6bb55f0 | |||
| 8d0b7a5ceb | |||
| 1606ea0053 | |||
| c9cbe479aa | |||
| 95376d3223 | |||
| 5ef9efb8a9 | |||
| 9bd0938951 | |||
| bf97a072dd | |||
| 31e06a1d61 | |||
| f5292f8b6c | |||
| c6cebdd125 | |||
| 9dc084ee50 | |||
| 90401189f8 | |||
| 07fba16ff2 | |||
| fd10d0db6a | |||
| b82a5d4633 | |||
| 4268e5c379 | |||
| dcd62f35ac | |||
| 8f93bb89f6 | |||
| 48ecde2a51 | |||
| bae4942276 | |||
| fc4d099d4f | |||
| 3e856e093d | |||
| 72f2a13574 | |||
| b9197bcb5d | |||
| 99c429d69c | |||
| 191fb3cb4e | |||
| cc462e2c01 | |||
| 159d89524d | |||
| c1eff5259e | |||
| eb2d4fead0 | |||
| bd96a72ce2 | |||
| e6a7b00596 | |||
| 0ca3127bce | |||
| db43862cea | |||
| f009ca10c6 | |||
| e74681e810 | |||
| 32170acbb3 | |||
| d138949a12 | |||
| 4ab34c4d76 | |||
| fb15883f8e | |||
| d4f7de5762 | |||
| 7ac0098c7f | |||
| f30f6e9796 | |||
| 5ea1780134 | |||
| 0b73504455 | |||
| 8be98062b2 | |||
| 5bc6772f64 | |||
| 459f6c953e | |||
| d81565ca71 | |||
| 58a3787f29 | |||
| 52a6c95d1e | |||
| 84285335a4 | |||
| f52e53519c | |||
| be98e3e65d | |||
| 1294109c55 | |||
| 1b929dd892 | |||
| f6f6d4db06 | |||
| 340a55c400 | |||
| 1e05a7409f | |||
| 321cb2344d | |||
| c01a551b7f | |||
| 7e4fb6d62a | |||
| 4408f7cc8f | |||
| 57befa124c | |||
| e1fa3f9fe5 | |||
| a8c4619576 | |||
| 942c0b282a | |||
| 0edd90732e | |||
| 593ff202a3 | |||
| f317fa12dd | |||
| 89621a163b | |||
| 1a39aa8433 | |||
| 193aeae834 | |||
| 0e1b6f0338 | |||
| cd9fe155ba | |||
| 1f87786437 | |||
| 56b9bb8bc4 | |||
| f5ab64ecea | |||
| 00834dcb7e | |||
| f1c7fec162 |
@@ -0,0 +1,2 @@
|
||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
--artifact-server-path=/tmp/act-artifacts
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: stripe-best-practices
|
||||
description: >-
|
||||
Guides Stripe integration decisions across API selection (Checkout Sessions vs
|
||||
PaymentIntents), Connect platform setup (Accounts v2, controller properties),
|
||||
billing/subscriptions, tax and registrations (Stripe Tax, automatic_tax,
|
||||
product tax codes), Treasury financial accounts, integration options
|
||||
(Checkout, Payment Element), migrating from deprecated Stripe APIs, and
|
||||
security best practices (API key management, restricted keys, webhooks,
|
||||
OAuth). Use when building, modifying, or reviewing any Stripe integration,
|
||||
including accepting payments, building marketplaces, integrating Stripe,
|
||||
processing payments, setting up subscriptions, collecting sales tax, VAT, or
|
||||
GST, creating connected accounts, or implementing secure key handling.
|
||||
|
||||
---
|
||||
|
||||
Latest Stripe API version: **2026-06-24.dahlia**. Always use the latest API version and SDK unless the user specifies otherwise.
|
||||
|
||||
If you don’t have a Stripe account, you can call a CLI command to generate working API keys with no registration required. You can install the Stripe CLI (`npm i -g @stripe/cli`) and run the command `stripe sandbox create` to get started.
|
||||
|
||||
API key default: Always recommend a [restricted API key (RAK)](https://docs.stripe.com/keys/restricted-api-keys.md) (`rk_` prefix) over a secret key (`sk_` prefix).
|
||||
|
||||
## Integration routing
|
||||
|
||||
| Building… | Recommended API | Details |
|
||||
| --- | --- | --- |
|
||||
| One-time payments | Checkout Sessions | <references/payments.md> |
|
||||
| Custom payment form with embedded UI | Checkout Sessions + Payment Element | <references/payments.md> |
|
||||
| Saving a payment method for later | Setup Intents | <references/payments.md> |
|
||||
| Connect platform or marketplace | Accounts v2 (`/v2/core/accounts`) | <references/connect.md> |
|
||||
| Usage-based billing (new integration) | Metronome | <references/billing.md> |
|
||||
| Subscriptions or recurring billing | Billing APIs + Checkout Sessions | <references/billing.md> |
|
||||
| Sales tax, VAT, or GST compliance | Stripe Tax + Registrations API | <references/tax.md> |
|
||||
| Embedded financial accounts / banking | v2 Financial Accounts | <references/treasury.md> |
|
||||
| Security (key management, RAKs, webhooks, OAuth, 2FA, Connect liability) | See security reference | <references/security.md> |
|
||||
|
||||
Read the relevant reference file before answering any integration question or writing code.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- *Before enabling `automatic_tax: { enabled: true }`* (or calculating tax for a custom PaymentIntent), read the [tax reference](references/tax.md) and confirm the user has an active registration. Without one, Stripe calculates and collects no tax while the user believes tax is on (the most common Stripe Tax mistake).
|
||||
|
||||
- *Never include `payment_method_types` in any Stripe API call*, with one exception: Terminal (in-person payments) integrations must pass `payment_method_types: ['card_present']` on the PaymentIntent. For all other integrations, omit this parameter entirely to enable dynamic payment methods, which enables you to configure payment method settings from the Dashboard and dynamically display the most relevant eligible payment methods to each customer to maximize conversion. To customize which payment methods you accept, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) or `excluded_payment_method_types` instead of `payment_method_types`.
|
||||
|
||||
- On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
|
||||
|
||||
## Key documentation
|
||||
|
||||
When the user’s request does not clearly fit a single domain above, consult:
|
||||
|
||||
- [Integration Options](https://docs.stripe.com/payments/payment-methods/integration-options.md) — Start here when designing any integration.
|
||||
- [API Tour](https://docs.stripe.com/payments-api/tour.md) — Overview of Stripe’s API surface.
|
||||
- [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) — Review before launching.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Billing / Subscriptions
|
||||
|
||||
## Table of contents
|
||||
|
||||
- When to use Billing APIs
|
||||
- Recommended frontend pairing
|
||||
- Usage-based billing
|
||||
- Traps to avoid
|
||||
|
||||
## When to use Billing APIs
|
||||
|
||||
If the user has a recurring revenue model (subscriptions, usage-based billing, seat-based pricing), use the Billing APIs to [plan their integration](https://docs.stripe.com/billing/subscriptions/design-an-integration.md) instead of a direct PaymentIntent integration.
|
||||
|
||||
Review the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) and [SaaS guide](https://docs.stripe.com/saas.md) to find the right pattern for the user’s pricing model.
|
||||
|
||||
## Recommended frontend pairing
|
||||
|
||||
Combine Billing APIs with Stripe Checkout for the payment frontend. Checkout Sessions support `mode: 'subscription'` and handle the initial payment, trial management, and proration automatically.
|
||||
|
||||
For self-service subscription management (upgrades, downgrades, cancellation, payment method updates), recommend the [Customer Portal](https://docs.stripe.com/customer-management/integrate-customer-portal.md).
|
||||
|
||||
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
|
||||
|
||||
## Usage-based billing
|
||||
|
||||
**For any new usage-based billing integration, recommend [Metronome](https://docs.stripe.com/billing/usage-based.md) — not the Billing Meters API.** Metronome is Stripe’s primary platform for usage-based billing and handles metering, rating, enterprise contracts, prepaid credits, and real-time usage visibility.
|
||||
|
||||
Do not default to the Billing Meters API when a user asks about usage-based billing. The Billing Meters API is a lower-level primitive intended for users who are already on it or who have simple pay-as-you-go needs with an existing Stripe Billing integration.
|
||||
|
||||
Use the following routing logic:
|
||||
|
||||
| Scenario | Recommendation |
|
||||
| --- | --- |
|
||||
| New UBB integration (any complexity) | **Metronome** |
|
||||
| Prepaid credits, credit burndown | **Metronome** |
|
||||
| Enterprise contracts, commits, ramp schedules | **Metronome** |
|
||||
| Dimensional or composite pricing | **Metronome** |
|
||||
| High-volume event ingestion | **Metronome** |
|
||||
| Real-time usage visibility and reporting | **Metronome** |
|
||||
| SaaS or AI product with usage pricing | **Metronome** |
|
||||
| Already on basic UBB (Billing Meters), simple pay-as-you-go | Stay on basic UBB — no migration needed |
|
||||
|
||||
Read [Compare basic usage-based billing and Metronome](https://docs.stripe.com/billing/subscriptions/usage-based/compare-metronome.md) for a full feature comparison. Read [Get started with Metronome](https://docs.stripe.com/billing/usage-based.md) to begin a Metronome integration.
|
||||
|
||||
## Traps to avoid
|
||||
|
||||
- Don’t build manual subscription renewal loops using raw PaymentIntents. Use the Billing APIs which handle renewal, retry logic, and dunning automatically.
|
||||
- Don’t use the deprecated `plan` object. Use [Prices](https://docs.stripe.com/api/prices.md) instead.
|
||||
- Don’t skip tax setup. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
|
||||
- Don’t put prices for different tiers or plans on a single product. Instead, create one Product for each plan a customer can choose. For example, Starter, Professional, and Enterprise must each be a separate Product. Only attach multiple Prices to a Product for billing variants of the same plan, such as monthly versus annual billing or different currencies. Avoid placing Prices for different tiers on a single Product. Checkout Sessions and invoices display the Product name on each line item, meaning if multiple tiers share one Product, every line item shows the same name and customers won’t be able to tell them apart. For more information, see [Model your product catalog](https://docs.stripe.com/products-prices/how-products-and-prices-work.md#model-your-catalog).
|
||||
- Don’t skip tax setup, and don’t assume enabling `automatic_tax` is enough. Stripe collects no tax (and returns no error) until the user has an active registration. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
|
||||
- *Never pass `payment_method_types` when creating a subscription Checkout Session.* Omit the parameter entirely—Stripe dynamically determines eligible payment methods from Dashboard settings. Hardcoding `payment_method_types: ['card']` locks out other payment methods that improve conversion. See [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md). Correct pattern:
|
||||
|
||||
```ts
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
// Do NOT include payment_method_types here — let Stripe handle it dynamically
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
subscription_data: { trial_period_days: 14 },
|
||||
success_url: `${url}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${url}/pricing`,
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,173 @@
|
||||
# Connect / platforms
|
||||
|
||||
## Critical rules (never violate)
|
||||
|
||||
1. **ALWAYS use Accounts v2 API** (`POST /v2/core/accounts`). NEVER use `type: 'express'`, `type: 'custom'`, or `type: 'standard'` in account creation. NEVER use `stripe.accounts.create({ type: ... })`. These are deprecated v1 patterns.
|
||||
2. **ALWAYS check v2 capability status** before processing. See “Go-live readiness” section below.
|
||||
3. **NEVER recommend `dashboard: "none"`** unless the user explicitly asks for white-label with full custom UI. Default to `express` for marketplaces and `full` for SaaS. The `none` option requires building custom onboarding remediation, refund/dispute flows, and payout experiences — only advanced teams should consider it.
|
||||
4. **ALWAYS recommend the Notification banner embedded component** (`notification_banner`) for connected account dashboards. It keeps accounts healthy as requirements evolve.
|
||||
5. **NEVER use `application_fee_amount` with separate charges and transfers.** Use transfer-math fee retention instead. `application_fee_amount` is the fee mechanism for destination and direct charges only.
|
||||
|
||||
## Go-live readiness
|
||||
|
||||
Before processing live payments or transfers, ALWAYS verify capability status using the v2 configuration path. Do NOT use deprecated v1 fields.
|
||||
|
||||
**For SaaS / Merchant accounts (direct charges):**
|
||||
|
||||
- Check: `configuration.merchant.capabilities.card_payments.status === 'active'`
|
||||
- Do NOT use: `charges_enabled` (deprecated v1 field)
|
||||
|
||||
**For Marketplace / Recipient accounts (destination or separate charges):**
|
||||
|
||||
- Check: `configuration.recipient.capabilities.stripe_balance.stripe_transfers.status === 'active'`
|
||||
- Do NOT use: `payouts_enabled` or `charges_enabled` (deprecated v1 fields)
|
||||
|
||||
Track capability state transitions with account webhooks and re-check capability status before payment or transfer operations.
|
||||
|
||||
## Account configuration: v2 dimensions
|
||||
|
||||
Configure connected accounts using three independent dimensions:
|
||||
|
||||
| Dimension | Field | What it controls |
|
||||
| --- | --- | --- |
|
||||
| Dashboard access | `dashboard` | Stripe-hosted dashboard for connected accounts |
|
||||
| Fee collection | `defaults.responsibilities.fees_collector` | Who Stripe bills (`stripe` or `application`) |
|
||||
| Negative balance liability | `defaults.responsibilities.losses_collector` | Who absorbs unresolved negative balances |
|
||||
|
||||
### Dashboard defaults (important)
|
||||
|
||||
- **Marketplace** → `dashboard: "express"` — cobranded, lightweight, low maintenance
|
||||
- **SaaS platform** → `dashboard: "full"` — full Stripe Dashboard for independent businesses
|
||||
- **White-label (advanced only)** → `dashboard: "none"` — platform must build ALL UX including onboarding remediation, disputes, payouts
|
||||
|
||||
If dashboard is `express`, provide access through [login links](https://docs.stripe.com/api/accounts/login_link/create.md). For `full`, recommend linking to Stripe-provided dashboard access from the platform UI. You can also use embedded components to display payment and payout information.
|
||||
|
||||
### SaaS vs. Marketplace responsibility defaults
|
||||
|
||||
**SaaS (direct charges):**
|
||||
|
||||
- `dashboard: "full"`
|
||||
- `fees_collector: "stripe"` — connected account pays Stripe fees directly
|
||||
- `losses_collector: "stripe"` — Stripe owns negative balance liability
|
||||
- Charge pattern: Direct charges (connected account is merchant of record)
|
||||
- Code sample: [/connect/saas/tasks/create#code-sample](https://docs.stripe.com/connect/saas/tasks/create.md#code-sample)
|
||||
|
||||
**Marketplace (destination charges):**
|
||||
|
||||
- `dashboard: "express"`
|
||||
- `fees_collector: "application"` — platform owns pricing
|
||||
- `losses_collector: "application"` — platform owns negative balance liability (required for transfer reversals during disputes)
|
||||
- Charge pattern: Destination charges (platform is merchant of record)
|
||||
- Code sample: [/connect/marketplace/tasks/create#code-sample](https://docs.stripe.com/connect/marketplace/tasks/create.md#code-sample)
|
||||
|
||||
## Business model to configuration mapping
|
||||
|
||||
| Business model | Dashboard | Fees | Losses | Charge pattern | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Marketplace | `express` | `application` | `application` | Destination | Platform owns checkout |
|
||||
| On-demand services | `express` | `application` | `application` | Destination | Fast seller onboarding |
|
||||
| SaaS platform with payments | `full` | `stripe` | `stripe` | Direct | Sellers run own businesses/stores, own customer relationship |
|
||||
| AI/API platform (SaaS) | `full` | `stripe` | `stripe` | Direct | Providers own payment relationship |
|
||||
| E-commerce enabler (Shopify-like) | `full` | `stripe` | `stripe` | Direct | Sellers create own online stores, accept own payments |
|
||||
| Crowdfunding | `express` | `application` | `application` | Separate charges and transfers | Hold-and-release / delayed payouts |
|
||||
| Subscription platform | `express` | `application` | `application` | Destination | Platform manages recurring checkout |
|
||||
| Multi-seller cart | `express` | `application` | `application` | Separate charges and transfers | Multiple sellers per transaction |
|
||||
| White-label commerce | `none` | `application` | `application` | Destination or direct | Advanced: platform controls all UX |
|
||||
|
||||
## Connected account capabilities (v2)
|
||||
|
||||
### Marketplace (Recipient accounts)
|
||||
|
||||
Create with `configuration.recipient` requesting `stripe_transfers` on `stripe_balance`. Do NOT request `configuration.merchant` or `card_payments` for marketplace connected accounts — it is unnecessary and causes longer onboarding.
|
||||
|
||||
### SaaS (Merchant accounts)
|
||||
|
||||
Create with `configuration.merchant` requesting `card_payments` (and other needed LPMs). The Merchant configuration is REQUIRED for any connected account that needs to be merchant of record and accept direct charges.
|
||||
|
||||
## Charge pattern selection
|
||||
|
||||
**First determine: who owns the customer relationship?**
|
||||
|
||||
- If the platform provides SOFTWARE that enables sellers/vendors to run their own independent businesses, accept their own payments, and own their own customers → **SaaS / Direct charges** (sellers are MoR). Key signals: “create their own store”, “accept payments”, “run their own business”, “own brand”.
|
||||
|
||||
- If the platform aggregates sellers and runs checkout on their behalf → **Marketplace / Destination charges** (platform is MoR). Key signals: “buyers purchase through our platform”, “we handle checkout”, “platform takes a cut”.
|
||||
|
||||
- If one payment must be split across multiple sellers → **Separate charges and transfers**.
|
||||
|
||||
- **Direct charges** (SaaS): Charge created on connected account. Connected account is merchant of record. Use `application_fee_amount` for platform revenue. Requires `configuration.merchant` + `dashboard: "full"` + `losses_collector: "stripe"`.
|
||||
|
||||
- **Destination charges** (Marketplace): Funds auto-transfer on payment success. Platform is MoR. Use `application_fee_amount` to collect platform fees. NOT for hold-and-release.
|
||||
|
||||
- **Separate charges and transfers**: Platform controls transfer timing. Collect fees by transferring less than the charge amount. Do NOT use `application_fee_amount`.
|
||||
|
||||
## Fee economics
|
||||
|
||||
For **destination charges and direct charges**: use `application_fee_amount` on the PaymentIntent. This is the standard fee mechanism.
|
||||
|
||||
- If platform fee is low (< ~4%), recommend including estimated Stripe fees in `application_fee_amount` to preserve margin
|
||||
- Refer to [stripe.com/pricing](https://stripe.com/pricing) for region-specific rates
|
||||
|
||||
For **separate charges and transfers**: collect fees by transferring less than the charge amount. NEVER use `application_fee_amount`.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Always use webhooks for reliable payment confirmation. Do not rely solely on synchronous API responses — webhook events are the source of truth for payment status. Verify incoming [webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) and consider allowlisting [Stripe’s public IP addresses](https://docs.stripe.com/ips.md).
|
||||
|
||||
## Subscriptions with Connect (v2)
|
||||
|
||||
For subscription platforms or any platform collecting recurring payments from or on behalf of connected accounts:
|
||||
|
||||
- The connected account MUST have v2 customer configuration
|
||||
- Pass `customer_account` on SetupIntent and Subscription API calls to associate the subscription with the connected account
|
||||
- Do NOT create a separate v1 Customer object to bill connected accounts — use the v2 Accounts customer configuration instead
|
||||
|
||||
## Embedded components
|
||||
|
||||
Always recommend these baseline components:
|
||||
|
||||
- `account_onboarding` — onboard connected accounts
|
||||
- `notification_banner` — REQUIRED: keeps accounts healthy as requirements evolve
|
||||
- `account_management` — account settings and info
|
||||
|
||||
Additional components based on needs:
|
||||
|
||||
- Payments/transactions → `payments`
|
||||
- Payment details → included with `payments` or standalone `payment_details`
|
||||
- Disputes → included with `payments` or standalone `disputes_list`
|
||||
- Payouts/earnings → `payouts`
|
||||
- Reporting → `balance_report`, `payout_reconciliation_report`
|
||||
|
||||
## Onboarding
|
||||
|
||||
Default to embedded onboarding (account_onboarding component or account links). Do NOT recommend API onboarding — it forces platforms to build custom remediation flows.
|
||||
|
||||
## Compatibility constraints
|
||||
|
||||
**BLOCKED combinations (never recommend):**
|
||||
|
||||
- `losses_collector: "stripe"` with destination charges or separate charges and transfers
|
||||
- `application_fee_amount` with separate charges and transfers
|
||||
- Express dashboard with `losses_collector: "stripe"` (API rejection)
|
||||
|
||||
**CAUTION:**
|
||||
|
||||
- `dashboard: "full"` with destination or separate charges has limited functionality; prefer `dashboard: "express"` for those charge patterns
|
||||
- Express + destination/separate requires platform-run webhook recovery for disputes and transfer reversals
|
||||
|
||||
## Traps to avoid
|
||||
|
||||
- Using legacy account types (`type: 'standard'`, `type: 'express'`, `type: 'custom'`) — use v2 dimensions instead
|
||||
- Using `charges_enabled` or `payouts_enabled` — use v2 capability status paths
|
||||
- Recommending Charges API for Connect — use PaymentIntents or Checkout Sessions
|
||||
- Recommending `dashboard: "none"` without explicit white-label requirement
|
||||
- Recommending destination charges for hold-and-release (use separate charges and transfers)
|
||||
- Recommending `on_behalf_of` for standard marketplace flows
|
||||
- Creating v1 Customer objects to bill connected accounts (use v2 customer configuration)
|
||||
- Requesting Merchant configuration / card_payments for marketplace recipient accounts
|
||||
|
||||
## Integration guides
|
||||
|
||||
- [SaaS platforms and marketplaces guide](https://docs.stripe.com/connect/saas-platforms-and-marketplaces.md) — Choosing the right integration approach.
|
||||
- [Interactive platform guide](https://docs.stripe.com/connect/interactive-platform-guide.md) — Step-by-step platform builder.
|
||||
- [Design an integration](https://docs.stripe.com/connect/design-an-integration.md) — Detailed risk and responsibility decisions.
|
||||
- [Connected account configuration (v2)](https://docs.stripe.com/connect/accounts-v2/connected-account-configuration.md) — Account setup reference.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Payments
|
||||
|
||||
## Table of contents
|
||||
|
||||
- API hierarchy
|
||||
- Integration surfaces
|
||||
- Payment Element guidance
|
||||
- Saving payment methods
|
||||
- Dynamic payment methods
|
||||
- Deprecated APIs and migration paths
|
||||
- PCI compliance
|
||||
|
||||
## API hierarchy
|
||||
|
||||
Use the [Checkout Sessions API](https://docs.stripe.com/api/checkout/sessions.md) (`checkout.sessions.create`) for on-session payments. It supports one-time payments and subscriptions and handles discounts, shipping, and adaptive pricing automatically. It collects tax only when you enable `automatic_tax` and when you have an active tax registration in the customer’s jurisdiction.
|
||||
|
||||
Use the [PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) for off-session payments, or when the user needs to model checkout state independently and create a charge.
|
||||
|
||||
**Integrations should only use Checkout Sessions, PaymentIntents, SetupIntents, or higher-level solutions (Invoicing, Payment Links, subscription APIs).**
|
||||
|
||||
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
|
||||
|
||||
## Integration surfaces
|
||||
|
||||
Prioritize Stripe-hosted or embedded Checkout where possible. Use in this order of preference:
|
||||
|
||||
1. **Payment Links** — No-code. Best for simple products.
|
||||
2. **Checkout** ([docs](https://docs.stripe.com/payments/checkout.md)) — Stripe-hosted or embedded form. Best for most web apps.
|
||||
3. **Payment Element** ([docs](https://docs.stripe.com/payments/payment-element.md)) — Embedded UI component for advanced customization.
|
||||
- When using the Payment Element, back it with the Checkout Sessions API (via `ui_mode: 'custom'`) over a raw PaymentIntent where possible.
|
||||
|
||||
**Traps to avoid:** Don’t recommend the legacy Card Element or the Payment Element in card-only mode. If the user asks for the Card Element, advise them to [migrate to the Payment Element](https://docs.stripe.com/payments/payment-element/migration.md).
|
||||
|
||||
## Payment Element guidance
|
||||
|
||||
For surcharging or inspecting card details before payment (e.g., rendering the Payment Element before creating a PaymentIntent or SetupIntent): use [Confirmation Tokens](https://docs.stripe.com/payments/finalize-payments-on-the-server.md). Don’t recommend `createPaymentMethod` or `createToken` from Stripe.js.
|
||||
|
||||
## Saving payment methods
|
||||
|
||||
Use the [Setup Intents API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for later use.
|
||||
|
||||
**Traps to avoid:** Don’t use the Sources API to save cards to customers. The Sources API is deprecated — Setup Intents is the correct approach.
|
||||
|
||||
## Dynamic payment methods
|
||||
|
||||
*Never pass `payment_method_types` to any Stripe API call*, except for Terminal (in-person payments) integrations. Omitting this parameter enables [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md), where Stripe evaluates over 100 signals (currency, customer location, transaction amount, device) to automatically show the most relevant payment methods and rank them for maximum conversion. Payment methods are managed from the [Dashboard](https://dashboard.stripe.com/settings/payment_methods) with no code changes required.
|
||||
|
||||
This applies to all integration patterns:
|
||||
|
||||
- `checkout.sessions.create`: omit `payment_method_types` entirely. Dynamic method selection is the default behavior.
|
||||
- `paymentIntents.create`: omit `payment_method_types`. On API versions 2023-08-16+, dynamic methods are the default. On older versions, pass `automatic_payment_methods: { enabled: true }`.
|
||||
- `setupIntents.create`: same as PaymentIntents above.
|
||||
- `subscriptions.create`: omit `payment_settings.payment_method_types`. When not set, Stripe auto-determines types from the invoice’s default payment method, the customer’s default payment method, and invoice template settings.
|
||||
- **Terminal** (`paymentIntents.create`): pass `payment_method_types: ['card_present']`. Required for all in-person payments. In Canada, also include `interac_present`: `['card_present', 'interac_present']`. This is the only valid use of `payment_method_types`.
|
||||
|
||||
See the [integration options guide](https://docs.stripe.com/payments/payment-methods/integration-options.md) for full details on dynamic versus manual configuration.
|
||||
|
||||
**Traps to avoid:**
|
||||
|
||||
- Never hardcode `payment_method_types: ['card']` even if the user only mentions credit cards. Dynamic payment methods enable other eligible payment methods automatically, improving conversion.
|
||||
- If the user wants to customize which payment methods appear, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) to manage methods per-integration or `excluded_payment_method_types` to exclude specific methods — never `payment_method_types`.
|
||||
- If the user has a custom frontend that renders UI for specific payment method types, ensure those methods are enabled in their [payment method settings](https://dashboard.stripe.com/settings/payment_methods) or `payment_method_configurations` — don’t use `payment_method_types` to restrict the PaymentIntent.
|
||||
|
||||
## Deprecated APIs and migration paths
|
||||
|
||||
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate to Checkout Sessions or PaymentIntents](https://docs.stripe.com/payments/payment-intents/migration/charges.md).
|
||||
|
||||
Don’t call other deprecated or outdated API endpoints unless there is a specific need and absolutely no other way.
|
||||
|
||||
| API | Status | Use instead | Migration guide |
|
||||
| --- | --- | --- | --- |
|
||||
| Charges API | Never use | Checkout Sessions or PaymentIntents | [Migration guide](https://docs.stripe.com/payments/payment-intents/migration/charges.md) |
|
||||
| Sources API | Deprecated | Setup Intents | [Setup Intents docs](https://docs.stripe.com/api/setup_intents.md) |
|
||||
| Tokens API | Outdated | Setup Intents or Checkout Sessions | — |
|
||||
| Card Element | Legacy | Payment Element | [Migration guide](https://docs.stripe.com/payments/payment-element/migration.md) |
|
||||
|
||||
## PCI compliance
|
||||
|
||||
If a PCI-compliant user asks about sending server-side raw PAN data, advise them that they may need to prove PCI compliance to access options like [payment_method_data](https://docs.stripe.com/api/payment_intents/create.md#create_payment_intent-payment_method_data).
|
||||
|
||||
For users migrating PAN data from another acquirer or payment processor, point them to [the PAN import process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Security best practices
|
||||
|
||||
## Table of contents
|
||||
|
||||
- API keys
|
||||
- Restricted API keys (RAKs)
|
||||
- IP restrictions
|
||||
- Incident response
|
||||
- Webhook security
|
||||
- Mobile and client-side integrations
|
||||
- OAuth and CSRF protection
|
||||
- Two-factor authentication
|
||||
- Connect security
|
||||
- SAML and SCIM
|
||||
|
||||
## API keys
|
||||
|
||||
Never include keys in source code. You might find example code like `client = new Stripe("sk_...")` that are for illustration only. Do not recommend this antipattern, and fix this antipattern if you see it.
|
||||
|
||||
Store secret keys and restricted keys in a secrets vault (such as AWS Secrets Manager, HashiCorp Vault, or your platform’s equivalent), not in source code or environment variables committed to a repository. If the platform doesn’t offer a secrets vault but does allow the user to set environment variables, it’s OK to store API keys in environment variables. Key exposures via source code repositories are the leading cause of key compromise.
|
||||
|
||||
Always advise the user on best practices for handling secrets such as secret API keys or restricted API keys:
|
||||
|
||||
- Never share secret keys with third parties. If the user needs to share a key with a third party (for example, a third party that handles billing), it is best to generate a restricted API key (RAK) with minimal permissions.
|
||||
- Rotate Stripe API keys when personnel with access to those keys depart.
|
||||
- Read [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md).
|
||||
|
||||
Code must never log keys or include them in error messages or analytics. Remove those from logs if you find them.
|
||||
|
||||
Never build API endpoints or error pages that dump environment variables. In addition to Stripe API keys, the environment may have other secrets.
|
||||
|
||||
Use separate keys for separate environments (production, staging, QA). This limits the blast radius if any single key is compromised.
|
||||
|
||||
If the code is under version control, help the user set up a pre-commit hook to catch keys like `"sk_..."` and `"rk_..."` in source code.
|
||||
|
||||
**Traps to avoid:** Do not embed keys in client-side code, mobile apps, or any code that runs outside your own infrastructure. Do not suggest that users substitute a real secret key into example code — point them to [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md) instead.
|
||||
|
||||
## Restricted API keys (RAKs)
|
||||
|
||||
Use [restricted API keys](https://docs.stripe.com/keys/restricted-api-keys.md) (prefix `rk_`) instead of secret keys (prefix `sk_`) wherever possible. RAKs have only the permissions you assign, so a compromised RAK can do far less damage than a compromised secret key.
|
||||
|
||||
Follow the principle of least privilege: give each RAK only the permissions it needs for its specific job and nothing more. Create a separate RAK for each service or use case.
|
||||
|
||||
Preferred migration approach:
|
||||
|
||||
1. Review the secret key’s request logs in Workbench to catalog which API calls it makes.
|
||||
2. Create a RAK in test mode with matching permissions.
|
||||
3. Use the [Stripe CLI](https://docs.stripe.com/stripe-cli.md)’s `stripe logs tail` command to watch logs.
|
||||
4. Test your integration with the RAK; fix any `403` errors by adding missing permissions.
|
||||
5. Create the equivalent live-mode RAK and replace the secret key.
|
||||
6. Rotate or expire the old secret key once confident.
|
||||
|
||||
**Traps to avoid:** Do not default to recommending secret keys. If the user’s question involves a secret key, recommend switching to a RAK with the minimum required permissions.
|
||||
|
||||
## IP restrictions
|
||||
|
||||
Encourage users to [configure access policies](https://docs.stripe.com/keys.md#access-policies) for every API key. Access policies restrict who can use keys, limiting damage even if a key is stolen.
|
||||
|
||||
Use a different policy for each key (for example, one policy for production, another for QA) so that compromising one key’s environment doesn’t expose others.
|
||||
|
||||
## Incident response
|
||||
|
||||
If a key is exposed or compromised, follow [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys), which can be summarized as:
|
||||
|
||||
1. **Roll the key immediately** — go to the [API keys page](https://dashboard.stripe.com/apikeys) and roll or delete the exposed key. Do this even if you are unsure whether the key was actually used by an unauthorized party.
|
||||
2. **Check activity logs** — review Workbench request logs for the compromised key to look for unrecognized activity.
|
||||
3. **Contact Stripe support** if you see activity you don’t recognize.
|
||||
|
||||
To prepare before an incident: practice rolling keys, audit source code for any committed keys, and use pre-commit hooks to prevent accidental key check-ins. See [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys).
|
||||
|
||||
## Webhook security
|
||||
|
||||
Always [verify webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) using Stripe’s webhook signing secret. Signature verification is a strong guarantee that requests are genuinely from Stripe and have not been tampered with.
|
||||
|
||||
For defense in depth, also [allowlist Stripe’s IP addresses](https://docs.stripe.com/ips.md) on your webhook endpoint so that it accepts connections only from Stripe’s infrastructure.
|
||||
|
||||
**Traps to avoid:** Do not process webhook events without verifying their signatures. Unverified webhooks can be spoofed.
|
||||
|
||||
## Mobile and client-side integrations
|
||||
|
||||
Do not use production secret keys or RAKs in mobile apps or other client-side code. Client-side code can be extracted and keys decompiled.
|
||||
|
||||
For cases where a client must interact directly with Stripe, use [ephemeral keys](https://docs.stripe.com/issuing/elements.md#ephemeral-key-authentication). Ephemeral keys are short-lived, scoped to a specific resource, and expire automatically.
|
||||
|
||||
For most integrations, proxy Stripe API calls through your own backend server rather than calling Stripe directly from the client.
|
||||
|
||||
## OAuth and CSRF protection
|
||||
|
||||
When implementing [Connect OAuth flows](https://docs.stripe.com/connect/oauth-reference.md), always use the `state` parameter to protect against CSRF attacks. Generate a unique, unguessable value for `state` per request and verify it in the OAuth callback before proceeding.
|
||||
|
||||
This applies to all Stripe OAuth surfaces: Connect, Link, and Stripe Apps.
|
||||
|
||||
## Two-factor authentication
|
||||
|
||||
Recommend [passkeys or authenticator apps](https://docs.stripe.com/security.md) rather than SMS-based 2FA for Stripe Dashboard access. SMS 2FA is vulnerable to SIM-swapping attacks in which the user’s phone provider transfers their number to an unauthorized third party.
|
||||
|
||||
Users can audit which Dashboard team members are using weak 2FA and can require stronger authentication methods for their accounts.
|
||||
|
||||
## Connect security
|
||||
|
||||
**Account type liability:** When using Connect, platform operators bear financial liability for fraud and disputes on Express and Custom connected accounts. Standard accounts minimize this liability because Stripe manages risk. Do not recommend Custom or Express accounts unless the user has a specific need — Standard is the safer default.
|
||||
|
||||
**Connect onboarding:** Use [Stripe-hosted onboarding](https://docs.stripe.com/connect/onboarding.md) rather than building a custom onboarding flow. Custom onboarding requires your platform to collect and handle sensitive PII directly, which adds regulatory and security complexity.
|
||||
|
||||
## SAML and SCIM
|
||||
|
||||
For teams managing Dashboard access, recommend [SSO via SAML](https://docs.stripe.com/get-started/account/sso.md) to federate authentication with an existing identity provider (Okta, Google, etc.). SSO centralizes access control and simplifies offboarding.
|
||||
|
||||
[SCIM provisioning](https://docs.stripe.com/get-started/account/sso/scim.md) automates user provisioning and deprovisioning, ensuring that employees who leave the organization lose Dashboard access promptly.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Tax / Stripe Tax
|
||||
|
||||
## Table of contents
|
||||
|
||||
- When tax applies
|
||||
- Two-step setup
|
||||
- Verify before you trust automatic tax
|
||||
- Choosing a product tax code
|
||||
- Diagnose zero tax
|
||||
- Per-integration setup
|
||||
- Connect platforms and marketplaces
|
||||
- Threshold and nexus monitoring
|
||||
- Registration safety
|
||||
- If jurisdictions are unknown
|
||||
- If the region or tax type isn’t supported
|
||||
|
||||
## When tax applies
|
||||
|
||||
Use Stripe Tax for any subscription, invoice, or Checkout Session where the user has customers across multiple jurisdictions. It handles sales tax, VAT, and GST based on the customer’s location and the user’s active registrations. See the [Tax overview](https://docs.stripe.com/tax.md) for supported regions and tax types.
|
||||
|
||||
## Two-step setup
|
||||
|
||||
1. Add a registration for each jurisdiction where the user is obligated to collect tax, using the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the [Dashboard](https://docs.stripe.com/tax/registering.md).
|
||||
2. Pass `automatic_tax: { enabled: true }` on the [Subscription](https://docs.stripe.com/api/subscriptions.md), [Invoice](https://docs.stripe.com/api/invoices.md), or [Checkout Session](https://docs.stripe.com/api/checkout/sessions.md) object.
|
||||
|
||||
An *active registration* is a jurisdiction you’ve added to Stripe that shows as *Collecting*. It’s per-jurisdiction, and not the same as having a Stripe account.
|
||||
|
||||
Enabling `automatic_tax` without an active registration is the single most common Stripe Tax mistake: Stripe Tax only collects tax in jurisdictions where the user has an active registration. Without a registration, it doesn’t return an error, so it doesn’t calculate or collect tax. The user thinks tax is on while collecting nothing. Never enable `automatic_tax` and assume the user is set up. Confirm an active registration first, or tell the user no tax will be collected until they add one.
|
||||
|
||||
**Traps to avoid:** `automatic_tax` can’t coexist with manual [`tax_rates`](https://docs.stripe.com/tax/tax-rates.md) (explicit rate objects) on the same object. Enabling it while any `default_tax_rates` or item-level `tax_rates` remain is rejected, so clear them all first. It’s all-or-nothing, not per line item. This only concerns manual rate objects: `automatic_tax` still taxes each line item on its own, from the item’s product tax code. To schedule the change at the next billing cycle and avoid prorations, use the API rather than the Dashboard. For bulk migrations, use the [Tax migration tool](https://docs.stripe.com/billing/taxes/migration.md), which removes the tax rates for you.
|
||||
|
||||
**Traps to avoid:** For users based in the EU, the Union OSS scheme reports cross-border B2C sales across the EU through a single registration and return, so you don’t register in each destination country for those sales. It doesn’t cover domestic or B2B sales. The user still needs a domestic registration in their home country. Confirm the specifics with the user’s tax advisor.
|
||||
|
||||
## Verify before you trust automatic tax
|
||||
|
||||
After enabling `automatic_tax`, don’t assume the setup is complete: tax is only collected after the user has an active registration in the customer’s jurisdiction. Have the user confirm their registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) (or in the Dashboard). With none, tax won’t be collected anywhere. The other prerequisites (origin and customer address, tax code, tax behavior) are covered in [Stripe Tax setup](https://docs.stripe.com/tax/set-up.md).
|
||||
|
||||
## Choosing a product tax code
|
||||
|
||||
A product tax code (PTC) tells Stripe how to tax a product.
|
||||
|
||||
- Never invent, guess, or hardcode a `txcd_` from memory. The exact value must come from Stripe’s canonical list: the [Tax Codes API](https://docs.stripe.com/api/tax_codes.md) or the [tax code guide](https://docs.stripe.com/tax/tax-codes.md).
|
||||
- Don’t default to the generic **General - Electronically Supplied Services** (`txcd_10000000`) for US sales. It’s too broad for US state-level taxability; pick a specific digital or SaaS code. See [tax codes for digital products](https://docs.stripe.com/tax/digital-products.md) and [tax codes for AI services](https://docs.stripe.com/tax/ai.md).
|
||||
- Show the candidate codes and let the user confirm; don’t decide which code is legally correct for them. (Tax code goes on the Product, `tax_behavior` on the Price. See [product tax codes and tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).)
|
||||
|
||||
## Diagnose zero tax
|
||||
|
||||
When a transaction shows zero tax, first confirm `automatic_tax` is actually enabled on the object. If it isn’t, Stripe doesn’t calculate tax at all. If it is, read the `taxability_reason` on the line item’s `taxes` to see why. On a Checkout Session, that breakdown isn’t returned by default: retrieve the session with `expand[]=line_items.data.taxes`.
|
||||
|
||||
The reason worth calling out is **`not_collecting`, which is ambiguous**: it means either **no active registration** in the customer’s jurisdiction (the usual cause; check registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md)) **or** a **Nontaxable product tax code** (`txcd_00000000`) on the product. `taxability_reason` can’t tell the two apart, so check the product’s tax code and rule out the Nontaxable code before concluding it’s a registration gap.
|
||||
|
||||
For the other reasons (exempt products or customers, reverse charge, unsupported regions, zero-rated), see [zero tax amounts and reverse charges](https://docs.stripe.com/tax/zero-tax.md).
|
||||
|
||||
## Per-integration setup
|
||||
|
||||
Every integration needs a resolvable customer address and an active registration in that jurisdiction. It also needs a product tax code and a `tax_behavior`, set on the product/price, or falling back to the account’s [preset tax code and default tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).
|
||||
|
||||
- **Checkout Sessions**: set `automatic_tax: { enabled: true }`. For a new customer, Checkout collects the address it needs, so don’t force `billing_address_collection: 'required'` (unnecessary for tax, and it adds checkout friction). For an existing or returning customer, Checkout uses their saved address by default; to tax the address entered at checkout instead, set `customer_update: { address: 'auto' }` and make sure Checkout actually collects a fresh address (a collected shipping address, or `billing_address_collection: 'required'` when you don’t collect shipping), or it keeps using the saved one. See [tax on Checkout](https://docs.stripe.com/tax/checkout.md).
|
||||
- **Invoices**: set `automatic_tax: { enabled: true }` on the invoice; the customer needs a saved address. See the [Invoices API](https://docs.stripe.com/api/invoices.md).
|
||||
- **Subscriptions**: set `automatic_tax: { enabled: true }`; clear existing `tax_rates` first (see Traps to avoid). See the [Subscriptions API](https://docs.stripe.com/api/subscriptions.md).
|
||||
- **Payment Links**: set `automatic_tax: { enabled: true }`.
|
||||
- **Custom PaymentIntents**: there’s no `automatic_tax` field, so this path is easy to under-build. Create a [tax calculation](https://docs.stripe.com/api/tax/calculations.md) with the customer’s address, set the PaymentIntent `amount` to the calculation total, and link the calculation to the PaymentIntent. You must also record a tax transaction from the calculation after payment, or the sale never appears in tax reports: the [simplified integration](https://docs.stripe.com/tax/payment-intent/simplified.md) records the transaction and refund reversals automatically once the calculation is linked, while the [custom integration](https://docs.stripe.com/tax/payment-intent/custom.md) records them yourself for line-item control.
|
||||
|
||||
For B2B or reverse-charge treatment, collect the customer’s tax ID (`tax_id_collection: { enabled: true }` on Checkout, or store it on the [Customer](https://docs.stripe.com/billing/customer/tax-ids.md)). Without a valid tax ID, Stripe Tax treats a cross-border B2B sale as B2C and charges tax. See [collect tax IDs](https://docs.stripe.com/tax/checkout/tax-ids.md).
|
||||
|
||||
## Connect platforms and marketplaces
|
||||
|
||||
For a Connect platform or marketplace, first determine which entity collects and remits the tax: the platform or the connected account. This is a legal determination, so route the final call to the user’s tax advisor rather than inferring it from whether they call themselves a platform or a marketplace. The practical signal is who the [merchant of record](https://docs.stripe.com/connect/merchant-of-record.md) is, which follows the charge type: direct charges make the connected account the merchant of record, and destination charges usually make it the platform. Marketplace-facilitator rules can override this, so have the advisor confirm. See [Stripe Tax with Connect](https://docs.stripe.com/tax/connect.md) for the decision.
|
||||
|
||||
Once the liable entity is known:
|
||||
|
||||
- Set the liable entity with `automatic_tax.liability` on Checkout, Invoices, Subscriptions, or Payment Links: `{ type: 'self' }` for the platform, or `{ type: 'account', account: '<id>' }` for the connected account. Destination and separate charges support both; a platform-liable direct charge uses the gated `{ type: 'application' }`. Custom PaymentIntents have no `automatic_tax` field, so follow the PaymentIntents path in the guides instead. Pick the guide by outcome: connected account collects, [tax for platforms](https://docs.stripe.com/tax/tax-for-platforms.md); platform collects, [tax for marketplaces](https://docs.stripe.com/tax/tax-for-marketplaces.md).
|
||||
- Registrations and tax settings belong to the liable entity. When the connected account is liable, confirm its [tax settings](https://docs.stripe.com/tax/settings-api.md) `status` is `active` before enabling `automatic_tax` on its payments, and manage its registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) using the `Stripe-Account` header (or Connect embedded components).
|
||||
|
||||
## Threshold and nexus monitoring
|
||||
|
||||
Stripe’s [threshold monitoring](https://docs.stripe.com/tax/monitoring.md) highlights *potential* registration obligations (no public API yet). Present it as information and route the decision to the user’s tax advisor. It’s up to the user to confirm whether registration is required; don’t tell them they must register.
|
||||
|
||||
## Registration safety
|
||||
|
||||
Guide, don’t advise. Never tell a user where they must register or whether they’re legally obligated. Recommend they consult their tax advisor to determine their obligations.
|
||||
|
||||
- The [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) can list, create, update, and expire registrations (set `expires_at` to expire; there’s no delete). A scheduled expiry can be changed, but an expiration that has taken effect is permanent (to collect again, the user adds a new registration), and there’s no pause. A head office address is required before adding a registration.
|
||||
- Adding a registration in Stripe records where the user is *already* registered. It doesn’t register them with the tax authority.
|
||||
- Creating or expiring a registration changes whether Stripe collects tax in that jurisdiction, but it doesn’t register or deregister the user with the tax authority. The user must do that separately. Prepare the change and have the user confirm it; never create or expire a registration automatically.
|
||||
|
||||
**How to register.** Present the paths that fit the user and let them (with their tax advisor) choose. Don’t pick for them.
|
||||
|
||||
- **Register themselves, then record it in Stripe**: the user registers with the tax authority, then records it with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard. See [Register for tax](https://docs.stripe.com/tax/registering.md).
|
||||
- **Ask Stripe to register (US only)**: for remote, out-of-state sellers with no physical presence in the state; no public API, requires a Tax Complete subscription, and doesn’t support in-state registrations. See [Use Stripe to register](https://docs.stripe.com/tax/use-stripe-to-register.md).
|
||||
- **Register outside the US with Taxually**: no public API; done through the Taxually app. See [Register outside the US with Taxually](https://docs.stripe.com/tax/use-taxually-to-register.md).
|
||||
|
||||
**Reporting and filing.** Stripe Tax calculates and collects tax but doesn’t file returns unless the user is on a filing product. Point users to the Dashboard [tax reports and exports](https://docs.stripe.com/tax/reports.md) to reconcile and remit; filing runs through Stripe (US) or Taxually (non-US).
|
||||
|
||||
## If jurisdictions are unknown
|
||||
|
||||
Don’t guess which jurisdictions apply. Ask the user which states or countries they have customers in, then add a registration for each with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard.
|
||||
|
||||
## If the region or tax type isn’t supported
|
||||
|
||||
Check the [supported countries list](https://docs.stripe.com/tax/supported-countries.md). If the jurisdiction isn’t listed, tell the user:
|
||||
|
||||
- Stripe Tax doesn’t support that region yet
|
||||
- They can collect tax manually using `tax_rates` on the subscription or invoice instead (not alongside `automatic_tax`; you can’t use both)
|
||||
- For unsupported tax types (customs duties, excise taxes), Stripe Tax doesn’t apply, so those are out of scope
|
||||
|
||||
Don’t attempt to approximate using a supported region as a proxy.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Treasury / Financial Accounts
|
||||
|
||||
## Table of contents
|
||||
|
||||
- v2 Financial Accounts API
|
||||
- Legacy v1 Treasury
|
||||
|
||||
## v2 Financial Accounts API
|
||||
|
||||
For embedded financial accounts (bank accounts, account and routing numbers, money movement), use the [v2 Financial Accounts API](https://docs.stripe.com/api/v2/core/vault/financial-accounts.md) (`POST /v2/core/vault/financial_accounts`). This is required for new integrations.
|
||||
|
||||
For Treasury for platforms concepts and guides, see the [Treasury for platforms overview](https://docs.stripe.com/treasury/connect.md).
|
||||
|
||||
## Legacy v1 Treasury
|
||||
|
||||
Don’t use the [v1 Treasury Financial Accounts API](https://docs.stripe.com/api/treasury/financial_accounts.md) (`POST /v1/treasury/financial_accounts`) for new integrations. Existing v1 integrations continue to work.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: stripe-directory
|
||||
description: >-
|
||||
Use when the user wants to find businesses, software, service providers, or
|
||||
partners for a specific industry, workflow, pain point, capability, or job to
|
||||
be done. Also use when the agent needs to programmatically purchase or consume
|
||||
a service. Use Stripe Directory to build a short relevant shortlist, even if
|
||||
the user does not mention Stripe Directory explicitly.
|
||||
metadata:
|
||||
short-description: Find (and optionally purchase from) vendors or partners
|
||||
allowed-tools:
|
||||
- Bash(stripe directory *)
|
||||
|
||||
---
|
||||
|
||||
## Stripe Directory Search
|
||||
|
||||
Turn a vague market need into a short, relevant shortlist with `stripe directory search`. Use this even when the user never says “Stripe Directory” — any request to find vendors, tools, partners, or providers for a vertical, workflow, pain point, or job-to-be-done.
|
||||
|
||||
Most requests are **discovery** — find and compare services. That is the core job below. Some services are also **MPP-supported** (MPP = Machine Payment Protocol), meaning you (the agent) can pay their HTTP 402 (Payment Required) endpoint and consume them directly. When the user actually wants to *use or buy* a service, present those results and offer to purchase — see “Purchasing” at the end.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Clarify only what’s missing**: buyer/vertical, job-to-be-done, must-have capability, geography (only if it matters).
|
||||
|
||||
2. **Search iteratively**: `stripe directory search "<query>" --format json`
|
||||
|
||||
- Short noun phrases, one angle per query; run 1-3, then broaden/narrow on results.
|
||||
- Angles to cover: vertical → workflow → pain point → adjacent. Two examples:
|
||||
- services/trades: vertical (`electrician software`, `electrical contractor`) → workflow (`field service management`, `dispatch invoicing estimates`) → pain point (`job scheduling`, `quote automation`) → adjacent (`home services automation`, `contractor crm`).
|
||||
- SaaS/software: vertical (`b2b saas billing`, `developer tools`) → workflow (`subscription management`, `usage-based metering`) → pain point (`failed payment recovery`, `revenue recognition`) → adjacent (`analytics dashboards`, `customer onboarding`).
|
||||
- Hard constraints → filters: `--countries-supported=US`, `--has-stripe-app=true`, `--link-supported=true`, `--stripe-projects-supported=true`.
|
||||
- If the user wants to *use/buy* a service, also pass `--mpp-supported` in at least one search to find results you can pay for programmatically.
|
||||
- Sparse niche? Raise `--limit` and try the next `--page` before concluding it’s empty.
|
||||
|
||||
3. **Dedupe & score** using `display_name`, `description`, `url`, `username` as evidence.
|
||||
|
||||
- Prefer results whose description/site clearly match the target workflow.
|
||||
- Prefer more trust signals over fewer: Projects provider, Link enabled, Marketplace app, Stripe Verified. For buy/use intent, also prefer MPP-supported results.
|
||||
- Thin description but strong brand/domain match → keep in a weaker bucket, don’t discard.
|
||||
|
||||
4. **Return a shortlist, not a dump** — 5-10 strong matches, grouped:
|
||||
|
||||
- **direct** / **adjacent** / **needs manual review**
|
||||
- Each entry: name · why it matched · URL (· which query surfaced it, when useful).
|
||||
- Projects providers: offer the follow-up. The JSON gives the exact commands under each result’s `projects.catalog_command` / `projects.install_command` (`stripe projects catalog <provider>`, `stripe projects add <provider>`).
|
||||
- MPP-supported results: note they’re purchasable and include `mpp.slug` / `mpp.url`.
|
||||
|
||||
5. **Be honest about weak results** — if sparse or generic, say so and adjust: broaden, narrow, or try synonyms rather than padding with noise.
|
||||
|
||||
Always report the exact queries (and filters) you ran so the user can keep iterating.
|
||||
|
||||
## Purchasing (only when the user wants to buy or consume a service)
|
||||
|
||||
MPP-supported results are payable directly. Don’t drive to purchase unprompted. When the user wants to buy, **present the full menu of payment methods and ask which they’d like to use** before doing anything:
|
||||
|
||||
> "Which payment method would you like to use?
|
||||
>
|
||||
> - **Link CLI** — Stripe-native, test mode available (recommended)
|
||||
- **Tempo** — crypto wallet
|
||||
- **Privy Agent Wallet CLI** — crypto wallet
|
||||
- **mppx** — debug-only fallback"
|
||||
|
||||
Once the user picks, silently run `which <tool> 2>/dev/null` to check if it’s installed. If not installed, offer to install it (for example, `npm i -g @stripe/link-cli` for Link CLI) and wait for confirmation before proceeding.
|
||||
|
||||
**Always show the price and get explicit user approval before any money moves**; prefer a no-charge test path first.
|
||||
|
||||
Short version:
|
||||
|
||||
1. Resolve the real callable endpoint from the result’s `mpp.slug` / `mpp.url`. `mpp.url` is often the mpp.dev landing form (`https://mpp.dev/services#<slug>`) — resolve the raw endpoint on [mpp.dev](https://mpp.dev) if so. Read the HTTP 402 challenge to confirm the amount: `curl -s -D - -o /dev/null <endpoint_url>` (look for `WWW-Authenticate`).
|
||||
2. Use the payer the user selected.
|
||||
- **`link-cli`** (Stripe-native Shared Payment Token, has a test mode, no crypto wallet, US Link accounts only; `npm i -g @stripe/link-cli`): `auth login` → `mpp decode --challenge "<value>"` (get `network_id`) → `spend-request create --credential-type shared_payment_token --network-id <id> --amount <cents ≤50000> --context "<100+ chars>" --request-approval` (blocks for approval) → `mpp pay <endpoint_url> --spend-request-id <approved_id>`.
|
||||
- **Tempo**: `tempo wallet login` / `services` / `request`.
|
||||
- **Privy**: `@privy-io/agent-wallet-cli`.
|
||||
- **mppx**: debug-only fallback.
|
||||
|
||||
Never invent results or skip the price/approval gate.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: stripe-docs
|
||||
description: >-
|
||||
Use when the user or agent needs to read, search, or look up Stripe
|
||||
documentation or API reference. Prefer this over curl or WebFetch for any
|
||||
docs.stripe.com content.
|
||||
metadata:
|
||||
short-description: Read and search Stripe documentation from the terminal
|
||||
allowed-tools:
|
||||
- Bash(stripe docs *)
|
||||
|
||||
---
|
||||
|
||||
Use `stripe docs` instead of fetching [docs.stripe.com](https://docs.stripe.com/.md) content directly with `curl` or `WebFetch`.
|
||||
|
||||
- Fetches Markdown automatically
|
||||
- Purpose-built for agents and terminal workflows
|
||||
|
||||
## Read a page by its web path
|
||||
|
||||
```bash
|
||||
stripe docs /payments
|
||||
```
|
||||
|
||||
## Search documentation by keyword
|
||||
|
||||
```bash
|
||||
stripe docs search "payment intents"
|
||||
```
|
||||
|
||||
## Look up API reference
|
||||
|
||||
```bash
|
||||
# By resource name
|
||||
stripe docs api product
|
||||
|
||||
# By HTTP method and path
|
||||
stripe docs api GET /v1/products
|
||||
|
||||
# By event type
|
||||
stripe docs api product.created
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: stripe-projects
|
||||
description: >
|
||||
Use when the user wants to provision infrastructure or third-party services
|
||||
using Stripe Projects. Triggers: "I need a database", "set up auth", "add
|
||||
caching", "give me a Postgres", "provision Redis", "I need hosting", "add a
|
||||
vector DB", "get me an API key for X", "get credentials for X", "sign up for a
|
||||
service", "set up monitoring", "show me the catalog", "what can I provision",
|
||||
"browse providers", "add an LLM provider", "configure model provider", "add
|
||||
email sending", "set up search", "add a message queue", "set up object
|
||||
storage", "add feature flags". Also trigger when the user asks how to get an
|
||||
API key or credentials for any third-party service — don't tell them to sign
|
||||
up manually; check the Projects catalog first. Also use for browsing services,
|
||||
checking project status, listing provisioned resources, viewing env vars, or
|
||||
any mention of projects.dev or adding/provisioning/connecting a cloud service.
|
||||
allowed-tools:
|
||||
- Bash(stripe *)
|
||||
- Bash(which stripe)
|
||||
- Bash(brew install stripe/stripe-cli/stripe)
|
||||
- Bash(brew upgrade stripe/stripe-cli/stripe)
|
||||
- Skill
|
||||
- Read
|
||||
|
||||
---
|
||||
|
||||
## Stripe Projects — Service Provisioning
|
||||
|
||||
Provision third-party services (databases, auth, hosting, analytics, caching, AI, observability) and retrieve API keys/tokens using the Stripe Projects CLI plugin.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Ensure Stripe CLI + Projects Plugin
|
||||
|
||||
Check if the Stripe CLI is available:
|
||||
|
||||
```bash
|
||||
which stripe && stripe --version
|
||||
```
|
||||
|
||||
If not installed or below version 1.40.0:
|
||||
|
||||
- **macOS (Homebrew):** `brew install stripe/stripe-cli/stripe` (or `brew upgrade stripe/stripe-cli/stripe`)
|
||||
- **Other platforms:** Direct the user to https://docs.stripe.com/stripe-cli/install for up-to-date instructions.
|
||||
|
||||
Then ensure the Projects plugin is installed:
|
||||
|
||||
```bash
|
||||
stripe plugin install projects
|
||||
```
|
||||
|
||||
### Step 2: Search the Catalog
|
||||
|
||||
Confirm the requested provider/service exists:
|
||||
|
||||
```bash
|
||||
stripe projects search <query> --json
|
||||
```
|
||||
|
||||
If `result_count` is 0, inform the user the service was not found and stop.
|
||||
|
||||
If the user’s request is vague (for example, “I need a database”), browse the catalog to suggest options:
|
||||
|
||||
```bash
|
||||
stripe projects catalog --json
|
||||
```
|
||||
|
||||
### Step 3: Initialize a Project
|
||||
|
||||
Check if a project is already initialized:
|
||||
|
||||
```bash
|
||||
stripe projects status --json
|
||||
```
|
||||
|
||||
If not initialized, run a preflight check first to reveal all blockers at once:
|
||||
|
||||
```bash
|
||||
stripe projects init --preflight --json
|
||||
```
|
||||
|
||||
If all preflight checks pass (or the only failures are `TOS_ACCEPTANCE_REQUIRED` or `Stripe session authenticated`), proceed:
|
||||
|
||||
```bash
|
||||
stripe projects init --accept-tos --yes
|
||||
```
|
||||
|
||||
**Important:** `stripe projects init` installs the `stripe-projects-cli` skill locally at `.claude/skills/stripe-projects-cli`. This skill contains the full post-init command reference.
|
||||
|
||||
### Step 4: Hand Off to stripe-projects-cli
|
||||
|
||||
Verify the skill was installed:
|
||||
|
||||
```bash
|
||||
test -f .claude/skills/stripe-projects-cli/SKILL.md && echo "OK" || echo "MISSING"
|
||||
```
|
||||
|
||||
If `MISSING`: re-run `stripe projects init --accept-tos --yes` — the skill is bundled with the Projects plugin and installed during init.
|
||||
|
||||
If `OK`: use the locally-installed `stripe-projects-cli` skill (invoke using the Skill tool with name `stripe-projects-cli`) to continue the workflow — adding services, managing credentials, and configuring the project.
|
||||
|
||||
### Step 5: Summarize and Suggest
|
||||
|
||||
After a successful service addition, provide output in this format:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Provider | `<provider name>` |
|
||||
| Service | `<service type>` |
|
||||
| Tier | `<tier>` |
|
||||
| Env vars | `<variable names only — never values>` |
|
||||
|
||||
Then suggest 3–5 complementary services from different categories in the catalog (for example, if user added a database, suggest auth, hosting, or observability). Only reference services that actually appear in `stripe projects catalog --json` output — never fabricate commands or provider names.
|
||||
|
||||
## CLI as Source of Truth
|
||||
|
||||
The CLI manages all state under `.projects/` and generates `.env` files. Don’t hand-edit these files. If you need to inspect project state, use the appropriate CLI command:
|
||||
|
||||
| Task | Command |
|
||||
| --- | --- |
|
||||
| View provisioned services | `stripe projects status --json` |
|
||||
| List env var names | `stripe projects env --json` |
|
||||
| Check project health | `stripe projects status --json` |
|
||||
| Browse available services | `stripe projects catalog --json` |
|
||||
|
||||
Only inspect `.projects/` or `.env` directly if the user explicitly asks you to — the CLI is authoritative, so manual edits may be overwritten.
|
||||
|
||||
## Project Variables
|
||||
|
||||
Use project variables when the user wants to store an environment variable that doesn’t come from a provisioned provider resource, such as an app URL, feature flag, or self-managed API key.
|
||||
|
||||
Create or update a project variable for the active environment:
|
||||
|
||||
```bash
|
||||
stripe projects variables set <name> --env-key <ENV_KEY> --value <value>
|
||||
```
|
||||
|
||||
A successful `variables set` syncs the active environment output file immediately. If the user doesn’t provide the value, run the command without `--value` only in interactive mode so the CLI can prompt securely. Never print secret values in your response.
|
||||
|
||||
Bind an existing project variable to the active environment:
|
||||
|
||||
```bash
|
||||
stripe projects env add <name> --variable --env-key <ENV_KEY>
|
||||
```
|
||||
|
||||
Remove a variable binding from the active environment without deleting the stored variable:
|
||||
|
||||
```bash
|
||||
stripe projects env remove <name> --variable
|
||||
```
|
||||
|
||||
List and delete project variables:
|
||||
|
||||
```bash
|
||||
stripe projects variables list --json
|
||||
stripe projects variables delete <name> --yes
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error code | Cause | Recovery |
|
||||
| --- | --- | --- |
|
||||
| `BROWSER_AUTH_REQUIRED` | No auth session and browser needed | Tell user to run `stripe login` — you cannot fix this |
|
||||
| `ACCOUNT_NOT_ELIGIBLE` | Account not onboarded for Projects | Tell user to run `stripe login` or visit https://projects.dev |
|
||||
| `TOS_ACCEPTANCE_REQUIRED` | Developer or provider terms not accepted | Re-run with `--accept-tos` |
|
||||
| `PROVIDER_NOT_LINKED` | Provider requires OAuth linking | Run `stripe projects link <provider>` — may open a browser |
|
||||
| `PLAN_REQUIRED` | Deployable needs a plan provisioned first | Provision the plan listed in the error, then retry |
|
||||
| `UNKNOWN_ERROR` | Unexpected failure | Show the full error message to the user and suggest running with `--debug` for diagnostics |
|
||||
| Service not in catalog | Query returned 0 results | Inform user; suggest `stripe projects catalog --json` to browse alternatives |
|
||||
| CLI not found | Stripe CLI not installed | Install using Homebrew (macOS) or follow https://docs.stripe.com/stripe-cli/install |
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: upgrade-stripe
|
||||
description: Guide for upgrading Stripe API versions and SDKs
|
||||
|
||||
---
|
||||
|
||||
The latest Stripe API version is 2026-06-24.dahlia - use this version when upgrading unless the user specifies a different target version.
|
||||
|
||||
# Upgrading Stripe Versions
|
||||
|
||||
This guide covers upgrading Stripe API versions, server-side SDKs, Stripe.js, and mobile SDKs.
|
||||
|
||||
## Understanding Stripe API Versioning
|
||||
|
||||
Stripe uses date-based API versions (e.g., `2026-06-24.dahlia`, `2025-08-27.basil`, `2024-12-18.acacia`). Your account’s API version determines request/response behavior.
|
||||
|
||||
### Types of Changes
|
||||
|
||||
**Backward-Compatible Changes** (don’t require code updates):
|
||||
|
||||
- New API resources
|
||||
- New optional request parameters
|
||||
- New properties in existing responses
|
||||
- Changes to opaque string lengths (e.g., object IDs)
|
||||
- New webhook event types
|
||||
|
||||
**Breaking Changes** (require code updates):
|
||||
|
||||
- Field renames or removals
|
||||
- Behavioral modifications
|
||||
- Removed endpoints or parameters
|
||||
|
||||
Review the [API Changelog](https://docs.stripe.com/changelog.md) for all changes between versions.
|
||||
|
||||
## Server-Side SDK Versioning
|
||||
|
||||
See [SDK Version Management](https://docs.stripe.com/sdks/set-version.md) for details.
|
||||
|
||||
### Dynamically-Typed Languages (Ruby, Python, PHP, Node.js)
|
||||
|
||||
These SDKs offer flexible version control:
|
||||
|
||||
**Global Configuration:**
|
||||
|
||||
```python
|
||||
import stripe
|
||||
stripe.api_version = '2026-06-24.dahlia'
|
||||
```
|
||||
|
||||
```ruby
|
||||
Stripe.api_version = '2026-06-24.dahlia'
|
||||
```
|
||||
|
||||
```javascript
|
||||
const stripe = require('stripe')('sk_test_xxx', {
|
||||
apiVersion: '2026-06-24.dahlia'
|
||||
});
|
||||
```
|
||||
|
||||
**Per-Request Override:**
|
||||
|
||||
```python
|
||||
stripe.Customer.create(
|
||||
email="customer@example.com",
|
||||
stripe_version='2026-06-24.dahlia'
|
||||
)
|
||||
```
|
||||
|
||||
### Strongly-Typed Languages (Java, Go, .NET)
|
||||
|
||||
These use a fixed API version matching the SDK release date. Don’t set a different API version for strongly-typed languages because response objects might not match the strong types in the SDK. Instead, update the SDK to target a new API version.
|
||||
|
||||
### Best Practice
|
||||
|
||||
Always specify the API version you’re integrating against in your code instead of relying on your account’s default API version:
|
||||
|
||||
```javascript
|
||||
// Good: Explicit version
|
||||
const stripe = require('stripe')('sk_test_xxx', {
|
||||
apiVersion: '2026-06-24.dahlia'
|
||||
});
|
||||
|
||||
// Avoid: Relying on account default
|
||||
const stripe = require('stripe')('sk_test_xxx');
|
||||
```
|
||||
|
||||
## Stripe.js Versioning
|
||||
|
||||
See [Stripe.js Versioning](https://docs.stripe.com/sdks/stripejs-versioning.md) for details.
|
||||
|
||||
Stripe.js uses an evergreen model with major releases (Acacia, Basil, Clover, Dahlia) on a biannual basis.
|
||||
|
||||
### Loading Versioned Stripe.js
|
||||
|
||||
**Via Script Tag:**
|
||||
|
||||
```html
|
||||
<script src="https://js.stripe.com/dahlia/stripe.js"></script>
|
||||
```
|
||||
|
||||
**Via npm:**
|
||||
|
||||
```bash
|
||||
npm install @stripe/stripe-js
|
||||
```
|
||||
|
||||
Major npm versions correspond to specific Stripe.js versions.
|
||||
|
||||
### API Version Pairing
|
||||
|
||||
Each Stripe.js version automatically pairs with its corresponding API version. For instance:
|
||||
|
||||
- Dahlia Stripe.js uses `2026-06-24.dahlia` API
|
||||
- Acacia Stripe.js uses `2024-12-18.acacia` API
|
||||
|
||||
You can’t override this association.
|
||||
|
||||
### Migrating from v3
|
||||
|
||||
1. Identify your current API version in code
|
||||
2. Review the changelog for relevant changes
|
||||
3. Consider gradually updating your API version before switching Stripe.js versions
|
||||
4. Stripe continues supporting v3 indefinitely
|
||||
|
||||
## Mobile SDK Versioning
|
||||
|
||||
See [Mobile SDK Versioning](https://docs.stripe.com/sdks/mobile-sdk-versioning.md) for details.
|
||||
|
||||
### iOS and Android SDKs
|
||||
|
||||
Both platforms follow **semantic versioning** (MAJOR.MINOR.PATCH):
|
||||
|
||||
- **MAJOR**: Breaking API changes
|
||||
- **MINOR**: New functionality (backward-compatible)
|
||||
- **PATCH**: Bug fixes (backward-compatible)
|
||||
|
||||
New features and fixes release only on the latest major version. Upgrade regularly to access improvements.
|
||||
|
||||
### React Native SDK
|
||||
|
||||
Uses a different model (0.x.y schema):
|
||||
|
||||
- **Minor version changes** (x): Breaking changes AND new features
|
||||
- **Patch updates** (y): Critical bug fixes only
|
||||
|
||||
### Backend Compatibility
|
||||
|
||||
All mobile SDKs work with any Stripe API version you use on your backend unless documentation specifies otherwise.
|
||||
|
||||
## Upgrade Checklist
|
||||
|
||||
1. Review the [API Changelog](https://docs.stripe.com/changelog.md) for changes between your current and target versions
|
||||
2. Check [Upgrades Guide](https://docs.stripe.com/upgrades.md) for migration guidance
|
||||
3. Update server-side SDK package version (e.g., `npm update stripe`, `pip install --upgrade stripe`)
|
||||
4. Update the `apiVersion` parameter in your Stripe client initialization
|
||||
5. Test your integration against the new API version using the `Stripe-Version` header
|
||||
6. Update webhook handlers to handle new event structures
|
||||
7. Update Stripe.js script tag or npm package version if needed
|
||||
8. Update mobile SDK versions in your package manager if needed
|
||||
9. Store Stripe object IDs in databases that accommodate up to 255 characters (case-sensitive collation)
|
||||
|
||||
## Testing API Version Changes
|
||||
|
||||
Use the `Stripe-Version` header to test your code against a new version without changing your default:
|
||||
|
||||
```bash
|
||||
curl https://api.stripe.com/v1/customers \
|
||||
-u sk_test_xxx: \
|
||||
-H "Stripe-Version: 2026-06-24.dahlia"
|
||||
```
|
||||
|
||||
Or in code:
|
||||
|
||||
```javascript
|
||||
const stripe = require('stripe')('sk_test_xxx', {
|
||||
apiVersion: '2026-06-24.dahlia' // Test with new version
|
||||
});
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Your webhook listener should handle unfamiliar event types gracefully
|
||||
- Test webhooks with the new version structure before upgrading
|
||||
- Breaking changes are tagged by affected product areas (Payments, Billing, Connect, etc.)
|
||||
- Multiple API versions coexist simultaneously, enabling staged adoption
|
||||
@@ -0,0 +1,52 @@
|
||||
# Scope Discipline Rules
|
||||
|
||||
These rules override any general instinct to "improve while I'm in there." Follow them on every task, no exceptions.
|
||||
|
||||
## Before touching any code
|
||||
|
||||
1. Restate the task in one sentence: what behavior must change, and what the expected outcome is.
|
||||
2. Identify the smallest set of files/functions responsible for that behavior. This is your **allowed scope**. Everything else is **protected**.
|
||||
3. Read the relevant code before editing it. Do not edit based on assumptions about how it probably works.
|
||||
|
||||
## The hard rule: ask before expanding scope
|
||||
|
||||
If, while working, you find that:
|
||||
- a file outside your allowed scope needs to change,
|
||||
- a dependency needs to be added/updated,
|
||||
- a test needs modification,
|
||||
- an unrelated bug is blocking you,
|
||||
- or a "cleaner" implementation would touch more than the minimum,
|
||||
|
||||
**stop and ask me before making that change.** Explain:
|
||||
- what you were trying to do,
|
||||
- why the fix requires going outside the original scope,
|
||||
- exactly what you want to change and where.
|
||||
|
||||
Wait for my answer. Do not proceed on your own judgment, even if you're confident it's correct or trivial.
|
||||
|
||||
This applies even to small things (renaming a variable for clarity, fixing a typo in an unrelated comment, reformatting a block you had to scroll past). If it's not required to satisfy the request, ask first.
|
||||
|
||||
## While editing
|
||||
|
||||
- Make the fewest-line, fewest-file change that correctly satisfies the request.
|
||||
- Preserve existing naming, structure, patterns, and formatting. Match the codebase's existing style, don't impose your own.
|
||||
- Never run project-wide formatters/linters-with-autofix/import-organizers as a side effect of a small change.
|
||||
- Never touch tests except to add new ones that validate the requested behavior — and only after confirming that's in scope.
|
||||
- Treat any uncommitted/staged changes already in the working tree as off-limits. Don't revert, reset, or absorb them into your edit.
|
||||
|
||||
## Before reporting done
|
||||
|
||||
Review your own diff, file by file, line by line. For anything you can't justify with "this was required by the explicit request," revert it.
|
||||
|
||||
Then report:
|
||||
- **Files changed** — list, with a one-line reason each tied directly to the request.
|
||||
- **Scope confirmation** — explicitly state: "No files, dependencies, tests, or config outside this list were modified."
|
||||
- **Anything you noticed but didn't touch** — unrelated bugs, tech debt, cleanup opportunities. Mention them, don't fix them.
|
||||
|
||||
## If the task genuinely can't be done without expanding scope
|
||||
|
||||
Say so plainly, explain what would need to change and why, and wait for confirmation. Don't silently do the bigger version, and don't pretend a partial/incorrect fix is complete.
|
||||
|
||||
---
|
||||
|
||||
**Default when uncertain: don't make the change, ask instead.**
|
||||
@@ -8,9 +8,13 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.next
|
||||
*/.next
|
||||
dist
|
||||
coverage
|
||||
tmp
|
||||
.env.local
|
||||
.env.*.local
|
||||
# Exclude any stale pages-router directories (all apps use App Router)
|
||||
apps/*/pages
|
||||
apps/*/src/pages
|
||||
|
||||
|
||||
+44
-15
@@ -1,37 +1,66 @@
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_HOST_LOCAL=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=password
|
||||
REDIS_URL=redis://redis:6379
|
||||
API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
DASHBOARD_URL=http://localhost:3001
|
||||
JWT_SECRET=dev-secret
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
ADMIN_URL=http://localhost:3000/admin
|
||||
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
|
||||
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
||||
# Asset prefixes for apps served through the homepage dev proxy.
|
||||
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
|
||||
JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
||||
ADMIN_SEED_PASSWORD=changeme123
|
||||
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
|
||||
ADMIN_SEED_PASSWORD=Qwerty0012345
|
||||
ADMIN_SEED_FIRST_NAME=Platform
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
CLERK_SECRET_KEY=placeholder
|
||||
NODE_ENV=development
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
|
||||
|
||||
# Email - disable Resend (placeholder), use SMTP instead
|
||||
RESEND_API_KEY=re_...
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
|
||||
# Email — Gmail SMTP for local Docker development
|
||||
EMAIL_PROVIDER=gmail
|
||||
RESEND_API_KEY=re_PLACEHOLDER
|
||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
# Use a Gmail app password, not your normal Google account password.
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=xmhg ibqy muoc rntc
|
||||
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
|
||||
|
||||
# Manual subscription payments for local development
|
||||
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true
|
||||
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true
|
||||
PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean
|
||||
BANK_TRANSFER_ENABLED=true
|
||||
BANK_TRANSFER_ACCOUNT_NAME=RentalDriveGo SARL
|
||||
BANK_TRANSFER_BANK_NAME=Local Development Bank
|
||||
BANK_TRANSFER_ACCOUNT_REFERENCE=DEV-MA64-0000-0000-0000
|
||||
BANK_TRANSFER_DUE_DAYS=7
|
||||
CHECK_PAYMENT_ENABLED=true
|
||||
CHECK_PAYMENT_PAYEE=RentalDriveGo SARL
|
||||
CHECK_PAYMENT_DELIVERY_ADDRESS=Local development billing desk
|
||||
CHECK_PAYMENT_DUE_DAYS=14
|
||||
|
||||
@@ -1,58 +1,95 @@
|
||||
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
MARKETPLACE_DOMAIN=rentaldrivego.ma
|
||||
API_DOMAIN=api.rentaldrivego.ma
|
||||
DASHBOARD_DOMAIN=dashboard.rentaldrivego.ma
|
||||
ADMIN_DOMAIN=admin.rentaldrivego.ma
|
||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
|
||||
|
||||
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# ── API ────────────────────────────────────────────────────────────────────────
|
||||
# ── Optional prebuilt image source ────────────────────────────────────────────
|
||||
# Used for production deployments. CI injects IMAGE_TAG automatically during
|
||||
# registry-backed deploys; set it manually when running docker compose directly.
|
||||
IMAGE_TAG=latest
|
||||
# APP_IMAGE and APP_VERSION are still accepted by helper scripts for compatibility.
|
||||
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
|
||||
# APP_VERSION=latest
|
||||
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
||||
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=dbadmin
|
||||
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
# SSR server-side calls go via the internal Docker network
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ADMIN_INTERNAL_URL=http://admin:3002
|
||||
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
||||
API_URL=https://api.rentaldrivego.ma
|
||||
# Baked into Next.js bundles at build time — MUST be set before running `npm run build`
|
||||
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
|
||||
# ── Frontend public URLs ───────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
||||
NEXT_PUBLIC_ADMIN_URL=https://admin.rentaldrivego.ma
|
||||
|
||||
# ── Frontend public URLs ──────────────────────────────────────────────────────
|
||||
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||
SITE_ORIGIN=https://rentaldrivego.ma
|
||||
|
||||
# Carplace public routes sit under /carplace on the public site domain.
|
||||
NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
||||
DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed browser origins. REQUIRED in production.
|
||||
CORS_ORIGINS=https://rentaldrivego.ma,https://dashboard.rentaldrivego.ma,https://admin.rentaldrivego.ma
|
||||
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
POSTGRES_PASSWORD=24DY@1u5FLCkNMeiO@p
|
||||
NODE_ENV=production
|
||||
# Shared parent-domain cookie so sessions work across rentaldrivego.ma and
|
||||
# api.rentaldrivego.ma (the admin/dashboard apps call the API cross-origin).
|
||||
# Must start with a leading dot.
|
||||
SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
|
||||
|
||||
# ── Email (choose one: Resend API or SMTP) ────────────────────────────────────
|
||||
# Option A — Resend
|
||||
RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||
|
||||
# ── Email ─────────────────────────────────────────────────────────────────────
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
# Option B — SMTP
|
||||
# MAIL_HOST=smtp.rentaldrivego.ma
|
||||
# MAIL_PORT=587
|
||||
# MAIL_USERNAME=smtp-user
|
||||
# MAIL_PASSWORD=smtp-password
|
||||
# MAIL_SCHEME=smtp
|
||||
# MAIL_FROM_ADDRESS=noreply@rentaldrivego.ma
|
||||
# MAIL_FROM_NAME=RentalDriveGo
|
||||
# Option A — Resend
|
||||
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
# Option B — SMTP (Gmail)
|
||||
# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend.
|
||||
EMAIL_PROVIDER=gmail
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=your-16-character-gmail-app-password
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
|
||||
# ── Firebase push notifications (optional) ────────────────────────────────────
|
||||
# FIREBASE_PROJECT_ID=your-firebase-project-id
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Traefik domains
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
API_DOMAIN=api.example.com
|
||||
PUBLIC_SITE_DOMAIN=example.com
|
||||
PGMANAGE_DOMAIN=pgmanage.example.com
|
||||
PORTAINER_DOMAIN=portainer.example.com
|
||||
REGISTRY_DOMAIN=registry.example.com
|
||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
||||
|
||||
# Image tag
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Database
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=placeholder
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
|
||||
# Cache
|
||||
REDIS_PASSWORD=placeholder
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# API
|
||||
API_PORT=4000
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
||||
|
||||
# Frontend public URLs
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
|
||||
NEXT_PUBLIC_CARPLACE_URL=https://example.com
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
||||
DASHBOARD_URL=https://example.com/dashboard
|
||||
ADMIN_URL=https://example.com/admin
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=https://example.com,https://www.example.com
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
SESSION_COOKIE_DOMAIN=.example.com
|
||||
NODE_ENV=production
|
||||
|
||||
# File storage
|
||||
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/storage
|
||||
|
||||
# PgManage
|
||||
PGMANAGE_DEFAULT_USERNAME=admin
|
||||
PGMANAGE_DEFAULT_PASSWORD=change-me
|
||||
|
||||
# Email
|
||||
EMAIL_FROM=noreply@example.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=your-smtp-user@example.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_FROM_ADDRESS=noreply@example.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=support@example.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
REDIS_URL=redis://redis:6379
|
||||
API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
JWT_SECRET=test-secret
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
NODE_ENV=test
|
||||
|
||||
+49
-30
@@ -4,7 +4,13 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Database ──────────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/rentaldrivego"
|
||||
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=dbadmin
|
||||
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
|
||||
# ─── API ───────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
@@ -12,54 +18,43 @@ API_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
|
||||
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
# Optional in local development. Required in production when auth spans sibling subdomains.
|
||||
# Example: SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
|
||||
SESSION_COOKIE_DOMAIN=
|
||||
|
||||
# ─── Clerk (Company employee auth) ────────────────────────────
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
||||
CLERK_SECRET_KEY=sk_test_...
|
||||
CLERK_SECRET_KEY=placeholder
|
||||
CLERK_WEBHOOK_SECRET=whsec_...
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
|
||||
|
||||
# ─── AmanPay (Primary payment provider) ───────────────────────
|
||||
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
|
||||
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
|
||||
AMANPAY_SECRET_KEY=your-amanpay-secret-key
|
||||
AMANPAY_BASE_URL=https://api.amanpay.net
|
||||
AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret
|
||||
|
||||
# ─── PayPal (Secondary payment provider) ──────────────────────
|
||||
# RentalDriveGo's own PayPal account (for collecting subscription fees)
|
||||
PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
PAYPAL_CLIENT_SECRET=your-paypal-client-secret
|
||||
PAYPAL_BASE_URL=https://api-m.paypal.com
|
||||
# Use https://api-m.sandbox.paypal.com for sandbox
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
|
||||
# ─── Cloudinary (Vehicle + brand photos) ──────────────────────
|
||||
CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||
CLOUDINARY_API_KEY=your-api-key
|
||||
CLOUDINARY_API_SECRET=your-api-secret
|
||||
CLOUDINARY_API_SECRET=placeholder
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||
|
||||
# ─── Resend (Email) ────────────────────────────────────────────
|
||||
RESEND_API_KEY=re_...
|
||||
EMAIL_FROM=noreply@rentaldrivego.com
|
||||
RESEND_API_KEY=placeholder
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
|
||||
# ─── Twilio (SMS + WhatsApp) ───────────────────────────────────
|
||||
TWILIO_ACCOUNT_SID=AC...
|
||||
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||
TWILIO_AUTH_TOKEN=placeholder
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
|
||||
|
||||
# ─── Firebase (Push notifications) ───────────────────────────
|
||||
FIREBASE_PROJECT_ID=your-project-id
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_PRIVATE_KEY=placeholder
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
@@ -71,22 +66,46 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
|
||||
# MAIL_* SMTP variables are intentionally omitted here.
|
||||
|
||||
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
||||
REDIS_PASSWORD=replace-with-production-redis-password
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ─── App URLs ──────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
|
||||
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||
# SITE_ORIGIN=https://your-production-domain.example
|
||||
|
||||
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||
NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
|
||||
# Public site is subdomain-based; use this for local dev:
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
DASHBOARD_URL=http://localhost:3001
|
||||
API_DOMAIN=api.rentaldrivego.ma
|
||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
|
||||
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
||||
ADMIN_SEED_PASSWORD=changeme123
|
||||
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
|
||||
ADMIN_SEED_PASSWORD=Qwerty0012345
|
||||
ADMIN_SEED_FIRST_NAME=Super
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
|
||||
# Email provider: auto, resend, smtp, or gmail.
|
||||
# Use a Gmail app password for Gmail SMTP, not your normal Google account password.
|
||||
EMAIL_PROVIDER=gmail
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=your-16-character-gmail-app-password
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
|
||||
|
||||
IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0
|
||||
|
||||
# ─── Misc ──────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NODE_ENV=production
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
name: Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [fix_branch]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# TODO: Remove after installing internal CA certificate on the runner
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
REGISTRY_HOST: 192.168.3.80
|
||||
DEPLOY_REGISTRY_HOST: 10.0.0.4
|
||||
DEPLOY_SSH_HOST: 10.0.0.1
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
DOCKER_PLATFORM: linux/amd64
|
||||
DEPLOY_ROOT: /opt/rentaldrivego
|
||||
|
||||
jobs:
|
||||
pipeline-tests:
|
||||
name: Pipeline Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL: redis://redis:6379
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret
|
||||
JWT_EXPIRY: 8h
|
||||
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
CHECKOUT_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "::error::git must be available in the runner image"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
|
||||
mkdir -p "$WORKSPACE"
|
||||
cd "$WORKSPACE"
|
||||
|
||||
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
|
||||
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
|
||||
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
|
||||
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
|
||||
echo "::error::Missing repository checkout context"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
|
||||
if [ ! -d .git ]; then
|
||||
git init
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
fi
|
||||
|
||||
git config --global --add safe.directory "$WORKSPACE"
|
||||
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
|
||||
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin "$SHA"
|
||||
fi
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
|
||||
- name: Generate database client
|
||||
run: npm run db:generate
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Apply test database migrations
|
||||
run: npm run db:deploy
|
||||
|
||||
- name: Synchronize test database schema
|
||||
run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
|
||||
- name: Integration tests
|
||||
run: npm run test:integration
|
||||
|
||||
build-image:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: pipeline-tests
|
||||
outputs:
|
||||
image_repository: ${{ steps.image-meta.outputs.repository }}
|
||||
docker_image: ${{ steps.image-meta.outputs.full }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
CHECKOUT_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "::error::git must be available in the runner image; this workflow cannot install git while runner DNS is unavailable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
|
||||
mkdir -p "$WORKSPACE"
|
||||
cd "$WORKSPACE"
|
||||
|
||||
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
|
||||
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
|
||||
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
|
||||
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
|
||||
echo "::error::Missing repository checkout context"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
|
||||
if [ ! -d .git ]; then
|
||||
git init
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
fi
|
||||
|
||||
git config --global --add safe.directory "$WORKSPACE"
|
||||
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
|
||||
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin "$SHA"
|
||||
fi
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Docker image metadata
|
||||
id: image-meta
|
||||
run: |
|
||||
REPO="${{ gitea.repository }}"
|
||||
TAG="${{ gitea.sha }}"
|
||||
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check Docker registry credentials
|
||||
id: registry-check
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::Registry credentials secrets not set — configure REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN; push will be skipped"
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Validate public URLs
|
||||
shell: bash
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
|
||||
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||
run: |
|
||||
validate_url() {
|
||||
name="$1"
|
||||
value="$2"
|
||||
if [ -z "$value" ]; then
|
||||
echo "::error::$name is missing — add it to Gitea Actions secrets"
|
||||
exit 1
|
||||
fi
|
||||
case "$value" in
|
||||
http://*|https://*) ;;
|
||||
*)
|
||||
echo "::error::$name must be an absolute URL (got: $value)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
|
||||
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
||||
fi
|
||||
|
||||
validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
|
||||
validate_url NEXT_PUBLIC_HOMEPAGE_URL "$NEXT_PUBLIC_HOMEPAGE_URL"
|
||||
validate_url NEXT_PUBLIC_CARPLACE_URL "$NEXT_PUBLIC_CARPLACE_URL"
|
||||
validate_url NEXT_PUBLIC_DASHBOARD_URL "$NEXT_PUBLIC_DASHBOARD_URL"
|
||||
validate_url NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL"
|
||||
validate_url SITE_ORIGIN "$SITE_ORIGIN"
|
||||
|
||||
- name: Check remote build credentials
|
||||
id: check-build-host
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then
|
||||
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$VPS_HOST" ] || \
|
||||
[ -z "${{ secrets.VPS_USER }}" ]; then
|
||||
echo "::error::VPS_USER and either VPS_IP or DEPLOY_SSH_HOST are required to build on the remote Docker host"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check SSH tools
|
||||
run: |
|
||||
if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then
|
||||
echo "::error::ssh, ssh-keygen, and tar must be available in the runner image; this workflow cannot install packages while runner DNS is unavailable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up SSH key
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using SSH host $VPS_HOST"
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
if [ -n "$VPS_SSH_KEY_B64" ]; then
|
||||
if ! printf '%s' "$VPS_SSH_KEY_B64" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_rsa 2>/tmp/vps_ssh_key_decode.err; then
|
||||
if [ -n "$VPS_SSH_KEY" ]; then
|
||||
echo "::warning::VPS_SSH_KEY_B64 is not valid base64; using VPS_SSH_KEY instead"
|
||||
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
else
|
||||
echo "::error::VPS_SSH_KEY_B64 is not valid base64. Store a base64-encoded private key there, or set VPS_SSH_KEY to the raw private key."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
fi
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
key_header="$(head -n 1 ~/.ssh/id_rsa || true)"
|
||||
key_size="$(wc -c < ~/.ssh/id_rsa | tr -d ' ')"
|
||||
case "$key_header" in
|
||||
"-----BEGIN "*PRIVATE*" KEY-----") ;;
|
||||
ssh-*|"ecdsa-"*|"sk-"*)
|
||||
echo "::error::Decoded SSH key looks like a public key, not a private key. Encode the private key file, not the .pub file."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::Decoded SSH key does not start with a private key header. Decoded byte count: $key_size."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if ! keygen_error="$(ssh-keygen -y -f ~/.ssh/id_rsa 2>&1 >/dev/null)"; then
|
||||
echo "::error::SSH private key is not readable by ssh-keygen: $keygen_error. Use an unencrypted private key or configure a CI-specific deploy key."
|
||||
exit 1
|
||||
fi
|
||||
ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub
|
||||
key_fingerprint="$(ssh-keygen -lf ~/.ssh/id_rsa.pub | awk '{print $2}')"
|
||||
echo "Loaded deploy key fingerprint: $key_fingerprint"
|
||||
echo "Deploy public key: $(cat ~/.ssh/id_rsa.pub)"
|
||||
touch ~/.ssh/known_hosts
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH authentication
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')"
|
||||
echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint"
|
||||
if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then
|
||||
echo "::error::SSH authentication failed. Verify VPS_USER matches the account that has deploy key fingerprint $key_fingerprint in ~/.ssh/authorized_keys on $VPS_HOST."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sync build context to VPS
|
||||
env:
|
||||
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
|
||||
"rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'"
|
||||
tar \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.next' \
|
||||
--exclude='dist' \
|
||||
--exclude='coverage' \
|
||||
-czf - . | ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
|
||||
"tar -xzf - -C '$REMOTE_BUILD_DIR'"
|
||||
|
||||
- name: Build and push on VPS
|
||||
env:
|
||||
PUSH_IMAGE: ${{ steps.registry-check.outputs.available == 'true' }}
|
||||
IMAGE_FULL: ${{ steps.image-meta.outputs.full }}
|
||||
IMAGE_LATEST: ${{ steps.image-meta.outputs.latest }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
|
||||
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
|
||||
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
||||
fi
|
||||
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
|
||||
set -e
|
||||
cd '$REMOTE_BUILD_DIR'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo 'Docker must be installed on the VPS build host' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ '$PUSH_IMAGE' = 'true' ]; then
|
||||
printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d | docker login '$DEPLOY_REGISTRY_HOST' \
|
||||
--username '$REGISTRY_USERNAME' \
|
||||
--password-stdin
|
||||
fi
|
||||
docker build \
|
||||
--file '$DOCKERFILE_PATH' \
|
||||
--platform '$DOCKER_PLATFORM' \
|
||||
--tag '$IMAGE_FULL' \
|
||||
--tag '$IMAGE_LATEST' \
|
||||
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
|
||||
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
|
||||
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
|
||||
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \
|
||||
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \
|
||||
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \
|
||||
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \
|
||||
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \
|
||||
.
|
||||
if [ '$PUSH_IMAGE' = 'true' ]; then
|
||||
docker push '$IMAGE_FULL'
|
||||
docker push '$IMAGE_LATEST'
|
||||
fi
|
||||
"
|
||||
|
||||
- name: Publish deployment files to VPS
|
||||
env:
|
||||
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }}
|
||||
IMAGE_TAG: ${{ gitea.sha }}
|
||||
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
|
||||
set -e
|
||||
cd '$REMOTE_BUILD_DIR'
|
||||
mkdir -p '$DEPLOY_ROOT/scripts'
|
||||
cp docker-compose.production.yml '$DEPLOY_ROOT/docker-compose.production.yml'
|
||||
cp docker-compose.portainer.production.yml '$DEPLOY_ROOT/docker-compose.portainer.production.yml'
|
||||
cp docker-compose.registry.production.yml '$DEPLOY_ROOT/docker-compose.registry.production.yml'
|
||||
cp traefik.yaml '$DEPLOY_ROOT/traefik.yaml'
|
||||
cp scripts/apply-env-secret-overrides.sh '$DEPLOY_ROOT/scripts/apply-env-secret-overrides.sh'
|
||||
cp scripts/describe-env-values.sh '$DEPLOY_ROOT/scripts/describe-env-values.sh'
|
||||
cp scripts/docker-prod-*.sh '$DEPLOY_ROOT/scripts/'
|
||||
cp scripts/preserve-env-values.sh '$DEPLOY_ROOT/scripts/preserve-env-values.sh'
|
||||
chmod +x '$DEPLOY_ROOT/scripts/'*.sh
|
||||
printf '%s\n' \
|
||||
'APP_IMAGE=$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
|
||||
'IMAGE_TAG=$IMAGE_TAG' \
|
||||
'REGISTRY_HOST=$DEPLOY_REGISTRY_HOST' \
|
||||
> '$DEPLOY_ROOT/release.env'
|
||||
chmod 600 '$DEPLOY_ROOT/release.env'
|
||||
echo 'Deployment files published to $DEPLOY_ROOT. Production was not deployed.'
|
||||
"
|
||||
@@ -0,0 +1,445 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
concurrency:
|
||||
group: test-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# TODO: Remove after installing internal CA certificate on the runner
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
NPM_CONFIG_FETCH_RETRIES: "5"
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type Check (all packages)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run db:generate
|
||||
- run: npm run type-check
|
||||
|
||||
api-tests:
|
||||
name: API Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run db:generate
|
||||
- run: npm run test:api
|
||||
|
||||
homepage-tests:
|
||||
name: Homepage Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:homepage
|
||||
|
||||
carplace-tests:
|
||||
name: Carplace Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:carplace
|
||||
|
||||
admin-tests:
|
||||
name: Admin Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run test:admin
|
||||
|
||||
dashboard-tests:
|
||||
name: Dashboard Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:dashboard
|
||||
|
||||
integration-tests:
|
||||
name: API Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL: redis://redis:6379
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret
|
||||
JWT_EXPIRY: 8h
|
||||
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
|
||||
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
|
||||
ls -la apps packages 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional --workspaces && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('rollup/package.json')" || {
|
||||
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
|
||||
ls -1 node_modules | head -n 80 || true
|
||||
exit 1
|
||||
}
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
|
||||
echo "Rollup ARM64 native binding already present."
|
||||
exit 0
|
||||
fi
|
||||
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
|
||||
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
|
||||
echo "::error::rollup version not found in package-lock.json"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
- run: npm run db:generate
|
||||
- run: npm run db:deploy
|
||||
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
- run: npm run test:api:integration
|
||||
@@ -7,11 +7,14 @@ node_modules/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
apps/api/storage/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.docker.production
|
||||
production/.env.docker.production
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
+331
-17
@@ -1,28 +1,342 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
DEPLOY_ROOT: /opt/rentaldrivego
|
||||
# Required: disable TLS so the docker client can reach the dind daemon
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
|
||||
build:
|
||||
# ====================================
|
||||
# TEST STAGE
|
||||
# Runs on every branch push and merge request
|
||||
# ====================================
|
||||
|
||||
api_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run db:generate
|
||||
script:
|
||||
- npm run type-check
|
||||
- npm run test:api
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
carplace_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run build --workspace @rentaldrivego/types
|
||||
script:
|
||||
- npm run test:carplace
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
admin_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npm run test:admin
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
dashboard_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run build --workspace @rentaldrivego/types
|
||||
script:
|
||||
- npm run test:dashboard
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
homepage_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npm run test:homepage
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
integration_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
services:
|
||||
- name: postgres:16-alpine
|
||||
alias: postgres
|
||||
variables:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
- name: redis:7-alpine
|
||||
alias: redis
|
||||
variables:
|
||||
# Used by db:deploy (Prisma migrations) and any code reading DATABASE_URL directly
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/rentaldrivego_test"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
NODE_ENV: test
|
||||
before_script:
|
||||
- npm ci
|
||||
# Overwrite the local .env.test so vitest integration config gets CI service hostnames
|
||||
# (the file uses postgres/redis aliases, not localhost)
|
||||
- |
|
||||
cat > apps/api/.env.test << 'EOF'
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL=redis://redis:6379
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRY=8h
|
||||
NODE_ENV=test
|
||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||
EOF
|
||||
script:
|
||||
- npm run db:generate
|
||||
- npm run db:deploy
|
||||
- npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
- npm run test:api:integration
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
# ====================================
|
||||
# BUILD STAGE
|
||||
# ====================================
|
||||
|
||||
build_image:
|
||||
stage: build
|
||||
image: docker:24
|
||||
needs:
|
||||
- api_tests
|
||||
- carplace_tests
|
||||
- admin_tests
|
||||
- dashboard_tests
|
||||
- homepage_tests
|
||||
- integration_tests
|
||||
services:
|
||||
- docker:dind
|
||||
script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker build -t $DOCKER_IMAGE .
|
||||
- docker push $DOCKER_IMAGE
|
||||
only:
|
||||
- main
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
- name: docker:24-dind
|
||||
alias: docker
|
||||
command: ["--tls=false"]
|
||||
variables:
|
||||
HEALTHCHECK_TCP_PORT: "2375"
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client
|
||||
- |
|
||||
attempts=0
|
||||
until docker info >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 60 ]; then
|
||||
echo "Docker daemon did not become ready after 60 seconds." >&2
|
||||
echo "On self-hosted runners, docker:dind requires the runner Docker executor to run with privileged = true." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for Docker daemon..."
|
||||
sleep 1
|
||||
done
|
||||
# Use one registry mode at a time:
|
||||
# 1) explicit REGISTRY_* vars for external/custom registries
|
||||
# 2) GitLab's built-in CI_REGISTRY_* vars for the integrated registry
|
||||
- |
|
||||
if [ -n "${REGISTRY_HOST:-}${REGISTRY_USER:-}${REGISTRY_PASSWORD:-}${REGISTRY_IMAGE:-}" ]; then
|
||||
test -n "${REGISTRY_HOST:-}" || { echo "REGISTRY_HOST is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_USER:-}" || { echo "REGISTRY_USER is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_PASSWORD:-}" || { echo "REGISTRY_PASSWORD is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_IMAGE:-}" || { echo "REGISTRY_IMAGE is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
export IMAGE_REPOSITORY="$REGISTRY_IMAGE"
|
||||
else
|
||||
export REGISTRY_HOST="${CI_REGISTRY:-}"
|
||||
export REGISTRY_USER="${CI_REGISTRY_USER:-}"
|
||||
export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-}"
|
||||
export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-}"
|
||||
fi
|
||||
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
|
||||
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
|
||||
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
|
||||
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
|
||||
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
|
||||
- export DOCKER_IMAGE_LATEST="$IMAGE_REPOSITORY:latest"
|
||||
- |
|
||||
echo "--- Registry Connectivity Preflight ---"
|
||||
echo "REGISTRY_HOST=$REGISTRY_HOST"
|
||||
cat /etc/resolv.conf || true
|
||||
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
getent hosts "$REGISTRY_HOST" || true
|
||||
elif command -v nslookup >/dev/null 2>&1; then
|
||||
nslookup "$REGISTRY_HOST" || true
|
||||
else
|
||||
echo "No host lookup tool available in runner image."
|
||||
fi
|
||||
|
||||
probe_output="$(wget -S --spider --timeout=15 --tries=1 "https://$REGISTRY_HOST/v2/" 2>&1 || true)"
|
||||
printf '%s\n' "$probe_output"
|
||||
|
||||
printf '%s\n' "$probe_output" | grep -F "401 Unauthorized" >/dev/null || {
|
||||
echo "Registry preflight failed: expected HTTPS /v2/ to return 401 Unauthorized before docker login." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! printf '%s\n' "$probe_output" | grep -Fi "Docker-Distribution-Api-Version: registry/2.0" >/dev/null; then
|
||||
echo "Registry preflight note: registry API header was not visible in wget output; continuing because HTTPS /v2/ returned 401." >&2
|
||||
fi
|
||||
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY"
|
||||
- ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE"
|
||||
- ssh $VPS_USER@$VPS_IP "docker stop myapp || true"
|
||||
- ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE"
|
||||
- echo "--- Building Docker Image ---"
|
||||
- |
|
||||
for var_name in NEXT_PUBLIC_API_URL NEXT_PUBLIC_MARKETPLACE_URL NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_ADMIN_URL; do
|
||||
var_value="$(printenv "$var_name" || true)"
|
||||
|
||||
if [ -z "$var_value" ]; then
|
||||
echo "$var_name is not set. Refusing to build a production image with missing public frontend URLs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$var_value" in
|
||||
*example.com*)
|
||||
echo "$var_name=$var_value still uses the placeholder example.com domain. Update the GitLab CI/CD variable before building." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
|
||||
- |
|
||||
export API_INTERNAL_URL="${API_INTERNAL_URL:-http://api:4000/api/v1}"
|
||||
export DASHBOARD_INTERNAL_URL="${DASHBOARD_INTERNAL_URL:-http://dashboard:3001}"
|
||||
export ADMIN_INTERNAL_URL="${ADMIN_INTERNAL_URL:-http://admin:3002}"
|
||||
|
||||
docker build \
|
||||
--build-arg API_INTERNAL_URL="$API_INTERNAL_URL" \
|
||||
--build-arg DASHBOARD_INTERNAL_URL="$DASHBOARD_INTERNAL_URL" \
|
||||
--build-arg ADMIN_INTERNAL_URL="$ADMIN_INTERNAL_URL" \
|
||||
--build-arg NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
--build-arg NEXT_PUBLIC_MARKETPLACE_URL="$NEXT_PUBLIC_MARKETPLACE_URL" \
|
||||
--build-arg NEXT_PUBLIC_DASHBOARD_URL="$NEXT_PUBLIC_DASHBOARD_URL" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_URL="$NEXT_PUBLIC_ADMIN_URL" \
|
||||
-t "$DOCKER_IMAGE" \
|
||||
-t "$DOCKER_IMAGE_LATEST" \
|
||||
-f "$DOCKERFILE_PATH" .
|
||||
- echo "--- Pushing to Registry ---"
|
||||
- docker push "$DOCKER_IMAGE"
|
||||
- docker push "$DOCKER_IMAGE_LATEST"
|
||||
rules:
|
||||
# On the default branch, always attempt the image build so missing registry
|
||||
# configuration fails loudly in before_script instead of skipping the job.
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
- when: never
|
||||
|
||||
# ====================================
|
||||
# DEPLOY STAGE
|
||||
# ====================================
|
||||
|
||||
deploy_to_vps:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
needs:
|
||||
- build_image
|
||||
before_script:
|
||||
- |
|
||||
if [ -n "${REGISTRY_HOST:-}${REGISTRY_USER:-}${REGISTRY_PASSWORD:-}${REGISTRY_IMAGE:-}" ]; then
|
||||
test -n "${REGISTRY_HOST:-}" || { echo "REGISTRY_HOST is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_USER:-}" || { echo "REGISTRY_USER is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_PASSWORD:-}" || { echo "REGISTRY_PASSWORD is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
test -n "${REGISTRY_IMAGE:-}" || { echo "REGISTRY_IMAGE is not set. Configure all REGISTRY_* variables together."; exit 1; }
|
||||
export IMAGE_REPOSITORY="$REGISTRY_IMAGE"
|
||||
else
|
||||
export REGISTRY_HOST="${CI_REGISTRY:-}"
|
||||
export REGISTRY_USER="${CI_REGISTRY_USER:-}"
|
||||
export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-}"
|
||||
export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-}"
|
||||
fi
|
||||
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
|
||||
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
|
||||
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
|
||||
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
|
||||
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
|
||||
- apk add --no-cache openssh-client
|
||||
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
|
||||
- eval "$(ssh-agent -s)"
|
||||
- |
|
||||
key_file="$(mktemp)"
|
||||
decoded_key_file="$(mktemp)"
|
||||
escaped_key_file="$(mktemp)"
|
||||
|
||||
cleanup_key_files() {
|
||||
rm -f "$key_file" "$decoded_key_file" "$escaped_key_file"
|
||||
}
|
||||
trap cleanup_key_files EXIT
|
||||
|
||||
if [ -f "${SSH_PRIVATE_KEY:-}" ]; then
|
||||
tr -d '\r' < "$SSH_PRIVATE_KEY" > "$key_file"
|
||||
else
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_file"
|
||||
fi
|
||||
|
||||
chmod 600 "$key_file"
|
||||
|
||||
if ! ssh-keygen -y -f "$key_file" >/dev/null 2>&1; then
|
||||
if [ ! -f "${SSH_PRIVATE_KEY:-}" ]; then
|
||||
printf '%b' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$escaped_key_file"
|
||||
if ssh-keygen -y -f "$escaped_key_file" >/dev/null 2>&1; then
|
||||
cp "$escaped_key_file" "$key_file"
|
||||
elif printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\r\n\t ' | base64 -d > "$decoded_key_file" 2>/dev/null; then
|
||||
tr -d '\r' < "$decoded_key_file" > "$key_file"
|
||||
fi
|
||||
chmod 600 "$key_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
ssh-keygen -y -f "$key_file" >/dev/null 2>&1 || {
|
||||
echo "SSH_PRIVATE_KEY must be an unencrypted OpenSSH private key. Supported formats: GitLab file variable, raw multiline key, single-line key with literal \\n escapes, or base64-encoded key." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ssh-add "$key_file"
|
||||
cleanup_key_files
|
||||
trap - EXIT
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
# Scan and trust the VPS host key (avoids manual known_hosts management)
|
||||
- ssh-keyscan -H "$VPS_IP" >> ~/.ssh/known_hosts
|
||||
- chmod 644 ~/.ssh/known_hosts
|
||||
script:
|
||||
- echo "--- Deploying $DOCKER_IMAGE to VPS ---"
|
||||
- |
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||
|
||||
echo "-- Syncing deployment assets --"
|
||||
ssh "$VPS_USER@$VPS_IP" "
|
||||
set -e
|
||||
mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'
|
||||
"
|
||||
scp docker-compose.production.yml docker-compose.portainer.production.yml docker-compose.registry.production.yml docker-compose.registry.local.yml traefik.yaml "$VPS_USER@$VPS_IP:$DEPLOY_ROOT/"
|
||||
scp scripts/docker-prod-common.sh scripts/docker-prod-deploy.sh scripts/docker-prod-up-registry.sh scripts/docker-registry-local-up.sh "$VPS_USER@$VPS_IP:$DEPLOY_ROOT/scripts/"
|
||||
scp docker/pgmanage/override.py "$VPS_USER@$VPS_IP:$DEPLOY_ROOT/docker/pgmanage/"
|
||||
|
||||
echo "-- Running deploy script on VPS --"
|
||||
ssh "$VPS_USER@$VPS_IP" "
|
||||
set -e
|
||||
cd '$DEPLOY_ROOT'
|
||||
APP_IMAGE='$IMAGE_REPOSITORY' \
|
||||
APP_VERSION='$CI_COMMIT_SHORT_SHA' \
|
||||
REGISTRY_HOST='$REGISTRY_HOST' \
|
||||
REGISTRY_USER='$REGISTRY_USER' \
|
||||
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
"
|
||||
rules:
|
||||
# Deploy only when both registry access and VPS credentials are available.
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && ((($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE)) && $SSH_PRIVATE_KEY && $VPS_IP && $VPS_USER)'
|
||||
when: manual
|
||||
- when: never
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json",
|
||||
"mcpServers": {
|
||||
"memory": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"]
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"${workspaceFolder}"
|
||||
]
|
||||
},
|
||||
"gitea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitea-mcp"],
|
||||
"env": {
|
||||
"GITEA_HOST": "https://192.168.3.80",
|
||||
"GITEA_ACCESS_TOKEN": "d9eb80ffe28f6e4e3ad5d5a032ca9c5f93ea5414"
|
||||
}
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "fetch-mcp"]
|
||||
},
|
||||
"git": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "git-mcp"],
|
||||
"env": {
|
||||
"GIT_DEFAULT_PATH": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fetch-timeout=120000
|
||||
fetch-retries=5
|
||||
fetch-retry-mintimeout=10000
|
||||
fetch-retry-maxtimeout=60000
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"servers": {}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"save-serve.language": "en"
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
## Docker Environments
|
||||
|
||||
Three Docker environments are available:
|
||||
|
||||
- `Dockerfile.dev` with `docker-compose.dev.yml`
|
||||
- `Dockerfile.test` with `docker-compose.test.yml`
|
||||
- `Dockerfile.production` with `docker-compose.production.yml`
|
||||
- `docker-compose.pgmanage.yml` for a standalone pgManage container
|
||||
|
||||
### Development
|
||||
|
||||
Use the full dev stack for local work with hot reload and bundled Postgres and Redis:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile full up --build
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
- marketplace: `http://localhost:3000`
|
||||
- dashboard: `http://localhost:3001`
|
||||
- admin: `http://localhost:3002`
|
||||
- public-site: `http://localhost:3003`
|
||||
- api: `http://localhost:4000`
|
||||
- pgAdmin: `http://localhost:5050`
|
||||
|
||||
Each dev app now runs in its own container and can be started independently with a profile tag:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile api up --build
|
||||
docker compose -f docker-compose.dev.yml --profile marketplace up --build
|
||||
docker compose -f docker-compose.dev.yml --profile dashboard up --build
|
||||
docker compose -f docker-compose.dev.yml --profile admin up --build
|
||||
docker compose -f docker-compose.dev.yml --profile public-site up --build
|
||||
docker compose -f docker-compose.dev.yml --profile tools up --build
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies.
|
||||
- frontend profiles also start `api` and its dependencies automatically.
|
||||
- `tools` starts only `pgadmin` plus its required `postgres` dependency.
|
||||
|
||||
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
|
||||
|
||||
Default dev platform administrator:
|
||||
|
||||
- email: `admin@rentaldrivego.com`
|
||||
- password: `changeme123`
|
||||
|
||||
If you intentionally want a fresh dev bootstrap:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
```
|
||||
|
||||
If you want to keep the database and only apply new schema changes manually:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"
|
||||
```
|
||||
|
||||
pgAdmin dev login:
|
||||
|
||||
- email: `admin@rentaldrivego.local`
|
||||
- email: `admin@rentaldrivego.dev`
|
||||
- password: `admin`
|
||||
|
||||
pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`.
|
||||
|
||||
pgAdmin Postgres connection:
|
||||
|
||||
- host: `postgres`
|
||||
- port: `5432`
|
||||
- database: `rentaldrivego`
|
||||
- username: `postgres`
|
||||
- password: `password`
|
||||
|
||||
### Standalone pgManage
|
||||
|
||||
If you want a standalone Postgres management UI without starting the full development stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.pgmanage.yml up -d
|
||||
```
|
||||
|
||||
It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`.
|
||||
From inside the container, connect to the local Postgres service through `host.docker.internal:5432`.
|
||||
|
||||
### Test
|
||||
|
||||
Use the test stack to run repeatable containerized verification:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
The test container runs:
|
||||
|
||||
- `npm run db:deploy`
|
||||
- `npm run db:generate`
|
||||
- `npm run type-check`
|
||||
- `npm run build`
|
||||
|
||||
### Production
|
||||
|
||||
The production stack runs behind **Traefik** (reverse proxy + automatic HTTPS via Let's Encrypt). All services communicate over a private Docker network (`internal`). Traefik reaches public-facing services via a separate `traefik-proxy` network.
|
||||
|
||||
#### 1. Point DNS to your server
|
||||
|
||||
Add an A record for every subdomain to your server's public IP before deploying so Let's Encrypt can issue certificates:
|
||||
|
||||
| Subdomain | Service |
|
||||
|---|---|
|
||||
| `rentaldrivego.ma` | marketplace + public site |
|
||||
| `api.rentaldrivego.ma` | API |
|
||||
| `dashboard.rentaldrivego.ma` | dashboard |
|
||||
| `admin.rentaldrivego.ma` | admin panel |
|
||||
| `pgmanage.rentaldrivego.ma` | pgManage (DB admin) |
|
||||
|
||||
#### 2. Install Docker and clone the repo
|
||||
|
||||
```bash
|
||||
# Install Docker (if not already installed)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
git clone <repo-url> rentaldrivego
|
||||
cd rentaldrivego
|
||||
```
|
||||
|
||||
#### 3. Create the shared Traefik network
|
||||
|
||||
Only needs to be done once per server. If it already exists this is a no-op.
|
||||
|
||||
```bash
|
||||
docker network create traefik-proxy
|
||||
```
|
||||
|
||||
#### 4. Configure environment variables
|
||||
|
||||
```bash
|
||||
cp .env.docker.production.example .env.docker.production
|
||||
```
|
||||
|
||||
Open `.env.docker.production` and fill in every value. The minimum required secrets are:
|
||||
|
||||
| Variable | What to set |
|
||||
|---|---|
|
||||
| `POSTGRES_PASSWORD` | Strong random password |
|
||||
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
|
||||
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
|
||||
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||
|
||||
All domain vars are pre-filled with `rentaldrivego.ma` subdomains and do not need changing unless you use a different domain.
|
||||
|
||||
#### 5. Start Traefik
|
||||
|
||||
Traefik must be running before the app stack so it can wire up routes at startup.
|
||||
|
||||
```bash
|
||||
docker compose -f traefik.yaml up -d
|
||||
```
|
||||
|
||||
#### 6. Build and start the app stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
```
|
||||
|
||||
Docker will:
|
||||
1. Build the monorepo image
|
||||
2. Run database migrations (`migrate` service)
|
||||
3. Start all app services (api, marketplace, dashboard, admin, public-site, pgmanage)
|
||||
|
||||
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
|
||||
|
||||
#### Updating after a code change
|
||||
|
||||
Pull the latest code and rebuild only the changed service:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.production.yml up --build -d --no-deps <service>
|
||||
# e.g. to redeploy only the API:
|
||||
docker compose -f docker-compose.production.yml up --build -d --no-deps api
|
||||
```
|
||||
|
||||
To rebuild everything:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
```
|
||||
|
||||
#### Apply database migrations without downtime
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml run --rm migrate
|
||||
```
|
||||
|
||||
#### View logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
|
||||
# Single service
|
||||
docker compose -f docker-compose.production.yml logs -f api
|
||||
```
|
||||
|
||||
#### Stop the stack
|
||||
|
||||
```bash
|
||||
# Stop containers but keep volumes (data is preserved)
|
||||
docker compose -f docker-compose.production.yml down
|
||||
|
||||
# Stop and delete all data (destructive — irreversible)
|
||||
docker compose -f docker-compose.production.yml down -v
|
||||
```
|
||||
|
||||
#### pgManage (DB admin UI)
|
||||
|
||||
pgManage is available at `https://pgmanage.rentaldrivego.ma`. To connect to the production database, add a connection inside pgManage with:
|
||||
|
||||
- **Host:** `localhost`
|
||||
- **Port:** `5432`
|
||||
- **Database:** `rentaldrivego`
|
||||
- **Username:** `postgres`
|
||||
- **Password:** value of `POSTGRES_PASSWORD` from `.env.docker.production`
|
||||
|
||||
### Notes
|
||||
|
||||
- The production image builds the whole monorepo once, then each service overrides its runtime command.
|
||||
- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume.
|
||||
- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser.
|
||||
- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image.
|
||||
- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database.
|
||||
- If you need database schema updates inside Docker, run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate
|
||||
```
|
||||
|
||||
If a cached base image still fails during `npm ci`, refresh it and rebuild without cache:
|
||||
|
||||
```bash
|
||||
docker pull node:20-bookworm
|
||||
docker compose -f docker-compose.dev.yml build --no-cache dashboard
|
||||
```
|
||||
+4
-1
@@ -3,13 +3,16 @@ FROM node:20-bookworm
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
||||
RUN corepack enable
|
||||
|
||||
COPY .npmrc ./
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
COPY config ./config
|
||||
|
||||
RUN npm ci --no-fund --no-audit
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
EXPOSE 3000 3001 3002 3004 4000
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
|
||||
|
||||
+47
-8
@@ -7,8 +7,36 @@ RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
COPY config ./config
|
||||
|
||||
RUN npm install
|
||||
# These URLs are read by Next.js config during `next build`, so the production
|
||||
# image must bake Docker-network service URLs instead of localhost defaults.
|
||||
ARG API_INTERNAL_URL=http://api:4000/api/v1
|
||||
ARG DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ARG ADMIN_INTERNAL_URL=http://admin:3002
|
||||
|
||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_HOMEPAGE_URL
|
||||
ARG NEXT_PUBLIC_CARPLACE_URL
|
||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_URL
|
||||
|
||||
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||
# Required in production — must be an absolute https:// URL with no path, query, or fragment.
|
||||
ARG SITE_ORIGIN
|
||||
|
||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
||||
DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
|
||||
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
||||
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
|
||||
NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
|
||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
||||
SITE_ORIGIN=$SITE_ORIGIN
|
||||
|
||||
RUN npm ci --include=optional
|
||||
RUN npm run db:generate
|
||||
RUN npm run build
|
||||
|
||||
@@ -16,15 +44,26 @@ FROM node:20-bookworm AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=production \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
||||
&& groupadd --system --gid 10001 app \
|
||||
&& useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \
|
||||
&& mkdir -p /var/lib/rentaldrivego/storage/public /var/lib/rentaldrivego/storage/private /tmp/rentaldrivego \
|
||||
&& chown -R app:app /app /var/lib/rentaldrivego /tmp/rentaldrivego
|
||||
|
||||
COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps ./apps
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
||||
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=app:app /app/apps ./apps
|
||||
COPY --from=builder --chown=app:app /app/packages ./packages
|
||||
COPY --from=builder --chown=app:app /app/config ./config
|
||||
COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"]
|
||||
|
||||
EXPOSE 3000 3001 3002 3004 4000
|
||||
|
||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ RUN npm install
|
||||
|
||||
ENV NODE_ENV=test
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"]
|
||||
CMD ["sh", "-c", "npm run db:deploy && npm run db:generate && npm run type-check && npm run build && npm run test:api:integration"]
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"project": "RentalDriveGo",
|
||||
"migration": "Apply Phase 16 design system to legacy Archive.zip frontends",
|
||||
"generated_at": "2026-06-26T02:14:35+00:00",
|
||||
"source_archives": {
|
||||
"legacy_archive": "Archive.zip",
|
||||
"phase16_evidence": "RentalDriveGo_Phase16_Evidence_Package_v1.0.zip"
|
||||
},
|
||||
"scope": {
|
||||
"homepage": "retained as embedded Phase 16 source of truth",
|
||||
"admin": "migrated",
|
||||
"dashboard": "migrated",
|
||||
"carplace": "migrated",
|
||||
"api": "unchanged"
|
||||
},
|
||||
"validation": {
|
||||
"typescript_syntax_files": 633,
|
||||
"typescript_syntax": "passed",
|
||||
"css_files_checked": 53,
|
||||
"css_balance": "passed",
|
||||
"design_migration_validator": "passed",
|
||||
"full_build": "not run because root workspace and local packages are absent from supplied archive"
|
||||
},
|
||||
"changed_file_count": 34,
|
||||
"files": [
|
||||
{
|
||||
"path": "DESIGN_MIGRATION_REPORT.md",
|
||||
"status": "added",
|
||||
"bytes": 3260,
|
||||
"sha256": "35c41d70484b917b38991791dfb2feb3e78234e4f30e2c99ba922d3d90ab83a6"
|
||||
},
|
||||
{
|
||||
"path": "scripts/validate-phase16-design.mjs",
|
||||
"status": "added",
|
||||
"bytes": 1891,
|
||||
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/app/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 1833,
|
||||
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/app/globals.css",
|
||||
"status": "modified",
|
||||
"bytes": 7525,
|
||||
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/components/CarplaceFooter.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 4632,
|
||||
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/components/CarplaceHeader.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10438,
|
||||
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/styles/phase16-tokens.css",
|
||||
"status": "added",
|
||||
"bytes": 4214,
|
||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 13979,
|
||||
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 2133,
|
||||
"sha256": "09399551351e3c7015e6700c3892cf81f2b45cf4d1b835e01645580e73608c05"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/globals.css",
|
||||
"status": "modified",
|
||||
"bytes": 16161,
|
||||
"sha256": "0d01004900a4a6c078e98167c910d02b5fa29b4ebeafe0520a45cb7f9274c8b8"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/styles/phase16-tokens.css",
|
||||
"status": "added",
|
||||
"bytes": 4214,
|
||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/components/layout/PublicFooter.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 8348,
|
||||
"sha256": "657f96cc725459f7bf7a668fe616aee4d295625c15366204b5987176fd60c8b8"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/components/layout/PublicHeader.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10250,
|
||||
"sha256": "3e5ace09e73afbb95f09a1acf164011764cc789474430962426793564b8db459"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/components/layout/Sidebar.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 18500,
|
||||
"sha256": "d0f7571149a073c39b6a1b4331e6055d8347a8092bd858d7128a37a19847b4d7"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 6888,
|
||||
"sha256": "9b6bbc1479529c51d5dd65ce0dffa4ed6245a1de687c2cc9b2f62d3a396a8f83"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/onboarding/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10593,
|
||||
"sha256": "a2255be38766258da3495191ce793da21992b6f4c13520d760959b7eff5ccc8e"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/reset-password/ResetPasswordPageClient.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 11998,
|
||||
"sha256": "3a8f0c268c825097da5a8814c26c2526103536b7b3e6f3f5d04e0311cd3fbe27"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/verify-email/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 5176,
|
||||
"sha256": "e1fc11c7e2af90c4b3614a1a8c6b9babe2ea5e9884eaa4e94fc0994462d554d6"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 14464,
|
||||
"sha256": "976e8f9244a8ca64e092373831970e3c4d7197ead33e3c478f6e8d9d375f9c3e"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 19760,
|
||||
"sha256": "861d033b4b86792644538f69a38634d46d6c58d43e87bbeb67dcef806cf6ab24"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/(dashboard)/settings/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 42414,
|
||||
"sha256": "c46bdfd9c1c14d471b1aa03cca5e6d91f49dd8405aa27502f040bd97c0d5a64d"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 1210,
|
||||
"sha256": "aaa6c6aedb14972a159a9bcf9f8dc0bdedd2681d8a1c37be989644975c94c243"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/globals.css",
|
||||
"status": "modified",
|
||||
"bytes": 8317,
|
||||
"sha256": "4134ea739c645e1550e75d16fdf1b45c13392fd153250840c1272603adbd2832"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/styles/phase16-tokens.css",
|
||||
"status": "added",
|
||||
"bytes": 4214,
|
||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10294,
|
||||
"sha256": "8390dba373ae0f8d02e8d872f5b0f1736f60553bf33e90f8c07c02207dedadda"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 13442,
|
||||
"sha256": "87995a735f7665723c791a6d528a14ebd78e529105e1bfa14654221248b62ca5"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/admin-users/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 12067,
|
||||
"sha256": "64dfa83abc16aef56347c1d832edb2b30f5f11497a6cb2908c5158d891031983"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/audit-logs/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 4369,
|
||||
"sha256": "0db9f45a4486dd4c3a8c89e82618fe49bf3f555aff9a7e8e16b028d2b53d72ef"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/billing/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 48678,
|
||||
"sha256": "598125e6be30d184c2277269e163bd3f54605a839a63041fa57ba27cb03cdadd"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/companies/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 8622,
|
||||
"sha256": "3513aa5a4ac525c0a6887cb3d77299c595b921e3e7f93001095f81f73f19fe31"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/containers/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 18580,
|
||||
"sha256": "d01c7cddedf9fb222554f8ea48fa44a515baae353b6675496eea7578b1c0a105"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/renters/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 6988,
|
||||
"sha256": "5ae947e70d674b930a5fc39f1c0577c25bb7c2ca484c761129fe091019aa0034"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/site-config/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 41636,
|
||||
"sha256": "800108cec0ea22c84ea8f60452ddb633e472237505a16a8bcc73ad89467b08ea"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/companies/[id]/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 50874,
|
||||
"sha256": "5b763c4280f9d561004dbb08faa945b7b868f48086ca3b0ac62cacb228db02d1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"package": "RentalDriveGo_Phase16_Operational_UI_v1.0",
|
||||
"generatedOn": "2026-06-25",
|
||||
"scope": [
|
||||
"dashboard",
|
||||
"admin"
|
||||
],
|
||||
"includedApplications": [
|
||||
"homepage",
|
||||
"carplace",
|
||||
"dashboard",
|
||||
"admin",
|
||||
"api"
|
||||
],
|
||||
"designAuthority": "Phase 16 homepage implementation",
|
||||
"routeCoverage": {
|
||||
"admin": 12,
|
||||
"dashboard": 19
|
||||
},
|
||||
"validation": {
|
||||
"typescriptSourceFilesParsed": 596,
|
||||
"typescriptSyntaxFailures": 0,
|
||||
"localImportsChecked": 190,
|
||||
"unresolvedLocalImports": 0,
|
||||
"designGate": "passed",
|
||||
"productionBuild": "not run: missing monorepo root, workspace package, lockfile, and installed dependencies"
|
||||
},
|
||||
"keyFiles": [
|
||||
"admin/src/styles/phase16-tokens.css",
|
||||
"admin/src/app/globals.css",
|
||||
"admin/src/app/dashboard/layout.tsx",
|
||||
"dashboard/src/styles/phase16-tokens.css",
|
||||
"dashboard/src/app/globals.css",
|
||||
"dashboard/src/app/(dashboard)/layout.tsx",
|
||||
"dashboard/src/components/layout/Sidebar.tsx",
|
||||
"dashboard/src/components/layout/TopBar.tsx",
|
||||
"scripts/validate-phase16-design.mjs"
|
||||
],
|
||||
"documentation": [
|
||||
"PHASE16_OPERATIONAL_UI_REPORT.md",
|
||||
"docs/PHASE16_OPERATIONAL_UI_GUIDE.md"
|
||||
]
|
||||
}
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
const ADMIN_BASE_PATH = '/admin'
|
||||
|
||||
// In Docker dev the admin app runs on port 3002 while the Carplace proxy
|
||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
|
||||
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix], frameSources: ['blob:'] })
|
||||
|
||||
const nextConfig = {
|
||||
basePath: ADMIN_BASE_PATH,
|
||||
...(assetPrefix ? { assetPrefix } : {}),
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -18,12 +30,24 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/storage/:path*',
|
||||
destination: `${apiOrigin}/storage/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
+24
-13
@@ -3,28 +3,39 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3002",
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3002",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3002",
|
||||
"type-check": "tsc --noEmit"
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "next start -H 0.0.0.0 -p 3002",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rentaldrivego/types": "*",
|
||||
"next": "14.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"zod": "^3.23.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "^16.2.9",
|
||||
"node-cron": "4.5.0",
|
||||
"nodemailer": "9.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^2.12.7",
|
||||
"lucide-react": "^0.376.0"
|
||||
"tailwindcss": "^3.4.3",
|
||||
"turbo": "2.10.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.4.0"
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
function resolveAdminNextPath(next: string | null) {
|
||||
const fallback = '/admin/dashboard'
|
||||
if (!next) return fallback
|
||||
|
||||
if (/^https?:\/\//i.test(next)) return next
|
||||
if (next.startsWith('/admin/')) return next
|
||||
if (next === '/admin') return '/admin/dashboard'
|
||||
if (next.startsWith('/')) return `/admin${next}`
|
||||
|
||||
return `/admin/${next.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export default function AuthRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('token')
|
||||
const next = params.get('next') || '/dashboard'
|
||||
const next = resolveAdminNextPath(params.get('next'))
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
// Clear the token from the URL
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
// Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie.
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
|
||||
router.replace(next)
|
||||
}, [router])
|
||||
window.location.replace(next)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canAccessAdminMetrics } from './AdminSessionContext'
|
||||
|
||||
describe('canAccessAdminMetrics', () => {
|
||||
it('allows finance and higher roles after admin 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'FINANCE', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPPORT', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPER_ADMIN', totpEnabled: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('denies viewer role and admins who still need 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'VIEWER', totpEnabled: true })).toBe(false)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: false })).toBe(false)
|
||||
expect(canAccessAdminMetrics(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
|
||||
|
||||
export type AdminSessionUser = {
|
||||
id: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
totpEnabled?: boolean
|
||||
}
|
||||
|
||||
const METRICS_ALLOWED_ROLES = new Set<AdminRole>(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE'])
|
||||
|
||||
const AdminSessionContext = createContext<AdminSessionUser | null>(null)
|
||||
|
||||
export function AdminSessionProvider({
|
||||
admin,
|
||||
children,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <AdminSessionContext.Provider value={admin}>{children}</AdminSessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useAdminSession() {
|
||||
return useContext(AdminSessionContext)
|
||||
}
|
||||
|
||||
export function canAccessAdminMetrics(admin: Pick<AdminSessionUser, 'role' | 'totpEnabled'> | null | undefined) {
|
||||
return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role))
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
@@ -10,28 +9,38 @@ interface AdminUser {
|
||||
lastName: string
|
||||
email: string
|
||||
role: string
|
||||
preferredLocale: 'ar' | 'en' | 'fr'
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
permissions?: { id: string; resource: string; actions: string[] }[]
|
||||
}
|
||||
|
||||
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
||||
const EMPTY_FORM = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'SUPPORT',
|
||||
preferredLocale: 'en',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
@@ -45,32 +54,73 @@ export default function AdminUsersPage() {
|
||||
|
||||
useEffect(() => { fetchAdmins() }, [])
|
||||
|
||||
async function createAdmin(e: React.FormEvent) {
|
||||
function openCreateModal() {
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setShowPassword(false)
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function openEditModal(admin: AdminUser) {
|
||||
setEditingAdminId(admin.id)
|
||||
setForm({
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
email: admin.email,
|
||||
password: '',
|
||||
role: admin.role,
|
||||
preferredLocale: admin.preferredLocale,
|
||||
isActive: admin.isActive,
|
||||
})
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setShowModal(false)
|
||||
setEditingAdminId(null)
|
||||
setShowPassword(false)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function saveAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(form),
|
||||
const isEditing = Boolean(editingAdminId)
|
||||
const payload = {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
preferredLocale: form.preferredLocale,
|
||||
isActive: form.isActive,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
}
|
||||
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
||||
method: isEditing ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
|
||||
setShowModal(false)
|
||||
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
if (!res.ok) throw new Error(json?.message ?? `Failed to ${isEditing ? 'update' : 'create'} admin`)
|
||||
closeModal()
|
||||
await fetchAdmins()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
|
||||
ADMIN: 'text-sky-400 bg-sky-950/40',
|
||||
SUPPORT: 'text-amber-400 bg-amber-950/40',
|
||||
SUPPORT: 'text-orange-400 bg-orange-950/40',
|
||||
FINANCE: 'text-violet-400 bg-violet-950/40',
|
||||
VIEWER: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
@@ -83,7 +133,7 @@ export default function AdminUsersPage() {
|
||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
+ New admin
|
||||
@@ -103,13 +153,14 @@ export default function AdminUsersPage() {
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Permissions</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Joined</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
) : admins.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
|
||||
@@ -130,6 +181,15 @@ export default function AdminUsersPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(a)}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -138,18 +198,19 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">New admin user</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
<h2 className="text-lg font-semibold">{editingAdminId ? 'Edit admin user' : 'New admin user'}</h2>
|
||||
<button onClick={closeModal} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createAdmin} className="space-y-4">
|
||||
<form onSubmit={saveAdmin} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||
<input
|
||||
required
|
||||
maxLength={50}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
@@ -159,6 +220,7 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
|
||||
<input
|
||||
required
|
||||
maxLength={50}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
@@ -170,21 +232,44 @@ export default function AdminUsersPage() {
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!editingAdminId}
|
||||
minLength={editingAdminId ? undefined : 8}
|
||||
maxLength={128}
|
||||
className="w-full px-3 py-2 pr-10 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-zinc-500 hover:text-zinc-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||
@@ -196,10 +281,33 @@ export default function AdminUsersPage() {
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.preferredLocale}
|
||||
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ar">العربية</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.isActive ? 'active' : 'inactive'}
|
||||
onChange={(e) => setForm({ ...form, isActive: e.target.value === 'active' })}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={creating} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{creating ? 'Creating…' : 'Create admin'}
|
||||
<button type="button" onClick={closeModal} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{saving ? (editingAdminId ? 'Saving…' : 'Creating…') : (editingAdminId ? 'Save changes' : 'Create admin')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
@@ -17,7 +16,7 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
CREATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
UPDATE: 'text-sky-400 bg-sky-950/40',
|
||||
DELETE: 'text-red-400 bg-red-950/40',
|
||||
SUSPEND: 'text-amber-400 bg-amber-950/40',
|
||||
SUSPEND: 'text-orange-400 bg-orange-950/40',
|
||||
ACTIVATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
LOGIN: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
@@ -29,13 +28,12 @@ export default function AdminAuditLogsPage() {
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/audit-logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setLogs(json.data ?? []))
|
||||
.then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.data ?? [])))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
PUBLIC_HOMEPAGE_GRID_COLUMNS,
|
||||
PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
getPublicHomepageSectionLimits,
|
||||
type PublicHomepageLayout,
|
||||
type PublicHomepageLayoutItem,
|
||||
type PublicHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
type Props = {
|
||||
layout: PublicHomepageLayout
|
||||
availableSections: PublicHomepageSectionType[]
|
||||
onChange: (layout: PublicHomepageLayout) => void
|
||||
onAddSection: (type: PublicHomepageSectionType) => void
|
||||
onRemoveSection: (type: PublicHomepageSectionType) => void
|
||||
}
|
||||
|
||||
type Interaction = {
|
||||
itemId: string
|
||||
mode: 'move' | 'resize'
|
||||
startX: number
|
||||
startY: number
|
||||
origin: PublicHomepageLayoutItem
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 52
|
||||
|
||||
const SECTION_META: Record<PublicHomepageSectionType, { label: string; tint: string; description: string }> = {
|
||||
hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' },
|
||||
offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' },
|
||||
vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' },
|
||||
pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const [interaction, setInteraction] = useState<Interaction | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!interaction) return
|
||||
const snap = interaction
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
|
||||
const deltaCols = Math.round((event.clientX - snap.startX) / colWidth)
|
||||
const deltaRows = Math.round((event.clientY - snap.startY) / ROW_HEIGHT)
|
||||
const limits = getPublicHomepageSectionLimits(snap.origin.type)
|
||||
|
||||
onChange({
|
||||
items: layout.items.map((item) => {
|
||||
if (item.id !== snap.itemId) return item
|
||||
if (snap.mode === 'move') {
|
||||
return {
|
||||
...item,
|
||||
x: clamp(snap.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.w + 1),
|
||||
y: clamp(snap.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.h + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const nextW = clamp(
|
||||
snap.origin.w + deltaCols,
|
||||
limits.minW,
|
||||
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.x + 1),
|
||||
)
|
||||
const nextH = clamp(
|
||||
snap.origin.h + deltaRows,
|
||||
limits.minH,
|
||||
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.y + 1),
|
||||
)
|
||||
|
||||
return {
|
||||
...item,
|
||||
w: nextW,
|
||||
h: nextH,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
setInteraction(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [interaction, layout.items, onChange])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4">
|
||||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-100">Homepage layout</h4>
|
||||
<p className="mt-1 text-xs text-zinc-500">Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSections.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onAddSection(type)}
|
||||
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300 transition hover:bg-emerald-500/20"
|
||||
>
|
||||
Add {SECTION_META[type].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative mt-4 hidden overflow-hidden rounded-[1.5rem] border border-zinc-800 bg-zinc-950 lg:block"
|
||||
style={{
|
||||
height: ROW_HEIGHT * PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${100 / PUBLIC_HOMEPAGE_GRID_COLUMNS}% ${ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="absolute overflow-hidden rounded-[1.25rem] border border-white/10 bg-zinc-900/95 shadow-xl shadow-black/30"
|
||||
style={{
|
||||
left: `${((item.x - 1) / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
top: `${(item.y - 1) * ROW_HEIGHT}px`,
|
||||
width: `${(item.w / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
height: `${item.h * ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'move',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className={`flex w-full cursor-grab items-start justify-between bg-gradient-to-r ${meta.tint} px-4 py-3 text-left active:cursor-grabbing`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">Drag</p>
|
||||
<p className="mt-1 text-sm font-black text-white">{meta.label}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-black/20 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
{item.w}x{item.h}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex h-[calc(100%-60px)] flex-col justify-between p-4">
|
||||
<p className="max-w-xs text-sm leading-6 text-zinc-300">{meta.description}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">x{item.x} y{item.y}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Resize ${meta.label}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'resize',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className="absolute bottom-2 right-2 h-7 w-7 rounded-full border border-white/15 bg-white/10 text-white"
|
||||
>
|
||||
<span className="block rotate-45 text-sm">+</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:hidden">
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">{meta.label}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{meta.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-zinc-500">Desktop drag canvas is available on larger screens.</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
@@ -12,6 +11,7 @@ interface Company {
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
contractSettings: { legalName: string | null } | null
|
||||
subscription: { plan: string; status: string } | null
|
||||
_count: { employees: number; vehicles: number }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
PENDING: 'text-orange-400 bg-orange-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function AdminCompaniesPage() {
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
company: 'Company',
|
||||
legalName: 'Legal name',
|
||||
slug: 'Slug',
|
||||
status: 'Status',
|
||||
plan: 'Plan',
|
||||
@@ -48,6 +49,7 @@ export default function AdminCompaniesPage() {
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
company: 'Entreprise',
|
||||
legalName: 'Raison sociale',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
plan: 'Plan',
|
||||
@@ -59,15 +61,16 @@ export default function AdminCompaniesPage() {
|
||||
},
|
||||
ar: {
|
||||
title: 'الشركات',
|
||||
search: 'ابحث بالاسم أو الـ slug…',
|
||||
search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
legalName: 'الاسم القانوني',
|
||||
slug: 'Slug',
|
||||
status: 'الحالة',
|
||||
plan: 'الخطة',
|
||||
fleet: 'الأسطول',
|
||||
vehicles: 'سيارات',
|
||||
vehicles: 'مركبة',
|
||||
view: 'عرض',
|
||||
suspend: 'تعليق',
|
||||
reactivate: 'إعادة التفعيل',
|
||||
@@ -81,16 +84,16 @@ export default function AdminCompaniesPage() {
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
async function fetchCompanies() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setCompanies(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setCompanies(list)
|
||||
setFiltered(list)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -107,11 +110,11 @@ export default function AdminCompaniesPage() {
|
||||
|
||||
async function changeStatus(id: string, status: string) {
|
||||
setActioning(id)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
@@ -162,6 +165,9 @@ export default function AdminCompaniesPage() {
|
||||
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.name}</p>
|
||||
{c.contractSettings?.legalName && c.contractSettings.legalName !== c.name ? (
|
||||
<p className="text-xs text-zinc-400">{copy.legalName}: {c.contractSettings.legalName}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-zinc-500">{c.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
||||
|
||||
@@ -1,437 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||
|
||||
interface CompanyContainer {
|
||||
id: string
|
||||
companyId: string
|
||||
dockerId: string | null
|
||||
containerName: string
|
||||
status: ContainerStatus
|
||||
port: number
|
||||
image: string
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
const map: Record<ContainerStatus, { label: string; className: string }> = {
|
||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-yellow-900 text-yellow-300' },
|
||||
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
||||
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
||||
}
|
||||
const { label, className } = map[status] ?? map.ERROR
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tail, setTail] = useState(150)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
|
||||
const json = await res.json()
|
||||
setLogs(json.data?.logs ?? '')
|
||||
} catch {
|
||||
setLogs('Failed to fetch logs.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
|
||||
<p className="text-xs text-zinc-400">{companyName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={tail}
|
||||
onChange={(e) => setTail(Number(e.target.value))}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
|
||||
>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={150}>Last 150 lines</option>
|
||||
<option value={500}>Last 500 lines</option>
|
||||
<option value={1000}>Last 1000 lines</option>
|
||||
</select>
|
||||
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Refresh
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-xs text-zinc-500">Loading…</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
|
||||
{logs || 'No logs available.'}
|
||||
</pre>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
|
||||
|
||||
export default function ContainersPage() {
|
||||
const [containers, setContainers] = useState<CompanyContainer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [provisioning, setProvisioning] = useState(false)
|
||||
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers`, { headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||
}
|
||||
setContainers(json.data ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load containers.')
|
||||
/* silent — keep old data */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
const id = setInterval(fetchContainers, 8000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchContainers])
|
||||
|
||||
async function provisionAll() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||
setProvisionResults(json.data?.results ?? [])
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Provisioning failed.')
|
||||
} finally {
|
||||
setProvisioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
|
||||
setBusy((b) => ({ ...b, [companyId]: true }))
|
||||
try {
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||
const url =
|
||||
action === 'remove'
|
||||
? `${API_BASE}/admin/containers/${companyId}`
|
||||
: `${API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
||||
}
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [companyId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = containers.filter(
|
||||
(c) =>
|
||||
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
const stats = {
|
||||
running: containers.filter((c) => c.status === 'RUNNING').length,
|
||||
stopped: containers.filter((c) => c.status === 'STOPPED').length,
|
||||
error: containers.filter((c) => c.status === 'ERROR').length,
|
||||
total: containers.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-8">
|
||||
{logsFor && (
|
||||
<LogsModal
|
||||
companyId={logsFor.companyId}
|
||||
companyName={logsFor.companyName}
|
||||
onClose={() => setLogsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={provisionAll}
|
||||
disabled={provisioning}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{provisioning ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Provisioning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
Provision All Accounts
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provisionResults !== null && (
|
||||
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Provisioning complete —{' '}
|
||||
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
|
||||
{provisionResults.some((r) => r.status === 'error') && (
|
||||
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
|
||||
)}
|
||||
</p>
|
||||
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{provisionResults.map((r) => (
|
||||
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
|
||||
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
|
||||
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mb-6 grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-yellow-400' },
|
||||
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<p className="text-xs text-zinc-500">{s.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by company name or container…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-zinc-500">
|
||||
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<th className="px-5 py-3 font-medium">Company</th>
|
||||
<th className="px-5 py-3 font-medium">Container</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium">Port</th>
|
||||
<th className="px-5 py-3 font-medium">Image</th>
|
||||
<th className="px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{filtered.map((c) => {
|
||||
const isBusy = busy[c.companyId] ?? false
|
||||
const isRunning = c.status === 'RUNNING'
|
||||
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
|
||||
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
|
||||
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-5 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.company.slug}</p>
|
||||
{c.errorMessage && (
|
||||
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
|
||||
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
|
||||
{c.containerName}
|
||||
{c.dockerId && (
|
||||
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<ActionButton
|
||||
label="Start"
|
||||
color="emerald"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'start')}
|
||||
/>
|
||||
)}
|
||||
{isRunning && (
|
||||
<ActionButton
|
||||
label="Stop"
|
||||
color="yellow"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'stop')}
|
||||
/>
|
||||
)}
|
||||
{(isRunning || isStopped) && (
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
color="blue"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'restart')}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label="Redeploy"
|
||||
color="purple"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'deploy')}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logs"
|
||||
color="zinc"
|
||||
disabled={isBusy || !c.dockerId}
|
||||
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Remove"
|
||||
color="red"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => {
|
||||
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
|
||||
act(c.companyId, 'remove')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-400">Out of scope</p>
|
||||
<h1 className="mt-3 text-2xl font-semibold text-zinc-100">Per-tenant containers disabled</h1>
|
||||
<p className="mt-4 text-sm leading-6 text-zinc-400">
|
||||
Company Docker container orchestration is not part of the production platform. The previous
|
||||
admin UI and Docker-socket control plane have been removed from GA because they created a
|
||||
high-privilege host trust boundary and were incomplete (no durable model or API surface).
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-500">
|
||||
If isolated runtimes become a commercial requirement, they must be rebuilt as a separate
|
||||
least-privilege deployment controller — never inside the business API.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
color,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
||||
yellow: 'border-yellow-800 text-yellow-400 hover:bg-yellow-900/40',
|
||||
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
||||
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
|
||||
const storedTheme = window.localStorage.getItem('rentaldrivego-theme') ?? window.localStorage.getItem('admin-theme')
|
||||
const theme = storedTheme === 'light' ? 'light' : 'dark'
|
||||
const params = new URLSearchParams({
|
||||
portal: 'admin',
|
||||
next: nextPath || '/dashboard',
|
||||
next: `/admin${nextPath || '/dashboard'}`,
|
||||
})
|
||||
return `${dashboardUrl}/sign-in?${params.toString()}`
|
||||
return `${websiteUrl}/en/${theme}/admin-sign-in?${params.toString()}`
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
@@ -25,80 +28,369 @@ const navLinks = [
|
||||
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||
{ href: '/dashboard/menu-management', key: 'menuManagement', icon: 'M4 6h16M4 12h16M4 18h10' },
|
||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
]
|
||||
|
||||
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { dict } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||
const [unreadNotifications, setUnreadNotifications] = useState(0)
|
||||
const [securitySetupOpen, setSecuritySetupOpen] = useState(false)
|
||||
const redirectingToLogin = useRef(false)
|
||||
|
||||
function redirectToLogin() {
|
||||
if (redirectingToLogin.current) return
|
||||
redirectingToLogin.current = true
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
} else {
|
||||
setReady(true)
|
||||
let cancelled = false
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return
|
||||
if (response.ok) {
|
||||
const json = await response.json().catch(() => null)
|
||||
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
|
||||
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setAdmin(resolvedAdmin)
|
||||
setReady(true)
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
|
||||
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
|
||||
.then((inbox) => {
|
||||
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
|
||||
})
|
||||
.catch(() => {})
|
||||
} else {
|
||||
redirectToLogin()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.name === 'AbortError') return
|
||||
if (!cancelled) redirectToLogin()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
void fetch('/admin/api/v1/admin/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-zinc-950">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" aria-label={dict.loading} />
|
||||
<div className="flex h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label={dict.loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900 transition-colors">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-zinc-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||
}`}
|
||||
<AdminSessionProvider admin={admin as AdminSessionUser}>
|
||||
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active ? 'bg-orange-600 text-white dark:bg-orange-500 dark:text-white' : 'text-stone-500 hover:text-blue-900 hover:bg-stone-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
{link.key === 'notifications' && unreadNotifications > 0 ? (
|
||||
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
{admin && !admin.totpEnabled ? (
|
||||
<button
|
||||
onClick={() => setSecuritySetupOpen(true)}
|
||||
className="mb-2 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-orange-700 transition-colors hover:bg-orange-50 hover:text-orange-800 dark:text-orange-300 dark:hover:bg-[#162038] dark:hover:text-orange-200"
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l2 2 4-4M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7l7-4z" />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-red-400"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex flex-col items-center gap-3 border-t border-zinc-800 pt-4">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
Enable 2FA
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
|
||||
{admin && securitySetupOpen ? (
|
||||
<Admin2FASetupDialog
|
||||
admin={admin}
|
||||
onEnrolled={(updatedAdmin) => {
|
||||
setAdmin(updatedAdmin)
|
||||
setSecuritySetupOpen(false)
|
||||
}}
|
||||
onClose={() => setSecuritySetupOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Admin2FASetupDialog({
|
||||
admin,
|
||||
onEnrolled,
|
||||
onClose,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
onEnrolled: (admin: AdminSessionUser) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
type SetupMethod = 'email' | 'authenticator'
|
||||
const [method, setMethod] = useState<SetupMethod | null>(null)
|
||||
const [secret, setSecret] = useState('')
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingSetup, setLoadingSetup] = useState(false)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
|
||||
async function startSetup(nextMethod: SetupMethod) {
|
||||
setMethod(nextMethod)
|
||||
setCode('')
|
||||
setError(null)
|
||||
setLoadingSetup(true)
|
||||
const endpoint = nextMethod === 'email'
|
||||
? `${ADMIN_API_BASE}/admin/auth/2fa/email/setup`
|
||||
: `${ADMIN_API_BASE}/admin/auth/2fa/setup`
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
|
||||
const data = json?.data ?? json
|
||||
setSecret(data?.secret ?? '')
|
||||
setQrCode(data?.qrCode ?? '')
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to start 2FA setup.')
|
||||
} finally {
|
||||
setLoadingSetup(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const normalizedCode = code.trim()
|
||||
if (!/^\d{6}$/.test(normalizedCode)) {
|
||||
setError('Enter the 6-digit code from your authenticator app.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setVerifying(true)
|
||||
const endpoint = method === 'email'
|
||||
? `${ADMIN_API_BASE}/admin/auth/2fa/email/verify`
|
||||
: `${ADMIN_API_BASE}/admin/auth/2fa/verify`
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: normalizedCode }),
|
||||
})
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
|
||||
const data = json?.data ?? json
|
||||
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
|
||||
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Invalid 2FA code.')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-6 text-stone-900 backdrop-blur-sm dark:text-slate-100">
|
||||
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">Security</p>
|
||||
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Enable 2FA</h1>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950 transition-colors">{children}</main>
|
||||
|
||||
{verifiedAdmin ? (
|
||||
<div className="mt-8 space-y-5">
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
2FA is enabled. Save your recovery codes before continuing.
|
||||
</div>
|
||||
{recoveryCodes.length > 0 ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
|
||||
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{recoveryCodes.map((recoveryCode) => (
|
||||
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
|
||||
{recoveryCode}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEnrolled(verifiedAdmin)}
|
||||
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
Continue to admin dashboard
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={verifyCode} className="mt-8 space-y-6">
|
||||
{!method ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSetup('email')}
|
||||
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Email code</span>
|
||||
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">{admin.email}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSetup('authenticator')}
|
||||
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</span>
|
||||
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">TOTP</span>
|
||||
</button>
|
||||
</div>
|
||||
) : loadingSetup ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
|
||||
Preparing setup...
|
||||
</div>
|
||||
) : method === 'email' ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
|
||||
Enter the 6-digit code sent to {admin.email}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
|
||||
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
|
||||
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
|
||||
Scan the QR code with your authenticator app, or enter the setup key manually.
|
||||
</p>
|
||||
{secret ? (
|
||||
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
|
||||
{secret}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
|
||||
placeholder="000000"
|
||||
disabled={!method || loadingSetup || verifying}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{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/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!method || loadingSetup || verifying || code.length !== 6}
|
||||
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{verifying ? 'Verifying...' : 'Enable 2FA'}
|
||||
</button>
|
||||
{method ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethod(null)
|
||||
setCode('')
|
||||
setError(null)
|
||||
setSecret('')
|
||||
setQrCode('')
|
||||
}}
|
||||
className="w-full rounded-full border border-stone-200 px-6 py-3 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
|
||||
>
|
||||
Choose another method
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
|
||||
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
|
||||
|
||||
type CompanyOption = {
|
||||
id: string
|
||||
name: string
|
||||
subscription?: { plan?: string | null } | null
|
||||
status: string
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
id: string
|
||||
systemKey: string | null
|
||||
label: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
icon: string | null
|
||||
parentId: string | null
|
||||
displayOrder: number
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
parent?: { id: string; label: string } | null
|
||||
roleVisibilities: Array<{ role: EmployeeRole }>
|
||||
subscriptionAssignments: Array<{ plan: Plan; displayOrder: number; isActive: boolean }>
|
||||
companyAssignments: Array<{
|
||||
companyId: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
company: { id: string; name: string }
|
||||
}>
|
||||
}
|
||||
|
||||
type AuditLog = {
|
||||
id: string
|
||||
action: string
|
||||
resource: string
|
||||
resourceId: string | null
|
||||
createdAt: string
|
||||
adminUser: { firstName: string; lastName: string; email: string } | null
|
||||
}
|
||||
|
||||
type PreviewItem = {
|
||||
id: string
|
||||
label: string
|
||||
systemKey: string | null
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
displayOrder: number
|
||||
visible: boolean
|
||||
source: 'subscription' | 'company' | 'none'
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
type PreviewResponse = {
|
||||
company: { id: string; name: string; status: string; subscription: { plan: Plan; status: string } | null }
|
||||
role: EmployeeRole
|
||||
subscriptionPlan: Plan | null
|
||||
items: PreviewItem[]
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
label: string
|
||||
systemKey: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string
|
||||
icon: string
|
||||
parentId: string
|
||||
displayOrder: string
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
roles: EmployeeRole[]
|
||||
plans: Plan[]
|
||||
companyIds: string[]
|
||||
}
|
||||
|
||||
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
|
||||
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
|
||||
|
||||
const INPUT =
|
||||
'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-2 focus:ring-orange-500'
|
||||
const LABEL = 'text-xs font-medium uppercase tracking-wider text-zinc-500'
|
||||
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
label: '',
|
||||
systemKey: '',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '',
|
||||
icon: '',
|
||||
parentId: '',
|
||||
displayOrder: '0',
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
roles: ['OWNER', 'MANAGER', 'AGENT'],
|
||||
plans: ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'],
|
||||
companyIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
function formFromItem(item: MenuItem): FormState {
|
||||
return {
|
||||
label: item.label,
|
||||
systemKey: item.systemKey ?? '',
|
||||
itemType: item.itemType,
|
||||
routeOrUrl: item.routeOrUrl ?? '',
|
||||
icon: item.icon ?? '',
|
||||
parentId: item.parentId ?? '',
|
||||
displayOrder: String(item.displayOrder),
|
||||
openInNewTab: item.openInNewTab,
|
||||
isRequired: item.isRequired,
|
||||
isActive: item.isActive,
|
||||
roles: item.roleVisibilities.map((entry) => entry.role),
|
||||
plans: item.subscriptionAssignments.map((entry) => entry.plan),
|
||||
companyIds: item.companyAssignments.map((entry) => entry.companyId),
|
||||
}
|
||||
}
|
||||
|
||||
function chip(text: string, tone = 'default') {
|
||||
const toneClass =
|
||||
tone === 'success'
|
||||
? 'bg-emerald-950/40 text-emerald-400'
|
||||
: tone === 'warn'
|
||||
? 'bg-orange-950/40 text-orange-400'
|
||||
: 'bg-zinc-800 text-zinc-300'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MenuManagementPage() {
|
||||
const [items, setItems] = useState<MenuItem[]>([])
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
|
||||
const [preview, setPreview] = useState<PreviewResponse | null>(null)
|
||||
const [previewCompanyId, setPreviewCompanyId] = useState('')
|
||||
const [previewRole, setPreviewRole] = useState<EmployeeRole>('OWNER')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() => items.filter((item) => item.itemType === 'PARENT_MENU' && item.id !== editingId),
|
||||
[items, editingId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [menuItems, companiesPage, auditPage] = await Promise.all([
|
||||
api<MenuItem[]>('/admin/menu-items'),
|
||||
api<{ data: CompanyOption[] }>('/admin/companies?page=1&pageSize=100'),
|
||||
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
|
||||
])
|
||||
setItems(menuItems)
|
||||
setCompanies(companiesPage.data)
|
||||
setAuditLogs(auditPage.data)
|
||||
if (!previewCompanyId && companiesPage.data[0]?.id) {
|
||||
setPreviewCompanyId(companiesPage.data[0].id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load menu management data.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function toggleArrayValue<K extends 'roles' | 'plans' | 'companyIds'>(key: K, value: FormState[K][number]) {
|
||||
setForm((prev) => {
|
||||
const values = prev[key] as string[]
|
||||
return {
|
||||
...prev,
|
||||
[key]: values.includes(value as string)
|
||||
? values.filter((entry) => entry !== value)
|
||||
: [...values, value as string],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshLists() {
|
||||
const [menuItems, auditPage] = await Promise.all([
|
||||
api<MenuItem[]>('/admin/menu-items'),
|
||||
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
|
||||
])
|
||||
setItems(menuItems)
|
||||
setAuditLogs(auditPage.data)
|
||||
}
|
||||
|
||||
async function submitForm(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
const payload = {
|
||||
label: form.label,
|
||||
systemKey: form.systemKey || null,
|
||||
itemType: form.itemType,
|
||||
routeOrUrl: form.routeOrUrl || null,
|
||||
icon: form.icon || null,
|
||||
parentId: form.parentId || null,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
openInNewTab: form.openInNewTab,
|
||||
isRequired: form.isRequired,
|
||||
isActive: form.isActive,
|
||||
roles: form.roles,
|
||||
subscriptionPlans: form.plans.map((plan) => ({
|
||||
plan,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
isActive: true,
|
||||
})),
|
||||
companyAssignments: form.companyIds.map((companyId) => ({
|
||||
companyId,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
isActive: true,
|
||||
})),
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await api(`/admin/menu-items/${editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setSuccess('Menu item updated.')
|
||||
} else {
|
||||
await api('/admin/menu-items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setSuccess('Menu item created.')
|
||||
}
|
||||
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save menu item.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
function startEdit(item: MenuItem) {
|
||||
setEditingId(item.id)
|
||||
setForm(formFromItem(item))
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function toggleStatus(item: MenuItem) {
|
||||
try {
|
||||
setError(null)
|
||||
await api(`/admin/menu-items/${item.id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !item.isActive }),
|
||||
})
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update status.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: MenuItem) {
|
||||
if (!window.confirm(`Delete "${item.label}"?`)) return
|
||||
try {
|
||||
setError(null)
|
||||
await api(`/admin/menu-items/${item.id}`, { method: 'DELETE' })
|
||||
if (editingId === item.id) {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
}
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to delete menu item.')
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
if (!previewCompanyId) return
|
||||
try {
|
||||
setPreviewing(true)
|
||||
setError(null)
|
||||
const result = await api<PreviewResponse>('/admin/menu-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ companyId: previewCompanyId, role: previewRole }),
|
||||
})
|
||||
setPreview(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load menu preview.')
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shell py-8">
|
||||
<div className="panel p-8 text-sm text-zinc-500">
|
||||
Loading menu management…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6 text-zinc-100">
|
||||
<section className="panel p-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Menu Management</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm text-zinc-400">
|
||||
Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
className="rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-400"
|
||||
>
|
||||
Create menu item
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(error || success) && (
|
||||
<section className="space-y-3">
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
{success && <div className="panel p-4 text-sm text-emerald-400">{success}</div>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="px-5 pt-5 text-xl font-semibold text-zinc-100">Menu items</h2>
|
||||
<p className="px-5 pb-1 pt-1 text-sm text-zinc-400">{items.length} configured items</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Item</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Assignments</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="align-top transition-colors hover:bg-zinc-800/30">
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.isRequired && chip('Required', 'warn')}
|
||||
{item.systemKey && chip(item.systemKey)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
{item.parent ? ` • child of ${item.parent.label}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-xs text-zinc-300">{item.itemType}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.subscriptionAssignments.length > 0
|
||||
? item.subscriptionAssignments.map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.plan}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-xs text-zinc-500">No plan assignments</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.companyAssignments.slice(0, 3).map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.company.name}
|
||||
</span>
|
||||
))}
|
||||
{item.companyAssignments.length > 3 && chip(`+${item.companyAssignments.length - 3} more`)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.roleVisibilities.map((entry) => (
|
||||
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{entry.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => startEdit(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleStatus(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
{item.isActive ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button type="button" onClick={() => removeItem(item)} className="rounded-lg border border-red-900/60 bg-red-950/20 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-900/30">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-zinc-100">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
|
||||
</p>
|
||||
</div>
|
||||
{editingId && (
|
||||
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-400 hover:text-orange-300">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-5" onSubmit={submitForm}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Label</span>
|
||||
<input className={INPUT} value={form.label} onChange={(e) => updateForm('label', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>System key</span>
|
||||
<input className={INPUT} value={form.systemKey} onChange={(e) => updateForm('systemKey', e.target.value)} placeholder="dashboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Type</span>
|
||||
<select className={INPUT} value={form.itemType} onChange={(e) => updateForm('itemType', e.target.value as MenuItemType)}>
|
||||
{ITEM_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>Icon</span>
|
||||
<input className={INPUT} value={form.icon} onChange={(e) => updateForm('icon', e.target.value)} placeholder="LayoutDashboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Route or URL</span>
|
||||
<input className={INPUT} value={form.routeOrUrl} onChange={(e) => updateForm('routeOrUrl', e.target.value)} placeholder="/reports or https://…" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>Parent menu</span>
|
||||
<select className={INPUT} value={form.parentId} onChange={(e) => updateForm('parentId', e.target.value)}>
|
||||
<option value="">No parent</option>
|
||||
{parentOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Display order</span>
|
||||
<input className={INPUT} type="number" min="0" value={form.displayOrder} onChange={(e) => updateForm('displayOrder', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(e) => updateForm('isActive', e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.openInNewTab} onChange={(e) => updateForm('openInNewTab', e.target.checked)} />
|
||||
New tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isRequired} onChange={(e) => updateForm('isRequired', e.target.checked)} />
|
||||
Required system item
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Role visibility</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{ROLES.map((role) => (
|
||||
<label key={role} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.roles.includes(role)} onChange={() => toggleArrayValue('roles', role)} />
|
||||
{role}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Subscription plans</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PLANS.map((plan) => (
|
||||
<label key={plan} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.plans.includes(plan)} onChange={() => toggleArrayValue('plans', plan)} />
|
||||
{plan}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Company-specific assignments</p>
|
||||
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-700 bg-zinc-900/30 p-3">
|
||||
{companies.map((company) => (
|
||||
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm text-zinc-200 transition-colors hover:bg-zinc-800/40">
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
|
||||
<span>{company.name}</span>
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{company.subscription?.plan ?? 'No plan'} • {company.status}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-lg bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-orange-400 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Saving…' : editingId ? 'Update menu item' : 'Create menu item'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Preview company menu</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Test the generated menu for a company and role before troubleshooting or saving follow-up changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[220px]">
|
||||
<label className={LABEL}>Company</label>
|
||||
<select className={INPUT} value={previewCompanyId} onChange={(e) => setPreviewCompanyId(e.target.value)}>
|
||||
{companies.map((company) => (
|
||||
<option key={company.id} value={company.id}>{company.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[160px]">
|
||||
<label className={LABEL}>Role</label>
|
||||
<select className={INPUT} value={previewRole} onChange={(e) => setPreviewRole(e.target.value as EmployeeRole)}>
|
||||
{ROLES.map((role) => <option key={role} value={role}>{role}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPreview}
|
||||
disabled={previewing || !previewCompanyId}
|
||||
className="rounded-lg bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-orange-400 disabled:opacity-60"
|
||||
>
|
||||
{previewing ? 'Previewing…' : 'Run preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<p className="text-sm font-semibold text-zinc-100">{preview.company.name}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{preview.items.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
|
||||
{chip(item.source === 'none' ? 'unassigned' : item.source)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-zinc-300">
|
||||
{item.reasons.map((reason, index) => (
|
||||
<li key={`${item.id}-${index}`}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Recent menu audit activity</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Recent platform-side changes to menu items and assignments.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{auditLogs.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-zinc-100">{entry.action}</p>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{entry.adminUser
|
||||
? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}`
|
||||
: 'Unknown admin'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
fetchAdminNotifications,
|
||||
formatNotificationDate,
|
||||
type NotificationsPageResult,
|
||||
} from '@/lib/adminNotifications'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface AdminInboxItem {
|
||||
id: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'text-sky-400 bg-sky-950/40',
|
||||
SMS: 'text-emerald-400 bg-emerald-950/40',
|
||||
WHATSAPP: 'text-teal-400 bg-teal-950/40',
|
||||
IN_APP: 'text-violet-400 bg-violet-950/40',
|
||||
PUSH: 'text-orange-400 bg-orange-950/40',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: 'text-yellow-400 bg-yellow-950/40',
|
||||
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||
FAILED: 'text-red-400 bg-red-950/40',
|
||||
QUEUED: 'text-sky-400 bg-sky-950/40',
|
||||
SKIPPED: 'text-zinc-400 bg-zinc-800',
|
||||
DEAD_LETTER: 'text-red-300 bg-red-950/60',
|
||||
READ: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
function formatRecipient(item: {
|
||||
recipientType: string | null
|
||||
recipientName: string | null
|
||||
recipientEmail: string | null
|
||||
employeeId: string | null
|
||||
renterId: string | null
|
||||
}) {
|
||||
const fallbackId = item.employeeId ?? item.renterId
|
||||
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
|
||||
return item.recipientType ? `${item.recipientType}: ${label}` : label
|
||||
}
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const [result, setResult] = useState<NotificationsPageResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterCompany, setFilterCompany] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
|
||||
|
||||
function loadInbox() {
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
|
||||
.then((json) => setInbox(json.data))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function load(p: number, signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchAdminNotifications(
|
||||
p,
|
||||
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
|
||||
signal,
|
||||
)
|
||||
.then((data) => setResult(data))
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setResult(null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load notifications')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!signal?.aborted) setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setPage(1)
|
||||
load(1, controller.signal)
|
||||
loadInbox()
|
||||
return () => controller.abort()
|
||||
}, [filterChannel, filterStatus, filterCompany])
|
||||
|
||||
function goToPage(p: number) {
|
||||
setPage(p)
|
||||
load(p)
|
||||
}
|
||||
|
||||
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
|
||||
|
||||
async function markRead(recipientId: string) {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
if (response.ok) loadInbox()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Full audit log of every notification sent across all companies.
|
||||
</p>
|
||||
</div>
|
||||
{result && (
|
||||
<span className="rounded-full bg-zinc-800 px-3 py-1 text-sm text-zinc-300">
|
||||
{result.total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="panel p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
|
||||
</div>
|
||||
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{inbox.data.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
|
||||
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
{CHANNELS.map((ch) => <option key={ch} value={ch}>{ch}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input
|
||||
value={filterCompany}
|
||||
onChange={(e) => setFilterCompany(e.target.value)}
|
||||
placeholder="Company ID"
|
||||
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Sent at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||
) : result.data.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{formatNotificationDate(item.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||
</td>
|
||||
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
|
||||
<p className="truncate">{formatRecipient(item)}</p>
|
||||
{item.recipientEmail && item.recipientName ? (
|
||||
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||
{item.type.replaceAll('_', ' ')}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CHANNEL_BADGE[item.channel] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 max-w-[220px]">
|
||||
<p className="truncate text-sm text-zinc-200">{item.title}</p>
|
||||
<p className="truncate text-xs text-zinc-500">{item.body}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{formatNotificationDate(item.sentAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
|
||||
<span className="text-xs text-zinc-500">
|
||||
Page {page} of {totalPages} — {result?.total.toLocaleString()} records
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Activity, ArrowRight, Building2, ClipboardList, ShieldCheck, Users, WalletCards } from 'lucide-react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
|
||||
|
||||
interface Metrics {
|
||||
totalCompanies: number
|
||||
@@ -15,66 +16,177 @@ interface Metrics {
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const { language } = useAdminI18n()
|
||||
const admin = useAdminSession()
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
platformOverview: 'Platform overview',
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Operations command',
|
||||
platformOverview: 'RentalDriveGo admin dashboard',
|
||||
subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||
liveSignal: 'Live platform signal',
|
||||
readiness: 'Operational readiness',
|
||||
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
||||
quickActions: 'Quick actions',
|
||||
mrr: 'Monthly recurring revenue',
|
||||
noMetrics: 'Metrics are limited for this admin role.',
|
||||
cards: [
|
||||
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'],
|
||||
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'],
|
||||
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'],
|
||||
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
|
||||
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
||||
],
|
||||
health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
platformOverview: 'Vue plateforme',
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Centre des opérations',
|
||||
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
||||
subtitle: 'Suivez la santé de la Carplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
liveSignal: 'Signal plateforme en direct',
|
||||
readiness: 'Disponibilité opérationnelle',
|
||||
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
||||
quickActions: 'Actions rapides',
|
||||
mrr: 'Revenu mensuel récurrent',
|
||||
noMetrics: 'Les métriques sont limitées pour ce rôle admin.',
|
||||
cards: [
|
||||
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'],
|
||||
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'],
|
||||
['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'],
|
||||
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
|
||||
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||
],
|
||||
health: ['Comptes locataires', 'Identité Carplace', 'Piste d’audit admin'],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
platformOverview: 'نظرة عامة على المنصة',
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'مركز العمليات',
|
||||
platformOverview: 'لوحة إدارة RentalDriveGo',
|
||||
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
|
||||
liveSignal: 'مؤشر المنصة المباشر',
|
||||
readiness: 'جاهزية التشغيل',
|
||||
readinessBody: 'مسارات الإدارة الأساسية متصلة وجاهزة لدعم المنصة.',
|
||||
quickActions: 'إجراءات سريعة',
|
||||
mrr: 'الإيراد الشهري المتكرر',
|
||||
noMetrics: 'المؤشرات محدودة لهذا الدور الإداري.',
|
||||
cards: [
|
||||
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
||||
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'],
|
||||
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
||||
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
|
||||
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
|
||||
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل RentalDriveGo.', 'عرض السجلات'],
|
||||
],
|
||||
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
|
||||
},
|
||||
}[language]
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/metrics`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
if (!canAccessAdminMetrics(admin)) {
|
||||
setMetrics(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setMetrics(json.data))
|
||||
.then(async (response) => {
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setMetrics(null)
|
||||
return
|
||||
}
|
||||
setMetrics((json?.data ?? json) as Metrics)
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [admin])
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US')
|
||||
const currencyFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const kpis = metrics
|
||||
? [
|
||||
{ label: 'Total companies', value: metrics.totalCompanies },
|
||||
{ label: copy.kpis[1], value: metrics.activeCompanies },
|
||||
{ label: copy.kpis[2], value: metrics.totalRenters },
|
||||
{ label: copy.kpis[3], value: metrics.totalReservations },
|
||||
{ label: copy.kpis[0], value: metrics.totalCompanies, icon: Building2, tone: 'text-blue-600 dark:text-blue-300', bg: 'bg-blue-50 dark:bg-blue-500/10' },
|
||||
{ label: copy.kpis[1], value: metrics.activeCompanies, icon: Activity, tone: 'text-emerald-600 dark:text-emerald-300', bg: 'bg-emerald-50 dark:bg-emerald-500/10' },
|
||||
{ label: copy.kpis[2], value: metrics.totalRenters, icon: Users, tone: 'text-violet-600 dark:text-violet-300', bg: 'bg-violet-50 dark:bg-violet-500/10' },
|
||||
{ label: copy.kpis[3], value: metrics.totalReservations, icon: ClipboardList, tone: 'text-orange-600 dark:text-orange-300', bg: 'bg-orange-50 dark:bg-orange-500/10' },
|
||||
]
|
||||
: []
|
||||
if (kpis.length > 0) kpis[0].label = copy.kpis[0]
|
||||
|
||||
const activeRate = metrics?.totalCompanies
|
||||
? Math.round((metrics.activeCompanies / metrics.totalCompanies) * 100)
|
||||
: 0
|
||||
const renterRatio = metrics?.totalCompanies
|
||||
? Math.round(metrics.totalRenters / Math.max(metrics.totalCompanies, 1))
|
||||
: 0
|
||||
|
||||
const actionCards = [
|
||||
{ href: '/dashboard/companies', icon: Building2, title: copy.cards[0][0], body: copy.cards[0][1], cta: copy.cards[0][2] },
|
||||
{ href: '/dashboard/renters', icon: Users, title: copy.cards[1][0], body: copy.cards[1][1], cta: copy.cards[1][2] },
|
||||
{ href: '/dashboard/audit-logs', icon: ShieldCheck, title: copy.cards[2][0], body: copy.cards[2][1], cta: copy.cards[2][2] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-8">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
|
||||
<section className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-[0_30px_90px_rgba(15,23,42,0.10)] transition-colors dark:border-blue-900/60 dark:bg-[#081427]/86 dark:shadow-[0_34px_100px_rgba(0,0,0,0.34)] sm:p-8">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#2563eb,#f97316,#10b981)]" />
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{copy.eyebrow}</p>
|
||||
<h1 className="mt-3 text-4xl font-black tracking-normal text-blue-950 dark:text-white sm:text-5xl">{copy.platformOverview}</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[15rem] items-center gap-3 rounded-2xl border border-stone-200/80 bg-stone-50/90 p-3 dark:border-blue-900/60 dark:bg-[#0d1b38]/85">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-blue-950 text-white dark:bg-orange-500">
|
||||
<WalletCards className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.mrr}</p>
|
||||
<p className="text-lg font-black text-blue-950 dark:text-white">{metrics?.mrr != null ? currencyFormatter.format(metrics.mrr) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{copy.health.map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-stone-200/70 bg-white/70 px-4 py-3 text-sm font-semibold text-stone-700 dark:border-blue-900/50 dark:bg-[#0d1b38]/70 dark:text-slate-200">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{copy.liveSignal}</p>
|
||||
<div className="mt-5 space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-semibold text-zinc-200">{copy.readiness}</span>
|
||||
<span className="font-black text-orange-600 dark:text-orange-300">{activeRate}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-blue-950">
|
||||
<div className="h-full rounded-full bg-[linear-gradient(90deg,#2563eb,#f97316)]" style={{ width: `${Math.min(activeRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-500">{copy.readinessBody}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
|
||||
<p className="text-xs text-zinc-500">{copy.kpis[1]}</p>
|
||||
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(metrics.activeCompanies) : '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
|
||||
<p className="text-xs text-zinc-500">{copy.kpis[2]}</p>
|
||||
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(renterRatio) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -85,27 +197,49 @@ export default function AdminDashboardPage() {
|
||||
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
|
||||
</div>
|
||||
))
|
||||
: kpis.map((kpi) => (
|
||||
<div key={kpi.label} className="panel p-6">
|
||||
<p className="text-xs text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p>
|
||||
: kpis.length > 0 ? kpis.map((kpi) => {
|
||||
const Icon = kpi.icon
|
||||
return (
|
||||
<div key={kpi.label} className="panel p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-2 text-3xl font-black text-zinc-200">{numberFormatter.format(kpi.value ?? 0)}</p>
|
||||
</div>
|
||||
<div className={`grid h-10 w-10 place-items-center rounded-2xl ${kpi.bg}`}>
|
||||
<Icon className={`h-5 w-5 ${kpi.tone}`} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className="panel p-6 md:col-span-2 lg:col-span-4">
|
||||
<p className="text-sm text-zinc-500">{copy.noMetrics}</p>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
['/dashboard/companies', ...copy.cards[0]],
|
||||
['/dashboard/renters', ...copy.cards[1]],
|
||||
['/dashboard/audit-logs', ...copy.cards[2]],
|
||||
].map(([href, title, body, cta]) => (
|
||||
<Link key={href} href={href} className="panel p-6 hover:border-zinc-700 transition-colors block">
|
||||
<p className="text-sm font-semibold text-zinc-200">{title}</p>
|
||||
<p className="mt-2 text-sm text-zinc-500">{body}</p>
|
||||
<p className="mt-4 text-xs text-emerald-400 font-medium">{cta}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-blue-950 dark:text-white">{copy.quickActions}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{copy.brand}</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{actionCards.map(({ href, icon: Icon, title, body, cta }) => (
|
||||
<Link key={href} href={href} className="panel group block p-6 hover:border-orange-300 dark:hover:border-orange-500/70">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-950 text-white dark:bg-orange-500">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-zinc-500 transition-transform group-hover:translate-x-1 group-hover:text-orange-500" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-5 text-base font-bold text-zinc-200">{title}</p>
|
||||
<p className="mt-2 min-h-[3rem] text-sm leading-6 text-zinc-500">{body}</p>
|
||||
<p className="mt-5 text-xs font-bold uppercase tracking-[0.16em] text-emerald-400">{cta}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Renter {
|
||||
id: string
|
||||
@@ -37,10 +36,10 @@ export default function AdminRentersPage() {
|
||||
title: 'Locataires',
|
||||
search: 'Rechercher des locataires…',
|
||||
name: 'Nom',
|
||||
email: 'Email',
|
||||
email: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
status: 'Statut',
|
||||
joined: 'Inscrit',
|
||||
joined: 'Date d’inscription',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucun locataire trouvé',
|
||||
blocked: 'Bloqué',
|
||||
@@ -55,7 +54,7 @@ export default function AdminRentersPage() {
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
status: 'الحالة',
|
||||
joined: 'تاريخ الانضمام',
|
||||
joined: 'تاريخ التسجيل',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على مستأجرين',
|
||||
blocked: 'محظور',
|
||||
@@ -71,18 +70,17 @@ export default function AdminRentersPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchRenters() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/renters`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
setRenters(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setRenters(list)
|
||||
setFiltered(list)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -103,9 +101,9 @@ export default function AdminRentersPage() {
|
||||
setActioning(id)
|
||||
try {
|
||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
||||
const res = await fetch(`${API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchRenters()
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageContent,
|
||||
type MarketplaceHomepageSectionType,
|
||||
type MarketplaceLanguage,
|
||||
cloneCarplaceHomepageContent,
|
||||
resolveCarplaceHomepageSections,
|
||||
type CarplaceHomepageConfig,
|
||||
type CarplaceHomepageContent,
|
||||
type CarplaceHomepageSectionType,
|
||||
type CarplaceLanguage,
|
||||
} from '@rentaldrivego/types'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
@@ -29,22 +28,22 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
PENDING: 'text-orange-400 bg-orange-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||
|
||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
||||
function cloneHomepageContent(content: CarplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
||||
const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
@@ -59,12 +58,12 @@ export default function AdminSiteConfigPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Site configuration',
|
||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
description: 'Edit the main Carplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
homepageTitle: 'Main website homepage',
|
||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
||||
homepageDescription: 'This controls the Carplace homepage shown on the main RentalDriveGo website.',
|
||||
saveHomepage: 'Save homepage',
|
||||
savingHomepage: 'Saving…',
|
||||
homepageSaved: 'Marketplace homepage saved.',
|
||||
homepageSaved: 'Carplace homepage saved.',
|
||||
search: 'Search by company or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
@@ -87,7 +86,7 @@ export default function AdminSiteConfigPage() {
|
||||
previewTitle: 'Live preview',
|
||||
previewDescription: 'Draft changes appear here before you save them.',
|
||||
homepageSections: 'Homepage sections',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main Carplace homepage.',
|
||||
addSection: 'Add',
|
||||
removeSection: 'Remove',
|
||||
expand: 'Expand',
|
||||
@@ -95,12 +94,12 @@ export default function AdminSiteConfigPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Configuration du site',
|
||||
description: 'Modifiez ici la homepage principale de la marketplace, ou ouvrez une entreprise pour gérer sa homepage publique et son menu.',
|
||||
homepageTitle: 'Homepage du site principal',
|
||||
homepageDescription: 'Cette section contrôle la homepage marketplace affichée sur le site principal RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la homepage',
|
||||
description: 'Modifiez ici la page d’accueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
homepageTitle: 'Page d’accueil du site principal',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil Carplace affichée sur le site principal de RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la page d’accueil',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Homepage marketplace enregistrée.',
|
||||
homepageSaved: 'Page d’accueil Carplace enregistrée.',
|
||||
search: 'Rechercher par entreprise ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
@@ -112,8 +111,8 @@ export default function AdminSiteConfigPage() {
|
||||
manageCompany: 'Ouvrir l’entreprise',
|
||||
companiesTitle: 'Sites de marque des entreprises',
|
||||
languageLabel: 'Langue',
|
||||
hero: 'Hero',
|
||||
surface: 'Bloc marketplace',
|
||||
hero: 'Bloc principal',
|
||||
surface: 'Bloc Carplace',
|
||||
companySection: 'Bloc opérateur',
|
||||
renterSection: 'Bloc client',
|
||||
valueSection: 'Blocs de valeur',
|
||||
@@ -122,8 +121,8 @@ export default function AdminSiteConfigPage() {
|
||||
closingSection: 'Appel à l’action final',
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
homepageSections: 'Sections de la homepage',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la homepage marketplace principale.',
|
||||
homepageSections: 'Sections de la page d’accueil',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil Carplace principale.',
|
||||
addSection: 'Ajouter',
|
||||
removeSection: 'Retirer',
|
||||
expand: 'Ouvrir',
|
||||
@@ -172,8 +171,8 @@ export default function AdminSiteConfigPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
||||
const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
|
||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||
@@ -184,27 +183,26 @@ export default function AdminSiteConfigPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const [companiesRes, homepageRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
|
||||
const companiesJson = await companiesRes.json()
|
||||
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
||||
setCompanies(companiesJson.data ?? [])
|
||||
setFiltered(companiesJson.data ?? [])
|
||||
setCompanies(companiesJson.data?.data ?? [])
|
||||
setFiltered(companiesJson.data?.data ?? [])
|
||||
|
||||
const homepageJson = await homepageRes.json()
|
||||
if (homepageRes.ok && homepageJson?.data) {
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -227,7 +225,7 @@ export default function AdminSiteConfigPage() {
|
||||
)
|
||||
}, [search, companies])
|
||||
|
||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
||||
function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
|
||||
setHomepage((current) => ({
|
||||
...current,
|
||||
[homepageLanguage]: {
|
||||
@@ -259,16 +257,16 @@ export default function AdminSiteConfigPage() {
|
||||
}
|
||||
|
||||
function getActiveSections() {
|
||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
||||
return resolveCarplaceHomepageSections(activeContent.sections)
|
||||
}
|
||||
|
||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
function addHomepageSection(section: CarplaceHomepageSectionType) {
|
||||
const sections = getActiveSections()
|
||||
if (sections.includes(section)) return
|
||||
updateHomepageContent({ sections: [...sections, section] })
|
||||
}
|
||||
|
||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
function removeHomepageSection(section: CarplaceHomepageSectionType) {
|
||||
const sections = getActiveSections().filter((item) => item !== section)
|
||||
if (sections.length === 0) return
|
||||
updateHomepageContent({ sections })
|
||||
@@ -277,20 +275,18 @@ export default function AdminSiteConfigPage() {
|
||||
async function saveHomepage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ homepage }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
||||
setHomepage(cloneHomepageContent(json.data as CarplaceHomepageConfig))
|
||||
setHomepageMessage(copy.homepageSaved)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -302,13 +298,15 @@ export default function AdminSiteConfigPage() {
|
||||
const activeContent = homepage[homepageLanguage]
|
||||
const activeSections = getActiveSections()
|
||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
||||
const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
|
||||
hero: copy.hero,
|
||||
surface: copy.surface,
|
||||
pillars: copy.valueSection,
|
||||
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
||||
features: copy.featureSection,
|
||||
howitworks: language === 'fr' ? 'Fonctionnement' : language === 'ar' ? 'كيفية العمل' : 'How it works',
|
||||
steps: copy.stepsSection,
|
||||
testimonials: language === 'fr' ? 'Témoignages' : language === 'ar' ? 'الشهادات' : 'Testimonials',
|
||||
closing: copy.closingSection,
|
||||
}
|
||||
|
||||
@@ -339,7 +337,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
||||
{(['en', 'fr', 'ar'] as CarplaceLanguage[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
@@ -416,7 +414,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700">{activeContent.heroKicker}</p>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700">{activeContent.heroKicker}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
|
||||
@@ -432,17 +430,17 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="space-y-10 px-5 py-6 lg:px-8">
|
||||
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<h4 className="text-4xl font-black tracking-[-0.04em] text-stone-950">{activeContent.heroTitle}</h4>
|
||||
<h4 className="text-4xl font-black tracking-[-0.04em] text-blue-950">{activeContent.heroTitle}</h4>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-stone-600">{activeContent.heroBody}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<span className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
|
||||
<span className="rounded-full bg-orange-600 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
|
||||
<span className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-700">{activeContent.exploreVehicles}</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeSections.includes('surface') ? (
|
||||
<div className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
|
||||
<div className="rounded-[1.5rem] bg-[#06132e] p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
@@ -463,9 +461,9 @@ export default function AdminSiteConfigPage() {
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
|
||||
<section className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
|
||||
<section className="rounded-[1.5rem] bg-[#06132e] p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
@@ -481,7 +479,7 @@ export default function AdminSiteConfigPage() {
|
||||
{activeContent.pillars.map((pillar, index) => (
|
||||
<article
|
||||
key={`${pillar.title}-${index}`}
|
||||
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-stone-900 bg-stone-950 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
|
||||
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-orange-700 bg-orange-600 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
|
||||
>
|
||||
<p className={`text-xs font-bold uppercase tracking-[0.24em] ${index === 1 ? 'text-stone-300' : 'text-stone-400'}`}>0{index + 1}</p>
|
||||
<h5 className="mt-3 text-xl font-black">{pillar.title}</h5>
|
||||
@@ -493,12 +491,12 @@ export default function AdminSiteConfigPage() {
|
||||
{activeSections.includes('audiences') ? <section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.companyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.companyTitle}</h5>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.companyTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.renterKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.renterTitle}</h5>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700">{activeContent.renterKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.renterTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
|
||||
</article>
|
||||
</section> : null}
|
||||
@@ -510,7 +508,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.features.map((feature, index) => (
|
||||
<div key={`${feature}-${index}`} className="flex items-start gap-3 rounded-[1rem] border border-stone-200 bg-white px-4 py-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
|
||||
@@ -521,13 +519,13 @@ export default function AdminSiteConfigPage() {
|
||||
) : <div />}
|
||||
{activeSections.includes('steps') ? (
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.stepsTitle}</h5>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.stepsTitle}</h5>
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.steps.map((step) => (
|
||||
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.stepLabel} {step.step}</p>
|
||||
<h6 className="mt-2 text-lg font-black text-stone-950">{step.title}</h6>
|
||||
<h6 className="mt-2 text-lg font-black text-blue-950">{step.title}</h6>
|
||||
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -536,13 +534,13 @@ export default function AdminSiteConfigPage() {
|
||||
) : <div />}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-stone-950 px-6 py-7 text-white">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{activeContent.readyKicker}</p>
|
||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-[#06132e] px-6 py-7 text-white">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-4 max-w-3xl text-3xl font-black tracking-[-0.04em]">{activeContent.readyTitle}</h5>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-8 text-stone-300">{activeContent.readyBody}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/20 px-5 py-3 text-sm font-semibold">{activeContent.viewPricing}</span>
|
||||
<span className="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-stone-950">{activeContent.createWorkspace}</span>
|
||||
<span className="rounded-full bg-orange-300 px-5 py-3 text-sm font-semibold text-blue-950">{activeContent.createWorkspace}</span>
|
||||
</div>
|
||||
</section> : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => ({
|
||||
redirect: vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })),
|
||||
}))
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
redirect: nextServer.redirect,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('admin favicon route', () => {
|
||||
it('redirects favicon requests to the generated icon route on the same origin', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves forwarded path base through the request URL origin only', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/admin/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
export default function AdminForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -17,9 +16,10 @@ export default function AdminForgotPasswordPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
@@ -36,7 +36,7 @@ export default function AdminForgotPasswordPage() {
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Forgot password</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Enter your admin email to receive a reset link.</p>
|
||||
</div>
|
||||
@@ -58,20 +58,21 @@ export default function AdminForgotPasswordPage() {
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Email</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
className="inline-flex w-full justify-center rounded-full bg-stone-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-50 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
+142
-13
@@ -11,7 +11,9 @@ html.dark {
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-50 antialiased transition-colors;
|
||||
@apply text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 28%, #f5f5f5 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@@ -19,17 +21,114 @@ body {
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
|
||||
@apply rounded-[1.75rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
html.dark .panel {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.light body {
|
||||
background-color: rgb(248 250 252);
|
||||
color: rgb(15 23 42);
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
/* ── Dark mode: zinc classes → deep navy blue ─────────────── */
|
||||
.bg-zinc-950 {
|
||||
background-color: #172554;
|
||||
}
|
||||
|
||||
.bg-zinc-950\/90 {
|
||||
background-color: rgb(23 37 84 / 0.9);
|
||||
}
|
||||
|
||||
.bg-zinc-900 {
|
||||
background-color: rgb(23 37 84 / 0.78);
|
||||
}
|
||||
|
||||
.bg-zinc-800,
|
||||
.bg-zinc-800\/50 {
|
||||
background-color: rgb(30 58 138 / 0.5);
|
||||
}
|
||||
|
||||
.border-zinc-800,
|
||||
.border-zinc-700 {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.text-zinc-100,
|
||||
.text-zinc-200 {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.text-zinc-500 {
|
||||
color: rgb(168 162 158);
|
||||
}
|
||||
|
||||
.text-zinc-400,
|
||||
.text-zinc-300 {
|
||||
color: rgb(120 113 108);
|
||||
}
|
||||
|
||||
.hover\:bg-zinc-800:hover,
|
||||
.hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(30 64 175 / 0.4);
|
||||
}
|
||||
|
||||
.hover\:text-zinc-200:hover {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
/* ── Primary action (emerald class → orange) ──────────────── */
|
||||
.text-emerald-400 {
|
||||
color: rgb(251 146 60);
|
||||
}
|
||||
|
||||
.bg-emerald-600 {
|
||||
background-color: rgb(234 88 12);
|
||||
}
|
||||
|
||||
.hover\:bg-emerald-500:hover {
|
||||
background-color: rgb(194 65 12);
|
||||
}
|
||||
|
||||
.dark .bg-emerald-600 {
|
||||
background-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
.dark .hover\:bg-emerald-500:hover {
|
||||
background-color: rgb(251 146 60);
|
||||
}
|
||||
|
||||
.bg-emerald-900\/30,
|
||||
.bg-emerald-950\/40,
|
||||
.bg-emerald-950\/50 {
|
||||
background-color: rgb(154 52 18 / 0.22);
|
||||
}
|
||||
|
||||
.border-emerald-500 {
|
||||
border-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
.focus\:ring-emerald-500:focus {
|
||||
--tw-ring-color: rgb(234 88 12 / 0.45);
|
||||
}
|
||||
|
||||
.hover\:bg-emerald-900\/50:hover {
|
||||
background-color: rgb(154 52 18 / 0.35);
|
||||
}
|
||||
|
||||
/* ── Light mode: zinc classes → white / stone-light ──────── */
|
||||
.light .bg-zinc-950 {
|
||||
background-color: rgb(248 250 252);
|
||||
background-color: rgb(255 253 248);
|
||||
}
|
||||
|
||||
.light .bg-zinc-950\/90 {
|
||||
@@ -37,39 +136,69 @@ body {
|
||||
}
|
||||
|
||||
.light .bg-zinc-900 {
|
||||
background-color: rgb(255 255 255);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
.light .bg-zinc-800,
|
||||
.light .bg-zinc-800\/50 {
|
||||
background-color: rgb(241 245 249);
|
||||
background-color: rgb(245 245 244);
|
||||
}
|
||||
|
||||
.light .border-zinc-800,
|
||||
.light .border-zinc-700 {
|
||||
border-color: rgb(226 232 240);
|
||||
border-color: rgb(231 229 228);
|
||||
}
|
||||
|
||||
.light .text-zinc-100,
|
||||
.light .text-zinc-200 {
|
||||
color: rgb(15 23 42);
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-zinc-50 {
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-zinc-500 {
|
||||
color: rgb(100 116 139);
|
||||
color: rgb(120 113 108);
|
||||
}
|
||||
|
||||
.light .text-zinc-400,
|
||||
.light .text-zinc-300 {
|
||||
color: rgb(71 85 105);
|
||||
color: rgb(87 83 78);
|
||||
}
|
||||
|
||||
.light .hover\:bg-zinc-800:hover,
|
||||
.light .hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(241 245 249);
|
||||
background-color: rgb(245 245 244);
|
||||
}
|
||||
|
||||
.light .hover\:text-zinc-200:hover {
|
||||
color: rgb(15 23 42);
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-amber-100,
|
||||
.light .text-amber-100\/80,
|
||||
.light .text-amber-200,
|
||||
.light .text-amber-300 {
|
||||
color: rgb(146 64 14);
|
||||
}
|
||||
|
||||
.light .text-emerald-300 {
|
||||
color: rgb(4 120 87);
|
||||
}
|
||||
|
||||
.light .text-rose-200,
|
||||
.light .text-rose-300,
|
||||
.light .text-red-400 {
|
||||
color: rgb(190 18 60);
|
||||
}
|
||||
|
||||
.light .text-sky-300 {
|
||||
color: rgb(3 105 161);
|
||||
}
|
||||
|
||||
.light .text-red-200,
|
||||
.light .text-red-300 {
|
||||
color: rgb(185 28 28);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Admin',
|
||||
description: 'Platform administration for RentalDriveGo.',
|
||||
description: 'RentalDriveGo platform administration.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<html lang="ar" dir="rtl" className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
<Script
|
||||
id="admin-theme-bootstrap"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var theme=localStorage.getItem('admin-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const requestHeaders = headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
|
||||
export default async function AdminLoginPage() {
|
||||
const requestHeaders = await headers()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
|
||||
const theme = rawTheme === 'light' ? 'light' : 'dark'
|
||||
const websiteUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
|
||||
redirect(`${websiteUrl}/en/${theme}/admin-sign-in?next=/admin/dashboard`)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
export default function AdminResetPasswordPage() {
|
||||
return (
|
||||
@@ -21,7 +20,9 @@ function AdminResetPasswordContent() {
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -33,9 +34,10 @@ function AdminResetPasswordContent() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -78,7 +80,7 @@ function AdminResetPasswordContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/login')}
|
||||
className="mt-2 w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors"
|
||||
className="mt-2 inline-flex w-full justify-center rounded-full bg-stone-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -94,33 +96,73 @@ function AdminResetPasswordContent() {
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">New password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Confirm password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirm ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
className="inline-flex w-full justify-center rounded-full bg-stone-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-50 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? 'Resetting…' : 'Reset password'}
|
||||
</button>
|
||||
@@ -141,7 +183,7 @@ function AdminResetShell({ children }: { children: React.ReactNode }) {
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Reset password</h1>
|
||||
</div>
|
||||
<div className="panel p-8">{children}</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${path}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import AdminRootPage from './page'
|
||||
|
||||
describe('admin public redirects', () => {
|
||||
it('sends the admin root to the login page', () => {
|
||||
expect(() => AdminRootPage()).toThrow('NEXT_REDIRECT:/login')
|
||||
expect(navigation.redirect).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||
export type AdminTheme = 'light' | 'dark'
|
||||
@@ -28,6 +28,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
auditLogs: 'Audit Logs',
|
||||
adminUsers: 'Admin Users',
|
||||
billing: 'Billing',
|
||||
pricing: 'Pricing',
|
||||
notifications: 'Notifications',
|
||||
menuManagement: 'Menu Management',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
@@ -48,6 +51,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
auditLogs: "Journaux d'audit",
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
billing: 'Facturation',
|
||||
pricing: 'Tarification',
|
||||
notifications: 'Notifications',
|
||||
menuManagement: 'Gestion du menu',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
@@ -68,6 +74,9 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
billing: 'الفوترة',
|
||||
pricing: 'الأسعار',
|
||||
notifications: 'الإشعارات',
|
||||
menuManagement: 'إدارة القوائم',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
@@ -81,6 +90,14 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
},
|
||||
}
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
type AdminI18nContext = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
@@ -92,29 +109,32 @@ type AdminI18nContext = {
|
||||
const Context = createContext<AdminI18nContext | null>(null)
|
||||
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
const [language, setLanguage] = useState<AdminLanguage>('ar')
|
||||
const [theme, setTheme] = useState<AdminTheme>('dark')
|
||||
// Skip the very first write so we don't overwrite a stored preference before
|
||||
// the hydration read-effect has applied it.
|
||||
const skipFirstLangWrite = useRef(true)
|
||||
const skipFirstThemeWrite = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
const storedTheme = window.localStorage.getItem('admin-theme')
|
||||
const storedTheme = window.localStorage.getItem(SHARED_THEME_KEY) ?? window.localStorage.getItem('admin-theme')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setTheme(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('light')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
if (skipFirstLangWrite.current) {
|
||||
skipFirstLangWrite.current = false
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem('admin-language', language)
|
||||
}, [language])
|
||||
|
||||
@@ -123,6 +143,12 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
document.documentElement.classList.add(theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.body.dataset.theme = theme
|
||||
if (skipFirstThemeWrite.current) {
|
||||
skipFirstThemeWrite.current = false
|
||||
return
|
||||
}
|
||||
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
|
||||
window.localStorage.setItem(SHARED_THEME_KEY, theme)
|
||||
window.localStorage.setItem('admin-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
@@ -140,67 +166,146 @@ export function useAdminI18n() {
|
||||
}
|
||||
|
||||
export function AdminLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useAdminI18n()
|
||||
const { language, setLanguage } = useAdminI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
const current = languageMeta[language]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{current.flag}</span>
|
||||
<span>{current.shortLabel}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const meta = languageMeta[value]
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => { setLanguage(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{meta.flag}</span>
|
||||
<span>{meta.shortLabel}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminThemeSwitcher() {
|
||||
const { theme, setTheme, dict } = useAdminI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
{dict.theme}
|
||||
</span>
|
||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{theme === 'light' ? dict.light : dict.dark}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => { setTheme(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={active ? 'text-inherit' : ''}>{value === 'light' ? dict.light : dict.dark}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicFooter() {
|
||||
const { language } = useAdminI18n()
|
||||
const currentLanguage = languageMeta[language]
|
||||
const dict = {
|
||||
en: {
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
preferences: 'Préférences d’administration',
|
||||
},
|
||||
ar: {
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-4 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/72">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<div className="flex items-center gap-3 text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
|
||||
<p>{dict.preferences}</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2.5 py-1 text-stone-700 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200">
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicHeader() {
|
||||
const { language } = useAdminI18n()
|
||||
const currentLanguage = languageMeta[language]
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/76">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-stone-500 dark:text-stone-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<span className="inline-flex min-w-[3.5rem] items-center justify-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2.5 py-2 text-xs font-semibold text-stone-700 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200">
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
</span>
|
||||
<Link href="/login" className="rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/components/PublicHeader', () => ({ default: function MockAdminHeader() { return React.createElement('admin-header') } }))
|
||||
vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter() { return React.createElement('admin-footer') } }))
|
||||
|
||||
import PublicShell from './PublicShell'
|
||||
|
||||
type WithChildren = { children?: React.ReactNode }
|
||||
|
||||
function childTypes(node: React.ReactElement<WithChildren>): string[] {
|
||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||
const type = child.type as any
|
||||
if (typeof type === 'string') return type
|
||||
return type.displayName ?? type.name ?? 'anonymous'
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin PublicShell', () => {
|
||||
it('always renders the admin public chrome around children', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section', { id: 'login' }) })
|
||||
|
||||
expect(childTypes(shell)).toEqual(['MockAdminHeader', 'div', 'MockAdminFooter'])
|
||||
})
|
||||
|
||||
it('uses a flex column root so footer placement stays stable', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section') })
|
||||
|
||||
expect(shell.props.className).toContain('flex')
|
||||
expect(shell.props.className).toContain('min-h-screen')
|
||||
expect(shell.props.className).toContain('flex-col')
|
||||
})
|
||||
})
|
||||
@@ -1,59 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import React from "react";
|
||||
|
||||
import PublicFooter from '@/components/PublicFooter'
|
||||
import PublicHeader from '@/components/PublicHeader'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useAdminI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
preferences: 'Preferences admin',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<PublicHeader />
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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('buildNotificationsQuery', () => {
|
||||
it('builds page, page size, and non-empty filters', async () => {
|
||||
const { buildNotificationsQuery } = await import('./adminNotifications')
|
||||
|
||||
expect(buildNotificationsQuery(3, {
|
||||
channel: 'EMAIL',
|
||||
status: 'FAILED',
|
||||
companyId: ' company_1 ',
|
||||
}).toString()).toBe('page=3&pageSize=50&channel=EMAIL&status=FAILED&companyId=company_1')
|
||||
})
|
||||
|
||||
it('omits empty filters', async () => {
|
||||
const { buildNotificationsQuery } = await import('./adminNotifications')
|
||||
|
||||
expect(buildNotificationsQuery(1, {
|
||||
channel: '',
|
||||
status: '',
|
||||
companyId: ' ',
|
||||
}).toString()).toBe('page=1&pageSize=50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchAdminNotifications', () => {
|
||||
it('returns the paginated data envelope', async () => {
|
||||
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
|
||||
const payload = { data: [], total: 0, page: 1, pageSize: 50, totalPages: 0 }
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: payload }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { fetchAdminNotifications } = await import('./adminNotifications')
|
||||
await expect(fetchAdminNotifications(1, { status: 'READ' })).resolves.toEqual(payload)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://internal-api/api/v1/admin/notifications?page=1&pageSize=50&status=READ',
|
||||
expect.objectContaining({ credentials: 'include', cache: 'no-store' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws API error messages', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: false,
|
||||
json: async () => ({ message: 'Support role required' }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { fetchAdminNotifications } = await import('./adminNotifications')
|
||||
await expect(fetchAdminNotifications(1)).rejects.toThrow('Support role required')
|
||||
})
|
||||
|
||||
it('throws when the API envelope is malformed', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { notifications: [] } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { fetchAdminNotifications } = await import('./adminNotifications')
|
||||
await expect(fetchAdminNotifications(1)).rejects.toThrow('Notifications response was not in the expected format')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNotificationDate', () => {
|
||||
it('formats missing or invalid dates as a placeholder', async () => {
|
||||
const { formatNotificationDate } = await import('./adminNotifications')
|
||||
|
||||
expect(formatNotificationDate(null)).toBe('-')
|
||||
expect(formatNotificationDate('not-a-date')).toBe('-')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ADMIN_API_BASE } from './api'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
locale: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
company: { name: string } | null
|
||||
companyId: string | null
|
||||
recipientType: 'EMPLOYEE' | 'RENTER' | null
|
||||
recipientName: string | null
|
||||
recipientEmail: string | null
|
||||
employeeId: string | null
|
||||
renterId: string | null
|
||||
}
|
||||
|
||||
export interface NotificationsPageResult {
|
||||
data: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages?: number
|
||||
}
|
||||
|
||||
export interface NotificationFilters {
|
||||
channel?: string
|
||||
status?: string
|
||||
companyId?: string
|
||||
}
|
||||
|
||||
function isNotificationsPageResult(value: unknown): value is NotificationsPageResult {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const candidate = value as Partial<NotificationsPageResult>
|
||||
return (
|
||||
Array.isArray(candidate.data) &&
|
||||
typeof candidate.total === 'number' &&
|
||||
typeof candidate.page === 'number' &&
|
||||
typeof candidate.pageSize === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function buildNotificationsQuery(page: number, filters: NotificationFilters = {}) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: '50',
|
||||
})
|
||||
|
||||
if (filters.channel) params.set('channel', filters.channel)
|
||||
if (filters.status) params.set('status', filters.status)
|
||||
if (filters.companyId?.trim()) params.set('companyId', filters.companyId.trim())
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export async function fetchAdminNotifications(
|
||||
page: number,
|
||||
filters: NotificationFilters = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<NotificationsPageResult> {
|
||||
const params = buildNotificationsQuery(page, filters)
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
})
|
||||
const json = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load notifications')
|
||||
}
|
||||
|
||||
if (!isNotificationsPageResult(json?.data)) {
|
||||
throw new Error('Notifications response was not in the expected format')
|
||||
}
|
||||
|
||||
return json.data
|
||||
}
|
||||
|
||||
export function formatNotificationDate(value: string | null) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleString()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('adminFetch', () => {
|
||||
it('requests through the server API base and returns the data envelope', async () => {
|
||||
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { companies: [] } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/companies', 'admin-token')).resolves.toEqual({ companies: [] })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://internal-api/api/v1/admin/companies', {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits authorization when no token is supplied', async () => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await adminFetch('/public')
|
||||
|
||||
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual({ cache: 'no-store', credentials: 'include' })
|
||||
})
|
||||
|
||||
it('throws the API message from failed responses', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, json: async () => ({ message: 'Admin only' }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/menu')).rejects.toThrow('Admin only')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
export const ADMIN_API_BASE =
|
||||
typeof window === 'undefined'
|
||||
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1')
|
||||
|
||||
export async function adminFetch<T>(path: string, token?: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
export async function adminFetch<T>(path: string, _token?: string): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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('does not expose internal development hosts in server redirects', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'host.docker.internal:3002', 'http')).toBe('http://localhost:3000/dashboard')
|
||||
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'admin:3002', 'http')).toBe('http://localhost:3000/dashboard')
|
||||
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'localhost:3002', 'http')).toBe('http://localhost:3000/dashboard')
|
||||
})
|
||||
|
||||
it('falls back when host is missing or the fallback is not parseable', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
|
||||
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,26 @@
|
||||
export function resolveBrowserAppUrl(fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const h = window.location.hostname
|
||||
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = window.location.protocol
|
||||
target.hostname = window.location.hostname
|
||||
target.hostname = h
|
||||
target.port = ''
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalHost(host: string): boolean {
|
||||
const hostname = host.split(':')[0]?.toLowerCase()
|
||||
return ['localhost', '127.0.0.1', 'host.docker.internal', 'dashboard', 'admin', 'api', 'homepage', 'carplace'].includes(hostname)
|
||||
}
|
||||
|
||||
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
|
||||
if (!host) return fallback
|
||||
if (!host || isInternalHost(host)) return fallback
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
if (request.headers.has('x-middleware-subrequest')) {
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,13 +15,27 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
# Manual subscription payments are intentionally off until every dependency is ready.
|
||||
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=false
|
||||
BANK_TRANSFER_ENABLED=false
|
||||
BANK_TRANSFER_ACCOUNT_NAME=
|
||||
BANK_TRANSFER_BANK_NAME=
|
||||
BANK_TRANSFER_ACCOUNT_REFERENCE=
|
||||
BANK_TRANSFER_DUE_DAYS=7
|
||||
CHECK_PAYMENT_ENABLED=false
|
||||
CHECK_PAYMENT_PAYEE=
|
||||
CHECK_PAYMENT_DELIVERY_ADDRESS=
|
||||
CHECK_PAYMENT_DUE_DAYS=14
|
||||
|
||||
# Evidence is private, quarantined, content-validated and fail-closed scanned.
|
||||
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=false
|
||||
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/uploads
|
||||
PRIVATE_STORAGE_PERSISTENCE_CONFIRMED=false
|
||||
PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED=false
|
||||
PAYMENT_EVIDENCE_SCANNER_PATH=/usr/bin/clamscan
|
||||
PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS=60000
|
||||
|
||||
# Collections notifications can be proven before automatic suspension is enabled.
|
||||
SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED=false
|
||||
SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED=false
|
||||
DEFAULT_BILLING_TIMEZONE=Africa/Casablanca
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://user:replace-with-password@host:5432/db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=replace-with-64-byte-random-secret
|
||||
JWT_EXPIRY=8h
|
||||
NODE_ENV=test
|
||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||
+34
-11
@@ -3,10 +3,27 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node --env-file=../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/index.ts",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "tsc",
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "node dist/index.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
"worker": "node dist/workers/index.js",
|
||||
"preworker:dev": "npm run build --workspace @rentaldrivego/types",
|
||||
"worker:dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/workers/index.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"pretest": "npm run build --workspace @rentaldrivego/types",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"pretest:integration": "npm run build --workspace @rentaldrivego/types",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:integration:watch": "vitest --config vitest.integration.config.ts",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:e2e:watch": "vitest --config vitest.e2e.config.ts",
|
||||
"test:api": "vitest run --config vitest.api.config.ts",
|
||||
"test:api:watch": "vitest --config vitest.api.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^3.4.3",
|
||||
@@ -17,22 +34,26 @@
|
||||
"dayjs": "^1.11.11",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"firebase-admin": "^12.1.0",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"multer": "^2.1.1",
|
||||
"next": "16.2.9",
|
||||
"node-cron": "^4.5.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"resend": "^3.2.0",
|
||||
"socket.io": "^4.7.5",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"turbo": "2.10.0",
|
||||
"twilio": "^5.1.0",
|
||||
"zod": "^3.23.0"
|
||||
"zod": "^3.23.0",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -45,9 +66,11 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import express, { type Request, type Response } from 'express'
|
||||
import cors, { type CorsOptions } from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
import { openApiDocument } from './swagger/openapi'
|
||||
import { getPublicStorageRoot } from './lib/storage'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter } from './middleware/rateLimiter'
|
||||
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
|
||||
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
|
||||
import { requestIdMiddleware } from './middleware/requestId'
|
||||
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
|
||||
|
||||
// ─── Module routes ────────────────────────────────────────────
|
||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||
import companyAuthRouter from './modules/auth/auth.company.routes'
|
||||
import employeeAuthRouter from './modules/auth/auth.employee.routes'
|
||||
import accountAuthRouter from './modules/auth/auth.account.routes'
|
||||
import unifiedAuthRouter from './modules/auth/auth.unified.routes'
|
||||
import renterAuthRouter from './modules/auth/auth.renter.routes'
|
||||
import teamRouter from './modules/team/team.routes'
|
||||
import offersRouter from './modules/offers/offer.routes'
|
||||
import analyticsRouter from './modules/analytics/analytics.routes'
|
||||
import notificationsRouter from './modules/notifications/notification.routes'
|
||||
import adminRouter from './modules/admin/admin.routes'
|
||||
import subscriptionsRouter, {
|
||||
subscriptionPublicRouter,
|
||||
} from './modules/subscriptions/subscription.routes'
|
||||
import paymentsRouter from './modules/payments/payment.routes'
|
||||
import billingRouter from './modules/billing/billing.routes'
|
||||
import customersRouter from './modules/customers/customer.routes'
|
||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||
import companiesRouter from './modules/companies/company.routes'
|
||||
import reservationsRouter from './modules/reservations/reservation.routes'
|
||||
import carplaceRouter from './modules/carplace/carplace.routes'
|
||||
import siteRouter from './modules/site/site.routes'
|
||||
import reviewsRouter from './modules/reviews/review.routes'
|
||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||
import licenseValidationRouter from './modules/licenses/license.validation.routes'
|
||||
import searchRouter from './modules/search/search.routes'
|
||||
|
||||
// ─── Centralized error handling ───────────────────────────────
|
||||
import { errorMiddleware } from './http/errors/errorMiddleware'
|
||||
|
||||
const v1 = '/api/v1'
|
||||
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:4000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001',
|
||||
'http://127.0.0.1:3002',
|
||||
'http://127.0.0.1:4000',
|
||||
]
|
||||
|
||||
const frontendOriginEnvKeys = [
|
||||
'SITE_ORIGIN',
|
||||
'WEBSITE_URL',
|
||||
'HOMEPAGE_URL',
|
||||
'DASHBOARD_URL',
|
||||
'ADMIN_URL',
|
||||
'CARPLACE_URL',
|
||||
'NEXT_PUBLIC_HOMEPAGE_URL',
|
||||
'NEXT_PUBLIC_WEBSITE_URL',
|
||||
'NEXT_PUBLIC_DASHBOARD_URL',
|
||||
'NEXT_PUBLIC_ADMIN_URL',
|
||||
'NEXT_PUBLIC_CARPLACE_URL',
|
||||
] as const
|
||||
|
||||
function normalizeConfiguredOrigin(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredCorsOrigins(env: NodeJS.ProcessEnv = process.env) {
|
||||
const configuredOrigins = (env.CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const frontendOrigins = frontendOriginEnvKeys
|
||||
.flatMap((key) => (env[key] ?? '').split(','))
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const rawOrigins = configuredOrigins.length > 0 || frontendOrigins.length > 0
|
||||
? [...configuredOrigins, ...frontendOrigins]
|
||||
: defaultCorsOrigins
|
||||
|
||||
return Array.from(new Set(rawOrigins.map(normalizeConfiguredOrigin).filter((origin): origin is string => Boolean(origin))))
|
||||
}
|
||||
|
||||
export const corsOrigins = getConfiguredCorsOrigins()
|
||||
|
||||
function isAllowedLocalDevOrigin(origin: string) {
|
||||
if (process.env.NODE_ENV === 'production') return false
|
||||
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
if (url.protocol !== 'http:') return false
|
||||
if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false
|
||||
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
|
||||
|
||||
const octets = url.hostname.split('.').map((part) => Number(part))
|
||||
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const first = octets[0]!
|
||||
const second = octets[1]!
|
||||
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isCorsOriginAllowed(origin: string | undefined) {
|
||||
if (!origin) return true
|
||||
return corsOrigins.includes(origin) || isAllowedLocalDevOrigin(origin)
|
||||
}
|
||||
|
||||
export const corsOptions: CorsOptions = {
|
||||
origin(origin, callback) {
|
||||
callback(null, isCorsOriginAllowed(origin))
|
||||
},
|
||||
credentials: true,
|
||||
}
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Liveness health check' },
|
||||
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
|
||||
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/search`, description: 'Global account search' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
|
||||
{ method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' },
|
||||
{ method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' },
|
||||
{ method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/features`, description: 'Subscription plan features' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
|
||||
export function createApp() {
|
||||
const app = express()
|
||||
const corsMiddleware = cors(corsOptions)
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
|
||||
app.use(sanitizeForwardedHeaders)
|
||||
app.use(requestIdMiddleware)
|
||||
app.use(metricsMiddleware)
|
||||
|
||||
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(requireTrustedOriginForCookieMutations)
|
||||
|
||||
// Customer identity documents must never be anonymously retrievable from the
|
||||
// public storage mount, even if an older raw storage URL leaks.
|
||||
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
|
||||
res.status(404).end()
|
||||
})
|
||||
app.use('/storage/companies/:companyId/reservations/:reservationId', (_req, res) => {
|
||||
res.status(404).end()
|
||||
})
|
||||
|
||||
// Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin
|
||||
// by the browser. Without this header, helmet's default CORP: same-origin reaches
|
||||
// 404 responses (when express.static calls next() on a miss) and the browser blocks
|
||||
// the response even for broken-image cases, producing ERR_BLOCKED_BY_RESPONSE.
|
||||
app.use('/storage', (_req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
|
||||
next()
|
||||
})
|
||||
|
||||
// Serve only explicitly public uploaded assets. Private documents are resolved
|
||||
// through authenticated API routes such as /customers/:id/license-image.
|
||||
app.use('/storage', express.static(getPublicStorageRoot()))
|
||||
|
||||
const publicDocsEnabled = process.env.NODE_ENV !== 'production' || process.env.ENABLE_PUBLIC_API_DOCS === 'true'
|
||||
const docsDisabled = (_req: Request, res: Response) => res.status(404).json({ error: 'not_found', message: 'API docs are not available in this environment', statusCode: 404 })
|
||||
|
||||
// Swagger UI — never expose API reconnaissance material publicly in production
|
||||
// unless explicitly enabled for a protected/internal deployment.
|
||||
if (publicDocsEnabled) {
|
||||
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
|
||||
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
|
||||
} else {
|
||||
app.use('/docs', docsDisabled)
|
||||
app.get('/api/v1/openapi.json', docsDisabled)
|
||||
}
|
||||
|
||||
// Webhooks must use raw body BEFORE express.json(); signature verification
|
||||
// must never reconstruct the payload with JSON.stringify(req.body).
|
||||
const removedOnlinePaymentWebhooks = (_req: Request, res: Response) => {
|
||||
res.status(404).json({ error: 'not_found', message: 'Online payment webhooks have been removed', statusCode: 404 })
|
||||
}
|
||||
app.use(`${v1}/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), webhookRouter)
|
||||
app.use(`${v1}/payments/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), removedOnlinePaymentWebhooks)
|
||||
app.use(`${v1}/subscriptions/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), removedOnlinePaymentWebhooks)
|
||||
|
||||
// Let /storage responses manage CORP explicitly so missing files still return
|
||||
// a normal cross-origin 404 instead of being blocked by Helmet's default
|
||||
// same-origin policy. Keep CSP explicit instead of letting browser security
|
||||
// drift into wishful thinking with headers.
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
||||
connectSrc: ["'self'", 'https:', 'wss:'],
|
||||
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null,
|
||||
},
|
||||
},
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
frameguard: { action: 'deny' },
|
||||
}))
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan((tokens, req, res) => {
|
||||
const requestId = (req as any).requestId ?? '-'
|
||||
return JSON.stringify({
|
||||
level: 'info',
|
||||
msg: 'http_request',
|
||||
requestId,
|
||||
method: tokens.method(req, res),
|
||||
url: tokens.url(req, res),
|
||||
status: Number(tokens.status(req, res)),
|
||||
durationMs: Number(tokens['response-time'](req, res)),
|
||||
contentLength: tokens.res(req, res, 'content-length'),
|
||||
})
|
||||
}))
|
||||
}
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ─────────────────────────────────────────────
|
||||
app.use(`${v1}/auth`, authLimiter, unifiedAuthRouter)
|
||||
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
app.use(`${v1}/admin/auth`, (req, res, next) => {
|
||||
if (req.method === 'GET' && req.path === '/me') return next()
|
||||
return authLimiter(req, res, next)
|
||||
})
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
||||
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
app.use(`${v1}/billing`, apiLimiter, billingRouter)
|
||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||
app.use(`${v1}/search`, apiLimiter, searchRouter)
|
||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||
|
||||
// ─── Health / Docs ──────────────────────────────────────────
|
||||
app.get(v1, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
health: '/health',
|
||||
docs: `${v1}/docs`,
|
||||
openApi: `${v1}/openapi.json`,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get('/metrics', async (_req, res) => {
|
||||
try {
|
||||
const { prisma } = await import('./lib/prisma')
|
||||
const [pending, published] = await Promise.all([
|
||||
prisma.notificationOutbox.count({ where: { status: 'PENDING' } }),
|
||||
prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }),
|
||||
])
|
||||
setGauge('notification_outbox_pending', pending)
|
||||
setGauge('notification_outbox_completed', published)
|
||||
} catch {
|
||||
/* leave previous gauges */
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
|
||||
res.status(200).send(renderPrometheusText())
|
||||
})
|
||||
|
||||
app.get('/ready', async (_req, res) => {
|
||||
const { prisma } = await import('./lib/prisma')
|
||||
const { redis } = await import('./lib/redis')
|
||||
const { checkStorageReady } = await import('./lib/storage')
|
||||
const checks: Record<string, 'ok' | 'error'> = { database: 'error', redis: 'error', storage: 'error' }
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
checks.database = 'ok'
|
||||
} catch {
|
||||
checks.database = 'error'
|
||||
}
|
||||
try {
|
||||
const pong = await redis.ping()
|
||||
checks.redis = pong === 'PONG' ? 'ok' : 'error'
|
||||
} catch {
|
||||
checks.redis = 'error'
|
||||
}
|
||||
try {
|
||||
await checkStorageReady()
|
||||
checks.storage = 'ok'
|
||||
} catch {
|
||||
checks.storage = 'error'
|
||||
}
|
||||
const ready = Object.values(checks).every((v) => v === 'ok')
|
||||
res.status(ready ? 200 : 503).json({
|
||||
status: ready ? 'ready' : 'not_ready',
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
if (!publicDocsEnabled) return docsDisabled(_req, res)
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
routes: routeDocs,
|
||||
swaggerUi: '/docs',
|
||||
openApi: '/api/v1/openapi.json',
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Error handler ──────────────────────────────────────────
|
||||
app.use(errorMiddleware)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"marketplaceHomepage": {
|
||||
"carplaceHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
@@ -7,31 +7,33 @@
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"howitworks",
|
||||
"steps",
|
||||
"testimonials",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.",
|
||||
"heroTitle": "Carplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the company’s own branded checkout.",
|
||||
"startTrial": "Start free trial",
|
||||
"exploreVehicles": "Explore vehicles",
|
||||
"surfaceLabel": "Marketplace surface",
|
||||
"surfaceLabel": "Carplace surface",
|
||||
"surfaceTitle": "Designed for two audiences at once.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The Carplace should show both without feeling like a template.",
|
||||
"liveLabel": "Live network",
|
||||
"trustedFleets": "Trusted fleets",
|
||||
"brandedFlows": "Branded booking flows",
|
||||
"multiTenant": "Multi-tenant operations",
|
||||
"companyKicker": "Operator workflow",
|
||||
"companyTitle": "Control inventory, offers, billing, and staff from one command layer.",
|
||||
"companyTitle": "Manage inventory, offers, billing, and staff from one control layer.",
|
||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||
"renterKicker": "Renter experience",
|
||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.",
|
||||
"renterBody": "The Carplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into Carplace discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
@@ -45,7 +47,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared marketplace visibility"
|
||||
"label": "Shared Carplace visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -59,21 +61,40 @@
|
||||
"featureLabel": "What the product covers",
|
||||
"features": [
|
||||
"Fleet management with multi-photo uploads",
|
||||
"Marketplace offers and redirect booking flow",
|
||||
"Carplace offers and redirect booking flow",
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
"howitworksKicker": "GETTING STARTED",
|
||||
"howitworksTitle": "How It Works",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Create Account",
|
||||
"description": "Sign up for your company workspace and choose your pricing plan for the 30-day free trial."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Add Your Fleet",
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "Start Selling",
|
||||
"description": "Renters discover your fleet, and bookings flow directly to your branded checkout."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "How companies launch",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Create your company workspace",
|
||||
"body": "Pick a plan, launch your 14-day trial, and verify the owner account."
|
||||
"body": "Pick a plan, launch your 30-day trial, and verify the owner account."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
||||
"body": "Upload photos once and control what appears on the Carplace and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -83,8 +104,8 @@
|
||||
],
|
||||
"stepLabel": "Step",
|
||||
"readyKicker": "Ready to launch",
|
||||
"readyTitle": "A marketplace homepage should sell the system in seconds.",
|
||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.",
|
||||
"readyTitle": "The Carplace homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the Carplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"viewPricing": "View pricing",
|
||||
"createWorkspace": "Create workspace"
|
||||
},
|
||||
@@ -95,31 +116,33 @@
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"howitworks",
|
||||
"steps",
|
||||
"testimonials",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine marketplace plus nette et plus forte.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.",
|
||||
"heroTitle": "Une vitrine Carplace plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Surface marketplace",
|
||||
"surfaceLabel": "Vitrine Carplace",
|
||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La Carplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
"trustedFleets": "Flottes fiables",
|
||||
"brandedFlows": "Parcours de marque",
|
||||
"multiTenant": "Opérations multi-tenant",
|
||||
"companyKicker": "Flux opérateur",
|
||||
"companyTitle": "Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.",
|
||||
"companyTitle": "Pilotez l’inventaire, les offres, la facturation et l’équipe depuis un seul centre de contrôle.",
|
||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||
"renterKicker": "Expérience client",
|
||||
"renterTitle": "Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.",
|
||||
"renterBody": "La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
||||
"renterBody": "La Carplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque."
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte Carplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
@@ -127,13 +150,13 @@
|
||||
},
|
||||
{
|
||||
"title": "Revenus en direct",
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement."
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise afin de préserver la relation client et l’encaissement."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité marketplace partagée"
|
||||
"label": "Visibilité Carplace partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -141,27 +164,46 @@
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "Paiements détenus par la société"
|
||||
"label": "Paiements gérés par l’entreprise"
|
||||
}
|
||||
],
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement multi-photos",
|
||||
"Offres marketplace et redirection vers la réservation",
|
||||
"Gestion de flotte avec téléversement de plusieurs photos",
|
||||
"Offres Carplace et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
"howitworksKicker": "MISE EN ROUTE",
|
||||
"howitworksTitle": "Comment ça marche",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Créer un compte",
|
||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Ajouter votre flotte",
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "Commencer à vendre",
|
||||
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "Comment les entreprises démarrent",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Créez votre espace entreprise",
|
||||
"body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire."
|
||||
"body": "Choisissez un plan, lancez votre essai de 30 jours et vérifiez le compte propriétaire."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la Carplace et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -171,8 +213,8 @@
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "Une homepage marketplace doit vendre le système en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.",
|
||||
"readyTitle": "La page d’accueil Carplace doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la Carplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
@@ -183,12 +225,14 @@
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"howitworks",
|
||||
"steps",
|
||||
"testimonials",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "واجهة سوق أوضح وأقوى.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.",
|
||||
"heroTitle": "واجهة سوق أكثر وضوحاً وتأثيراً.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.",
|
||||
"startTrial": "ابدأ التجربة المجانية",
|
||||
"exploreVehicles": "استكشف السيارات",
|
||||
"surfaceLabel": "واجهة السوق",
|
||||
@@ -198,24 +242,24 @@
|
||||
"trustedFleets": "أساطيل موثوقة",
|
||||
"brandedFlows": "مسارات حجز مخصصة",
|
||||
"multiTenant": "عمليات متعددة الشركات",
|
||||
"companyKicker": "تدفق المشغل",
|
||||
"companyKicker": "سير عمل المشغل",
|
||||
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
||||
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
||||
"renterKicker": "تجربة المستأجر",
|
||||
"renterTitle": "دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.",
|
||||
"renterBody": "السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.",
|
||||
"renterTitle": "دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.",
|
||||
"renterBody": "هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "نشر موحد",
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
},
|
||||
{
|
||||
"title": "اكتشاف مؤهل",
|
||||
"body": "العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة."
|
||||
"body": "تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة."
|
||||
},
|
||||
{
|
||||
"title": "مسار إيراد مباشر",
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه."
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
@@ -232,19 +276,38 @@
|
||||
"label": "مدفوعات مملوكة للشركة"
|
||||
}
|
||||
],
|
||||
"featureLabel": "ما الذي يغطيه المنتج",
|
||||
"featureLabel": "ما الذي يقدمه المنتج",
|
||||
"features": [
|
||||
"إدارة الأسطول مع رفع عدة صور",
|
||||
"عروض السوق والتحويل إلى مسار الحجز",
|
||||
"موقع حجز عام مخصص لكل شركة",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"howitworksKicker": "البدء",
|
||||
"howitworksTitle": "كيف يعمل",
|
||||
"howitworksSteps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "إنشاء حساب",
|
||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "أضف أسطولك",
|
||||
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"title": "ابدأ البيع",
|
||||
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
|
||||
}
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "أنشئ مساحة شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك."
|
||||
"title": "أنشئ مساحة عمل شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
@@ -260,9 +323,9 @@
|
||||
"stepLabel": "الخطوة",
|
||||
"readyKicker": "جاهز للانطلاق",
|
||||
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.",
|
||||
"viewPricing": "عرض الأسعار",
|
||||
"createWorkspace": "إنشاء المساحة"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
|
||||
import { errorMiddleware } from './errorMiddleware'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
function handle(error: unknown) {
|
||||
const res = createResponseStub()
|
||||
errorMiddleware(error, {} as any, res, vi.fn())
|
||||
return res
|
||||
}
|
||||
|
||||
describe('AppError subclasses', () => {
|
||||
it.each([
|
||||
[new ValidationError('Bad payload'), 400, 'validation_error'],
|
||||
[new UnauthorizedError(), 401, 'unauthorized'],
|
||||
[new ForbiddenError(), 403, 'forbidden'],
|
||||
[new NotFoundError(), 404, 'not_found'],
|
||||
[new ConflictError(), 409, 'conflict'],
|
||||
])('sets stable status and error code for %s', (error, statusCode, code) => {
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect(error.statusCode).toBe(statusCode)
|
||||
expect(error.error).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorMiddleware', () => {
|
||||
it('normalizes Zod errors into validation responses', () => {
|
||||
const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
|
||||
expect(result.success).toBe(false)
|
||||
|
||||
const res = handle(result.success ? new Error('unexpected') : result.error)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
statusCode: 400,
|
||||
issues: expect.any(Array),
|
||||
}))
|
||||
})
|
||||
|
||||
it('normalizes Prisma not found errors', () => {
|
||||
const res = handle({ code: 'P2025' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'not_found',
|
||||
message: 'Resource not found',
|
||||
statusCode: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Prisma unique constraint errors', () => {
|
||||
const res = handle({ code: 'P2002' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(409)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'conflict',
|
||||
message: 'A resource with this value already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const res = handle({ code })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'database_schema_mismatch',
|
||||
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||
statusCode: 503,
|
||||
requestId: undefined,
|
||||
})
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('preserves AppError metadata in the response body', () => {
|
||||
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'payment_required',
|
||||
message: 'Plan required',
|
||||
statusCode: 402,
|
||||
requiredPlan: 'PRO',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a 500 internal error response', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const res = handle(new Error('boom'))
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined })
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { AppError } from './index'
|
||||
|
||||
function withRequestId(req: Request, payload: Record<string, unknown>) {
|
||||
return { ...payload, requestId: req.requestId }
|
||||
}
|
||||
|
||||
export function errorMiddleware(err: any, req: Request, res: Response, _next: NextFunction) {
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json(withRequestId(req, {
|
||||
error: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
issues: err.issues,
|
||||
statusCode: 400,
|
||||
}))
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json(withRequestId(req, { error: 'not_found', message: 'Resource not found', statusCode: 404 }))
|
||||
}
|
||||
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
||||
}
|
||||
|
||||
if (err.code === 'P2021' || err.code === 'P2022') {
|
||||
console.error('[API Error] Database schema mismatch', { requestId: req.requestId, err })
|
||||
return res.status(503).json(withRequestId(req, {
|
||||
error: 'database_schema_mismatch',
|
||||
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||
statusCode: 503,
|
||||
}))
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
||||
return res.status(err.statusCode).json(withRequestId(req, {
|
||||
error: err.error,
|
||||
message: err.statusCode >= 500 ? 'Internal server error' : err.message,
|
||||
statusCode: err.statusCode,
|
||||
...(err.statusCode >= 500 ? {} : err.data),
|
||||
}))
|
||||
}
|
||||
|
||||
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500
|
||||
const safeStatusCode = statusCode >= 400 && statusCode < 600 ? statusCode : 500
|
||||
|
||||
if (safeStatusCode >= 500) {
|
||||
console.error('[API Error]', { requestId: req.requestId, err })
|
||||
}
|
||||
|
||||
res.status(safeStatusCode).json(withRequestId(req, {
|
||||
error: safeStatusCode >= 500 ? 'internal_error' : (err.code ?? 'request_error'),
|
||||
message: safeStatusCode >= 500 ? 'Internal server error' : (err.message ?? 'Request failed'),
|
||||
statusCode: safeStatusCode,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class AppError extends Error {
|
||||
readonly statusCode: number
|
||||
readonly error: string
|
||||
readonly data?: Record<string, unknown>
|
||||
|
||||
constructor(message: string, statusCode: number, error: string, data?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.error = error
|
||||
this.data = data
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message = 'Validation error') {
|
||||
super(message, 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(message, 404, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'A resource with this value already exists') {
|
||||
super(message, 409, 'conflict')
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'forbidden')
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401, 'unauthorized')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Response } from 'express'
|
||||
|
||||
export function ok<T>(res: Response, data: T): void {
|
||||
res.json({ data })
|
||||
}
|
||||
|
||||
export function created<T>(res: Response, data: T): void {
|
||||
res.status(201).json({ data })
|
||||
}
|
||||
|
||||
export function noContent(res: Response): void {
|
||||
res.status(204).end()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Response } from 'express'
|
||||
import { created, noContent, ok } from './index'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
res.end.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('http/respond helpers', () => {
|
||||
it('ok responds with the expected data envelope', () => {
|
||||
const res = createResponseStub()
|
||||
const payload = { id: 'vehicle_1', name: 'Dacia Logan' }
|
||||
|
||||
ok(res, payload)
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(res.json).toHaveBeenCalledWith({ data: payload })
|
||||
})
|
||||
|
||||
it('created responds with status 201 and the expected data envelope', () => {
|
||||
const res = createResponseStub()
|
||||
const payload = { id: 'reservation_1' }
|
||||
|
||||
created(res, payload)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(201)
|
||||
expect(res.json).toHaveBeenCalledWith({ data: payload })
|
||||
})
|
||||
|
||||
it('noContent responds with status 204 and no body', () => {
|
||||
const res = createResponseStub()
|
||||
|
||||
noContent(res)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(204)
|
||||
expect(res.end).toHaveBeenCalledWith()
|
||||
expect(res.json).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import path from 'path'
|
||||
import multer from 'multer'
|
||||
import { ValidationError } from '../errors'
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
|
||||
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
|
||||
['image/jpeg', ['.jpg', '.jpeg']],
|
||||
['image/png', ['.png']],
|
||||
['image/webp', ['.webp']],
|
||||
['image/gif', ['.gif']],
|
||||
])
|
||||
|
||||
/**
|
||||
* Shared multer instance used by all upload endpoints.
|
||||
* Files are kept in memory so services receive a Buffer — no temp-file cleanup needed.
|
||||
*/
|
||||
export const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE, files: 5, fields: 20, parts: 30 },
|
||||
})
|
||||
|
||||
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 readImageDimensions(file: Express.Multer.File): { width: number; height: number } | null {
|
||||
const buffer = file.buffer
|
||||
|
||||
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
||||
}
|
||||
|
||||
if (buffer.length >= 10 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||
const chunk = buffer.subarray(12, 16).toString('ascii')
|
||||
if (chunk === 'VP8X' && buffer.length >= 30) {
|
||||
const width = 1 + buffer.readUIntLE(24, 3)
|
||||
const height = 1 + buffer.readUIntLE(27, 3)
|
||||
return { width, height }
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.length >= 10 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
let offset = 2
|
||||
while (offset + 9 < buffer.length) {
|
||||
if (buffer[offset] !== 0xff) return null
|
||||
const marker = buffer.readUInt8(offset + 1)
|
||||
const length = buffer.readUInt16BE(offset + 2)
|
||||
if (length < 2) return null
|
||||
if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
|
||||
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
|
||||
}
|
||||
offset += 2 + length
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function assertSafeImageDimensions(file: Express.Multer.File) {
|
||||
const dimensions = readImageDimensions(file)
|
||||
if (!dimensions) return
|
||||
const maxPixels = 24_000_000
|
||||
const maxSide = 8_000
|
||||
if (dimensions.width <= 0 || dimensions.height <= 0 || dimensions.width > maxSide || dimensions.height > maxSide || dimensions.width * dimensions.height > maxPixels) {
|
||||
throw new ValidationError(`Image dimensions are too large for "${file.originalname}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeImageContent(file: Express.Multer.File) {
|
||||
const detected = detectImageType(file.buffer)
|
||||
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
|
||||
throw new ValidationError(`Unsupported or spoofed image file: ${file.originalname}`)
|
||||
}
|
||||
|
||||
if (file.mimetype !== detected.mime) {
|
||||
throw new ValidationError(`MIME type does not match file content for "${file.originalname}"`)
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
const allowedExtensions = ALLOWED_IMAGE_TYPES.get(detected.mime) ?? []
|
||||
if (ext && !allowedExtensions.includes(ext)) {
|
||||
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
|
||||
}
|
||||
|
||||
assertSafeImageDimensions(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a file was provided and is an image by content, not by client claims.
|
||||
*/
|
||||
export function assertImageFile(
|
||||
file: Express.Multer.File | undefined,
|
||||
fieldLabel = 'file',
|
||||
): asserts file is Express.Multer.File {
|
||||
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
|
||||
assertSafeImageContent(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that at least one file was provided and all files are image content.
|
||||
*/
|
||||
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
|
||||
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
|
||||
for (const file of files) assertSafeImageContent(file)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertPaymentEvidenceFile, sanitizeEvidenceFilename } from './paymentEvidence'
|
||||
|
||||
function file(buffer: Buffer, originalname: string, mimetype: string): Express.Multer.File {
|
||||
return { buffer, originalname, mimetype, size: buffer.length } as Express.Multer.File
|
||||
}
|
||||
|
||||
function png(width = 16, height = 16) {
|
||||
const head = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from([0, 0, 0, 13]),
|
||||
Buffer.from('IHDR'),
|
||||
])
|
||||
const dimensions = Buffer.alloc(8)
|
||||
dimensions.writeUInt32BE(width, 0)
|
||||
dimensions.writeUInt32BE(height, 4)
|
||||
const ihdrRest = Buffer.alloc(9)
|
||||
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
|
||||
return Buffer.concat([head, dimensions, ihdrRest, iend])
|
||||
}
|
||||
|
||||
function jpeg(width = 16, height = 16) {
|
||||
return Buffer.from([
|
||||
0xff, 0xd8,
|
||||
0xff, 0xc0, 0x00, 0x0b, 0x08,
|
||||
(height >> 8) & 0xff, height & 0xff,
|
||||
(width >> 8) & 0xff, width & 0xff,
|
||||
0x01, 0x01, 0x11, 0x00,
|
||||
0xff, 0xd9,
|
||||
])
|
||||
}
|
||||
|
||||
describe('payment evidence validation', () => {
|
||||
it('accepts a structurally bounded PDF by content', () => {
|
||||
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
|
||||
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
|
||||
})
|
||||
|
||||
it('accepts PDFs with trailing bytes after the EOF marker', () => {
|
||||
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n\u0000\u0000')
|
||||
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
|
||||
})
|
||||
|
||||
it('accepts PDFs that contain common byte sequences inside document content', () => {
|
||||
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n(<html><svg>PK\u0003\u0004)</script>\nendobj\n%%EOF')
|
||||
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
|
||||
})
|
||||
|
||||
it('accepts valid evidence files reported with compatible browser MIME aliases', () => {
|
||||
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
|
||||
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/octet-stream'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
|
||||
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/x-pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
|
||||
})
|
||||
|
||||
it('accepts valid image evidence with trailing bytes and common JPEG extensions', () => {
|
||||
expect(assertPaymentEvidenceFile(file(Buffer.concat([png(), Buffer.from('\n')]), 'receipt.png', 'image/x-png'))).toEqual({ mime: 'image/png', ext: '.png' })
|
||||
expect(assertPaymentEvidenceFile(file(Buffer.concat([jpeg(), Buffer.from('\n')]), 'receipt.jfif', 'image/pjpeg'))).toEqual({ mime: 'image/jpeg', ext: '.jpg' })
|
||||
})
|
||||
|
||||
it('rejects spoofed MIME types and active content', () => {
|
||||
const html = Buffer.from('<!doctype html><script>alert(1)</script>')
|
||||
expect(() => assertPaymentEvidenceFile(file(html, 'receipt.pdf', 'application/pdf'))).toThrow(/suspicious/i)
|
||||
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)])
|
||||
expect(() => assertPaymentEvidenceFile(file(png, 'receipt.pdf', 'application/pdf'))).toThrow(/valid PDF, JPEG, and PNG/i)
|
||||
})
|
||||
|
||||
it('sanitizes filenames without allowing path traversal', () => {
|
||||
expect(sanitizeEvidenceFilename('../../bank<receipt>.pdf')).toBe('bank_receipt_.pdf')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import path from 'path'
|
||||
import multer from 'multer'
|
||||
import { ValidationError } from '../errors'
|
||||
|
||||
export const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
export const PAYMENT_EVIDENCE_MAX_FILES = 3
|
||||
export const PAYMENT_EVIDENCE_MAX_TOTAL_SIZE = 20 * 1024 * 1024
|
||||
|
||||
export const paymentEvidenceUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: PAYMENT_EVIDENCE_MAX_FILE_SIZE, files: 1, fields: 5, parts: 8 },
|
||||
})
|
||||
|
||||
export type DetectedPaymentEvidence = {
|
||||
mime: 'application/pdf' | 'image/jpeg' | 'image/png'
|
||||
ext: '.pdf' | '.jpg' | '.png'
|
||||
}
|
||||
|
||||
const allowedExtensions: Record<DetectedPaymentEvidence['mime'], string[]> = {
|
||||
'application/pdf': ['.pdf'],
|
||||
'image/jpeg': ['.jpg', '.jpeg', '.jpe', '.jfif'],
|
||||
'image/png': ['.png'],
|
||||
}
|
||||
|
||||
const allowedDeclaredMimes: Record<DetectedPaymentEvidence['mime'], string[]> = {
|
||||
'application/pdf': ['application/pdf', 'application/x-pdf', 'application/octet-stream'],
|
||||
'image/jpeg': ['image/jpeg', 'image/pjpeg', 'application/octet-stream'],
|
||||
'image/png': ['image/png', 'image/x-png', 'application/octet-stream'],
|
||||
}
|
||||
|
||||
function hasSpoofedLeadingContainerSignature(buffer: Buffer) {
|
||||
const prefix = buffer.subarray(0, 512)
|
||||
const text = prefix.toString('latin1').trimStart().toLowerCase()
|
||||
return text.startsWith('<script')
|
||||
|| text.startsWith('<!doctype html')
|
||||
|| text.startsWith('<html')
|
||||
|| text.startsWith('<svg')
|
||||
|| prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
|
||||
|| prefix.subarray(0, 4).equals(Buffer.from([0x4d, 0x5a, 0x90, 0x00]))
|
||||
|| prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
||||
}
|
||||
|
||||
function detectType(buffer: Buffer): DetectedPaymentEvidence | null {
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
|
||||
const iendIndex = buffer.lastIndexOf(iend)
|
||||
if (buffer.length >= 33 && iendIndex >= 0 && buffer.length - iendIndex <= 2048) {
|
||||
return { mime: 'image/png', ext: '.png' }
|
||||
}
|
||||
}
|
||||
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
const eoiIndex = buffer.lastIndexOf(Buffer.from([0xff, 0xd9]))
|
||||
if (eoiIndex >= 0 && buffer.length - eoiIndex <= 2048) {
|
||||
return { mime: 'image/jpeg', ext: '.jpg' }
|
||||
}
|
||||
}
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
|
||||
const content = buffer.toString('latin1')
|
||||
// Real-world PDFs may include a newline or small binary marker after EOF.
|
||||
// Require an EOF marker near the end instead of at the exact final byte.
|
||||
const eofIndex = content.lastIndexOf('%%EOF')
|
||||
if (eofIndex >= 0 && content.length - eofIndex <= 2048 && !/\/Encrypt\b/.test(content)) return { mime: 'application/pdf', ext: '.pdf' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function assertSafeJpegDimensions(buffer: Buffer) {
|
||||
let offset = 2
|
||||
while (offset + 8 < buffer.length) {
|
||||
if (buffer[offset] !== 0xff) { offset += 1; continue }
|
||||
const marker = buffer[offset + 1]
|
||||
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
offset += 2
|
||||
continue
|
||||
}
|
||||
const segmentLength = buffer.readUInt16BE(offset + 2)
|
||||
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) break
|
||||
const isStartOfFrame = marker !== undefined
|
||||
&& ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf))
|
||||
if (isStartOfFrame) {
|
||||
const height = buffer.readUInt16BE(offset + 5)
|
||||
const width = buffer.readUInt16BE(offset + 7)
|
||||
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
|
||||
throw new ValidationError('Image dimensions are too large')
|
||||
}
|
||||
return
|
||||
}
|
||||
offset += 2 + segmentLength
|
||||
}
|
||||
throw new ValidationError('The JPEG file is malformed')
|
||||
}
|
||||
|
||||
function assertSafePngDimensions(buffer: Buffer) {
|
||||
if (buffer.length < 24) throw new ValidationError('The PNG file is malformed')
|
||||
const width = buffer.readUInt32BE(16)
|
||||
const height = buffer.readUInt32BE(20)
|
||||
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
|
||||
throw new ValidationError('Image dimensions are too large')
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPaymentEvidenceFile(file: Express.Multer.File | undefined): DetectedPaymentEvidence {
|
||||
if (!file) throw new ValidationError('A payment evidence file is required')
|
||||
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
|
||||
throw new ValidationError('Payment evidence files must be between 1 byte and 10 MB')
|
||||
}
|
||||
if (hasSpoofedLeadingContainerSignature(file.buffer)) throw new ValidationError('Unsupported or suspicious payment evidence file')
|
||||
|
||||
const detected = detectType(file.buffer)
|
||||
if (!detected) throw new ValidationError('Only valid PDF, JPEG, and PNG evidence files are accepted')
|
||||
const declaredMime = file.mimetype.toLowerCase()
|
||||
if (!allowedDeclaredMimes[detected.mime].includes(declaredMime)) {
|
||||
throw new ValidationError('The declared file type does not match its content')
|
||||
}
|
||||
|
||||
const extension = path.extname(file.originalname).toLowerCase()
|
||||
if (!allowedExtensions[detected.mime].includes(extension)) {
|
||||
throw new ValidationError('The filename extension does not match the file content')
|
||||
}
|
||||
if (detected.mime === 'image/png') assertSafePngDimensions(file.buffer)
|
||||
if (detected.mime === 'image/jpeg') assertSafeJpegDimensions(file.buffer)
|
||||
return detected
|
||||
}
|
||||
|
||||
export function sanitizeEvidenceFilename(value: string) {
|
||||
const base = path.basename(value).normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, '').replace(/[^\p{L}\p{N}._ -]/gu, '_')
|
||||
return (base || 'payment-evidence').slice(0, 180)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ValidationError } from '../errors'
|
||||
import { assertImageFile, assertImageFiles } from './index'
|
||||
|
||||
const file = (overrides: Partial<Express.Multer.File> = {}) => ({
|
||||
fieldname: 'file',
|
||||
originalname: 'photo.jpg',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 123,
|
||||
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xdb]),
|
||||
stream: undefined as any,
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('upload assertions', () => {
|
||||
it('accepts a single image file and narrows the route input', () => {
|
||||
expect(() => assertImageFile(file())).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects missing single file uploads with a field-specific error', () => {
|
||||
expect(() => assertImageFile(undefined, 'license image')).toThrow(ValidationError)
|
||||
expect(() => assertImageFile(undefined, 'license image')).toThrow('A license image is required')
|
||||
})
|
||||
|
||||
it('rejects non-image single file uploads', () => {
|
||||
expect(() => assertImageFile(file({ mimetype: 'application/pdf', originalname: 'license.pdf' }))).toThrow('MIME type does not match file content')
|
||||
})
|
||||
|
||||
it('accepts multiple image files and rejects empty or mixed batches', () => {
|
||||
expect(() => assertImageFiles([file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) })])).not.toThrow()
|
||||
expect(() => assertImageFiles([], 'inspection photos')).toThrow('At least one inspection photos file is required')
|
||||
expect(() => assertImageFiles([
|
||||
file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }),
|
||||
file({ originalname: 'report.pdf', mimetype: 'application/pdf', buffer: Buffer.from('%PDF') }),
|
||||
])).toThrow('Unsupported or spoofed image file: report.pdf')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Request } from 'express'
|
||||
import type { ZodTypeAny, output } from 'zod'
|
||||
|
||||
export function parseBody<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.body)
|
||||
}
|
||||
|
||||
export function parseQuery<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.query)
|
||||
}
|
||||
|
||||
export function parseParams<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.params)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Request } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { parseBody, parseParams, parseQuery } from './index'
|
||||
|
||||
describe('http/validate parsers', () => {
|
||||
it('parseBody returns typed and coerced request body values', () => {
|
||||
const schema = z.object({ seats: z.coerce.number().int().min(1), brand: z.string().min(1) })
|
||||
const req = { body: { seats: '5', brand: 'Toyota' } } as Request
|
||||
|
||||
expect(parseBody(schema, req)).toEqual({ seats: 5, brand: 'Toyota' })
|
||||
})
|
||||
|
||||
it('parseQuery validates query values and throws ZodError for invalid input', () => {
|
||||
const schema = z.object({ page: z.coerce.number().int().positive() })
|
||||
const req = { query: { page: '0' } } as unknown as Request
|
||||
|
||||
expect(() => parseQuery(schema, req)).toThrow('Number must be greater than 0')
|
||||
})
|
||||
|
||||
it('parseParams returns validated path parameters', () => {
|
||||
const schema = z.object({ vehicleId: z.string().min(1) })
|
||||
const req = { params: { vehicleId: 'veh_123' } } as unknown as Request
|
||||
|
||||
expect(parseParams(schema, req)).toEqual({ vehicleId: 'veh_123' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Request } from 'express'
|
||||
import { ValidationError } from './errors'
|
||||
|
||||
export function getRawBodyString(req: Request) {
|
||||
if (!Buffer.isBuffer(req.body)) {
|
||||
throw new ValidationError('Webhook route must be mounted with express.raw before JSON parsing')
|
||||
}
|
||||
return req.body.toString('utf8')
|
||||
}
|
||||
|
||||
export function parseRawJsonBody<T = unknown>(req: Request): T {
|
||||
const rawBody = getRawBodyString(req)
|
||||
try {
|
||||
return JSON.parse(rawBody) as T
|
||||
} catch {
|
||||
throw new ValidationError('Malformed webhook JSON')
|
||||
}
|
||||
}
|
||||
+75
-233
@@ -1,95 +1,51 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import http from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import cron from 'node-cron'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { z } from 'zod'
|
||||
import type { Socket } from 'socket.io'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||
import { assertStorageConfiguration } from './lib/storage'
|
||||
import { createApp, corsOrigins } from './app'
|
||||
import { verifyAnyActorToken } from './security/tokens'
|
||||
import { getSessionCookieName } from './security/sessionCookies'
|
||||
import { startOutboxWorker, startScheduledJobs } from './workers/jobs'
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────
|
||||
import webhookRouter from './routes/webhooks'
|
||||
import companyAuthRouter from './routes/auth.company'
|
||||
import employeeAuthRouter from './routes/auth.employee'
|
||||
import renterAuthRouter from './routes/auth.renter'
|
||||
import vehiclesRouter from './routes/vehicles'
|
||||
import reservationsRouter from './routes/reservations'
|
||||
import teamRouter from './routes/team'
|
||||
import customersRouter from './routes/customers'
|
||||
import offersRouter from './routes/offers'
|
||||
import analyticsRouter from './routes/analytics'
|
||||
import notificationsRouter from './routes/notifications'
|
||||
import marketplaceRouter from './routes/marketplace'
|
||||
import adminRouter from './routes/admin'
|
||||
import companiesRouter from './routes/companies'
|
||||
import subscriptionsRouter from './routes/subscriptions'
|
||||
import siteRouter from './routes/site'
|
||||
import paymentsRouter from './routes/payments'
|
||||
|
||||
const app = express()
|
||||
const app = createApp()
|
||||
const server = http.createServer(app)
|
||||
assertStorageConfiguration()
|
||||
|
||||
// Trust the first hop from a reverse proxy so req.ip and rate-limiting
|
||||
// use the real client IP (from X-Forwarded-For) rather than the proxy's IP.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
const v1 = '/api/v1'
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001',
|
||||
'http://127.0.0.1:3002',
|
||||
'http://127.0.0.1:3003',
|
||||
]
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
||||
: defaultCorsOrigins
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||
})
|
||||
|
||||
const redisMessageSchema = z.object({
|
||||
type: z.string(),
|
||||
payload: z.unknown(),
|
||||
})
|
||||
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getSocketSessionToken(socket: Socket): string | undefined {
|
||||
const explicitToken = socket.handshake.auth?.token
|
||||
if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim()
|
||||
|
||||
const cookieHeader = socket.request.headers.cookie
|
||||
return (
|
||||
readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ??
|
||||
readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ??
|
||||
readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticate socket connections via JWT before joining user rooms
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||
const token = getSocketSessionToken(socket)
|
||||
if (!token) return next()
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
const payload = verifyAnyActorToken(token)
|
||||
;(socket as any).authenticatedUserId = payload.sub
|
||||
next()
|
||||
} catch {
|
||||
@@ -99,12 +55,9 @@ io.use((socket, next) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId = (socket as any).authenticatedUserId as string | undefined
|
||||
if (userId) {
|
||||
socket.join(`user:${userId}`)
|
||||
}
|
||||
if (userId) socket.join(`user:${userId}`)
|
||||
})
|
||||
|
||||
// Redis pub/sub → broadcast to connected clients
|
||||
const subscriber = redis.duplicate()
|
||||
subscriber.psubscribe('notifications:*', (err) => {
|
||||
if (err) console.error('[Redis] Subscribe error:', err)
|
||||
@@ -112,177 +65,66 @@ subscriber.psubscribe('notifications:*', (err) => {
|
||||
subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
const validated = redisMessageSchema.parse(parsed)
|
||||
const userId = channel.replace('notifications:', '')
|
||||
io.to(`user:${userId}`).emit('notification', validated)
|
||||
io.to(`user:${userId}`).emit('notification', parsed)
|
||||
} catch (err) {
|
||||
console.error('[Redis] Invalid notification message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Middleware ────────────────────────────────────────────────
|
||||
// Serve local file storage
|
||||
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
|
||||
// Embedded jobs only when explicitly enabled (single-process local/dev).
|
||||
// Production should run `npm run worker` / Compose `api-worker` instead.
|
||||
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') {
|
||||
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process')
|
||||
startOutboxWorker()
|
||||
startScheduledJobs()
|
||||
}
|
||||
|
||||
// Webhook must use raw body BEFORE express.json()
|
||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
||||
const PORT = Number(process.env.API_PORT ?? 4000)
|
||||
const HOST = process.env.API_HOST ?? '0.0.0.0'
|
||||
|
||||
app.use(helmet())
|
||||
app.use(cors({ origin: corsOrigins, credentials: true }))
|
||||
app.use(morgan('combined'))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ───────────────────────────────────────────────
|
||||
// Auth routes: strict brute-force protection
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
// Admin routes: dedicated limit
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
// Public unauthenticated routes
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
|
||||
// Authenticated company/renter routes
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
|
||||
// ─── Health check ─────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`[API] Server running on ${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
docsUrl: '/docs',
|
||||
routes: routeDocs,
|
||||
let shuttingDown = false
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
console.log(`[API] ${signal} received, draining`)
|
||||
|
||||
server.close((err) => {
|
||||
if (err) console.error('[API] HTTP close error:', err.message)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/docs', (_req, res) => {
|
||||
const rows = routeDocs
|
||||
.map(
|
||||
(route) => `
|
||||
<tr>
|
||||
<td>${route.method}</td>
|
||||
<td><code>${route.path}</code></td>
|
||||
<td>${route.description}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>RentalDriveGo API Docs</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { margin-bottom: 8px; }
|
||||
p { color: #475569; }
|
||||
.meta { margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RentalDriveGo API</h1>
|
||||
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// ─── Error handler ────────────────────────────────────────────
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const statusCode = err.statusCode ?? 500
|
||||
const message = err.message ?? 'Internal server error'
|
||||
const code = err.code ?? 'internal_error'
|
||||
|
||||
if (statusCode >= 500) {
|
||||
console.error('[API Error]', err)
|
||||
try {
|
||||
io.close()
|
||||
} catch (err: any) {
|
||||
console.error('[API] Socket.IO close error:', err?.message ?? err)
|
||||
}
|
||||
|
||||
// Zod validation error
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
|
||||
try {
|
||||
await subscriber.quit()
|
||||
} catch {
|
||||
subscriber.disconnect()
|
||||
}
|
||||
|
||||
// Prisma not found
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
|
||||
try {
|
||||
await redis.quit()
|
||||
} catch {
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
// Prisma unique constraint
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
|
||||
try {
|
||||
await prisma.$disconnect()
|
||||
} catch (err: any) {
|
||||
console.error('[API] Prisma disconnect error:', err?.message ?? err)
|
||||
}
|
||||
|
||||
res.status(statusCode).json({ error: code, message, statusCode })
|
||||
})
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ─── Scheduled jobs ───────────────────────────────────────────
|
||||
|
||||
// Daily: flag expiring/expired licenses
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
|
||||
for (const c of customers) {
|
||||
if (!c.licenseExpiry) continue
|
||||
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
const expired = c.licenseExpiry <= new Date()
|
||||
const expiring = !expired && daysLeft < 90
|
||||
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
|
||||
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Daily: send trial-ending reminders (3 days before trial end)
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
|
||||
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
const owner = sub.company.employees[0]
|
||||
if (owner) {
|
||||
await prisma.notification.create({
|
||||
data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 14-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────
|
||||
const PORT = process.env.API_PORT ?? 4000
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[API] Server running on port ${PORT}`)
|
||||
})
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
|
||||
export { app, io }
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
carplaceReservationEmail,
|
||||
resetPasswordEmail,
|
||||
signupEmail,
|
||||
} from './emailTranslations'
|
||||
|
||||
describe('emailTranslations', () => {
|
||||
const trialEnd = new Date('2026-07-15T12:00:00.000Z')
|
||||
|
||||
it('renders signup subjects in every supported locale', () => {
|
||||
expect(signupEmail.subject('en')).toContain('workspace')
|
||||
expect(signupEmail.subject('fr')).toContain('espace')
|
||||
expect(signupEmail.subject('ar')).toContain('جاهزة')
|
||||
})
|
||||
|
||||
it('renders localized signup text with billing details', () => {
|
||||
const text = signupEmail.text({
|
||||
firstName: 'Aya',
|
||||
companyName: 'Atlas Cars',
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
trialEnd,
|
||||
}, 'fr')
|
||||
|
||||
expect(text).toContain('Bonjour Aya')
|
||||
expect(text).toContain('Atlas Cars')
|
||||
expect(text).toContain('Forfait : PRO (annuel)')
|
||||
expect(text).toContain('Paiements : virement bancaire ou chèque.')
|
||||
})
|
||||
|
||||
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
|
||||
const html = resetPasswordEmail.html('https://example.test/reset/token', 'Mina', 45, 'ar')
|
||||
|
||||
expect(html).toContain('dir="rtl"')
|
||||
expect(html).toContain('https://example.test/reset/token')
|
||||
expect(html).toContain('45')
|
||||
})
|
||||
|
||||
it('includes optional contact phone in Carplace reservation HTML when present', () => {
|
||||
const html = carplaceReservationEmail.html({
|
||||
firstName: 'Yassine',
|
||||
vehicleYear: 2024,
|
||||
vehicleMake: 'Dacia',
|
||||
vehicleModel: 'Duster',
|
||||
companyName: 'Atlas Cars',
|
||||
startDate: new Date('2026-08-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-08-05T00:00:00.000Z'),
|
||||
totalDays: 4,
|
||||
rateDisplay: '400.00',
|
||||
totalDisplay: '1600.00',
|
||||
email: 'yassine@example.test',
|
||||
phone: '+212600000000',
|
||||
}, 'en')
|
||||
|
||||
expect(html).toContain('2024 Dacia Duster')
|
||||
expect(html).toContain('Atlas Cars')
|
||||
expect(html).toContain('yassine@example.test or +212600000000')
|
||||
})
|
||||
|
||||
it('formats dates with the locale-specific formatter', () => {
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'en')).toContain('2026')
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'fr')).toContain('2026')
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toMatch(/2026|٢٠٢٦/)
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toContain('فبراير')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
export type Lang = 'en' | 'fr' | 'ar'
|
||||
|
||||
function t<T>(map: Record<Lang, T>, lang: Lang): T {
|
||||
return map[lang] ?? map['fr']
|
||||
}
|
||||
|
||||
const dateLocale: Record<Lang, string> = { en: 'en-GB', fr: 'fr-FR', ar: 'ar-MA' }
|
||||
|
||||
export function formatDate(date: Date, lang: Lang, opts?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleDateString(dateLocale[lang], opts ?? { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ─── Signup confirmation ──────────────────────────────────────────────────────
|
||||
|
||||
export const signupEmail = {
|
||||
subject: (lang: Lang) => t({
|
||||
en: 'Your workspace is ready — RentalDriveGo',
|
||||
fr: 'Votre espace de travail est prêt — RentalDriveGo',
|
||||
ar: 'مساحة عملك جاهزة — RentalDriveGo',
|
||||
}, lang),
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
currency: string
|
||||
trialEnd: Date
|
||||
}, lang: Lang): string => {
|
||||
const trialStr = formatDate(opts.trialEnd, lang)
|
||||
return t({
|
||||
en: [
|
||||
`Hi ${opts.firstName},`,
|
||||
'',
|
||||
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||
`Currency: ${opts.currency}`,
|
||||
'Payments: bank transfer or check.',
|
||||
`Free trial ends on ${trialStr}.`,
|
||||
'',
|
||||
'Your workspace is ready. Sign in with the email and password you chose during signup.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
fr: [
|
||||
`Bonjour ${opts.firstName},`,
|
||||
'',
|
||||
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
|
||||
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
|
||||
`Devise : ${opts.currency}`,
|
||||
'Paiements : virement bancaire ou chèque.',
|
||||
`La période d'essai gratuit se termine le ${trialStr}.`,
|
||||
'',
|
||||
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
ar: [
|
||||
`مرحباً ${opts.firstName}،`,
|
||||
'',
|
||||
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
|
||||
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
|
||||
`العملة: ${opts.currency}`,
|
||||
'الدفع: تحويل بنكي أو شيك.',
|
||||
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
|
||||
'',
|
||||
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
}, lang)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Password reset ───────────────────────────────────────────────────────────
|
||||
|
||||
export const resetPasswordEmail = {
|
||||
subject: (lang: Lang) => t({
|
||||
en: 'Reset your RentalDriveGo password',
|
||||
fr: 'Réinitialisez votre mot de passe RentalDriveGo',
|
||||
ar: 'إعادة تعيين كلمة مرور RentalDriveGo',
|
||||
}, lang),
|
||||
|
||||
html: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => {
|
||||
const isRtl = lang === 'ar'
|
||||
const dir = isRtl ? 'rtl' : 'ltr'
|
||||
return t({
|
||||
en: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
|
||||
<p>This link expires in ${expireMinutes} minutes. If you did not request this, you can safely ignore this email.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
fr: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>Bonjour ${firstName},</p>
|
||||
<p>Vous avez demandé la réinitialisation du mot de passe de votre compte RentalDriveGo.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Réinitialiser mon mot de passe</a></p>
|
||||
<p>Ce lien expire dans ${expireMinutes} minutes. Si vous n'avez pas fait cette demande, ignorez simplement cet e-mail.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
ar: `<div dir="rtl" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>مرحباً ${firstName}،</p>
|
||||
<p>طلبت إعادة تعيين كلمة مرور حساب RentalDriveGo الخاص بك.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">إعادة تعيين كلمة المرور</a></p>
|
||||
<p>تنتهي صلاحية هذا الرابط خلال ${expireMinutes} دقيقة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني بأمان.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
}, lang)
|
||||
},
|
||||
|
||||
text: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => t({
|
||||
en: `Hi ${firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${expireMinutes} minutes.\n\nRentalDriveGo`,
|
||||
fr: `Bonjour ${firstName},\n\nRéinitialisez votre mot de passe ici : ${resetUrl}\n\nCe lien expire dans ${expireMinutes} minutes.\n\nRentalDriveGo`,
|
||||
ar: `مرحباً ${firstName}،\n\nأعد تعيين كلمة مرورك هنا: ${resetUrl}\n\nينتهي هذا الرابط خلال ${expireMinutes} دقيقة.\n\nRentalDriveGo`,
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Carplace reservation request ─────────────────────────────────────────
|
||||
|
||||
export const carplaceReservationEmail = {
|
||||
subject: (vehicleName: string, lang: Lang) => t({
|
||||
en: `Reservation Request Received — ${vehicleName}`,
|
||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||
ar: `تم استلام طلب الحجز — ${vehicleName}`,
|
||||
}, lang),
|
||||
|
||||
html: (opts: {
|
||||
firstName: string
|
||||
vehicleYear: number
|
||||
vehicleMake: string
|
||||
vehicleModel: string
|
||||
companyName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalDays: number
|
||||
rateDisplay: string
|
||||
totalDisplay: string
|
||||
email: string
|
||||
phone?: string
|
||||
}, lang: Lang): string => {
|
||||
const startStr = formatDate(opts.startDate, lang)
|
||||
const endStr = formatDate(opts.endDate, lang)
|
||||
const isRtl = lang === 'ar'
|
||||
const l = t({
|
||||
en: {
|
||||
greeting: `Hi ${opts.firstName},`,
|
||||
intro: 'Your reservation request has been received and is pending company confirmation.',
|
||||
company: 'Company', pickup: 'Pick-up date', returnD: 'Return date',
|
||||
duration: 'Duration', daily: 'Daily rate', total: 'Estimated total', days: 'day(s)',
|
||||
footer: `The company will review your request and get in touch shortly. You may be contacted at ${opts.email}${opts.phone ? ` or ${opts.phone}` : ''}.`,
|
||||
},
|
||||
fr: {
|
||||
greeting: `Bonjour ${opts.firstName},`,
|
||||
intro: 'Votre demande de réservation a été reçue et est en attente de confirmation.',
|
||||
company: 'Société', pickup: 'Date de prise en charge', returnD: 'Date de retour',
|
||||
duration: 'Durée', daily: 'Tarif journalier', total: 'Total estimé', days: 'jour(s)',
|
||||
footer: `La société examinera votre demande et vous contactera prochainement. Vous pouvez être contacté à ${opts.email}${opts.phone ? ` ou ${opts.phone}` : ''}.`,
|
||||
},
|
||||
ar: {
|
||||
greeting: `مرحباً ${opts.firstName}،`,
|
||||
intro: 'تم استلام طلب الحجز وهو في انتظار تأكيد الشركة.',
|
||||
company: 'الشركة', pickup: 'تاريخ الاستلام', returnD: 'تاريخ الإرجاع',
|
||||
duration: 'المدة', daily: 'السعر اليومي', total: 'الإجمالي التقديري', days: 'يوم',
|
||||
footer: `ستراجع الشركة طلبك وستتواصل معك قريباً. يمكن التواصل معك على ${opts.email}${opts.phone ? ` أو ${opts.phone}` : ''}.`,
|
||||
},
|
||||
}, lang)
|
||||
|
||||
return `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
|
||||
<h2 style="margin-bottom:4px">${l.greeting}</h2>
|
||||
<p style="color:#57534e">${l.intro}</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<h3 style="margin-bottom:12px">${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel}</h3>
|
||||
<p><strong>${l.company}:</strong> ${opts.companyName}</p>
|
||||
<p><strong>${l.pickup}:</strong> ${startStr}</p>
|
||||
<p><strong>${l.returnD}:</strong> ${endStr}</p>
|
||||
<p><strong>${l.duration}:</strong> ${opts.totalDays} ${l.days}</p>
|
||||
<p><strong>${l.daily}:</strong> ${opts.rateDisplay} MAD</p>
|
||||
<p><strong>${l.total}:</strong> ${opts.totalDisplay} MAD</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<p style="color:#57534e;font-size:13px">${l.footer}</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
vehicleYear: number
|
||||
vehicleMake: string
|
||||
vehicleModel: string
|
||||
companyName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalDays: number
|
||||
rateDisplay: string
|
||||
totalDisplay: string
|
||||
}, lang: Lang): string => {
|
||||
const startStr = formatDate(opts.startDate, lang)
|
||||
const endStr = formatDate(opts.endDate, lang)
|
||||
return t({
|
||||
en: `Hi ${opts.firstName},\n\nYour reservation request for the ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} from ${opts.companyName} has been received.\n\nDates: ${startStr} → ${endStr}\nDuration: ${opts.totalDays} day(s)\nDaily rate: ${opts.rateDisplay} MAD\nEstimated total: ${opts.totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
|
||||
fr: `Bonjour ${opts.firstName},\n\nVotre demande de réservation pour la ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} auprès de ${opts.companyName} a été reçue.\n\nDates : ${startStr} → ${endStr}\nDurée : ${opts.totalDays} jour(s)\nTarif journalier : ${opts.rateDisplay} MAD\nTotal estimé : ${opts.totalDisplay} MAD\n\nLa société confirmera votre demande prochainement.\n\nMerci !`,
|
||||
ar: `مرحباً ${opts.firstName}،\n\nتم استلام طلب حجزك لسيارة ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} من ${opts.companyName}.\n\nالتواريخ: ${startStr} → ${endStr}\nالمدة: ${opts.totalDays} يوم\nالسعر اليومي: ${opts.rateDisplay} MAD\nالإجمالي التقديري: ${opts.totalDisplay} MAD\n\nستؤكد الشركة طلبك قريباً.\n\nشكراً!`,
|
||||
}, lang)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Booking confirmed ────────────────────────────────────────────────────────
|
||||
|
||||
export const bookingConfirmedNotif = {
|
||||
title: (lang: Lang) => t({
|
||||
en: 'Booking Confirmed',
|
||||
fr: 'Réservation confirmée',
|
||||
ar: 'تم تأكيد الحجز',
|
||||
}, lang),
|
||||
body: (lang: Lang) => t({
|
||||
en: 'Your booking has been confirmed.',
|
||||
fr: 'Votre réservation a été confirmée.',
|
||||
ar: 'تم تأكيد حجزك.',
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Post-rental review request ───────────────────────────────────────────────
|
||||
|
||||
export const reviewRequestEmail = {
|
||||
subject: (vehicleLabel: string, lang: Lang) => t({
|
||||
en: `How was your rental? — ${vehicleLabel}`,
|
||||
fr: `Comment s'est passée votre location ? — ${vehicleLabel}`,
|
||||
ar: `كيف كانت تجربة الإيجار؟ — ${vehicleLabel}`,
|
||||
}, lang),
|
||||
|
||||
html: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
vehicleLabel: string
|
||||
reviewUrl: string
|
||||
}, lang: Lang): string => {
|
||||
const isRtl = lang === 'ar'
|
||||
const l = t({
|
||||
en: {
|
||||
greeting: `Hi ${opts.firstName},`,
|
||||
body1: `Thank you for renting with <strong>${opts.companyName}</strong>. We hope you enjoyed your <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: 'Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.',
|
||||
cta: 'Leave a review',
|
||||
disclaimer: 'This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.',
|
||||
},
|
||||
fr: {
|
||||
greeting: `Bonjour ${opts.firstName},`,
|
||||
body1: `Merci d'avoir loué chez <strong>${opts.companyName}</strong>. Nous espérons que vous avez apprécié votre <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: "Votre avis aide la société à s'améliorer et aide les autres clients à faire des choix éclairés. Cela ne prend que 30 secondes.",
|
||||
cta: 'Laisser un avis',
|
||||
disclaimer: "Ce lien est unique à votre réservation et ne peut être utilisé qu'une seule fois. Si vous n'avez pas loué de véhicule récemment, vous pouvez ignorer cet e-mail.",
|
||||
},
|
||||
ar: {
|
||||
greeting: `مرحباً ${opts.firstName}،`,
|
||||
body1: `شكراً لاختيار <strong>${opts.companyName}</strong>. نأمل أنك استمتعت بـ <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: 'تساعد ملاحظاتك الشركة على التحسين وتساعد العملاء الآخرين على اتخاذ قرارات مستنيرة. لا يستغرق الأمر سوى 30 ثانية.',
|
||||
cta: 'اترك تقييماً',
|
||||
disclaimer: 'هذا الرابط فريد لحجزك ولا يمكن استخدامه إلا مرة واحدة. إذا لم تستأجر مركبة مؤخراً، يمكنك تجاهل هذا البريد الإلكتروني.',
|
||||
},
|
||||
}, lang)
|
||||
|
||||
return `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
|
||||
<h2 style="margin-bottom:4px">${l.greeting}</h2>
|
||||
<p style="color:#57534e">${l.body1}</p>
|
||||
<p style="color:#57534e">${l.body2}</p>
|
||||
<div style="margin:32px 0;text-align:center">
|
||||
<a href="${opts.reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
|
||||
${l.cta}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#a8a29e;font-size:12px">${l.disclaimer}</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
vehicleLabel: string
|
||||
reviewUrl: string
|
||||
}, lang: Lang): string => t({
|
||||
en: `Hi ${opts.firstName},\n\nThank you for renting with ${opts.companyName}. We hope you enjoyed your ${opts.vehicleLabel}.\n\nLeave a review here (one-time link):\n${opts.reviewUrl}\n\nThank you!`,
|
||||
fr: `Bonjour ${opts.firstName},\n\nMerci d'avoir loué chez ${opts.companyName}. Nous espérons que vous avez apprécié votre ${opts.vehicleLabel}.\n\nLaissez un avis ici (lien unique) :\n${opts.reviewUrl}\n\nMerci !`,
|
||||
ar: `مرحباً ${opts.firstName}،\n\nشكراً لاختيار ${opts.companyName}. نأمل أنك استمتعت بـ ${opts.vehicleLabel}.\n\nاترك تقييماً هنا (رابط فريد):\n${opts.reviewUrl}\n\nشكراً!`,
|
||||
}, lang),
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getIdempotentResult, setIdempotentResult } from './idempotencyStore'
|
||||
|
||||
describe('idempotencyStore (memory)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.IDEMPOTENCY_STORE = 'memory'
|
||||
})
|
||||
|
||||
it('returns miss then hit for the same fingerprint', async () => {
|
||||
const key = `k-${Date.now()}`
|
||||
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({ kind: 'miss' })
|
||||
await setIdempotentResult('carplace', key, 'fp1', { id: 'reservation_1' })
|
||||
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({
|
||||
kind: 'hit',
|
||||
result: { id: 'reservation_1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('detects fingerprint conflicts', async () => {
|
||||
const key = `conflict-${Date.now()}`
|
||||
await setIdempotentResult('carplace', key, 'fp1', { id: 'a' })
|
||||
await expect(getIdempotentResult('carplace', key, 'fp2')).resolves.toEqual({ kind: 'conflict' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { redis } from '../lib/redis'
|
||||
|
||||
const MEMORY = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const DEFAULT_TTL_SECONDS = 15 * 60
|
||||
|
||||
function useMemory() {
|
||||
return process.env.IDEMPOTENCY_STORE === 'memory' || process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
export type IdempotencyHit =
|
||||
| { kind: 'miss' }
|
||||
| { kind: 'hit'; result: unknown }
|
||||
| { kind: 'conflict' }
|
||||
|
||||
export async function getIdempotentResult(
|
||||
scope: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
): Promise<IdempotencyHit> {
|
||||
const redisKey = `idempotency:${scope}:${key}`
|
||||
|
||||
if (useMemory()) {
|
||||
const cached = MEMORY.get(redisKey)
|
||||
if (!cached || cached.expiresAt <= Date.now()) return { kind: 'miss' }
|
||||
if (cached.fingerprint !== fingerprint) return { kind: 'conflict' }
|
||||
return { kind: 'hit', result: cached.result }
|
||||
}
|
||||
|
||||
const raw = await redis.get(redisKey)
|
||||
if (!raw) return { kind: 'miss' }
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { fingerprint: string; result: unknown }
|
||||
if (parsed.fingerprint !== fingerprint) return { kind: 'conflict' }
|
||||
return { kind: 'hit', result: parsed.result }
|
||||
} catch {
|
||||
return { kind: 'miss' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function setIdempotentResult(
|
||||
scope: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
result: unknown,
|
||||
ttlSeconds = DEFAULT_TTL_SECONDS,
|
||||
): Promise<void> {
|
||||
const redisKey = `idempotency:${scope}:${key}`
|
||||
const payload = JSON.stringify({ fingerprint, result })
|
||||
|
||||
if (useMemory()) {
|
||||
MEMORY.set(redisKey, { expiresAt: Date.now() + ttlSeconds * 1000, fingerprint, result })
|
||||
return
|
||||
}
|
||||
|
||||
await redis.set(redisKey, payload, 'EX', ttlSeconds)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeAndFormat, toTitleCase, toTitleCaseAll, toLowerCase, toUpperCase, preserveOriginal, getMaxLength } from './inputValidation'
|
||||
|
||||
describe('toTitleCase', () => {
|
||||
it('uppercases first letter and lowercases rest of each word', () => {
|
||||
expect(toTitleCase('john doe')).toBe('John Doe')
|
||||
expect(toTitleCase('MARIA')).toBe('Maria')
|
||||
expect(toTitleCase('new york')).toBe('New York')
|
||||
})
|
||||
|
||||
it('preserves numbers and special chars that survive sanitization', () => {
|
||||
expect(toTitleCase('123 main st.')).toBe('123 Main St.')
|
||||
})
|
||||
|
||||
it('handles single word', () => {
|
||||
expect(toTitleCase('hello')).toBe('Hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toTitleCaseAll', () => {
|
||||
it('uppercases first letter of every word', () => {
|
||||
expect(toTitleCaseAll('acme corporation ltd.')).toBe('Acme Corporation Ltd.')
|
||||
expect(toTitleCaseAll('123 main street, apt 4b')).toBe('123 Main Street, Apt 4b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toLowerCase', () => {
|
||||
it('lowercases all characters', () => {
|
||||
expect(toLowerCase('John.Doe@Example.COM')).toBe('john.doe@example.com')
|
||||
expect(toLowerCase('ALLCAPS')).toBe('allcaps')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toUpperCase', () => {
|
||||
it('uppercases letters, leaves numbers unchanged', () => {
|
||||
expect(toUpperCase('abc-123')).toBe('ABC-123')
|
||||
expect(toUpperCase('ab123cd')).toBe('AB123CD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserveOriginal', () => {
|
||||
it('returns the string unchanged', () => {
|
||||
expect(preserveOriginal('SW1A 1AA')).toBe('SW1A 1AA')
|
||||
expect(preserveOriginal('12345')).toBe('12345')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeAndFormat', () => {
|
||||
// ─── Title Case fields
|
||||
|
||||
it('formats name fields (title case, max 50)', () => {
|
||||
expect(sanitizeAndFormat('john doe', 'name')).toBe('John Doe')
|
||||
expect(sanitizeAndFormat('MARIA', 'name')).toBe('Maria')
|
||||
})
|
||||
|
||||
it('formats streetAddress (title case, max 100)', () => {
|
||||
expect(sanitizeAndFormat('123 main st.', 'streetAddress')).toBe('123 Main St')
|
||||
expect(sanitizeAndFormat('elm street 42', 'streetAddress')).toBe('Elm Street 42')
|
||||
})
|
||||
|
||||
it('formats city (title case, max 50)', () => {
|
||||
expect(sanitizeAndFormat('new york', 'city')).toBe('New York')
|
||||
})
|
||||
|
||||
it('formats pickupLocation and returnLocation', () => {
|
||||
expect(sanitizeAndFormat('airport terminal 1', 'pickupLocation')).toBe('Airport Terminal 1')
|
||||
expect(sanitizeAndFormat('downtown garage', 'returnLocation')).toBe('Downtown Garage')
|
||||
})
|
||||
|
||||
it('formats country (title case)', () => {
|
||||
expect(sanitizeAndFormat('united kingdom', 'country')).toBe('United Kingdom')
|
||||
})
|
||||
|
||||
// ─── Title Case All fields
|
||||
|
||||
it('formats fullAddress (title case all, max 200)', () => {
|
||||
expect(sanitizeAndFormat('123 main street, apt 4b', 'fullAddress')).toBe('123 Main Street, Apt 4b')
|
||||
})
|
||||
|
||||
it('formats commercialName (title case all, max 100)', () => {
|
||||
expect(sanitizeAndFormat('acme corporation', 'commercialName')).toBe('Acme Corporation')
|
||||
})
|
||||
|
||||
it('formats legalCompanyName (title case all, max 100)', () => {
|
||||
expect(sanitizeAndFormat('global rentals llc', 'legalCompanyName')).toBe('Global Rentals Llc')
|
||||
})
|
||||
|
||||
// ─── Email
|
||||
|
||||
it('formats email (lowercase)', () => {
|
||||
expect(sanitizeAndFormat('John.Doe@Example.COM', 'email')).toBe('john.doe@example.com')
|
||||
})
|
||||
|
||||
// ─── Uppercase fields
|
||||
|
||||
it('formats licensePlate (uppercase, preserves hyphens)', () => {
|
||||
expect(sanitizeAndFormat('abc-123', 'licensePlate')).toBe('ABC-123')
|
||||
})
|
||||
|
||||
it('formats VIN (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('1hgbh41jxmn109186', 'vin')).toBe('1HGBH41JXMN109186')
|
||||
})
|
||||
|
||||
it('formats passportNumber (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('ab123456', 'passportNumber')).toBe('AB123456')
|
||||
})
|
||||
|
||||
it('formats driverLicenseNumber (uppercase, preserves hyphens)', () => {
|
||||
expect(sanitizeAndFormat('d123-456-789', 'driverLicenseNumber')).toBe('D123-456-789')
|
||||
})
|
||||
|
||||
it('formats licenseCategory (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('b', 'licenseCategory')).toBe('B')
|
||||
})
|
||||
|
||||
// ─── Car fields
|
||||
|
||||
it('formats carMark (title case)', () => {
|
||||
expect(sanitizeAndFormat('TOYOTA', 'carMark')).toBe('Toyota')
|
||||
expect(sanitizeAndFormat('mercedes-benz', 'carMark')).toBe('Mercedes-Benz')
|
||||
})
|
||||
|
||||
it('formats carModel (title case)', () => {
|
||||
expect(sanitizeAndFormat('corolla', 'carModel')).toBe('Corolla')
|
||||
})
|
||||
|
||||
it('formats carColor (title case)', () => {
|
||||
expect(sanitizeAndFormat('midnight-blue', 'carColor')).toBe('Midnight-Blue')
|
||||
})
|
||||
|
||||
// ─── ZIP Code (preserved)
|
||||
|
||||
it('preserves ZIP code format', () => {
|
||||
expect(sanitizeAndFormat('12345', 'zipCode')).toBe('12345')
|
||||
expect(sanitizeAndFormat('SW1A 1AA', 'zipCode')).toBe('SW1A 1AA')
|
||||
})
|
||||
|
||||
// ─── Sanitization
|
||||
|
||||
it('removes disallowed characters', () => {
|
||||
expect(sanitizeAndFormat('John!@#', 'name')).toBe('John')
|
||||
})
|
||||
|
||||
it('strips disallowed characters including angle brackets', () => {
|
||||
const result = sanitizeAndFormat('test<script>alert(1)</script>', 'name')
|
||||
expect(result).toBe('Testscriptalertscript')
|
||||
})
|
||||
|
||||
// ─── Truncation
|
||||
|
||||
it('truncates to max length', () => {
|
||||
const longName = 'A'.repeat(60)
|
||||
expect(sanitizeAndFormat(longName, 'name')).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('does not truncate strings within limit', () => {
|
||||
expect(sanitizeAndFormat('John', 'name')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxLength', () => {
|
||||
it('returns correct max lengths', () => {
|
||||
expect(getMaxLength('name')).toBe(50)
|
||||
expect(getMaxLength('streetAddress')).toBe(100)
|
||||
expect(getMaxLength('fullAddress')).toBe(200)
|
||||
expect(getMaxLength('email')).toBe(100)
|
||||
expect(getMaxLength('licensePlate')).toBe(30)
|
||||
expect(getMaxLength('zipCode')).toBe(10)
|
||||
expect(getMaxLength('carMark')).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user