Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca1a014c01 | |||
| d01ab85cbb | |||
| 610572eafe | |||
| 73f1af185f | |||
| c1833c1def | |||
| 521398b0fc | |||
| 74d276b656 | |||
| 3110b40cf9 | |||
| 08f28aeb93 | |||
| fb9c0ad25a | |||
| 322cfae824 | |||
| 7fac3c3aa8 | |||
| bff3aad1ca | |||
| f2a9637058 | |||
| bfc2194e24 | |||
| 6f8de14b4a | |||
| a6ed74b1be | |||
| 1741574eb2 | |||
| d78f9f2696 | |||
| 3fe3597553 | |||
| 9243beddd3 | |||
| a6c3c42ec9 | |||
| ee6bb89fb6 | |||
| ad4f614130 | |||
| 8e7691d38a | |||
| 9939f75c03 | |||
| e69b68b73c | |||
| 41c1553b1f | |||
| 38f4c3243e | |||
| ed915ec539 | |||
| 667617296b | |||
| 1c61111da3 | |||
| 6fed5b083b | |||
| 4f868d751e | |||
| e15a668f8e | |||
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 | |||
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 | |||
| b3131f7710 | |||
| fadfdd3435 | |||
| 427c7c6204 | |||
| 6a8ad7b4be | |||
| 875ade4351 | |||
| 2a0bb10531 | |||
| 262250564a | |||
| a680d6de9f | |||
| 4a5ae0bb8b | |||
| c21b48c704 | |||
| cf190e5ac5 | |||
| a3b080f542 | |||
| 229bfea263 | |||
| 49b085e59e | |||
| cd15b391ac | |||
| 11d7dbb06e | |||
| d41f0b6ec9 | |||
| 03f8dd9afe | |||
| d4fcc33bc1 | |||
| cdc2210eaf | |||
| 6bf9caa53a | |||
| c8ed27157f | |||
| 6baca1a459 | |||
| faf49119ea | |||
| ce70fe8480 | |||
| 55571b1e59 | |||
| c0ff22394c | |||
| 395d2f43cc | |||
| 6f42b56ad5 | |||
| 85c4302f7a | |||
| 726151d90b | |||
| a13af83655 | |||
| b58bdadad4 | |||
| d375a099f0 | |||
| 666dd64580 | |||
| 188bef2ece | |||
| 57258bca92 | |||
| c7ab4c6b4e | |||
| b77dd04b15 | |||
| 11157b872c | |||
| 8273d08f15 | |||
| b023c068eb | |||
| 2c1e7af797 | |||
| 08460f93d4 | |||
| 392b0991a5 | |||
| 46369fda01 | |||
| 647afcfbe7 | |||
| b12bca8818 | |||
| a79d0defa4 | |||
| de1bb9bd02 | |||
| a5812dca9a | |||
| 7d58c78cb9 | |||
| f308c84325 | |||
| 2cf5b56441 | |||
| f84516a65b | |||
| 219b4c8365 | |||
| 9c50c9f054 | |||
| 49d81190d4 | |||
| eeef108f7e | |||
| c7df5c83a4 | |||
| c46f27edef | |||
| 542a607b53 | |||
| a31d05b7c2 | |||
| 22fd5fb2cc | |||
| 7c4e20099d | |||
| 3521a0ff4f | |||
| 2c85bf8597 | |||
| 7b22bc4087 | |||
| 1f48712e42 | |||
| 0bf4c6cf4c |
@@ -17,3 +17,7 @@ LITELLM_MASTER_KEY=
|
||||
|
||||
# Portal Database (CloudNativePG)
|
||||
DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal
|
||||
|
||||
# Threema relay (central pieced-threema-gateway)
|
||||
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
|
||||
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
|
||||
|
||||
@@ -61,29 +61,22 @@ jobs:
|
||||
fi
|
||||
echo "version=${next}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install skopeo
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq skopeo
|
||||
|
||||
- name: Push with skopeo debug
|
||||
- name: Build and push image
|
||||
env:
|
||||
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker build --pull -t "${REGISTRY}/${IMAGE}:${VERSION}" .
|
||||
docker save "${REGISTRY}/${IMAGE}:${VERSION}" -o /tmp/image.tar
|
||||
|
||||
AUTH=$(printf '%s:%s' "$REG_USER" "$REG_PASS" | base64 -w0)
|
||||
mkdir -p /tmp/auth
|
||||
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$AUTH" > /tmp/auth/auth.json
|
||||
|
||||
# --debug prints HTTP request/response details
|
||||
skopeo --debug copy --authfile /tmp/auth/auth.json \
|
||||
"docker-archive:/tmp/image.tar" \
|
||||
"docker://${REGISTRY}/${IMAGE}:${VERSION}" 2>&1 | tail -100
|
||||
printf '%s' "$REG_PASS" \
|
||||
| docker login "${REGISTRY}" -u "$REG_USER" --password-stdin
|
||||
docker build \
|
||||
--pull \
|
||||
-t "${REGISTRY}/${IMAGE}:${VERSION}" \
|
||||
-t "${REGISTRY}/${IMAGE}:latest" \
|
||||
.
|
||||
docker push "${REGISTRY}/${IMAGE}:${VERSION}"
|
||||
docker push "${REGISTRY}/${IMAGE}:latest"
|
||||
|
||||
- name: Tag git commit with version
|
||||
env:
|
||||
|
||||
134
README.md
134
README.md
@@ -1,100 +1,54 @@
|
||||
# PieCed Portal
|
||||
# PieCed Portal — Billing Phase 1 patch (suspend-via-admin fix)
|
||||
|
||||
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform.
|
||||
Single-file fix on top of the Phase 1 v2 drop.
|
||||
|
||||
## Stack
|
||||
## What it fixes
|
||||
|
||||
| Layer | Choice |
|
||||
|-------|--------|
|
||||
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
|
||||
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
|
||||
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
|
||||
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
|
||||
| i18n | next-intl 4.x (en/de) |
|
||||
| Styling | Tailwind CSS 4 |
|
||||
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
|
||||
The admin panel's suspend/resume button hits
|
||||
`/api/admin/tenants/[name]/suspend` (a different route from the
|
||||
customer-side `/api/tenants/[name]/suspend`). The v2 drop only
|
||||
hooked the customer route — admin suspends were going to K8s
|
||||
without producing a row in `tenant_suspension_events`.
|
||||
|
||||
## Setup
|
||||
This patch adds the same `recordSuspensionEvent` hook to the
|
||||
admin route. No other code paths affected; no schema changes.
|
||||
|
||||
### 1. ZITADEL Application
|
||||
|
||||
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform":
|
||||
|
||||
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE**
|
||||
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
|
||||
3. Post-logout URI: `https://app.pieced.ch/login`
|
||||
4. Note Client ID and Client Secret
|
||||
|
||||
### 2. OpenBao Secrets
|
||||
|
||||
```bash
|
||||
bao kv put pieced/portal/oidc \
|
||||
client_id="<from step 1>" \
|
||||
client_secret="<from step 1>" \
|
||||
nextauth_secret="$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
### 3. Build & Push
|
||||
|
||||
```bash
|
||||
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
|
||||
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
|
||||
```
|
||||
|
||||
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
|
||||
|
||||
### 4. DNS
|
||||
|
||||
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Fill in values — K8s client uses ~/.kube/config locally
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── auth/[...nextauth]/route.ts # NextAuth handler
|
||||
│ │ ├── tenants/route.ts # Tenant CRUD (K8s API)
|
||||
│ │ └── usage/route.ts # Usage stub
|
||||
│ ├── [locale]/
|
||||
│ │ ├── layout.tsx # Locale layout + NavShell
|
||||
│ │ ├── page.tsx # Redirect → /dashboard
|
||||
│ │ ├── login/page.tsx # ZITADEL sign-in
|
||||
│ │ ├── dashboard/page.tsx # Customer dashboard
|
||||
│ │ └── admin/page.tsx # Platform admin tenant list
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── globals.css # Tailwind 4 theme
|
||||
├── components/
|
||||
│ ├── layout/nav-shell.tsx # Header + navigation
|
||||
│ └── ui/ # Reusable UI components
|
||||
├── i18n/
|
||||
│ ├── routing.ts # next-intl 4.x routing config
|
||||
│ ├── navigation.ts # Localized Link, redirect, etc.
|
||||
│ └── request.ts # Server-side i18n config
|
||||
├── lib/
|
||||
│ ├── auth.ts # NextAuth v5 + ZITADEL config
|
||||
│ ├── k8s.ts # K8s client for PiecedTenant CRs
|
||||
│ ├── litellm.ts # LiteLLM API client
|
||||
│ └── session.ts # Session helpers
|
||||
├── messages/
|
||||
│ ├── en.json
|
||||
│ └── de.json
|
||||
└── types/index.ts # Shared TypeScript types
|
||||
src/app/api/admin/tenants/[name]/suspend/route.ts MODIFIED
|
||||
```
|
||||
|
||||
## Session Roadmap
|
||||
## Deploy
|
||||
|
||||
- **6.1** ← This session: scaffold, auth, basic pages
|
||||
- **6.2**: Instance management, package config, usage display
|
||||
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
|
||||
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
- **6.5**: Admin panel (tenant lifecycle, billing overview)
|
||||
Extract over your `pieced-portal/` tree, rebuild, redeploy as
|
||||
usual. After the new image is running, verify:
|
||||
|
||||
1. Suspend any test tenant from the `/admin` panel.
|
||||
2. Check the events table:
|
||||
|
||||
```bash
|
||||
kubectl -n pieced-system exec -it portal-db-1 -- psql -U postgres -d portal -c \
|
||||
"SELECT * FROM tenant_suspension_events ORDER BY id DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
Expect a fresh `suspended` row for the tenant you just toggled.
|
||||
|
||||
3. Resume → expect a `resumed` row.
|
||||
|
||||
## Why I missed this
|
||||
|
||||
Both routes share the same shape (PATCH/POST that sets
|
||||
`spec.suspend`), but they differ on:
|
||||
|
||||
- URL path (`/api/admin/tenants/...` vs `/api/tenants/...`)
|
||||
- Method (POST vs PATCH)
|
||||
- Authorization (platform-only vs owner+platform)
|
||||
- Caller (admin panel vs customer cancel button)
|
||||
|
||||
When I grepped for the suspend hook target I matched on the
|
||||
customer endpoint and didn't audit cross-cutting admin
|
||||
duplicates. I've since checked every site that calls
|
||||
`patchTenantSpec`, `createTenant`, or `deleteTenant` — this was
|
||||
the only missed billing-relevant one. Other `patchTenantSpec`
|
||||
sites are confirmed non-billing (openClawImage, channelUsers).
|
||||
|
||||
70
deploy/README-threema.md
Normal file
70
deploy/README-threema.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Wiring Threema relay into the portal
|
||||
|
||||
Drop-in files in this archive:
|
||||
|
||||
```
|
||||
src/lib/packages.ts # add 'threema' to catalog + customProvisioning flag
|
||||
src/lib/threema-relay.ts # new — admin API client
|
||||
src/app/api/tenants/[name]/threema/route.ts # new — POST provision / DELETE deprovision
|
||||
src/app/api/tenants/[name]/threema/routes/route.ts # new — atomic add/remove of a single Threema ID
|
||||
src/components/channel-users/channel-users.tsx # branch threema through relay-managed endpoint
|
||||
src/components/packages/package-card.tsx # handle customProvisioning enable/disable
|
||||
deploy/patch-i18n-threema.mjs # idempotent i18n key injection
|
||||
```
|
||||
|
||||
## Manual steps after dropping in
|
||||
|
||||
1. `.env` (and `.env.example`) — add:
|
||||
```
|
||||
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
|
||||
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
|
||||
```
|
||||
The portal pod's OpenBao client should also read `secret/data/threema-gateway/admin` and surface `token` as this env var (existing ESO pattern in the portal's Helm chart).
|
||||
|
||||
2. Patch the message files (one-time):
|
||||
```bash
|
||||
node deploy/patch-i18n-threema.mjs
|
||||
```
|
||||
|
||||
3. Re-export `CHANNEL_PACKAGE_IDS` is unchanged in source; verify
|
||||
`tenants/[name]/page.tsx` still derives the enabled-channels list
|
||||
from it — it should now include `threema` automatically once a
|
||||
tenant has it in `spec.packages`.
|
||||
|
||||
4. Type-check:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Flow summary
|
||||
|
||||
### Enabling Threema for a tenant
|
||||
1. Customer toggles the threema package card.
|
||||
2. PackageCard sees `customProvisioning: true` → POSTs `/api/tenants/<name>/threema`.
|
||||
3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`.
|
||||
4. Handler writes them to OpenBao at `secret/data/tenants/tenant-<name>/threema-relay`.
|
||||
5. PackageCard then PATCHes `tenant.spec.packages` to include `threema`.
|
||||
6. Operator reconciles: ExternalSecret syncs OpenBao → Secret; OpenClaw pod restarts with `THREEMA_RELAY_*` env vars; plugin registers `threema` channel.
|
||||
|
||||
### Customer adds a Threema ID
|
||||
1. UI calls `POST /api/tenants/<name>/threema/routes` with `{threemaId}`.
|
||||
2. Handler calls relay `POST /admin/routes` (uniqueness enforced at PK).
|
||||
3. On 201 or 409-from-same-tenant: handler patches K8s `spec.channelUsers.threema`.
|
||||
4. On 409-from-other-tenant: 409 to client with explanation.
|
||||
5. On K8s patch failure after relay success: handler compensates by `DELETE /admin/routes/...` at the relay.
|
||||
|
||||
### Customer removes a Threema ID
|
||||
1. UI calls `DELETE /api/tenants/<name>/threema/routes?threemaId=...`.
|
||||
2. Handler patches K8s `spec.channelUsers.threema` to drop the ID.
|
||||
3. Handler calls relay `DELETE /admin/routes/...` (404 = idempotent OK).
|
||||
4. If relay drop fails: K8s already updated, surface warning but treat as success — relay deletes are idempotent on retry.
|
||||
|
||||
### Disabling Threema for a tenant
|
||||
1. Customer disables the threema card.
|
||||
2. PackageCard DELETEs `/api/tenants/<name>/threema`.
|
||||
3. Handler calls relay `DELETE /admin/tokens/<name>` (cascades to all routes for this tenant).
|
||||
4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-<name>/threema-relay`.
|
||||
5. PackageCard then PATCHes `tenant.spec.packages` to drop `threema`.
|
||||
6. Operator reconciles: ExternalSecret targets a missing OpenBao path → Secret deleted → OpenClaw pod restarts without `threema` channel.
|
||||
|
||||
There's a small window (between step 4 and the operator's reconcile) where the pod still thinks it has a relay token but the relay has revoked it. Outbound during that window returns 401 from the relay; inbound is blackholed at the relay because routes are gone. Both are graceful failures.
|
||||
130
deploy/patch-i18n-threema.mjs
Normal file
130
deploy/patch-i18n-threema.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node deploy/patch-i18n-threema.mjs
|
||||
*
|
||||
* Idempotently injects (or overwrites) customer-facing Threema texts:
|
||||
* - packages.threema.{description, instructions, disclaimer}
|
||||
* - channelUsers.threemaIdHelp
|
||||
* - channelUsers.threemaSetup.{title, step1, step2, step3, qrAlt}
|
||||
*
|
||||
* Replaces the earlier version of this script entirely. The new texts:
|
||||
* - Drop "Gateway account" jargon (customers don't know it)
|
||||
* - Drop asterisk-prefix references (customers don't see / type it)
|
||||
* - Tell the customer to add their OWN Threema ID, not someone else's
|
||||
* - Disclose that Threema charges per message via the gateway
|
||||
* - Walk through the QR-scan + add-your-ID flow explicitly
|
||||
*
|
||||
* Re-running is safe — keys are set, not merged, so this is the
|
||||
* source of truth for the values it touches.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
pkg: {
|
||||
description:
|
||||
"Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
|
||||
instructions:
|
||||
"1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
|
||||
disclaimer:
|
||||
"Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates.",
|
||||
},
|
||||
channelHelp:
|
||||
"Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
|
||||
setup: {
|
||||
title: "Add the assistant to your Threema",
|
||||
step1: "Open Threema on your phone.",
|
||||
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||
step3: "Then add your own Threema ID below.",
|
||||
qrAlt: "QR code to add {gateway} as a Threema contact",
|
||||
bannerTitle: "Set up Threema",
|
||||
bannerBody: "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
|
||||
bannerButton: "Show QR code",
|
||||
},
|
||||
},
|
||||
de: {
|
||||
pkg: {
|
||||
description:
|
||||
"Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
|
||||
instructions:
|
||||
"1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
disclaimer:
|
||||
"Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan.",
|
||||
},
|
||||
channelHelp:
|
||||
"Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
|
||||
setup: {
|
||||
title: "Assistenten zu Threema hinzufügen",
|
||||
step1: "Öffnen Sie Threema auf Ihrem Telefon.",
|
||||
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||
bannerTitle: "Threema einrichten",
|
||||
bannerBody: "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
|
||||
bannerButton: "QR-Code anzeigen",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
pkg: {
|
||||
description:
|
||||
"Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
|
||||
instructions:
|
||||
"1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
|
||||
disclaimer:
|
||||
"Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur.",
|
||||
},
|
||||
channelHelp:
|
||||
"Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
|
||||
setup: {
|
||||
title: "Ajouter l'assistant à Threema",
|
||||
step1: "Ouvrez Threema sur votre téléphone.",
|
||||
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
|
||||
bannerTitle: "Configurer Threema",
|
||||
bannerBody: "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
|
||||
bannerButton: "Afficher le QR code",
|
||||
},
|
||||
},
|
||||
it: {
|
||||
pkg: {
|
||||
description:
|
||||
"Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
instructions:
|
||||
"1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
|
||||
disclaimer:
|
||||
"I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali.",
|
||||
},
|
||||
channelHelp:
|
||||
"Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
|
||||
setup: {
|
||||
title: "Aggiungi l'assistente a Threema",
|
||||
step1: "Apri Threema sul tuo telefono.",
|
||||
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
|
||||
bannerTitle: "Configura Threema",
|
||||
bannerBody: "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
|
||||
bannerButton: "Mostra QR code",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const [lang, entries] of Object.entries(i18n)) {
|
||||
const path = `src/messages/${lang}.json`;
|
||||
const json = JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
json.packages = json.packages ?? {};
|
||||
json.packages.threema = {
|
||||
description: entries.pkg.description,
|
||||
instructions: entries.pkg.instructions,
|
||||
disclaimer: entries.pkg.disclaimer,
|
||||
};
|
||||
|
||||
json.channelUsers = json.channelUsers ?? {};
|
||||
json.channelUsers.threemaIdHelp = entries.channelHelp;
|
||||
json.channelUsers.threemaSetup = entries.setup;
|
||||
|
||||
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
|
||||
console.log(`Patched ${path}`);
|
||||
}
|
||||
@@ -5,7 +5,11 @@ const withNextIntl = createNextIntlPlugin();
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pg"],
|
||||
// pg uses native node bindings, @react-pdf/renderer pulls in
|
||||
// fontkit / pdfkit which don't play nicely with webpack bundling.
|
||||
// Both are pure server-side concerns; mark external so Next ships
|
||||
// them as Node modules rather than bundling.
|
||||
serverExternalPackages: ["pg", "@react-pdf/renderer"],
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
587
package-lock.json
generated
587
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@react-pdf/renderer": "^4.4.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"next": "^15.5.15",
|
||||
@@ -18,6 +19,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,6 +75,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
@@ -1089,6 +1100,30 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1453,6 +1488,183 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/font": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
|
||||
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/pdfkit": "^5.1.1",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"fontkit": "^2.0.2",
|
||||
"is-url": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/image": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
|
||||
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/svg": "^1.1.0",
|
||||
"jay-peg": "^1.1.1",
|
||||
"png-js": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
|
||||
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/image": "^3.1.0",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/stylesheet": "^6.2.1",
|
||||
"@react-pdf/textkit": "^6.3.0",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"emoji-regex-xs": "^1.0.0",
|
||||
"queue": "^6.0.1",
|
||||
"yoga-layout": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/pdfkit": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
|
||||
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"fontkit": "^2.0.2",
|
||||
"jay-peg": "^1.1.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^2.0.0",
|
||||
"vite-compatible-readable-stream": "^3.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/primitives": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
|
||||
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0-rc-603e6108-20241029",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
|
||||
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/textkit": "^6.3.0",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^2.1.4",
|
||||
"normalize-svg-path": "^1.1.0",
|
||||
"parse-svg-path": "^0.1.2",
|
||||
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
|
||||
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/font": "^4.0.8",
|
||||
"@react-pdf/layout": "^4.6.1",
|
||||
"@react-pdf/pdfkit": "^5.1.1",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/reconciler": "^2.0.0",
|
||||
"@react-pdf/render": "^4.5.1",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"queue": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
|
||||
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"color-string": "^2.1.4",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/svg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
|
||||
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/primitives": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
|
||||
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"bidi-js": "^1.0.2",
|
||||
"hyphen": "^1.6.4",
|
||||
"unicode-properties": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
|
||||
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/font": "^4.0.8",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/stylesheet": "^6.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2617,6 +2829,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/abs-svg-path": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -3029,6 +3247,35 @@
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
@@ -3053,6 +3300,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -3155,6 +3420,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3175,6 +3449,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/color-string/node_modules/color-name": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -3355,6 +3650,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -3389,6 +3690,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex-xs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -4006,6 +4313,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
@@ -4019,7 +4335,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
@@ -4082,6 +4397,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -4146,6 +4467,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -4458,6 +4796,27 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-rgb-for-reals": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/hyphen": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/icu-minify": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz",
|
||||
@@ -4510,6 +4869,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -4899,6 +5264,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-weakmap": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@@ -4986,6 +5357,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jay-peg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restructure": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -5005,11 +5385,16 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -5406,6 +5791,25 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -5433,7 +5837,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -5461,6 +5864,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-engine": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -5844,6 +6253,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-svg-path": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.5",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
||||
@@ -5857,7 +6275,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6066,6 +6483,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -6079,6 +6502,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -6214,6 +6643,14 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/po-parser": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||
@@ -6259,6 +6696,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -6331,7 +6774,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -6359,6 +6801,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6405,7 +6856,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
@@ -6452,6 +6902,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.6",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||
@@ -6496,6 +6955,12 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -6557,6 +7022,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -6901,6 +7386,15 @@
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -7037,6 +7531,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
||||
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@@ -7086,6 +7597,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-arc-to-cubic-bezier": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
@@ -7151,6 +7668,12 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -7380,6 +7903,32 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||
@@ -7446,6 +7995,26 @@
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite-compatible-readable-stream": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
@@ -7626,6 +8195,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@react-pdf/renderer": "^4.4.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"next": "^15.5.15",
|
||||
@@ -20,6 +21,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
public/threema/qr_code_AIAGENT.png
Normal file
BIN
public/threema/qr_code_AIAGENT.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
64
scripts/verify-find-key-by-alias.mjs
Normal file
64
scripts/verify-find-key-by-alias.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Smoke-test for the FindKeyByAlias parsing logic — runs the JSON
|
||||
// permutations LiteLLM has been seen to emit through the unmarshal
|
||||
// paths and confirms each ends up at the expected outcome.
|
||||
//
|
||||
// Since the operator can't run inside this sandbox, this is a
|
||||
// JS port of the parsing flow. It exercises decisions the Go code
|
||||
// makes line-for-line.
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "newer object shape, alias matches",
|
||||
body: { keys: [{ token: "tk-1", key_alias: "acme-abc12345" }, { token: "tk-2", key_alias: "beta-def67890" }] },
|
||||
expected: "tk-1",
|
||||
},
|
||||
{
|
||||
name: "newer object shape, alias does not match",
|
||||
body: { keys: [{ token: "tk-2", key_alias: "beta-def67890" }] },
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "newer object shape, empty keys array",
|
||||
body: { keys: [] },
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "older string shape — cannot filter, return empty",
|
||||
body: { keys: ["sk-abc", "sk-def"] },
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "matching alias but missing token field",
|
||||
body: { keys: [{ key_alias: "acme-abc12345" }] },
|
||||
expected: "",
|
||||
},
|
||||
];
|
||||
|
||||
function findKeyByAlias(body, keyAlias) {
|
||||
// Mirror the Go logic exactly.
|
||||
let asObjects;
|
||||
try {
|
||||
asObjects = body;
|
||||
if (!asObjects || !Array.isArray(asObjects.keys)) return "";
|
||||
for (const k of asObjects.keys) {
|
||||
// Skip non-objects (= older string shape)
|
||||
if (typeof k !== "object" || k === null) continue;
|
||||
if (k.key_alias === keyAlias && k.token) {
|
||||
return k.token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
for (const c of cases) {
|
||||
const got = findKeyByAlias(c.body, "acme-abc12345");
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got="${got}" want="${c.expected}" [${c.name}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
32
scripts/verify-personal-org.mjs
Normal file
32
scripts/verify-personal-org.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
// Standalone JS port of `lib/personal-org.ts::isPersonalOrgName`
|
||||
// for offline verification.
|
||||
|
||||
const PERSONAL_ORG_SUFFIX = " (Personal)";
|
||||
|
||||
function isPersonalOrgName(orgName) {
|
||||
if (!orgName) return false;
|
||||
return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX);
|
||||
}
|
||||
|
||||
const cases = [
|
||||
["Bob Müller (Personal)", true, "personal account"],
|
||||
["Acme GmbH", false, "company"],
|
||||
["Acme (Personal) Ltd", false, "suffix in middle does not count"],
|
||||
["Bob (Personal) ", true, "trailing whitespace tolerated"],
|
||||
["Bob (personal)", false, "case-sensitive — lowercase doesn't match"],
|
||||
["", false, "empty"],
|
||||
[null, false, "null"],
|
||||
[undefined, false, "undefined"],
|
||||
["Bob (Personal)x", false, "non-trailing suffix"],
|
||||
[" (Personal)", true, "minimal — empty user name (degenerate but matches)"],
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
for (const [name, expected, note] of cases) {
|
||||
const got = isPersonalOrgName(name);
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}] input=${JSON.stringify(name)}`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
38
scripts/verify-role-gates.mjs
Normal file
38
scripts/verify-role-gates.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
// Standalone JS port of `lib/session.ts::canMutate` and `isCustomerOwner`
|
||||
// for offline verification.
|
||||
//
|
||||
// SessionUser shape mirrors the TypeScript interface:
|
||||
// { roles: Role[], isPlatform: boolean, ... }
|
||||
|
||||
function canMutate(user) {
|
||||
return user.isPlatform || user.roles.includes("owner");
|
||||
}
|
||||
|
||||
function isCustomerOwner(user) {
|
||||
return !user.isPlatform && user.roles.includes("owner");
|
||||
}
|
||||
|
||||
const cases = [
|
||||
// [user, fn, expected, note]
|
||||
[{ isPlatform: true, roles: ["platform_admin"] }, canMutate, true, "platform admin can mutate"],
|
||||
[{ isPlatform: true, roles: ["platform_operator"] }, canMutate, true, "platform operator can mutate"],
|
||||
[{ isPlatform: false, roles: ["owner"] }, canMutate, true, "customer owner can mutate"],
|
||||
[{ isPlatform: false, roles: ["user"] }, canMutate, false, "customer user cannot mutate"],
|
||||
[{ isPlatform: false, roles: [] }, canMutate, false, "no roles cannot mutate"],
|
||||
[{ isPlatform: false, roles: ["owner", "user"] }, canMutate, true, "owner+user (owner wins)"],
|
||||
|
||||
[{ isPlatform: true, roles: ["platform_admin", "owner"] }, isCustomerOwner, false, "platform user with owner role is NOT customerOwner"],
|
||||
[{ isPlatform: false, roles: ["owner"] }, isCustomerOwner, true, "pure customer owner"],
|
||||
[{ isPlatform: false, roles: ["user"] }, isCustomerOwner, false, "customer user is not customerOwner"],
|
||||
[{ isPlatform: false, roles: [] }, isCustomerOwner, false, "empty roles is not customerOwner"],
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
for (const [user, fn, expected, note] of cases) {
|
||||
const got = fn(user);
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
98
scripts/verify-team.mjs
Normal file
98
scripts/verify-team.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
// Standalone JS port of `lib/team.ts::isValidInviteRole` and the
|
||||
// org-membership decision used by POST /api/tenants/[name]/assignments.
|
||||
|
||||
function isValidInviteRole(role) {
|
||||
return role === "owner" || role === "user";
|
||||
}
|
||||
|
||||
// Mirrors the assignment-time check: target user must exist in the
|
||||
// org's member list. Returns true if assign should proceed.
|
||||
function canAssign(targetUserId, orgMembers) {
|
||||
return orgMembers.some((m) => m.userId === targetUserId);
|
||||
}
|
||||
|
||||
// Mirrors the dropdown candidate-filter on the AssignedUsersPanel:
|
||||
// only `user`-role members who aren't already assigned, excluding
|
||||
// owners (who have implicit access).
|
||||
function pickCandidates(orgMembers, alreadyAssigned) {
|
||||
const assigned = new Set(alreadyAssigned);
|
||||
return orgMembers.filter(
|
||||
(m) =>
|
||||
!assigned.has(m.userId) &&
|
||||
m.roles.includes("user") &&
|
||||
!m.roles.includes("owner")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const orgMembers = [
|
||||
{ userId: "u-1", roles: ["owner"] },
|
||||
{ userId: "u-2", roles: ["user"] },
|
||||
{ userId: "u-3", roles: ["user"] },
|
||||
{ userId: "u-4", roles: [] }, // member with no role yet
|
||||
{ userId: "u-5", roles: ["owner", "user"] }, // dual-role
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
||||
console.log("--- isValidInviteRole ---");
|
||||
const inviteCases = [
|
||||
["owner", true, "owner is valid"],
|
||||
["user", true, "user is valid"],
|
||||
["viewer", false, "viewer rejected (dropped in Slice 5)"],
|
||||
["platform_admin", false, "platform_admin not invitable"],
|
||||
["platform_operator", false, "platform_operator not invitable"],
|
||||
["", false, "empty rejected"],
|
||||
["OWNER", false, "case-sensitive"],
|
||||
];
|
||||
for (const [role, expected, note] of inviteCases) {
|
||||
const got = isValidInviteRole(role);
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log("\n--- canAssign (membership check) ---");
|
||||
const assignCases = [
|
||||
["u-1", true, "owner can be assigned (idempotent for owners)"],
|
||||
["u-2", true, "user-role member can be assigned"],
|
||||
["u-99", false, "non-member rejected"],
|
||||
["", false, "empty userId rejected"],
|
||||
];
|
||||
for (const [targetId, expected, note] of assignCases) {
|
||||
const got = canAssign(targetId, orgMembers);
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log("\n--- pickCandidates (assign dropdown) ---");
|
||||
const candidateCases = [
|
||||
{
|
||||
assigned: [],
|
||||
expected: ["u-2", "u-3"],
|
||||
note: "user-role members minus owners (u-5 is owner+user, excluded)",
|
||||
},
|
||||
{
|
||||
assigned: ["u-2"],
|
||||
expected: ["u-3"],
|
||||
note: "u-2 already assigned, only u-3 remains",
|
||||
},
|
||||
{
|
||||
assigned: ["u-2", "u-3"],
|
||||
expected: [],
|
||||
note: "everyone assigned",
|
||||
},
|
||||
];
|
||||
for (const c of candidateCases) {
|
||||
const got = pickCandidates(orgMembers, c.assigned).map((m) => m.userId);
|
||||
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
97
scripts/verify-tenant-naming.mjs
Normal file
97
scripts/verify-tenant-naming.mjs
Normal file
@@ -0,0 +1,97 @@
|
||||
// Standalone JS port of deriveTenantName for offline verification.
|
||||
// Mirror lib/tenant-naming.ts byte-for-byte logic.
|
||||
|
||||
const MAX_NAMESPACE_LEN = 63;
|
||||
const NAMESPACE_PREFIX = "tenant-";
|
||||
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length;
|
||||
const SUFFIX_HEX_LEN = 8;
|
||||
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1;
|
||||
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN;
|
||||
|
||||
function slugify(input) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function requestIdSuffix(requestId) {
|
||||
const hex = requestId.replace(/-/g, "").toLowerCase();
|
||||
if (!/^[0-9a-f]{8}/.test(hex)) {
|
||||
throw new Error(`Invalid request id: ${requestId}`);
|
||||
}
|
||||
return hex.slice(0, SUFFIX_HEX_LEN);
|
||||
}
|
||||
|
||||
function deriveTenantName(kind, companyName, requestId) {
|
||||
const suffix = requestIdSuffix(requestId);
|
||||
if (kind === "personal") return `p-${suffix}`;
|
||||
const rawSlug = slugify(companyName);
|
||||
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
|
||||
if (!slug) return `t-${suffix}`;
|
||||
return `${slug}-${suffix}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cases = [
|
||||
// [kind, companyName, requestId, expected, note]
|
||||
["company", "Acme GmbH", "abc12345-1234-1234-1234-123456789abc", "acme-gmbh-abc12345", "basic company"],
|
||||
["company", "Müller AG", "abc12345-aaaa", "m-ller-ag-abc12345", "umlaut → '-'"],
|
||||
["company", "!!!", "abc12345-aaaa", "t-abc12345", "no alnum → 't-' fallback"],
|
||||
["personal", "irrelevant", "abc12345-aaaa", "p-abc12345", "personal ignores companyName"],
|
||||
["personal", "", "abc12345-aaaa", "p-abc12345", "personal with empty companyName"],
|
||||
["company", " Trim Me ", "abc12345-aaaa", "trim-me-abc12345", "leading/trailing whitespace"],
|
||||
["company", "Foo---Bar", "abc12345-aaaa", "foo-bar-abc12345", "consecutive hyphens collapse"],
|
||||
["company", "A very long company name that absolutely will exceed the slug limit easily", "abc12345-aaaa", null, "must be <= 56 chars"],
|
||||
["company", "----", "abc12345-aaaa", "t-abc12345", "all-hyphen → fallback"],
|
||||
["company", "ACME", "ABCDEF12-...", "acme-abcdef12", "uppercase UUID is lowercased"],
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
for (const [kind, name, id, expected, note] of cases) {
|
||||
let got;
|
||||
let err = null;
|
||||
try {
|
||||
got = deriveTenantName(kind, name, id);
|
||||
} catch (e) {
|
||||
err = e.message;
|
||||
}
|
||||
|
||||
// Special length-only cases
|
||||
if (expected === null) {
|
||||
const ok = got && got.length <= 56;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} len(${got}) = ${got?.length} [${note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
console.log(`THROW ${err} [${note}]`);
|
||||
if (expected === "throw") pass++; else fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// Should-throw cases
|
||||
console.log("\nThrow cases:");
|
||||
const throwCases = [
|
||||
["company", "Acme", "", "empty requestId"],
|
||||
["company", "Acme", "xyz", "non-hex requestId"],
|
||||
["company", "Acme", "1234567", "too short (7 chars)"],
|
||||
];
|
||||
for (const [kind, name, id, note] of throwCases) {
|
||||
let threw = false;
|
||||
try { deriveTenantName(kind, name, id); } catch { threw = true; }
|
||||
console.log(`${threw ? "PASS" : "FAIL"} threw=${threw} [${note}]`);
|
||||
if (threw) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
120
scripts/verify-visibility.mjs
Normal file
120
scripts/verify-visibility.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
// Standalone JS port of `lib/visibility.ts` for offline verification.
|
||||
// Mirrors the synchronous decision logic — DB call (assignments) is
|
||||
// faked as an array param.
|
||||
|
||||
function scopeFor(user) {
|
||||
if (user.isPlatform) return "all";
|
||||
if (user.roles.includes("owner")) return "org";
|
||||
return "assigned";
|
||||
}
|
||||
|
||||
function listVisibleTenants(user, all, assignments = []) {
|
||||
const scope = scopeFor(user);
|
||||
if (scope === "all") return all;
|
||||
|
||||
const orgScoped = all.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
if (scope === "org") return orgScoped;
|
||||
|
||||
const allowed = new Set(assignments);
|
||||
return orgScoped.filter((t) => allowed.has(t.metadata.name));
|
||||
}
|
||||
|
||||
function canUserSeeTenant(user, tenant, assignments = []) {
|
||||
const scope = scopeFor(user);
|
||||
if (scope === "all") return true;
|
||||
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
|
||||
return false;
|
||||
}
|
||||
if (scope === "org") return true;
|
||||
return assignments.includes(tenant.metadata.name);
|
||||
}
|
||||
|
||||
function canSeeInflightRequests(user) {
|
||||
return scopeFor(user) !== "assigned";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const platformAdmin = { isPlatform: true, roles: ["platform_admin"], orgId: "platform-org", id: "u-admin" };
|
||||
const owner = { isPlatform: false, roles: ["owner"], orgId: "org-acme", id: "u-owner" };
|
||||
const userOnly = { isPlatform: false, roles: ["user"], orgId: "org-acme", id: "u-alice" };
|
||||
const noRoles = { isPlatform: false, roles: [], orgId: "org-acme", id: "u-bob" };
|
||||
|
||||
const tenantA = { metadata: { name: "acme-prod-12345678", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
|
||||
const tenantB = { metadata: { name: "acme-dev-87654321", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
|
||||
const tenantC = { metadata: { name: "other-corp-aaaa", labels: { "pieced.ch/zitadel-org-id": "org-other" } } };
|
||||
|
||||
const allTenants = [tenantA, tenantB, tenantC];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listVisibleTenants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const listCases = [
|
||||
{ user: platformAdmin, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321", "other-corp-aaaa"], note: "platform sees all" },
|
||||
{ user: owner, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner sees all org tenants" },
|
||||
{ user: owner, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner ignores assignment table even if rows exist" },
|
||||
{ user: userOnly, assignments: [], expected: [], note: "user with no assignments sees nothing" },
|
||||
{ user: userOnly, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678"], note: "user sees only assigned tenants" },
|
||||
{ user: userOnly, assignments: ["acme-prod-12345678", "acme-dev-87654321"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "user sees multiple assigned tenants" },
|
||||
{ user: userOnly, assignments: ["other-corp-aaaa"], expected: [], note: "stale assignment to other-org tenant doesn't leak" },
|
||||
{ user: noRoles, assignments: [], expected: [], note: "no roles is treated as user-scope (empty)" },
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
||||
console.log("--- listVisibleTenants ---");
|
||||
for (const c of listCases) {
|
||||
const got = listVisibleTenants(c.user, allTenants, c.assignments).map((t) => t.metadata.name);
|
||||
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canUserSeeTenant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n--- canUserSeeTenant ---");
|
||||
const seeCases = [
|
||||
{ user: platformAdmin, tenant: tenantA, assignments: [], expected: true, note: "platform sees same-cluster tenant" },
|
||||
{ user: platformAdmin, tenant: tenantC, assignments: [], expected: true, note: "platform sees other-org tenant" },
|
||||
{ user: owner, tenant: tenantA, assignments: [], expected: true, note: "owner sees own-org tenant" },
|
||||
{ user: owner, tenant: tenantC, assignments: [], expected: false, note: "owner does NOT see other-org tenant" },
|
||||
{ user: userOnly, tenant: tenantA, assignments: ["acme-prod-12345678"], expected: true, note: "user sees assigned tenant" },
|
||||
{ user: userOnly, tenant: tenantA, assignments: [], expected: false, note: "user does NOT see un-assigned own-org tenant" },
|
||||
{ user: userOnly, tenant: tenantC, assignments: ["other-corp-aaaa"], expected: false, note: "user does NOT see other-org tenant even with stale assignment" },
|
||||
];
|
||||
|
||||
for (const c of seeCases) {
|
||||
const got = canUserSeeTenant(c.user, c.tenant, c.assignments);
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canSeeInflightRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n--- canSeeInflightRequests ---");
|
||||
const requestCases = [
|
||||
{ user: platformAdmin, expected: true, note: "platform sees in-flight" },
|
||||
{ user: owner, expected: true, note: "owner sees in-flight" },
|
||||
{ user: userOnly, expected: false, note: "user-role does NOT see in-flight" },
|
||||
{ user: noRoles, expected: false, note: "no-roles does NOT see in-flight" },
|
||||
];
|
||||
|
||||
for (const c of requestCases) {
|
||||
const got = canSeeInflightRequests(c.user);
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
506
scripts/zitadel-roles.mjs
Normal file
506
scripts/zitadel-roles.mjs
Normal file
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* zitadel-roles.mjs — diagnose and repair the OpenClaw Platform project's
|
||||
* role keys + customer authorizations. Group A of the bug triage.
|
||||
*
|
||||
* Subcommands
|
||||
* -----------
|
||||
* diagnose Print the project's current roles and a raw dump
|
||||
* of all authorizations granted on the project.
|
||||
* Read-only. Safe to run any time.
|
||||
*
|
||||
* apply Idempotently create the four canonical role keys
|
||||
* (owner, user, platform_admin, platform_operator)
|
||||
* if they are missing. Existing roles are left as
|
||||
* they are; legacy keys (e.g. "customer") are NOT
|
||||
* deleted by this command — see `migrate-auth`.
|
||||
*
|
||||
* migrate-auth <user> Drop every authorization the given user holds
|
||||
* on the project and replace with a single
|
||||
* authorization carrying ["owner"]. Use after
|
||||
* `apply` to promote a legacy customer to the
|
||||
* new role keys. Idempotent.
|
||||
*
|
||||
* migrate-grants Ensure every existing project grant on the
|
||||
* OpenClaw Platform project includes both
|
||||
* `owner` and `user` role keys. Without `user`
|
||||
* in the grant, `CreateAuthorization` for an
|
||||
* invited member returns Errors.Project.Role.NotFound
|
||||
* (Bug 21). Idempotent: grants already containing
|
||||
* both keys are skipped.
|
||||
*
|
||||
* Env vars (loaded from .env if you run with `node --env-file=.env`):
|
||||
* ZITADEL_ISSUER e.g. https://auth.pieced.ch
|
||||
* ZITADEL_SA_PAT PAT for pieced-sa (IAM_OWNER)
|
||||
* ZITADEL_PROJECT_ID e.g. 367435120493199793
|
||||
*
|
||||
* Examples
|
||||
* --------
|
||||
* node --env-file=.env scripts/zitadel-roles.mjs diagnose
|
||||
* node --env-file=.env scripts/zitadel-roles.mjs apply
|
||||
* node --env-file=.env scripts/zitadel-roles.mjs migrate-auth 12345...
|
||||
*
|
||||
* The script does not import from src/ on purpose — it must be runnable
|
||||
* even when the portal can't start (which is the failure mode we're
|
||||
* here to repair).
|
||||
*/
|
||||
|
||||
const ISSUER = process.env.ZITADEL_ISSUER;
|
||||
const PAT = process.env.ZITADEL_SA_PAT;
|
||||
const PROJECT_ID = process.env.ZITADEL_PROJECT_ID;
|
||||
|
||||
if (!ISSUER || !PAT || !PROJECT_ID) {
|
||||
console.error(
|
||||
"Missing env. Need ZITADEL_ISSUER, ZITADEL_SA_PAT, ZITADEL_PROJECT_ID."
|
||||
);
|
||||
console.error("Run with: node --env-file=.env scripts/zitadel-roles.mjs ...");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Canonical role set — must match types/index.ts (CustomerRole + PlatformRole).
|
||||
const CANONICAL = [
|
||||
{ key: "owner", displayName: "Customer Owner", group: "Customer" },
|
||||
{ key: "user", displayName: "Customer User", group: "Customer" },
|
||||
{ key: "platform_admin", displayName: "Platform Admin", group: "Platform" },
|
||||
{
|
||||
key: "platform_operator",
|
||||
displayName: "Platform Operator",
|
||||
group: "Platform",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP plumbing — Connect RPC against ZITADEL v2 services.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function rpc(service, method, body) {
|
||||
const url = `${ISSUER}/${service}/${method}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${PAT}`,
|
||||
"Connect-Protocol-Version": "1",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
const err = new Error(`${service}/${method} -> ${res.status}: ${text}`);
|
||||
err.status = res.status;
|
||||
err.body = text;
|
||||
throw err;
|
||||
}
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
const projectSvc = "zitadel.project.v2.ProjectService";
|
||||
const authSvc = "zitadel.authorization.v2.AuthorizationService";
|
||||
|
||||
async function listProjectRoles() {
|
||||
const data = await rpc(projectSvc, "ListProjectRoles", {
|
||||
projectId: PROJECT_ID,
|
||||
});
|
||||
return Array.isArray(data?.projectRoles) ? data.projectRoles : [];
|
||||
}
|
||||
|
||||
async function addProjectRole(roleKey, displayName, group) {
|
||||
return rpc(projectSvc, "AddProjectRole", {
|
||||
projectId: PROJECT_ID,
|
||||
roleKey,
|
||||
displayName,
|
||||
...(group ? { group } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Connect RPC filter shape for ListAuthorizations is a oneof variant
|
||||
* map — each filter has a discriminator key matching one of the variants
|
||||
* documented as `authorization_ids|in_user_ids|organization_id|project_id|
|
||||
* role_key|...`. Different ZITADEL services and versions differ on the
|
||||
* exact wrapper naming (e.g. `projectId` vs `projectIdFilter`) and on
|
||||
* whether ID values are bare strings or wrapped in `{ id: "..." }`.
|
||||
*
|
||||
* Rather than guess, we probe candidate shapes until ZITADEL accepts one.
|
||||
* The winner tells us exactly what to bake into `lib/zitadel.ts`. Each
|
||||
* candidate is labelled so the diagnostic output makes the right choice
|
||||
* obvious.
|
||||
*/
|
||||
const FILTER_CANDIDATES = [
|
||||
// No filter at all — ZITADEL returns whatever the SA can see. Slowest
|
||||
// but always works; useful as a control.
|
||||
{
|
||||
label: "no-filter",
|
||||
build: () => ({}),
|
||||
},
|
||||
// Pattern from discussion #8831 (roleKey -> key+method). Plausible
|
||||
// generalisation: project_id -> projectId.id
|
||||
{
|
||||
label: "projectId.id",
|
||||
build: (projectId) => ({ filters: [{ projectId: { id: projectId } }] }),
|
||||
},
|
||||
// Pattern from ProjectService.ListProjects (organizationIdFilter -> organizationId).
|
||||
{
|
||||
label: "projectIdFilter.id",
|
||||
build: (projectId) => ({
|
||||
filters: [{ projectIdFilter: { id: projectId } }],
|
||||
}),
|
||||
},
|
||||
// Same family but with the value field named after the filter, like the
|
||||
// user search API uses (`organizationIdQuery: { organizationId: "..." }`).
|
||||
{
|
||||
label: "projectIdFilter.projectId",
|
||||
build: (projectId) => ({
|
||||
filters: [{ projectIdFilter: { projectId } }],
|
||||
}),
|
||||
},
|
||||
// Bare-string variant — just in case.
|
||||
{
|
||||
label: "projectId (bare string)",
|
||||
build: (projectId) => ({ filters: [{ projectId }] }),
|
||||
},
|
||||
];
|
||||
|
||||
const USER_FILTER_CANDIDATES = [
|
||||
{ label: "userId.id", key: "userId", build: (id) => ({ id }) },
|
||||
{ label: "userIdFilter.id", key: "userIdFilter", build: (id) => ({ id }) },
|
||||
{ label: "userIdFilter.userId", key: "userIdFilter", build: (id) => ({ userId: id }) },
|
||||
];
|
||||
|
||||
/**
|
||||
* Try every candidate; return on the first one that returns 200. Logs each
|
||||
* attempt so a reader can see which shape won.
|
||||
*/
|
||||
async function probeListAuthorizations(extraFilters = []) {
|
||||
for (const c of FILTER_CANDIDATES) {
|
||||
const body = c.build(PROJECT_ID);
|
||||
if (extraFilters.length > 0) {
|
||||
body.filters = (body.filters || []).concat(extraFilters);
|
||||
}
|
||||
body.pagination = { limit: 500 };
|
||||
try {
|
||||
const data = await rpc(authSvc, "ListAuthorizations", body);
|
||||
const count = Array.isArray(data?.authorizations)
|
||||
? data.authorizations.length
|
||||
: 0;
|
||||
console.log(` OK ${c.label.padEnd(28)} -> ${count} authorization(s)`);
|
||||
return { label: c.label, body, data };
|
||||
} catch (err) {
|
||||
const oneLine = String(err.body || err.message)
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 110);
|
||||
console.log(` FAIL ${c.label.padEnd(28)} -> ${oneLine}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listUserAuthorizations(userId) {
|
||||
// Use the same project-filter shape that won the probe, plus a user-id
|
||||
// filter probed independently.
|
||||
const probed = await probeListAuthorizations();
|
||||
if (!probed) throw new Error("No filter shape accepted by ZITADEL");
|
||||
|
||||
for (const u of USER_FILTER_CANDIDATES) {
|
||||
const body = JSON.parse(JSON.stringify(probed.body));
|
||||
body.filters = (body.filters || []).concat([
|
||||
{ [u.key]: u.build(userId) },
|
||||
]);
|
||||
try {
|
||||
const data = await rpc(authSvc, "ListAuthorizations", body);
|
||||
console.log(` user filter ${u.label} accepted.`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
// Try next.
|
||||
}
|
||||
}
|
||||
// Fallback: return all and filter client-side from the user dump.
|
||||
return probed.data;
|
||||
}
|
||||
|
||||
async function deleteAuthorization(authorizationId) {
|
||||
return rpc(authSvc, "DeleteAuthorization", { id: authorizationId });
|
||||
}
|
||||
|
||||
async function createAuthorization(userId, organizationId, roleKeys) {
|
||||
return rpc(authSvc, "CreateAuthorization", {
|
||||
userId,
|
||||
projectId: PROJECT_ID,
|
||||
organizationId,
|
||||
roleKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async function listProjectGrants() {
|
||||
// Same approach as authorizations: skip server-side filters, narrow
|
||||
// client-side by projectId. Pilot scale; cheap.
|
||||
const data = await rpc(projectSvc, "ListProjectGrants", {
|
||||
pagination: { limit: 500 },
|
||||
});
|
||||
const all = Array.isArray(data?.projectGrants) ? data.projectGrants : [];
|
||||
return all.filter((g) => g?.projectId === PROJECT_ID);
|
||||
}
|
||||
|
||||
async function updateProjectGrant(grantedOrganizationId, roleKeys) {
|
||||
return rpc(projectSvc, "UpdateProjectGrant", {
|
||||
projectId: PROJECT_ID,
|
||||
grantedOrganizationId,
|
||||
roleKeys,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function diagnose() {
|
||||
console.log(`Project: ${PROJECT_ID}`);
|
||||
console.log(`Issuer: ${ISSUER}\n`);
|
||||
|
||||
console.log("--- Project roles ---");
|
||||
const roles = await listProjectRoles();
|
||||
if (roles.length === 0) {
|
||||
console.log(" (none)");
|
||||
} else {
|
||||
for (const r of roles) {
|
||||
console.log(` key=${r.key.padEnd(20)} displayName=${r.displayName ?? ""} group=${r.group ?? ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const present = new Set(roles.map((r) => r.key));
|
||||
const missing = CANONICAL.filter((c) => !present.has(c.key));
|
||||
const legacy = roles.filter((r) => !CANONICAL.some((c) => c.key === r.key));
|
||||
|
||||
console.log("\n--- Canonical key check ---");
|
||||
for (const c of CANONICAL) {
|
||||
console.log(` ${present.has(c.key) ? "OK " : "MISS"} ${c.key}`);
|
||||
}
|
||||
if (legacy.length > 0) {
|
||||
console.log("\n Non-canonical keys still on the project:");
|
||||
for (const r of legacy) console.log(` ${r.key}`);
|
||||
console.log(" (consider migrating any authorizations off these.)");
|
||||
}
|
||||
|
||||
console.log("\n--- Authorizations on project (probing filter shape) ---");
|
||||
const probed = await probeListAuthorizations();
|
||||
if (!probed) {
|
||||
console.log(
|
||||
"\nNo filter shape was accepted. Cannot enumerate authorizations."
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`\nWinning filter shape: ${probed.label}`);
|
||||
console.log("Raw response (first 2 entries):");
|
||||
const trimmed = {
|
||||
...probed.data,
|
||||
authorizations: (probed.data.authorizations || []).slice(0, 2),
|
||||
};
|
||||
console.log(JSON.stringify(trimmed, null, 2));
|
||||
|
||||
// Parsed view — what `lib/zitadel.ts::listOrgAuthorizations` SHOULD return
|
||||
// once the parser is fixed. Useful for confirming the response field
|
||||
// names without wading through the raw blob.
|
||||
const auths = probed.data.authorizations || [];
|
||||
console.log(`\nParsed (${auths.length} authorization(s)):`);
|
||||
for (const a of auths) {
|
||||
const userId = a.user?.id ?? "?";
|
||||
const userName = a.user?.displayName ?? a.user?.preferredLoginName ?? "";
|
||||
const orgId = a.organization?.id ?? "?";
|
||||
const orgName = a.organization?.name ?? "";
|
||||
const roleKeys = Array.isArray(a.roles)
|
||||
? a.roles.map((r) => r.key).join(",")
|
||||
: "(none)";
|
||||
console.log(
|
||||
` ${a.id?.slice(0, 12) ?? "?"}… user=${userName} (${userId.slice(0, 10)}…) org=${orgName} roles=[${roleKeys}]`
|
||||
);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(
|
||||
`\nNext step: run \`apply\` to create ${missing.length} missing role(s).`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("\nAll canonical roles present.");
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const existing = await listProjectRoles();
|
||||
const present = new Set(existing.map((r) => r.key));
|
||||
|
||||
let created = 0;
|
||||
for (const c of CANONICAL) {
|
||||
if (present.has(c.key)) {
|
||||
console.log(`SKIP ${c.key} (already exists)`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await addProjectRole(c.key, c.displayName, c.group);
|
||||
console.log(`ADD ${c.key}`);
|
||||
created++;
|
||||
} catch (err) {
|
||||
// ZITADEL returns AlreadyExists if a role with the same key was
|
||||
// created in a race; treat as success so the script stays idempotent.
|
||||
if (
|
||||
err.body &&
|
||||
/already.*exist/i.test(err.body)
|
||||
) {
|
||||
console.log(`SKIP ${c.key} (already exists, race)`);
|
||||
continue;
|
||||
}
|
||||
console.error(`FAIL ${c.key}: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. ${created} role(s) created.`);
|
||||
}
|
||||
|
||||
async function migrateAuth(userId) {
|
||||
if (!userId) {
|
||||
console.error("Usage: migrate-auth <userId>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Verify owner role exists before we touch anything; otherwise we'd
|
||||
// delete authorizations and fail to recreate them.
|
||||
const roles = await listProjectRoles();
|
||||
if (!roles.some((r) => r.key === "owner")) {
|
||||
console.error("Project has no `owner` role. Run `apply` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Listing authorizations for user ${userId} on project ${PROJECT_ID}...`);
|
||||
const auths = await listUserAuthorizations(userId);
|
||||
const list = Array.isArray(auths?.authorizations) ? auths.authorizations : [];
|
||||
// Filter client-side to the requested user, in case the user filter probe
|
||||
// didn't narrow things down.
|
||||
const userAuths = list.filter((a) => a.user?.id === userId);
|
||||
|
||||
if (userAuths.length === 0) {
|
||||
console.log("No existing authorizations found. Cannot infer organizationId.");
|
||||
console.log("Pass it explicitly via the env: ORG_ID=... or use the portal flow.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick the organizationId from any of the existing authorizations — it
|
||||
// should be the same across all of them for a single user/project pair.
|
||||
const orgIds = [...new Set(userAuths.map((a) => a.organization?.id).filter(Boolean))];
|
||||
if (orgIds.length !== 1) {
|
||||
console.error(`Expected exactly 1 organizationId, got ${orgIds.length}: ${orgIds.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const orgId = orgIds[0];
|
||||
|
||||
console.log(`Found ${userAuths.length} authorization(s) in org ${orgId}:`);
|
||||
for (const a of userAuths) {
|
||||
const id = a.id ?? "?";
|
||||
const keys = Array.isArray(a.roles) ? a.roles.map((r) => r.key).join(",") : "(none)";
|
||||
console.log(` ${id} roles=[${keys}]`);
|
||||
}
|
||||
|
||||
// Already correct?
|
||||
if (
|
||||
userAuths.length === 1 &&
|
||||
Array.isArray(userAuths[0].roles) &&
|
||||
userAuths[0].roles.length === 1 &&
|
||||
userAuths[0].roles[0].key === "owner"
|
||||
) {
|
||||
console.log("Already correct — no changes needed.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nDeleting existing authorizations...");
|
||||
for (const a of userAuths) {
|
||||
if (!a.id) continue;
|
||||
await deleteAuthorization(a.id);
|
||||
console.log(` deleted ${a.id}`);
|
||||
}
|
||||
|
||||
console.log("Creating fresh owner authorization...");
|
||||
const created = await createAuthorization(userId, orgId, ["owner"]);
|
||||
console.log(` created ${JSON.stringify(created)}`);
|
||||
console.log("Done.");
|
||||
}
|
||||
|
||||
async function migrateGrants() {
|
||||
// Ensure every existing project grant for the OpenClaw Platform project
|
||||
// includes the `user` role alongside `owner`. Without `user` in the
|
||||
// grant, the granted org cannot invite members in `user` role —
|
||||
// `CreateAuthorization` returns `Errors.Project.Role.NotFound`.
|
||||
//
|
||||
// Idempotent: grants already containing both keys are skipped.
|
||||
// Per UpdateProjectGrant docs, `roleKeys` is REPLACE not MERGE — we
|
||||
// re-send the full desired set every time.
|
||||
const desired = ["owner", "user"];
|
||||
const grants = await listProjectGrants();
|
||||
|
||||
if (grants.length === 0) {
|
||||
console.log("No project grants found on this project.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${grants.length} grant(s) on project ${PROJECT_ID}:`);
|
||||
for (const g of grants) {
|
||||
const current = Array.isArray(g.grantedRoleKeys)
|
||||
? g.grantedRoleKeys
|
||||
: [];
|
||||
const hasAll = desired.every((k) => current.includes(k));
|
||||
const action = hasAll ? "SKIP" : "FIX ";
|
||||
console.log(
|
||||
` ${action} ${g.grantedOrganizationName.padEnd(30)} current=[${current.join(",")}]`
|
||||
);
|
||||
}
|
||||
|
||||
let fixed = 0;
|
||||
for (const g of grants) {
|
||||
const current = Array.isArray(g.grantedRoleKeys)
|
||||
? g.grantedRoleKeys
|
||||
: [];
|
||||
if (desired.every((k) => current.includes(k))) continue;
|
||||
// Preserve any extra roles the grant already has on top of the
|
||||
// desired set (e.g. someone manually added `viewer` for a special
|
||||
// case). Set semantics: union.
|
||||
const merged = [...new Set([...current, ...desired])];
|
||||
try {
|
||||
await updateProjectGrant(g.grantedOrganizationId, merged);
|
||||
console.log(
|
||||
` updated ${g.grantedOrganizationName} -> [${merged.join(",")}]`
|
||||
);
|
||||
fixed++;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
` FAIL ${g.grantedOrganizationName}: ${err.message}`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. ${fixed} grant(s) updated.`);
|
||||
}
|
||||
|
||||
const [, , cmd, ...rest] = process.argv;
|
||||
|
||||
const commands = {
|
||||
diagnose,
|
||||
apply,
|
||||
"migrate-auth": () => migrateAuth(rest[0]),
|
||||
"migrate-grants": migrateGrants,
|
||||
};
|
||||
|
||||
const fn = commands[cmd];
|
||||
if (!fn) {
|
||||
console.error(
|
||||
"Usage: zitadel-roles.mjs <diagnose|apply|migrate-auth <userId>|migrate-grants>"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fn().catch((err) => {
|
||||
console.error(err.message ?? err);
|
||||
if (err.body) console.error("body:", err.body);
|
||||
process.exit(1);
|
||||
});
|
||||
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { GenerateForm } from "@/components/admin/billing/generate-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/generate — testing tool to compute & commit an
|
||||
* invoice for a given (org, period).
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Admin picks org + year/month + locale (default auto-detected
|
||||
* from country).
|
||||
* 2. "Preview" runs computeInvoiceDraft (dryRun) — shows lines,
|
||||
* totals, warnings.
|
||||
* 3. "Commit" persists + renders the PDF.
|
||||
*
|
||||
* The org dropdown is hydrated server-side here so the page loads
|
||||
* with the list pre-populated. Per-org billing status (address
|
||||
* present / open balance) is fetched on demand from /api/admin/
|
||||
* billing/orgs since it can change as admin edits.
|
||||
*/
|
||||
export default async function AdminBillingGeneratePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Build initial org list from tenant labels.
|
||||
const tenants = await listTenants();
|
||||
const orgMap = new Map<string, string[]>();
|
||||
for (const t of tenants) {
|
||||
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!oid) continue;
|
||||
if (!orgMap.has(oid)) orgMap.set(oid, []);
|
||||
orgMap.get(oid)!.push(t.metadata.name);
|
||||
}
|
||||
// Hydrate company name + country in parallel.
|
||||
const orgList = await Promise.all(
|
||||
[...orgMap.entries()].map(async ([orgId, tenantNames]) => {
|
||||
const billing = await getOrgBilling(orgId).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: orgId,
|
||||
tenantNames,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing,
|
||||
};
|
||||
})
|
||||
);
|
||||
orgList.sort((a, b) =>
|
||||
(a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("generateTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("generatePageDesc")}</p>
|
||||
</div>
|
||||
<GenerateForm orgs={orgList} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoice-drafts/[id] — full editor for an
|
||||
* in-progress custom invoice.
|
||||
*
|
||||
* Phase 8. Server-loads the draft + the org's billing snapshot
|
||||
* (used to display the bill-to block preview), then hands off to
|
||||
* the client editor for the interactive line-management UI.
|
||||
*
|
||||
* The snapshot is loaded read-only for display. The actual VAT
|
||||
* computation happens server-side at issue time via
|
||||
* computeCustomInvoiceTotals, which re-reads the same snapshot.
|
||||
* That two-time read is intentional: the editor's preview math
|
||||
* is a hint, the issue-time read is authoritative — if the
|
||||
* customer updates their billing address between Draft and Issue,
|
||||
* the invoice reflects the new address.
|
||||
*/
|
||||
export default async function InvoiceDraftEditorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const draft = await getInvoiceDraftById(id);
|
||||
if (!draft) notFound();
|
||||
const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoice-drafts"
|
||||
label={t("backToDrafts")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("editorPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{orgBilling?.companyName ?? draft.zitadelOrgId}
|
||||
</p>
|
||||
</div>
|
||||
<CustomInvoiceEditor
|
||||
draft={draft}
|
||||
orgBilling={orgBilling}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, listAllInvoiceDrafts } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { DraftList } from "@/components/admin/billing/draft-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoice-drafts — list of all open custom-invoice
|
||||
* drafts across orgs.
|
||||
*
|
||||
* Phase 8. Each draft is a JSONB blob the admin is composing into
|
||||
* an invoice; visible only to platform admins. From here the admin
|
||||
* can resume editing or discard.
|
||||
*
|
||||
* Building an org-name map by reading tenant labels (for the set of
|
||||
* known orgs) + getOrgBilling per org (for the actual company name)
|
||||
* so the table can show "Customer X" instead of a raw ZITADEL org id.
|
||||
*/
|
||||
export default async function AdminInvoiceDraftsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [drafts, tenants] = await Promise.all([
|
||||
listAllInvoiceDrafts(),
|
||||
listTenants().catch(() => []),
|
||||
]);
|
||||
|
||||
// Build the set of distinct ZITADEL org ids from tenant labels,
|
||||
// PLUS the set referenced by any current draft. Drafts may target
|
||||
// orgs that don't have tenants yet (rare but possible), so we
|
||||
// union both sources before fetching billing rows.
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
for (const d of drafts) {
|
||||
orgIds.add(d.zitadelOrgId);
|
||||
}
|
||||
// Look up billing in parallel — same pattern as
|
||||
// /api/admin/billing/orgs uses. Failure for any single org is
|
||||
// non-fatal (falls back to the raw id in the table).
|
||||
const orgNamePairs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return [oid, billing?.companyName ?? null] as const;
|
||||
})
|
||||
);
|
||||
const orgNameMap: Record<string, string> = {};
|
||||
for (const [oid, name] of orgNamePairs) {
|
||||
if (name) orgNameMap[oid] = name;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("draftsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("draftsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
40
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
40
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||
* download) is a client component for the interactive bits.
|
||||
*
|
||||
* Phase 7: also passes any linked credit notes so the detail view
|
||||
* can show the "this invoice was voided / partially refunded" panel
|
||||
* without an extra round-trip.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
const creditNotes = await listCreditNotesForInvoice(id);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/new — entry point for the custom-invoice
|
||||
* flow. The admin picks an org, clicks Continue, and lands on the
|
||||
* editor at /admin/billing/invoice-drafts/<new-id>.
|
||||
*
|
||||
* Phase 8. Org list is built from tenant labels + each org's
|
||||
* billing config (we need the company name and the
|
||||
* has-billing-snapshot flag to gate the picker — orgs without a
|
||||
* snapshot can't be invoiced until they complete onboarding or
|
||||
* admin sets the billing info manually).
|
||||
*/
|
||||
export default async function NewInvoicePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Tenants give us org membership; getOrgBilling per org gives us
|
||||
// the snapshot status. We dedupe by org id since one org can own
|
||||
// many tenants.
|
||||
const tenants = await listTenants();
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing && !!billing.companyName,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (admin's most likely target),
|
||||
// then alphabetically by company name.
|
||||
orgs.sort((a, b) => {
|
||||
if (a.hasBillingAddress !== b.hasBillingAddress) {
|
||||
return a.hasBillingAddress ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoices"
|
||||
label={t("backToInvoices")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("newInvoicePageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("newInvoicePageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<NewInvoiceForm orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoicesTable } from "@/components/admin/billing/invoices-table";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices — list of all issued invoices, filterable
|
||||
* by status and month. Click a row to drill into detail.
|
||||
*
|
||||
* Server-renders the initial table with no filters applied (showing
|
||||
* the most recent 200). Client filters trigger a fetch with query
|
||||
* params and re-render in place.
|
||||
*/
|
||||
export default async function AdminInvoicesListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const invoices = await listInvoices({ limit: 200 });
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("invoicesTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("invoicesPageDesc")}</p>
|
||||
</div>
|
||||
<InvoicesTable initialInvoices={invoices} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/orgs — list of orgs with their payment mode
|
||||
* settings.
|
||||
*
|
||||
* Phase 9b-2. The customer's /settings/billing only exposes the
|
||||
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
|
||||
* customer must contact support to request it, admin flips the
|
||||
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
|
||||
* pause-switch for support situations.
|
||||
*
|
||||
* The page is intentionally minimal: org name, country, current
|
||||
* mode, has-saved-card indicator, and toggles. Detail-level work
|
||||
* (open balances, invoice list) is on the existing pages
|
||||
* (/admin/billing, /admin/billing/invoices).
|
||||
*/
|
||||
export default async function AdminOrgsPaymentModePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
|
||||
// labels are the source of truth for org membership. We dedupe by
|
||||
// org id since one org can own many tenants.
|
||||
const tenants = await listTenants().catch(() => []);
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const [billing, cfg] = await Promise.all([
|
||||
getOrgBilling(oid).catch(() => null),
|
||||
getOrgBillingConfig(oid),
|
||||
]);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
|
||||
cardLabel:
|
||||
cfg.stripePmBrand && cfg.stripePmLast4
|
||||
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
|
||||
: null,
|
||||
payByInvoice: !!cfg.payByInvoice,
|
||||
autoChargeEnabled: cfg.autoChargeEnabled !== false,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (most actionable), then by name.
|
||||
orgs.sort((a, b) => {
|
||||
if (!!a.companyName !== !!b.companyName) {
|
||||
return a.companyName ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("orgsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("orgsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<OrgPaymentModeList orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
136
src/app/[locale]/admin/billing/page.tsx
Normal file
136
src/app/[locale]/admin/billing/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /admin/billing — landing page with sub-section links and a
|
||||
* quick overview of orgs in arrears.
|
||||
*
|
||||
* Sub-pages:
|
||||
* - /admin/billing/pricing — platform + skill prices
|
||||
* - /admin/billing/generate — manual invoice generator (testing)
|
||||
* - /admin/billing/invoices — invoice list/detail
|
||||
*
|
||||
* The Phase 2 customer-side /billing landing page is added in
|
||||
* Phase 3.
|
||||
*/
|
||||
export default async function AdminBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Sweep open invoices past due → 'overdue' so the counters below
|
||||
// reflect reality without needing a cron.
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const balances = await getOrgOpenBalances().catch(() => []);
|
||||
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
|
||||
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
CHF {totalOpen.toFixed(2)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
{totalOverdue > 0 ? (
|
||||
<span className="text-error">{totalOverdue}</span>
|
||||
) : (
|
||||
totalOverdue
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/generate">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("generateTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/invoices">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/orgs">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
{balances.length > 0 && (
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("orgIdCol")}</th>
|
||||
<th className="pb-2 text-right">{t("openCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("overdueCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalOpenCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.map((b) => (
|
||||
<tr key={b.zitadelOrgId} className="border-t border-border">
|
||||
<td className="py-2 font-mono text-xs">{b.zitadelOrgId}</td>
|
||||
<td className="py-2 text-right">{b.openCount}</td>
|
||||
<td className="py-2 text-right">
|
||||
{b.overdueCount > 0 ? (
|
||||
<span className="text-error">{b.overdueCount}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
CHF {b.totalOpenChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getPlatformPricing, listSkillPricing } from "@/lib/db";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PricingEditor } from "@/components/admin/billing/pricing-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/pricing — edit platform-wide pricing config
|
||||
* (monthly fee, setup fee, Threema per-message, VAT rate for
|
||||
* CH/LI) and per-skill daily prices.
|
||||
*
|
||||
* Single-row platform_pricing semantics: one global pricing
|
||||
* config applies to every tenant. No per-tenant overrides in
|
||||
* v1.
|
||||
*/
|
||||
export default async function AdminBillingPricingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [pricing, skillPricing] = await Promise.all([
|
||||
getPlatformPricing(),
|
||||
listSkillPricing(),
|
||||
]);
|
||||
|
||||
// Surface every package in the catalog so admin can price any of
|
||||
// them — UI defaults the picker to skill-kind entries but doesn't
|
||||
// hard-block other kinds (a future scenario where a non-skill
|
||||
// package gets a per-day price shouldn't need a code change).
|
||||
const catalog = Object.values(PACKAGE_CATALOG).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("pricingTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("pricingPageDesc")}</p>
|
||||
</div>
|
||||
<PricingEditor
|
||||
initialPricing={pricing}
|
||||
initialSkillPricing={skillPricing}
|
||||
catalog={catalog}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
44
src/app/[locale]/admin/cron/page.tsx
Normal file
44
src/app/[locale]/admin/cron/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getLastSuccessfulCronRuns,
|
||||
listRecentCronRuns,
|
||||
} from "@/lib/db";
|
||||
import { CronControls } from "@/components/admin/cron/cron-controls";
|
||||
|
||||
/**
|
||||
* /admin/cron — automation dashboard.
|
||||
*
|
||||
* Shows:
|
||||
* - Last successful run of each kind, with relative time
|
||||
* - Two "Run now" buttons (admin-triggered manual sweeps)
|
||||
* - Recent runs table (last 30)
|
||||
*
|
||||
* Platform-admin gated server-side.
|
||||
*/
|
||||
export default async function AdminCronPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user || !user.isPlatform) redirect("/login");
|
||||
const t = await getTranslations("adminCron");
|
||||
|
||||
const [recent, lastSuccess] = await Promise.all([
|
||||
listRecentCronRuns(30),
|
||||
getLastSuccessfulCronRuns(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
<CronControls
|
||||
initialRecent={recent}
|
||||
initialLastSuccess={lastSuccess}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
71
src/app/[locale]/admin/openclaw/page.tsx
Normal file
71
src/app/[locale]/admin/openclaw/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants, getOpenClawDefaults } from "@/lib/k8s";
|
||||
import { OpenClawAdminPanel } from "@/components/admin/openclaw-admin-panel";
|
||||
|
||||
/**
|
||||
* /admin/openclaw — platform-default OpenClaw image + per-tenant
|
||||
* overrides table.
|
||||
*
|
||||
* Two sections:
|
||||
* 1. Default — readable from `pieced-openclaw-config` ConfigMap.
|
||||
* Editable via the same form. Empty fields show as "(unset)"
|
||||
* and the operator falls back to its built-in default in that
|
||||
* case (intentionally invisible to the portal — the binary's
|
||||
* baked version moves with releases and we don't want the UI
|
||||
* to claim a misleading "current default").
|
||||
* 2. Tenant table — every tenant in the cluster with its current
|
||||
* override (or "follows default"). Clicking a row opens a small
|
||||
* inline editor.
|
||||
*
|
||||
* Authorization is gated server-side: `user.isPlatform` only. Any
|
||||
* other user gets redirected to /dashboard.
|
||||
*/
|
||||
export default async function OpenClawAdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("openclawAdmin");
|
||||
|
||||
// Parallel fetch — defaults and tenants are independent.
|
||||
const [defaults, tenants] = await Promise.all([
|
||||
getOpenClawDefaults(),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
// Sort tenants: overridden first (more interesting to review),
|
||||
// then alphabetically by display name. Helps the admin spot which
|
||||
// tenants are off the platform default at a glance.
|
||||
const sorted = [...tenants].sort((a, b) => {
|
||||
const aOverride = a.spec.openClawImage ? 1 : 0;
|
||||
const bOverride = b.spec.openClawImage ? 1 : 0;
|
||||
if (aOverride !== bOverride) return bOverride - aOverride;
|
||||
return (a.spec.displayName || a.metadata.name).localeCompare(
|
||||
b.spec.displayName || b.metadata.name
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<OpenClawAdminPanel
|
||||
initialDefaults={defaults}
|
||||
tenants={sorted.map((tn) => ({
|
||||
name: tn.metadata.name,
|
||||
displayName: tn.spec.displayName || tn.metadata.name,
|
||||
phase: tn.status?.phase ?? "Unknown",
|
||||
override: tn.spec.openClawImage?.tag
|
||||
? { tag: tn.spec.openClawImage.tag }
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("admin") };
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -19,14 +25,60 @@ export default async function AdminPage() {
|
||||
}
|
||||
|
||||
const tenants = await listTenants();
|
||||
// Phase 2.5: badge counter for the skill-activation admin queue.
|
||||
// Cheap COUNT(*) on a partial-indexed status='pending' column —
|
||||
// bounded by request volume and never expected to be high.
|
||||
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
|
||||
() => 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
||||
<div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
||||
</div>
|
||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/admin/skills/pending"
|
||||
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||
pendingSkillCount > 0
|
||||
? "border-warning text-warning hover:bg-warning/10"
|
||||
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{t("skillsQueueTool")}</span>
|
||||
{pendingSkillCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
|
||||
{pendingSkillCount}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/billing"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("billingTool")}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/cron"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("cronTool")}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
|
||||
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
|
||||
|
||||
/**
|
||||
* /admin/skills/pending — admin queue for manual-setup skill
|
||||
* activation requests. Each row shows tenant, skill, requester
|
||||
* info, and offers Approve / Reject actions.
|
||||
*
|
||||
* Server-renders the initial list. Approval/rejection trigger a
|
||||
* client-side fetch + router.refresh() so the row disappears and
|
||||
* the count updates without a hard reload.
|
||||
*/
|
||||
export default async function AdminPendingSkillRequestsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminSkills");
|
||||
|
||||
const pending = await listPendingSkillActivationRequests();
|
||||
|
||||
// Hydrate display fields: skill name from catalog, org company name
|
||||
// from billing. Skill name fallback to skillId for off-catalog
|
||||
// entries (shouldn't happen but defensive). Company name is
|
||||
// looked up lazily per row; dedup'd via a Map so we don't issue
|
||||
// duplicate getOrgBilling calls for the same org.
|
||||
const seenOrg = new Map<string, string | null>();
|
||||
const rows = await Promise.all(
|
||||
pending.map(async (r) => {
|
||||
if (!seenOrg.has(r.zitadelOrgId)) {
|
||||
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
|
||||
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
|
||||
}
|
||||
const def = getPackageDef(r.skillId);
|
||||
return {
|
||||
...r,
|
||||
skillName: def?.name ?? r.skillId,
|
||||
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin" label={t("backToAdmin")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
<PendingSkillRequests initialRows={rows} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
|
||||
|
||||
/**
|
||||
* /billing/[invoiceNumber] — single-invoice view.
|
||||
*
|
||||
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
|
||||
* format printed on the PDF and in the issuance email). Org
|
||||
* filter is enforced in the DB query — a customer trying another
|
||||
* org's number gets 404, not 403, to avoid leaking the existence
|
||||
* of other orgs' invoices.
|
||||
*/
|
||||
export default async function CustomerInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ invoiceNumber: string; locale: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const { invoiceNumber } = await params;
|
||||
const t = await getTranslations("customerBilling");
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<BackLink href="/billing" label={t("backToBilling")} />
|
||||
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
90
src/app/[locale]/billing/page.tsx
Normal file
90
src/app/[locale]/billing/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
listCreditNotesForOrg,
|
||||
listInvoices,
|
||||
syncOverdueInvoices,
|
||||
} from "@/lib/db";
|
||||
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
||||
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
|
||||
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
|
||||
/**
|
||||
* /billing — customer's billing home.
|
||||
*
|
||||
* Shows three things:
|
||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||
* (or the already-issued invoice for the current month, if
|
||||
* that ran early).
|
||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||
* newest first. Status is reflected with a colored badge.
|
||||
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
|
||||
* refunds) for this org, with PDF download links. Hidden
|
||||
* entirely when there are none (the common case).
|
||||
*
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("billing") };
|
||||
}
|
||||
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("customerBilling");
|
||||
|
||||
// Sync overdue status before listing — cheap, idempotent.
|
||||
try {
|
||||
await syncOverdueInvoices();
|
||||
} catch (e) {
|
||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||
}
|
||||
|
||||
// Parallel fetch — invoices + credit notes are independent.
|
||||
const [invoices, creditNotes] = await Promise.all([
|
||||
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||
listCreditNotesForOrg(user.orgId, 200),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("currentPeriodHeading")}
|
||||
</h2>
|
||||
{/* Phase 6: pass the owner flag so the no-config CTA shows
|
||||
the right call-to-action vs the right hint. */}
|
||||
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2 mb-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("historyHeading")}
|
||||
</h2>
|
||||
<CustomerInvoiceList invoices={invoices} />
|
||||
</section>
|
||||
|
||||
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
|
||||
returns null when there are no credit notes, so this whole
|
||||
section disappears for orgs in normal operation. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<section className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("creditNotesHeading")}
|
||||
</h2>
|
||||
<CustomerCreditNoteList creditNotes={creditNotes} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
87
src/app/[locale]/dashboard/edit/[id]/page.tsx
Normal file
87
src/app/[locale]/dashboard/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getTenantRequestById } from "@/lib/db";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
|
||||
/**
|
||||
* /dashboard/edit/[id] — re-opens the onboarding wizard with the
|
||||
* fields of a still-pending request pre-filled (Bug 6). On submit,
|
||||
* the wizard PATCHes /api/onboarding/[id] instead of POSTing to
|
||||
* /api/onboarding.
|
||||
*
|
||||
* Hard guards
|
||||
* -----------
|
||||
* - Logged-in customer owner (or platform user) only — same as the
|
||||
* /dashboard/new page.
|
||||
* - Request must exist, belong to the caller's org, and be in 'pending'
|
||||
* status. Editing approved/provisioning rows would race against the
|
||||
* operator; we redirect such cases back to the dashboard rather than
|
||||
* render an invalid wizard.
|
||||
*
|
||||
* Pre-fill
|
||||
* --------
|
||||
* The wizard takes a single `editingRequest` prop — when present, it
|
||||
* (a) pre-populates state from those values and (b) targets the PATCH
|
||||
* endpoint on submit. When absent, it behaves exactly as today (POST
|
||||
* to /api/onboarding).
|
||||
*
|
||||
* Note on encrypted secrets
|
||||
* -------------------------
|
||||
* Per-package secrets are NEVER decrypted server-side and exposed to
|
||||
* the client (would be a clear security regression). When editing,
|
||||
* the wizard opens with empty secret fields and the user re-enters
|
||||
* any they want to change. If they don't touch the package-secrets
|
||||
* UI, the existing encrypted blob in the DB is preserved by the
|
||||
* PATCH endpoint (it only re-encrypts when the wizard sends a
|
||||
* non-empty secrets payload).
|
||||
*/
|
||||
export default async function EditRequestPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.isPlatform) redirect("/dashboard");
|
||||
if (!canMutate(user)) redirect("/dashboard");
|
||||
|
||||
const tr = await getTenantRequestById(id);
|
||||
if (!tr) redirect("/dashboard");
|
||||
if (tr.zitadelOrgId !== user.orgId) redirect("/dashboard");
|
||||
if (tr.status !== "pending") redirect("/dashboard");
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const tOnboarding = await getTranslations("onboarding");
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<BackLink href="/dashboard" label={t("title")} />
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{tOnboarding("editRequestTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{tOnboarding("editRequestDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<OnboardingFlow
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
editingRequest={{
|
||||
id: tr.id,
|
||||
instanceName: tr.instanceName ?? "",
|
||||
agentName: tr.agentName,
|
||||
soulMd: tr.soulMd ?? "",
|
||||
agentsMd: tr.agentsMd ?? "",
|
||||
packages: tr.packages,
|
||||
billingAddress: tr.billingAddress,
|
||||
billingNotes: tr.billingNotes ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/app/[locale]/dashboard/new/page.tsx
Normal file
89
src/app/[locale]/dashboard/new/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
|
||||
/**
|
||||
* /dashboard/new — wizard for creating an additional instance for an
|
||||
* existing customer. Reachable from the dashboard "+ Create new instance"
|
||||
* link.
|
||||
*
|
||||
* Slice 3: this page is the entry point for follow-up instances. The
|
||||
* first-instance case is still served inline on /dashboard. Both paths
|
||||
* mount the same <OnboardingFlow>; the API resolves the difference
|
||||
* server-side based on whether prior approved rows exist for the org.
|
||||
*
|
||||
* Platform admins are redirected to /dashboard — they shouldn't be
|
||||
* creating tenant instances under their own org.
|
||||
*
|
||||
* Slice 5: customer-side `user` role is also redirected — only owners
|
||||
* may create new instances. The server-side POST handler enforces the
|
||||
* same; this redirect is purely UX so /user-role members don't land on
|
||||
* a wizard that will 403 on submit.
|
||||
*
|
||||
* Bug 5: personal accounts that already hold a tenant or have one
|
||||
* in-flight are sent back to the dashboard with the same UX rationale.
|
||||
* Matching API guard lives in `/api/onboarding`.
|
||||
*/
|
||||
export default async function NewInstancePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.isPlatform) redirect("/dashboard");
|
||||
if (!canMutate(user)) redirect("/dashboard");
|
||||
|
||||
if (user.isPersonal) {
|
||||
const [allTenants, activeRequests] = await Promise.all([
|
||||
listTenants(),
|
||||
listActiveTenantRequestsByOrgId(user.orgId),
|
||||
]);
|
||||
const ownTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
if (
|
||||
personalAccountAtCapacity(
|
||||
user.isPersonal,
|
||||
ownTenants.length,
|
||||
activeRequests.length
|
||||
)
|
||||
) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const [orgBilling, pricing] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getPlatformPricing(),
|
||||
]);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<BackLink href="/dashboard" label={t("title")} />
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("createInstance")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("createInstanceDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<OnboardingFlow
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getTenantRequestByOrgId } from "@/lib/db";
|
||||
import {
|
||||
listActiveTenantRequestsByOrgId,
|
||||
syncProvisioningStatuses,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canSeeInflightRequests,
|
||||
isUserScoped,
|
||||
} from "@/lib/visibility";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { WarningBadge } from "@/components/ui/warning-badge";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("dashboard") };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -20,7 +37,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const allTenants = await listTenants();
|
||||
|
||||
// Platform users see overview of all tenants
|
||||
// Platform users see overview of all tenants — unchanged from pre-Slice-3.
|
||||
if (user.isPlatform) {
|
||||
const phaseCount = allTenants.reduce<Record<string, number>>((acc, t) => {
|
||||
const phase = t.status?.phase ?? "Pending";
|
||||
@@ -133,19 +150,162 @@ export default async function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Regular user: find their tenant
|
||||
const myTenant = allTenants.find(
|
||||
// ---------------------------------------------------------------------
|
||||
// Customer view (Slice 3 multi-tenant + Slice 6 visibility scoping)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Slice 6: orgTenants becomes "visible tenants for this user". For an
|
||||
// owner that's all of the org's tenants; for a `user`-role member
|
||||
// it's only the tenants they've been assigned to via
|
||||
// tenant_user_assignments. The dashboard renders fewer cards in the
|
||||
// user-role case but otherwise uses the same template.
|
||||
const orgTenants = await listVisibleTenants(user, allTenants);
|
||||
|
||||
// For the "no instances yet" empty state, we want to know whether
|
||||
// this user is being scoped down. A `user`-role with 0 visible
|
||||
// tenants gets a different message than an owner with 0 tenants
|
||||
// (the user might just need an assignment; the owner needs to
|
||||
// create one).
|
||||
const userScoped = isUserScoped(user);
|
||||
|
||||
// Pending/in-flight requests are only shown to roles that can act on
|
||||
// them. `user`-role customers see no request cards.
|
||||
//
|
||||
// syncProvisioningStatuses runs on every dashboard load: it walks
|
||||
// active and provisioning rows and reconciles them against the
|
||||
// current cluster state. Without this, the operator-initiated
|
||||
// 60-day TTL deletion (Bug 37b) leaves the portal showing "Your
|
||||
// assistant is ready!" cards for tenants that no longer exist —
|
||||
// the operator deletes the CR, but the DB row stays at active=true
|
||||
// until something updates it. Running the sync at every dashboard
|
||||
// load keeps the portal eventually consistent with the cluster
|
||||
// without needing a separate cron/job.
|
||||
//
|
||||
// Cost: one K8s GET per row in (active, provisioning) status. At
|
||||
// pilot scale this is small; if it grows we'd cache or move to a
|
||||
// periodic background job.
|
||||
if (canSeeInflightRequests(user)) {
|
||||
await syncProvisioningStatuses();
|
||||
}
|
||||
const orgRequests = canSeeInflightRequests(user)
|
||||
? await listActiveTenantRequestsByOrgId(user.orgId)
|
||||
: [];
|
||||
|
||||
// Bug 35: orgs that already have a billing record skip the wizard's
|
||||
// billing step. Fetched here so the dashboard's empty-state mount of
|
||||
// OnboardingFlow knows what to do; for the additional-tenant flow at
|
||||
// /dashboard/new we fetch the same flag in that route's own server
|
||||
// component.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
const platformPricing = await getPlatformPricing();
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it. We compare against
|
||||
// *all* org tenants here (not just visible ones) — otherwise a
|
||||
// request whose tenant is invisible to the caller would erroneously
|
||||
// show as in-flight.
|
||||
const orgScopedTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const inflightRequests = orgRequests.filter(
|
||||
(r) =>
|
||||
// Only show provision (initial creation) requests on the
|
||||
// dashboard. Resume requests (Bug 37a) belong with their
|
||||
// specific tenant — the SubscriptionToggle on the tenant
|
||||
// detail page renders the pending state there. Showing them
|
||||
// on the dashboard too would duplicate the surface and
|
||||
// confuse customers about which tenant they refer to.
|
||||
r.requestType !== "resume" &&
|
||||
(!r.tenantName ||
|
||||
!orgScopedTenants.some((t) => t.metadata.name === r.tenantName))
|
||||
);
|
||||
|
||||
// No tenant → check for existing request, show onboarding flow
|
||||
if (!myTenant) {
|
||||
const existingRequest = await getTenantRequestByOrgId(user.orgId);
|
||||
// Treat "deleted" as no request — customer can re-onboard
|
||||
const initialState =
|
||||
!existingRequest || existingRequest.status === "deleted"
|
||||
? "no_request"
|
||||
: existingRequest.status;
|
||||
// Slice 5: only owners (and platform users, who'd typically be using
|
||||
// the admin panel anyway) see the "Create new instance" link. A
|
||||
// `user`-role member sees the dashboard but not the create flow —
|
||||
// they need to ask an owner.
|
||||
//
|
||||
// Bug 5: personal accounts are 1-instance by design. Once a personal
|
||||
// account has either an active tenant OR an in-flight request, the
|
||||
// create button must disappear. The matching server-side guard is
|
||||
// in `/api/onboarding` so direct POSTs are also rejected.
|
||||
const personalAtCapacity = personalAccountAtCapacity(
|
||||
user.isPersonal,
|
||||
orgScopedTenants.length,
|
||||
inflightRequests.length
|
||||
);
|
||||
const canCreate = canMutate(user) && !personalAtCapacity;
|
||||
|
||||
// First-time / no-visibility branch.
|
||||
//
|
||||
// Three sub-cases:
|
||||
// 1. owner / platform with 0 tenants and 0 requests → show wizard.
|
||||
// 2. owner / platform with 0 visibility but the org HAS tenants →
|
||||
// shouldn't happen (owners see all org tenants). Defensive
|
||||
// fall-through to the wizard.
|
||||
// 3. user-role with 0 visible tenants → show "ask your owner"
|
||||
// message, with copy distinguishing whether the org has any
|
||||
// tenants at all.
|
||||
if (orgTenants.length === 0 && inflightRequests.length === 0) {
|
||||
if (userScoped) {
|
||||
// Slice 6 empty state for `user` role. The org might or might
|
||||
// not have tenants — either way this user has none assigned.
|
||||
// The two messages are subtly different: "no instances exist"
|
||||
// means owner needs to create one; "you're not assigned" means
|
||||
// owner needs to grant access.
|
||||
const orgHasTenants = orgScopedTenants.length > 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<div className="text-center py-6">
|
||||
<h2 className="font-display text-base font-semibold text-text-primary mb-2">
|
||||
{orgHasTenants
|
||||
? t("noAssignmentsTitle")
|
||||
: t("noInstancesYetTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{orgHasTenants
|
||||
? t("noAssignmentsDescription")
|
||||
: t("noInstancesYetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
// Belt-and-braces: any role that's neither owner-with-create nor
|
||||
// user-scope ends up here (e.g. weird cases like a session with
|
||||
// no roles at all). Same generic message as before.
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary text-center py-6">
|
||||
{t("noAccessNoInstances")}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -161,68 +321,122 @@ export default async function DashboardPage() {
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<OnboardingFlow
|
||||
orgName={user.orgName}
|
||||
initialState={initialState as any}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantName = myTenant.metadata.name;
|
||||
|
||||
// Returning customer: list of tenants + in-flight requests, plus
|
||||
// a button to add another instance (owners only).
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
<div className="mb-8 animate-in flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-surface-0 text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instance status card */}
|
||||
<div className="mb-6 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<CardHeader>{t("instanceStatus")}</CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge phase={myTenant.status?.phase ?? "Pending"} />
|
||||
{myTenant.spec.agentName && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{myTenant.spec.agentName}
|
||||
</span>
|
||||
)}
|
||||
{/* In-flight (pending/approved/provisioning/rejected) requests */}
|
||||
{inflightRequests.length > 0 && (
|
||||
<div className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("inflightRequests")}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{inflightRequests.map((r) => (
|
||||
<ProvisioningStatus
|
||||
key={r.id}
|
||||
requestId={r.id}
|
||||
canAct={canMutate(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{myTenant.spec.packages && myTenant.spec.packages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{myTenant.spec.packages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2.5 py-0.5"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage — no teamId passed, backend resolves from session */}
|
||||
<div className="mb-6 animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("usage")}
|
||||
</h2>
|
||||
<UsageDisplay />
|
||||
</div>
|
||||
{/* Active tenants */}
|
||||
{orgTenants.length > 0 && (
|
||||
<div className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("instances")}
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{orgTenants.map((tenant) => (
|
||||
<Link
|
||||
key={tenant.metadata.name}
|
||||
href={`/tenants/${tenant.metadata.name}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full hover:border-accent/40 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-text-primary truncate">
|
||||
{tenant.spec.displayName || tenant.metadata.name}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-text-muted truncate mt-0.5">
|
||||
{tenant.metadata.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
|
||||
<WarningBadge warnings={tenant.status?.warnings ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link to tenant detail */}
|
||||
<Link
|
||||
href={`/tenants/${tenantName}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-dim transition-colors animate-in animate-in-delay-3"
|
||||
>
|
||||
<span>→</span> {t("manage")}
|
||||
</Link>
|
||||
{tenant.spec.agentName && (
|
||||
<div className="text-xs text-text-secondary mb-2">
|
||||
{tenant.spec.agentName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tenant.spec.packages && tenant.spec.packages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{tenant.spec.packages.slice(0, 4).map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
{tenant.spec.packages.length > 4 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
+{tenant.spec.packages.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs font-medium text-accent group-hover:text-accent-dim transition-colors">
|
||||
{t("manage")} →
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
72
src/app/[locale]/error.tsx
Normal file
72
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* Error boundary for the [locale] segment. Catches render/data errors
|
||||
* thrown by any page below the locale layout (which is where K8s, DB,
|
||||
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
|
||||
* so translations are available. Root-layout failures fall through to
|
||||
* global-error.tsx instead.
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
useEffect(() => {
|
||||
// Surface the error for log scraping; the digest correlates with
|
||||
// the server-side stack in production.
|
||||
console.error("Portal error boundary:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-error"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
|
||||
{error?.digest && (
|
||||
<p className="text-[11px] font-mono text-text-muted mb-6">
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { notFound } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NavShell } from "@/components/layout/nav-shell";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
|
||||
// template lets each page export a short `title` (e.g. "Dashboard")
|
||||
// that renders as "Dashboard · PieCed". Pages that export no metadata
|
||||
// fall back to the default below.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("common");
|
||||
const appName = t("appName");
|
||||
return {
|
||||
title: {
|
||||
default: `${appName} Portal`,
|
||||
template: `%s · ${appName}`,
|
||||
},
|
||||
description: "PieCed IT — Multi-tenant AI assistant platform",
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PieCed Portal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
<NavShell session={session}>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
25
src/app/[locale]/loading.tsx
Normal file
25
src/app/[locale]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loading skeleton for the [locale] segment. Shown during navigation
|
||||
* while a server component fetches (the dashboard, for instance, does
|
||||
* listTenants() + one K8s GET per provisioning row). Textless on
|
||||
* purpose so it needs no translations and adds no layout shift.
|
||||
*/
|
||||
export default function LocaleLoading() {
|
||||
return (
|
||||
<div className="animate-pulse" aria-hidden="true">
|
||||
<div className="mb-8">
|
||||
<div className="h-7 w-48 rounded-md bg-surface-2" />
|
||||
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-28 rounded-xl border border-border bg-surface-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link, getPathname } from "@/i18n/navigation";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||
@@ -24,10 +26,7 @@ export default function LoginPage() {
|
||||
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
||||
{/* Logo mark */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="absolute inset-0 rounded-lg bg-accent/15" />
|
||||
<div className="absolute inset-[5px] rounded-md bg-accent" />
|
||||
</div>
|
||||
<Logo className="h-14 w-auto text-accent" />
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
||||
@@ -39,7 +38,14 @@ export default function LoginPage() {
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||
onClick={() =>
|
||||
signIn("zitadel", {
|
||||
// Preserve the active locale across the OIDC round-trip.
|
||||
// A bare "/dashboard" would resolve to the default (de)
|
||||
// locale on return; getPathname prefixes it as needed.
|
||||
callbackUrl: getPathname({ href: "/dashboard", locale }),
|
||||
})
|
||||
}
|
||||
className="
|
||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||
bg-accent text-surface-0 cursor-pointer
|
||||
|
||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 404 for the [locale] segment. Triggered by notFound() calls in pages
|
||||
* below the locale layout. (A notFound() thrown by the locale layout
|
||||
* itself — e.g. an unknown locale — resolves to the framework default,
|
||||
* which is acceptable for that narrow case.)
|
||||
*/
|
||||
export default async function LocaleNotFound() {
|
||||
const t = await getTranslations("errors");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
|
||||
404
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("notFoundTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{t("notFoundDescription")}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirect } from "@/i18n/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
export default async function RootPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Locale-aware redirect: a bare next/navigation redirect("/dashboard")
|
||||
// drops the prefix and lands non-default-locale users on the German
|
||||
// dashboard. The i18n redirect prefixes per the active locale.
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/dashboard", locale });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, forwardRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
type FormState = "idle" | "submitting" | "success" | "error";
|
||||
type AccountType = "personal" | "company";
|
||||
|
||||
/**
|
||||
* Registration entry — Bug 1 redesign.
|
||||
*
|
||||
* Previously a hidden checkbox ("Register as an individual") sat on top
|
||||
* of the company-flavoured form, which buried personal accounts under a
|
||||
* single click that most users miss. The new layout puts a primary
|
||||
* account-type chooser at the top: two large cards, one for Personal,
|
||||
* one for Company. Selection is required before the form below
|
||||
* appears, so the rest of the layout adapts cleanly without a
|
||||
* collapsing-checkbox feel.
|
||||
*
|
||||
* Bug 12: per-field validation runs on submit. The native HTML required
|
||||
* attribute already blocks empty submits at the browser level; the
|
||||
* server-side Zod schema in `/api/register` is the authoritative
|
||||
* second line of defence.
|
||||
*
|
||||
* Behaviour:
|
||||
* - "Personal account": company-name field is hidden; on submit, the
|
||||
* server generates an opaque `personal-{8hex}` org name (Bug 9).
|
||||
* - "Company account": company-name field is required; the server
|
||||
* additionally runs the duplicate-domain check.
|
||||
* - Returning users (those who arrive here by accident) can switch
|
||||
* types after picking — the choice cards stay clickable above the
|
||||
* form. Field state is preserved across switches so they don't
|
||||
* have to re-type their name.
|
||||
*/
|
||||
export default function RegisterPage() {
|
||||
const t = useTranslations("register");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
const [accountType, setAccountType] = useState<AccountType | null>(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
companyName: "",
|
||||
givenName: "",
|
||||
@@ -21,32 +50,64 @@ export default function RegisterPage() {
|
||||
const [state, setState] = useState<FormState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Radiogroup keyboard support. `role="radio"` requires roving
|
||||
// tabindex (one tab stop) + arrow-key navigation between options —
|
||||
// native buttons don't move focus on arrows. The selected card is
|
||||
// the tab stop; when nothing is selected yet the first card is
|
||||
// focusable so keyboard users can enter the group.
|
||||
const TYPES: AccountType[] = ["personal", "company"];
|
||||
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const rovingTabIndex = (type: AccountType, index: number) =>
|
||||
accountType === type || (accountType === null && index === 0) ? 0 : -1;
|
||||
|
||||
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
next = (index + 1) % TYPES.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
next = (index - 1 + TYPES.length) % TYPES.length;
|
||||
}
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setAccountType(TYPES[next]);
|
||||
cardRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const isPersonal = accountType === "personal";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!accountType) return; // Should be impossible — submit button is gated
|
||||
setError("");
|
||||
setState("submitting");
|
||||
|
||||
try {
|
||||
// Build the request body explicitly. For personals we omit
|
||||
// companyName so the server generates an opaque ZITADEL org name
|
||||
// (`personal-{8hex}`); the Zod schema accepts the omission.
|
||||
const body: Record<string, unknown> = {
|
||||
givenName: form.givenName,
|
||||
familyName: form.familyName,
|
||||
email: form.email,
|
||||
isPersonal,
|
||||
};
|
||||
if (!isPersonal) {
|
||||
body.companyName = form.companyName;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
companyName: form.companyName,
|
||||
givenName: form.givenName,
|
||||
familyName: form.familyName,
|
||||
email: form.email,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
// Localize known structured codes; fall back to server-supplied
|
||||
// English message for everything else (validation, ZITADEL errors,
|
||||
// generic 500s).
|
||||
if (data.code === "duplicate_domain" && data.domain) {
|
||||
throw new Error(t("duplicateDomain", { domain: data.domain }));
|
||||
}
|
||||
@@ -83,7 +144,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToLogin")}
|
||||
</button>
|
||||
@@ -102,100 +163,223 @@ export default function RegisterPage() {
|
||||
<p className="text-sm text-text-secondary">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Company name */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("companyName")}
|
||||
</label>
|
||||
<input
|
||||
name="companyName"
|
||||
type="text"
|
||||
required
|
||||
value={form.companyName}
|
||||
onChange={handleChange}
|
||||
placeholder={t("companyNamePlaceholder")}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/* Account type chooser — required first step */}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("accountTypeLabel")}
|
||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||
>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[0] = el;
|
||||
}}
|
||||
selected={accountType === "personal"}
|
||||
onClick={() => setAccountType("personal")}
|
||||
tabIndex={rovingTabIndex("personal", 0)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||
label={t("personalCardTitle")}
|
||||
description={t("personalCardDescription")}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[1] = el;
|
||||
}}
|
||||
selected={accountType === "company"}
|
||||
onClick={() => setAccountType("company")}
|
||||
tabIndex={rovingTabIndex("company", 1)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||
label={t("companyCardTitle")}
|
||||
description={t("companyCardDescription")}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 21V7l9-4 9 4v14M9 21V11h6v10M5 21h14"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Form — only shown after a choice is made. Animation
|
||||
delay-2 lines up with the cards animating in first, so
|
||||
the form feels like it appears in response to selection. */}
|
||||
{accountType && (
|
||||
<Card className="animate-in animate-in-delay-2">
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
{/* Company name — only for company accounts (Bug 2 mirror) */}
|
||||
{!isPersonal && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("companyName")}
|
||||
</label>
|
||||
<input
|
||||
name="companyName"
|
||||
type="text"
|
||||
required
|
||||
value={form.companyName}
|
||||
onChange={handleChange}
|
||||
placeholder={t("companyNamePlaceholder")}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("givenName")}
|
||||
</label>
|
||||
<input
|
||||
name="givenName"
|
||||
type="text"
|
||||
required
|
||||
value={form.givenName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("familyName")}
|
||||
</label>
|
||||
<input
|
||||
name="familyName"
|
||||
type="text"
|
||||
required
|
||||
value={form.familyName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("givenName")}
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
name="givenName"
|
||||
type="text"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.givenName}
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder={isPersonal ? "you@example.ch" : "you@company.ch"}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("familyName")}
|
||||
</label>
|
||||
<input
|
||||
name="familyName"
|
||||
type="text"
|
||||
required
|
||||
value={form.familyName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="you@company.ch"
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-text-muted text-center mt-4">
|
||||
{t("hasAccount")}{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent hover:text-accent-dim transition-colors"
|
||||
>
|
||||
{tCommon("login")}
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted text-center mt-4">
|
||||
{t("hasAccount")}{" "}
|
||||
<a
|
||||
href="/login"
|
||||
className="text-accent hover:text-accent-dim transition-colors"
|
||||
>
|
||||
{tCommon("login")}
|
||||
</a>
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-text-muted text-center mt-6 animate-in animate-in-delay-2">
|
||||
<p className="text-xs text-text-muted text-center mt-6 animate-in animate-in-delay-3">
|
||||
{t("footer")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-type radio card. Visually a card, semantically a radio: arrow
|
||||
* keys move between cards, Space/Enter selects.
|
||||
*
|
||||
* Selected state is rendered with the accent ring + tinted background;
|
||||
* unselected is the standard surface-2 with hover affordance. The icon
|
||||
* and text colours intensify when selected to give a clear "this one
|
||||
* is on" signal beyond just the border colour.
|
||||
*/
|
||||
const AccountTypeCard = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
tabIndex: number;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
>(function AccountTypeCard(
|
||||
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold mb-0.5 ${
|
||||
selected ? "text-text-primary" : "text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
71
src/app/[locale]/settings/billing/page.tsx
Normal file
71
src/app/[locale]/settings/billing/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||
|
||||
/**
|
||||
* /settings/billing — customer-side billing details management.
|
||||
*
|
||||
* Owner-only by visibility: non-owner members get a 404 (same
|
||||
* response as if the page didn't exist). The link to this page
|
||||
* is also hidden from non-owners on /billing and elsewhere, but
|
||||
* the page itself enforces too — a non-owner who learns the URL
|
||||
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||
*
|
||||
* First-time visitors see an empty form. Subsequent visits see
|
||||
* the current values, editable. Save creates or updates via the
|
||||
* shared upsert path; the row's existence drives whether the
|
||||
* monthly issuance cron will pick this org up.
|
||||
*
|
||||
* Phase 9: also renders the saved-card section (Set up auto-pay /
|
||||
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
|
||||
* auto-pay / Remove card) when billing info is on file, plus a
|
||||
* footer note explaining that bank transfer is available on request.
|
||||
*/
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
// Non-owners get a 404 — see comment above.
|
||||
if (!user.roles.includes("owner")) notFound();
|
||||
|
||||
const t = await getTranslations("settingsBilling");
|
||||
const [existing, config] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getOrgBillingConfig(user.orgId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<BillingSettingsForm
|
||||
initial={existing}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
{/* Phase 9: saved-card section. Only shown once billing info
|
||||
exists — without an address Stripe can't create the
|
||||
customer object, so the "Set up auto-pay" button would
|
||||
fail anyway. We give a clear hint up there if the form
|
||||
is empty (no need to surface the card UI). */}
|
||||
{existing && (
|
||||
<div className="animate-in animate-in-delay-2 mt-8">
|
||||
<SavedCardSection
|
||||
config={config}
|
||||
isPayByInvoice={!!config?.payByInvoice}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
95
src/app/[locale]/settings/page.tsx
Normal file
95
src/app/[locale]/settings/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /settings — landing page for user/org-level configuration (Bug 35
|
||||
* intentionally landed billing here rather than at /billing because we
|
||||
* expect more settings categories: notifications, API keys, default
|
||||
* workspace templates, etc.). Currently lists a single category card;
|
||||
* the layout scales to a sidebar nav once there are 3+.
|
||||
*
|
||||
* Access: any authenticated user (the cards themselves gate further;
|
||||
* non-owner users would not see "Billing" as actionable, etc.).
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("settings") };
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
// Build the list of settings cards. Each entry has a stable key, a
|
||||
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||
// visible to every signed-in user (it's their own identity).
|
||||
// Billing stays gated behind canMutate.
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visible: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: "profile",
|
||||
href: "/settings/profile",
|
||||
title: t("profileTitle"),
|
||||
description: t("profileDescription"),
|
||||
// Every signed-in user can edit their own first/last name.
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
href: "/settings/billing",
|
||||
title: t("billingTitle"),
|
||||
// Personal customers (B2C) don't have a VAT number; the
|
||||
// description shouldn't mention one. Same pattern used in the
|
||||
// form itself (label/field gating).
|
||||
description: user.isPersonal
|
||||
? t("billingDescriptionPersonal")
|
||||
: t("billingDescription"),
|
||||
// Owners and platform admins can edit billing. `user` role
|
||||
// can't even view it — billing details aren't useful to them.
|
||||
visible: canMutate(user),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleSections = sections.filter((s) => s.visible);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{visibleSections.length === 0 && (
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary">{t("nothingForYou")}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 animate-in animate-in-delay-1">
|
||||
{visibleSections.map((s) => (
|
||||
<Link
|
||||
key={s.key}
|
||||
href={s.href}
|
||||
className="block rounded-xl border border-border bg-surface-1 p-4 hover:border-text-secondary transition-colors"
|
||||
>
|
||||
<div className="font-medium text-text-primary">{s.title}</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{s.description}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getHumanUserDetail } from "@/lib/zitadel";
|
||||
import { ProfileSettingsForm } from "@/components/settings/profile-form";
|
||||
|
||||
/**
|
||||
* /settings/profile — every authenticated user can edit their own
|
||||
* first + last name. Email is shown read-only; changing it requires
|
||||
* verification and is left to ZITADEL's own self-service flow.
|
||||
*
|
||||
* Personal vs company accounts:
|
||||
* - Both can edit their first/last name in ZITADEL.
|
||||
* - Personal accounts get an extra hint: editing the ZITADEL name
|
||||
* does NOT change how the customer's name appears on invoices.
|
||||
* Invoice identity is in org_billing.company_name (the "Full
|
||||
* name" field on /settings/billing) and is intentionally
|
||||
* editable separately, because legal/billing identity may not
|
||||
* match preferred display identity.
|
||||
* - Company accounts see an org-membership hint instead.
|
||||
*
|
||||
* Server-fetches the current profile from ZITADEL via the
|
||||
* service-account PAT so the form starts with the canonical values
|
||||
* rather than whatever happens to be in the JWT (the JWT name might
|
||||
* be stale if the user updated their name in ZITADEL Console).
|
||||
*/
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("settingsProfile");
|
||||
|
||||
let initial = { firstName: "", lastName: "", email: user.email };
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
initial = {
|
||||
firstName: profile.givenName,
|
||||
lastName: profile.familyName,
|
||||
email: profile.email || user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
// Identity provider unreachable: render the form with whatever
|
||||
// we know from the session. The session has a combined `name`,
|
||||
// not split parts, so we leave first/last empty and let the user
|
||||
// re-enter. Server logs catch the underlying failure.
|
||||
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<ProfileSettingsForm
|
||||
initial={initial}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
103
src/app/[locale]/support/[id]/page.tsx
Normal file
103
src/app/[locale]/support/[id]/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSupportTicketById,
|
||||
listCommentsForTicket,
|
||||
} from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { TicketStatusBadge } from "@/components/support/ticket-status-badge";
|
||||
import { TicketCategoryLabel } from "@/components/support/ticket-category-label";
|
||||
import { TicketThread } from "@/components/support/ticket-thread";
|
||||
import { TicketAdminControls } from "@/components/support/ticket-admin-controls";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* /support/[id] — single ticket detail.
|
||||
*
|
||||
* Same UI for customer and admin; admin gets an extra
|
||||
* `<TicketAdminControls>` block for changing status/category. The
|
||||
* customer side gets a "Close ticket" link if they want to mark it
|
||||
* resolved themselves.
|
||||
*
|
||||
* Authorization mirrors the API: customer sees their own; platform
|
||||
* admin sees any. 404 (not 403) when a customer accesses someone
|
||||
* else's ticket — don't leak existence.
|
||||
*/
|
||||
export default async function TicketDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const { id } = await params;
|
||||
const ticket = await getSupportTicketById(id);
|
||||
if (!ticket) notFound();
|
||||
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
|
||||
notFound();
|
||||
}
|
||||
const comments = await listCommentsForTicket(id);
|
||||
const t = await getTranslations("support");
|
||||
const f = await getFormatter();
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-6 animate-in">
|
||||
<BackLink href="/support" label={t("title")} />
|
||||
<div className="flex items-start justify-between gap-3 mt-2">
|
||||
<h1 className="font-display text-2xl font-semibold">
|
||||
{ticket.title}
|
||||
</h1>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-2 flex items-center gap-2 flex-wrap">
|
||||
<TicketCategoryLabel category={ticket.category} />
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t("openedBy", {
|
||||
name: ticket.contactName,
|
||||
when: formatDateTime(ticket.createdAt, f),
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">#{ticket.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original ticket description, rendered as the first message
|
||||
in the thread. Visually distinct via the customer-author
|
||||
styling (handled inside <TicketThread>). */}
|
||||
<div className="space-y-4 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between text-xs text-text-muted mb-2">
|
||||
<span className="font-medium text-text-primary">
|
||||
{ticket.contactName}
|
||||
</span>
|
||||
<span>{formatDateTime(ticket.createdAt, f)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-primary whitespace-pre-wrap">
|
||||
{ticket.description}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<TicketThread
|
||||
ticketId={ticket.id}
|
||||
ticketStatus={ticket.status}
|
||||
comments={comments}
|
||||
isPlatform={user.isPlatform}
|
||||
isOwnTicket={ticket.zitadelUserId === user.id}
|
||||
/>
|
||||
|
||||
{user.isPlatform && (
|
||||
<TicketAdminControls
|
||||
ticketId={ticket.id}
|
||||
currentStatus={ticket.status}
|
||||
currentCategory={ticket.category}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
37
src/app/[locale]/support/new/page.tsx
Normal file
37
src/app/[locale]/support/new/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { TicketCreateForm } from "@/components/support/ticket-create-form";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
|
||||
/**
|
||||
* /support/new — create ticket form.
|
||||
*
|
||||
* Platform admins shouldn't open tickets via this UI (they'd be
|
||||
* opening one as if from a customer, which is confusing). Redirect
|
||||
* them back to the queue. Non-admins of any role can create.
|
||||
*/
|
||||
export default async function NewTicketPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.isPlatform) redirect("/support");
|
||||
const t = await getTranslations("support");
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<BackLink href="/support" label={t("title")} />
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("newTicketTitle")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("newTicketSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<TicketCreateForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
102
src/app/[locale]/support/page.tsx
Normal file
102
src/app/[locale]/support/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
listSupportTicketsForUser,
|
||||
listAllSupportTickets,
|
||||
} from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { TicketStatusBadge } from "@/components/support/ticket-status-badge";
|
||||
import { TicketCategoryLabel } from "@/components/support/ticket-category-label";
|
||||
|
||||
/**
|
||||
* /support — ticket list.
|
||||
*
|
||||
* Customers see their own tickets only (per Feature 5: per-user
|
||||
* scope, NOT per-org). Platform admins see the global queue. Same
|
||||
* UI shell, different list source — the rendering logic is
|
||||
* identical because the per-row data is the same shape.
|
||||
*
|
||||
* Sorting: newest activity first (the DB query already orders by
|
||||
* updated_at DESC). Open tickets bubble to the top by virtue of
|
||||
* having recent activity, but we don't sort by status; that's a
|
||||
* filter the admin can add later if the queue grows.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("support") };
|
||||
}
|
||||
|
||||
export default async function SupportListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("support");
|
||||
const f = await getFormatter();
|
||||
|
||||
const tickets = user.isPlatform
|
||||
? await listAllSupportTickets()
|
||||
: await listSupportTicketsForUser(user.id);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{user.isPlatform ? t("titleAdmin") : t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPlatform ? t("subtitleAdmin") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{!user.isPlatform && (
|
||||
<Link
|
||||
href="/support/new"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{t("newTicket")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary text-center py-6">
|
||||
{user.isPlatform ? t("emptyAdmin") : t("empty")}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2 animate-in animate-in-delay-1">
|
||||
{tickets.map((tk) => (
|
||||
<Link
|
||||
key={tk.id}
|
||||
href={`/support/${tk.id}`}
|
||||
className="block rounded-xl border border-border bg-surface-1 p-4 hover:border-text-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-text-primary truncate">
|
||||
{tk.title}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 flex items-center gap-2">
|
||||
<TicketCategoryLabel category={tk.category} />
|
||||
<span>·</span>
|
||||
<span>{formatRelative(tk.updatedAt, f)}</span>
|
||||
{user.isPlatform && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{tk.contactEmail}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TicketStatusBadge status={tk.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
86
src/app/[locale]/team/page.tsx
Normal file
86
src/app/[locale]/team/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getSessionUser, canMutate, isCustomerOwner } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getOrgMembers } from "@/lib/team";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { TeamList } from "@/components/team/team-list";
|
||||
import { InviteForm } from "@/components/team/invite-form";
|
||||
import { AccessOverview } from "@/components/team/access-overview";
|
||||
|
||||
/**
|
||||
* /team — manage org members.
|
||||
*
|
||||
* Visible to owners and platform users only (`canMutate`). User-role
|
||||
* members are redirected away — they shouldn't browse the roster.
|
||||
*
|
||||
* The page loads members server-side for the initial render. The
|
||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||
* updates after invites and refreshes.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("team") };
|
||||
}
|
||||
|
||||
export default async function TeamPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!canMutate(user)) redirect("/dashboard");
|
||||
// Bug 8: personal accounts have no team to manage. The page is
|
||||
// structurally meaningless and the invite form would create extra
|
||||
// ZITADEL users in a single-user org. Redirect cleanly. The matching
|
||||
// API guards in `/api/team` and `/api/team/invite` enforce the same
|
||||
// rule on direct calls.
|
||||
if (user.isPersonal) redirect("/dashboard");
|
||||
|
||||
const t = await getTranslations("team");
|
||||
const tDashboard = await getTranslations("dashboard");
|
||||
|
||||
const members = await getOrgMembers(user.orgId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<BackLink href="/dashboard" label={tDashboard("title")} />
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("inviteSectionTitle")}
|
||||
</h2>
|
||||
<Card>
|
||||
<InviteForm />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("membersSectionTitle")}{" "}
|
||||
<span className="text-text-muted/60 tabular-nums">
|
||||
({members.length})
|
||||
</span>
|
||||
</h2>
|
||||
<TeamList
|
||||
initialMembers={members}
|
||||
currentUserId={user.id}
|
||||
canEditRoles={isCustomerOwner(user)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Access overview — single place to see which member can reach
|
||||
which assistant, instead of checking each tenant page. */}
|
||||
<section className="mt-8 animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("accessTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-3">{t("accessDescription")}</p>
|
||||
<AccessOverview />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import {
|
||||
getPendingResumeRequestForTenant,
|
||||
listSkillActivationRequestsForTenant,
|
||||
listSkillPricing,
|
||||
} from "@/lib/db";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { WarningBadge } from "@/components/ui/warning-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { PackageList } from "@/components/packages/package-list";
|
||||
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
||||
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
||||
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
||||
import { ConnectPanel } from "@/components/tenants/connect-panel";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
||||
|
||||
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
|
||||
// CHANNEL_PACKAGES used to be a hardcoded literal here
|
||||
// (`["telegram", "discord", "email"]`). It now derives from the
|
||||
// portal-side catalog so adding a new channel anywhere only requires
|
||||
// editing src/lib/packages.ts. The `email` channel was dropped as
|
||||
// part of the Phase A package-model rework — IMAP/SMTP is now the
|
||||
// `mail` skill instead.
|
||||
const CHANNEL_PACKAGES = CHANNEL_PACKAGE_IDS;
|
||||
|
||||
export default async function TenantDetailPage({
|
||||
params,
|
||||
@@ -26,14 +43,43 @@ export default async function TenantDetailPage({
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) notFound();
|
||||
|
||||
// Scope check
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
// Slice 6: visibility check encompasses org membership AND, for
|
||||
// user-role members, the tenant_user_assignments check. notFound()
|
||||
// (404) rather than redirect/403 to avoid leaking tenant existence.
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Slice 5: editable surface gated on owner role. Platform users always
|
||||
// can edit; customer-side, only `owner` may. `user`-role members see
|
||||
// the same page but with edit controls hidden / fields read-only.
|
||||
const canEdit = canMutate(user);
|
||||
|
||||
// Bug 31: customer-side cancel/resume control. Same gate as canEdit
|
||||
// — only owners (or platform staff) may toggle the subscription.
|
||||
// The current state comes from spec.suspend on the CR.
|
||||
const isSuspended = Boolean(tenant.spec.suspend);
|
||||
|
||||
// Bug 37a: when the tenant is suspended, an owner can request
|
||||
// reactivation (admin-gated). Look up whether one is in flight so
|
||||
// the SubscriptionToggle can render the right state.
|
||||
const pendingResumeRequest = isSuspended
|
||||
? await getPendingResumeRequestForTenant(name)
|
||||
: null;
|
||||
|
||||
// Bug 7: assigned-users panel is meaningless for personal tenants
|
||||
// (sole-owner by definition; the only "assignee" is the owner
|
||||
// themselves). We hide the panel when EITHER the CR carries the
|
||||
// `pieced.ch/personal=true` label (set at approve time for new
|
||||
// personal tenants) OR the viewer is on a personal account (covers
|
||||
// legacy tenants approved before the label was added; the customer
|
||||
// sees their own personal tenant). Platform admins viewing a legacy
|
||||
// unlabeled personal tenant are the only case where this falls
|
||||
// through to "show panel" — operators can `kubectl label` to fix.
|
||||
const isPersonalTenant =
|
||||
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
|
||||
user.isPersonal;
|
||||
|
||||
const enabledPackages = tenant.spec.packages || [];
|
||||
const workspaceFiles = tenant.spec.workspaceFiles || {};
|
||||
const enabledChannels = enabledPackages.filter((pkg) =>
|
||||
@@ -41,11 +87,23 @@ export default async function TenantDetailPage({
|
||||
);
|
||||
const channelUsers = tenant.spec.channelUsers || {};
|
||||
|
||||
// Admins inspecting another tenant's usage: pass teamId explicitly.
|
||||
// Customers viewing their own: no teamId, backend resolves from session.
|
||||
const usageTeamId = user.isPlatform
|
||||
? tenant.status?.litellmTeamId || undefined
|
||||
: undefined;
|
||||
// Phase 2.5: surface pending and most-recently-rejected skill
|
||||
// activation requests so PackageCard can render the inline
|
||||
// "Manual review pending" / "Activation rejected" states.
|
||||
// Pricing drives the cost-disclosure dialog before enable.
|
||||
// Both fetches are best-effort — an empty list is the safe
|
||||
// fallback if the DB call fails (cards just show normal toggles).
|
||||
const [activationRequests, skillPricing] = await Promise.all([
|
||||
listSkillActivationRequestsForTenant(name).catch(() => []),
|
||||
listSkillPricing().catch(() => []),
|
||||
]);
|
||||
|
||||
// Bug 19 fix: every viewer (customer or admin) passes the tenant
|
||||
// name to UsageDisplay. The /api/usage route resolves team+alias
|
||||
// from the tenant CR's status and applies the visibility check, so
|
||||
// no per-role branching is needed here. Previous version only
|
||||
// passed identifiers for platform admins; customers got "the first
|
||||
// visible tenant" by API fallback, mingling siblings.
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -56,6 +114,7 @@ export default async function TenantDetailPage({
|
||||
{tenant.spec.displayName || name}
|
||||
</h1>
|
||||
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
|
||||
<WarningBadge warnings={tenant.status?.warnings ?? []} />
|
||||
</div>
|
||||
{tenant.spec.agentName && (
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
@@ -76,12 +135,108 @@ export default async function TenantDetailPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bug 31: prominent banner when the subscription is cancelled.
|
||||
Sits between header and content so it's the first thing the
|
||||
owner sees. Says clearly what state means, and that data is
|
||||
preserved. The Resume action lives in the SubscriptionToggle
|
||||
at the bottom — duplicating it here would clutter the banner
|
||||
for the much-more-common active case. */}
|
||||
{isSuspended && (
|
||||
<div className="mb-8 animate-in animate-in-delay-1 bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-amber-300">
|
||||
{t("suspendedTitle")}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{t("suspendedDescription")}
|
||||
</div>
|
||||
{/* Retention countdown. suspendedAt is stamped by the
|
||||
operator on first transition to suspended; missing
|
||||
values fall through silently rather than rendering
|
||||
garbage (operator hasn't reconciled yet, edge case).
|
||||
The 60-day window is the operator's
|
||||
retentionAfterSuspend constant; if you change one,
|
||||
change both. We don't expose the constant via API —
|
||||
the value rarely changes and duplicating it here
|
||||
beats fetching a single int over the network. */}
|
||||
{tenant.status?.suspendedAt && (() => {
|
||||
const suspendedAt = new Date(tenant.status.suspendedAt);
|
||||
const deletionAt = new Date(suspendedAt);
|
||||
deletionAt.setDate(deletionAt.getDate() + 60);
|
||||
const now = new Date();
|
||||
const msRemaining = deletionAt.getTime() - now.getTime();
|
||||
const daysRemaining = Math.max(
|
||||
0,
|
||||
Math.ceil(msRemaining / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
// < 7 days: red/critical to draw attention. Otherwise
|
||||
// amber, matching the banner.
|
||||
const urgent = daysRemaining < 7;
|
||||
return (
|
||||
<div
|
||||
className={`text-xs mt-2 ${
|
||||
urgent ? "text-red-400" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{t("suspendedSince", {
|
||||
date: formatDateTime(
|
||||
tenant.status.suspendedAt,
|
||||
f
|
||||
),
|
||||
})}
|
||||
{" · "}
|
||||
{daysRemaining > 0
|
||||
? t("suspendedDeletionIn", {
|
||||
days: daysRemaining,
|
||||
date: formatDateTime(
|
||||
deletionAt.toISOString(),
|
||||
f
|
||||
),
|
||||
})
|
||||
: t("suspendedDeletionImminent")}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect: how the customer actually reaches their assistant.
|
||||
The portal manages the assistant; the assistant lives in the
|
||||
customer's messaging app. This bridges that gap right at the
|
||||
top of the page (and calls out the case where no channel is
|
||||
enabled, which would otherwise leave a running assistant
|
||||
unreachable). */}
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<ConnectPanel
|
||||
tenantName={name}
|
||||
enabledChannels={enabledChannels}
|
||||
phase={tenant.status?.phase ?? "Pending"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Usage */}
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("usage")}
|
||||
</h2>
|
||||
<UsageDisplay teamId={usageTeamId} />
|
||||
<UsageDisplay tenant={name} canEditBudget={canEdit} />
|
||||
</section>
|
||||
|
||||
{/* Packages */}
|
||||
@@ -93,6 +248,9 @@ export default async function TenantDetailPage({
|
||||
tenantName={name}
|
||||
enabledPackages={enabledPackages}
|
||||
conditions={tenant.status?.conditions}
|
||||
canEdit={canEdit}
|
||||
activationRequests={activationRequests}
|
||||
skillPricing={skillPricing}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -103,6 +261,7 @@ export default async function TenantDetailPage({
|
||||
tenantName={name}
|
||||
enabledChannels={enabledChannels}
|
||||
initialChannelUsers={channelUsers}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
@@ -112,8 +271,54 @@ export default async function TenantDetailPage({
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("workspaceFiles")}
|
||||
</h2>
|
||||
<WorkspaceEditor tenantName={name} files={workspaceFiles} />
|
||||
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
|
||||
</section>
|
||||
|
||||
{/* Slice 7: Assigned users — visible to anyone who can see the
|
||||
tenant, editable only by owners/platform users. The component
|
||||
fetches its own data so the page doesn't need to await.
|
||||
Bug 7: hidden entirely for personal tenants. */}
|
||||
{!isPersonalTenant && (
|
||||
<section className="mt-8 animate-in animate-in-delay-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("assignedUsers")}
|
||||
</h2>
|
||||
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Bug 31: subscription cancel/resume — owners + platform staff
|
||||
only. Lives at the bottom of the page (rather than near the
|
||||
status badge) to add deliberate friction; mis-clicking
|
||||
"Cancel subscription" from the top would be too easy. The
|
||||
control itself opens a confirmation modal before sending. */}
|
||||
{canEdit && (
|
||||
<section className="mt-12 pt-8 border-t border-border animate-in animate-in-delay-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("subscriptionTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{isSuspended
|
||||
? t("subscriptionDescriptionSuspended")
|
||||
: t("subscriptionDescriptionActive")}
|
||||
</p>
|
||||
<SubscriptionToggle
|
||||
tenantName={name}
|
||||
suspended={isSuspended}
|
||||
isPlatform={user.isPlatform}
|
||||
pendingResumeRequest={
|
||||
pendingResumeRequest
|
||||
? {
|
||||
id: pendingResumeRequest.id,
|
||||
createdAt: pendingResumeRequest.createdAt,
|
||||
customerNotes:
|
||||
pendingResumeRequest.customerNotes ?? null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
src/app/api/admin/billing/backfill/route.ts
Normal file
70
src/app/api/admin/billing/backfill/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { backfillTenantBillingLifecycle } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/backfill
|
||||
*
|
||||
* One-off bootstrap that reads every live PiecedTenant CR and
|
||||
* mirrors it into the Phase 1 billing tables:
|
||||
* - tenant_billing_lifecycle.created_at ← CR's creationTimestamp
|
||||
* - tenant_skill_events: one 'enabled' event per package in
|
||||
* spec.packages, anchored at the CR's creationTimestamp
|
||||
* - tenant_suspension_events: one 'suspended' event if the CR is
|
||||
* currently suspended (anchored at status.suspendedAt)
|
||||
*
|
||||
* Idempotent — re-running is safe. The helper only inserts rows
|
||||
* for tenants that have no lifecycle row / no events yet; running
|
||||
* twice produces zero additional rows.
|
||||
*
|
||||
* Authorization: platform role only. The body of the request is
|
||||
* ignored.
|
||||
*
|
||||
* Response: counts of rows inserted, mostly for sanity-checking
|
||||
* (expect non-zero on first run, zero on subsequent runs).
|
||||
*
|
||||
* Phase 2 will surface this behind an admin UI button.
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const tenants = await listTenants();
|
||||
const result = await backfillTenantBillingLifecycle(
|
||||
tenants.map((t) => ({
|
||||
name: t.metadata.name,
|
||||
// Tenants without the org label exist as a pre-Slice-3
|
||||
// artifact; we still record them but with 'unknown' as the
|
||||
// org id, which surfaces them in admin reports for manual
|
||||
// labelling. Per-org billing computation skips rows with
|
||||
// org id = 'unknown'.
|
||||
zitadelOrgId:
|
||||
t.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? "unknown",
|
||||
createdAt: t.metadata.creationTimestamp
|
||||
? new Date(t.metadata.creationTimestamp)
|
||||
: new Date(),
|
||||
packages: t.spec.packages ?? [],
|
||||
suspendedAt: t.status?.suspendedAt
|
||||
? new Date(t.status.suspendedAt)
|
||||
: null,
|
||||
}))
|
||||
);
|
||||
return NextResponse.json({
|
||||
message: "Backfill complete.",
|
||||
tenantsExamined: tenants.length,
|
||||
...result,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Backfill failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Backfill failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
66
src/app/api/admin/billing/generate/route.ts
Normal file
66
src/app/api/admin/billing/generate/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { generateInvoice } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/generate
|
||||
*
|
||||
* Compute (and optionally commit) an invoice for an (org, year,
|
||||
* month). Platform-only — this is the testing/admin tool.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* zitadelOrgId: string,
|
||||
* year: number (e.g. 2026),
|
||||
* month: number (1-12),
|
||||
* locale?: 'de' | 'en' | 'fr' | 'it', // default: from country
|
||||
* dryRun?: boolean // default: false
|
||||
* }
|
||||
*
|
||||
* Response on success:
|
||||
* {
|
||||
* draft: InvoiceDraft, // line breakdown + warnings
|
||||
* invoice: Invoice | null, // null when dryRun=true
|
||||
* }
|
||||
*
|
||||
* If an invoice for that (org, period) already exists, returns
|
||||
* 409 with a clear message. Use the delete endpoint first to
|
||||
* regenerate.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
zitadelOrgId: z.string().min(1),
|
||||
year: z.number().int().min(2020).max(2100),
|
||||
month: z.number().int().min(1).max(12),
|
||||
locale: z.enum(["de", "en", "fr", "it"]).optional(),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await generateInvoice(parsed.data);
|
||||
return NextResponse.json(result);
|
||||
} catch (e: any) {
|
||||
console.error("Invoice generation failed:", e);
|
||||
const msg = safeError(e, "Generation failed");
|
||||
// Specific 409 for the "already exists" case so the UI can
|
||||
// show a "delete first" link.
|
||||
const status = /already exists/i.test(msg) ? 409 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
}
|
||||
64
src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
Normal file
64
src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
CustomInvoiceValidationError,
|
||||
issueCustomInvoiceDraft,
|
||||
} from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoice-drafts/[id]/issue
|
||||
*
|
||||
* Phase 8. Convert a draft into a real invoice:
|
||||
* - Validate payload (must have lines, valid dates, billing snapshot)
|
||||
* - Allocate invoice number from the shared year-scoped counter
|
||||
* - Persist invoice with source='custom'
|
||||
* - Render PDF
|
||||
* - Email customer
|
||||
* - Delete the draft
|
||||
*
|
||||
* Returns the issued Invoice on success. Errors map cleanly to
|
||||
* HTTP codes:
|
||||
* 400 — validation failure (CustomInvoiceValidationError)
|
||||
* 404 — draft id doesn't exist (also CustomInvoiceValidationError
|
||||
* since the orchestrator can't tell apart "draft missing"
|
||||
* from "invalid input" — the message string discriminates)
|
||||
* 500 — anything else (DB error, Stripe error not applicable here)
|
||||
*
|
||||
* Idempotency: this endpoint is NOT idempotent. Issuing twice
|
||||
* allocates two invoice numbers. The admin UI disables the submit
|
||||
* button while in-flight, but for safety the backend handles
|
||||
* double-submit by failing on the second call (the draft was
|
||||
* deleted by the first).
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const invoice = await issueCustomInvoiceDraft({
|
||||
draftId: id,
|
||||
issuedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ invoice });
|
||||
} catch (e) {
|
||||
if (e instanceof CustomInvoiceValidationError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to issue custom invoice") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
CustomInvoiceValidationError,
|
||||
renderCustomDraftPreview,
|
||||
} from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoice-drafts/[id]/preview
|
||||
*
|
||||
* Phase 8. Render the current draft as a PDF without persisting an
|
||||
* invoice. The bytes are returned inline so the browser displays
|
||||
* the document in a new tab. The invoice number on the rendered
|
||||
* PDF is the placeholder "DRAFT" — no real number is allocated.
|
||||
*
|
||||
* Useful for the admin's "Review" step in the draft → review →
|
||||
* issue flow.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const pdf = await renderCustomDraftPreview(id);
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
// Inline so the browser displays the PDF immediately. The
|
||||
// filename is a guide — most browsers ignore it for inline
|
||||
// disposition but it shows on the "Save as" dialog.
|
||||
"Content-Disposition": `inline; filename="invoice-draft-${id}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CustomInvoiceValidationError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to render preview") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
120
src/app/api/admin/billing/invoice-drafts/[id]/route.ts
Normal file
120
src/app/api/admin/billing/invoice-drafts/[id]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
deleteInvoiceDraft,
|
||||
getInvoiceDraftById,
|
||||
updateInvoiceDraft,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import type { CustomInvoiceDraftPayload } from "@/types";
|
||||
|
||||
/**
|
||||
* /api/admin/billing/invoice-drafts/[id]
|
||||
*
|
||||
* Phase 8.
|
||||
*
|
||||
* GET — fetch one draft
|
||||
* PUT — overwrite the payload (full replace, not patch)
|
||||
* DELETE — discard the draft
|
||||
*
|
||||
* All require platform admin. The org boundary is *not* enforced
|
||||
* here: a platform admin can edit any draft regardless of which
|
||||
* org it targets. If we ever introduce a per-org admin role,
|
||||
* scope filtering would go in this file.
|
||||
*/
|
||||
|
||||
const lineSchema = z.object({
|
||||
description: z.string().trim().min(1).max(500),
|
||||
quantity: z.number().finite(),
|
||||
unitPriceChf: z.number().finite(),
|
||||
});
|
||||
|
||||
const payloadSchema = z.object({
|
||||
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
locale: z.enum(["de", "en", "fr", "it"]),
|
||||
paymentMethod: z.enum(["invoice", "card"]),
|
||||
adminNotes: z.string().max(2000).optional(),
|
||||
lines: z.array(lineSchema).max(100),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const draft = await getInvoiceDraftById(id);
|
||||
if (!draft) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to load draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = payloadSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const updated = await updateInvoiceDraft(
|
||||
id,
|
||||
parsed.data as CustomInvoiceDraftPayload
|
||||
);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ draft: updated });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const deleted = await deleteInvoiceDraft(id);
|
||||
return NextResponse.json({ deleted });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to delete draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
src/app/api/admin/billing/invoice-drafts/route.ts
Normal file
94
src/app/api/admin/billing/invoice-drafts/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
createInvoiceDraft,
|
||||
listAllInvoiceDrafts,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import type { CustomInvoiceDraftPayload } from "@/types";
|
||||
|
||||
/**
|
||||
* /api/admin/billing/invoice-drafts
|
||||
*
|
||||
* Phase 8. Drafts for the admin "New invoice" flow.
|
||||
*
|
||||
* GET — list all open drafts across all orgs, newest-touched first.
|
||||
* POST — create a new draft for an org with an initial (possibly
|
||||
* empty) payload. Returns the inserted draft.
|
||||
*
|
||||
* Both require platform admin. Drafts have no customer-facing
|
||||
* surface: they aren't reachable from /billing or any non-admin
|
||||
* route.
|
||||
*/
|
||||
|
||||
const lineSchema = z.object({
|
||||
description: z.string().trim().min(1).max(500),
|
||||
quantity: z.number().finite(),
|
||||
unitPriceChf: z.number().finite(),
|
||||
});
|
||||
|
||||
const payloadSchema = z.object({
|
||||
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
locale: z.enum(["de", "en", "fr", "it"]),
|
||||
paymentMethod: z.enum(["invoice", "card"]),
|
||||
adminNotes: z.string().max(2000).optional(),
|
||||
lines: z.array(lineSchema).max(100),
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
zitadelOrgId: z.string().trim().min(1),
|
||||
payload: payloadSchema,
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const drafts = await listAllInvoiceDrafts();
|
||||
return NextResponse.json({ drafts });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to list drafts") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const draft = await createInvoiceDraft({
|
||||
zitadelOrgId: parsed.data.zitadelOrgId,
|
||||
createdBy: user.id,
|
||||
payload: parsed.data.payload as CustomInvoiceDraftPayload,
|
||||
});
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to create draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal file
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { markInvoicePaid } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/mark-paid
|
||||
*
|
||||
* Manually mark an open/overdue invoice as paid. Used for the
|
||||
* "pay by invoice" flow where the customer transfers money to
|
||||
* the bank account printed on the PDF and the admin reconciles
|
||||
* by hand.
|
||||
*
|
||||
* Body (all optional):
|
||||
* {
|
||||
* paidAt?: ISO timestamp, // defaults to now
|
||||
* note?: string // free-form, stored in paid_method_detail
|
||||
* }
|
||||
*
|
||||
* paid_by is set to the admin user's id automatically.
|
||||
* Idempotent: trying to mark an already-paid invoice returns 409.
|
||||
*
|
||||
* Phase 4 will introduce a parallel auto-paid path triggered by
|
||||
* Stripe webhooks; for Phase 2 this is the only way to flip the
|
||||
* status.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
paidAt: z.string().datetime().optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const detail = parsed.data.note
|
||||
? `${user.id}: ${parsed.data.note}`
|
||||
: user.id;
|
||||
const invoice = await markInvoicePaid(id, {
|
||||
paidBy: "manual",
|
||||
paidMethodDetail: detail,
|
||||
paidAt: parsed.data.paidAt ? new Date(parsed.data.paidAt) : undefined,
|
||||
});
|
||||
if (!invoice) {
|
||||
// Either not found or status not in {open, overdue}.
|
||||
return NextResponse.json(
|
||||
{ error: "Invoice not found, or already paid/void." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(invoice);
|
||||
} catch (e) {
|
||||
console.error("Failed to mark invoice paid:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Mark-paid failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal file
48
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getInvoicePdf } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoices/[id]/pdf
|
||||
*
|
||||
* Streams the stored PDF bytes for an invoice. The bytea column is
|
||||
* read once and returned as an octet stream; no on-the-fly
|
||||
* re-rendering — PDFs are immutable once issued.
|
||||
*
|
||||
* Phase 3 will add a parallel customer-facing route at
|
||||
* /api/billing/invoices/[id]/pdf with org-scoped authorization.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const pdf = await getInvoicePdf(id);
|
||||
if (!pdf) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
// Web `Response`'s BodyInit accepts BufferSource, which IS satisfied
|
||||
// by a Uint8Array. But the pg-returned Buffer types as
|
||||
// `Uint8Array<ArrayBufferLike>` (the @types/node 22+ generic form),
|
||||
// and lib.dom's BufferSource only accepts `Uint8Array<ArrayBuffer>` —
|
||||
// the narrower concrete form. The variance kills assignability,
|
||||
// even though Buffer extends Uint8Array at runtime.
|
||||
//
|
||||
// `Uint8Array.from(buf)` allocates a fresh typed array; the result
|
||||
// is `Uint8Array<ArrayBuffer>` (concrete generic), which BodyInit
|
||||
// accepts. Copy cost is trivial at PDF sizes.
|
||||
const body = Uint8Array.from(pdf.data);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/refund
|
||||
*
|
||||
* Phase 7. Refunds a paid invoice (full or partial) and issues a
|
||||
* credit note. For Stripe-paid invoices, calls Stripe's Refund API
|
||||
* before any local recording. For invoice-paid customers (bank
|
||||
* transfer), records the refund locally and assumes the admin
|
||||
* handled the actual money movement out-of-band.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* amountChf: number, // positive, <= remaining refundable
|
||||
* reason: string // required, free-text, max 500
|
||||
* }
|
||||
*
|
||||
* Authorization: platform admin.
|
||||
*
|
||||
* Status codes:
|
||||
* 200 — refund issued, credit note returned
|
||||
* 400 — bad request (zero/negative amount, etc.)
|
||||
* 401 / 403 — not authenticated / not platform admin
|
||||
* 409 — invoice not in a refundable state, or amount exceeds remaining
|
||||
* 500 — Stripe call failed or another internal error
|
||||
*
|
||||
* Idempotency caveats: this endpoint is NOT idempotent against
|
||||
* client retries. Issuing two refunds quickly will result in two
|
||||
* Stripe refund calls (and two credit notes). The admin UI should
|
||||
* disable the submit button while the request is in flight to
|
||||
* prevent accidental double-clicks. The Stripe charge.refunded
|
||||
* webhook is idempotent and will not double-count if it fires
|
||||
* after this endpoint already recorded the refund.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
amountChf: z.number().positive().multipleOf(0.01),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const creditNote = await refundInvoice({
|
||||
invoiceId: id,
|
||||
amountChf: parsed.data.amountChf,
|
||||
reason: parsed.data.reason,
|
||||
refundedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ creditNote });
|
||||
} catch (e) {
|
||||
if (e instanceof RefundNotAllowedError) {
|
||||
return NextResponse.json(
|
||||
{ error: e.message, currentStatus: e.currentStatus },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Refund failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal file
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { deleteInvoice, getInvoiceDetail } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoices/[id]
|
||||
* Detail view: invoice + lines.
|
||||
*
|
||||
* DELETE /api/admin/billing/invoices/[id]
|
||||
* Hard delete (testing tool). Invoice number is consumed — gaps
|
||||
* in the sequence are intentional and documented. Reminders
|
||||
* (and their PDFs) cascade-delete via the FK.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const ok = await deleteInvoice(id);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ message: "Deleted." });
|
||||
} catch (e) {
|
||||
console.error("Failed to delete invoice:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Delete failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { voidInvoice, VoidNotAllowedError } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/void
|
||||
*
|
||||
* Phase 7. Voids an unpaid invoice and issues a credit note.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* reason: string // required, free-text, max 500
|
||||
* }
|
||||
*
|
||||
* Authorization: platform admin (same as mark-paid, generate, etc.).
|
||||
* The acting user's ID lands in invoices.voided_by and on the
|
||||
* credit_notes.issued_by audit columns.
|
||||
*
|
||||
* Status codes:
|
||||
* 200 — voided, credit note returned in body
|
||||
* 400 — bad request (missing reason etc.)
|
||||
* 401 / 403 — not authenticated / not platform admin
|
||||
* 409 — invoice not in a voidable state
|
||||
* 500 — anything else (Stripe shouldn't apply here, but if PDF
|
||||
* render fails the void still went through — see body
|
||||
* payload for the credit-note number to re-render later)
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const creditNote = await voidInvoice({
|
||||
invoiceId: id,
|
||||
reason: parsed.data.reason,
|
||||
voidedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ creditNote });
|
||||
} catch (e) {
|
||||
if (e instanceof VoidNotAllowedError) {
|
||||
return NextResponse.json(
|
||||
{ error: e.message, currentStatus: e.currentStatus },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Void failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/admin/billing/invoices/route.ts
Normal file
44
src/app/api/admin/billing/invoices/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import type { InvoiceStatus } from "@/types";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoices
|
||||
*
|
||||
* List invoices for admin. Optional filters:
|
||||
* ?status=open|paid|overdue|void|uncollectible
|
||||
* ?orgId=...
|
||||
* ?month=YYYY-MM
|
||||
* ?limit=200
|
||||
*
|
||||
* Refreshes overdue status on each call (cheap UPDATE), so the
|
||||
* admin list always reflects the latest due-date math without
|
||||
* needing a cron.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status") as InvoiceStatus | null;
|
||||
const orgId = searchParams.get("orgId");
|
||||
const month = searchParams.get("month");
|
||||
const limitParam = searchParams.get("limit");
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10))) : 200;
|
||||
|
||||
const invoices = await listInvoices({
|
||||
status: status ?? undefined,
|
||||
zitadelOrgId: orgId ?? undefined,
|
||||
periodMonth: month ?? undefined,
|
||||
limit,
|
||||
});
|
||||
return NextResponse.json(invoices);
|
||||
}
|
||||
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getOrgBillingConfig,
|
||||
setAutoChargeEnabled,
|
||||
updateOrgBillingConfig,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/orgs/[orgId]/payment-mode
|
||||
*
|
||||
* Phase 9b-2. Admin-only override of an org's billing mode:
|
||||
* - payByInvoice (boolean) — flip the customer's account to
|
||||
* bank-transfer billing. Auto-charge is skipped entirely for
|
||||
* these orgs; they receive the regular issued-invoice email
|
||||
* and pay manually. Switching ON also implicitly stops
|
||||
* attempting card charges even if a saved card exists.
|
||||
* - autoChargeEnabled (boolean) — pause auto-charge without
|
||||
* committing to pay-by-invoice. Useful during disputes or
|
||||
* billing investigations.
|
||||
*
|
||||
* Either flag may be omitted; the endpoint only writes what's
|
||||
* provided. Returns the updated config.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
payByInvoice: z.boolean().optional(),
|
||||
autoChargeEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ orgId: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { orgId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { payByInvoice, autoChargeEnabled } = parsed.data;
|
||||
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (payByInvoice !== undefined) {
|
||||
await updateOrgBillingConfig(orgId, { payByInvoice });
|
||||
}
|
||||
if (autoChargeEnabled !== undefined) {
|
||||
await setAutoChargeEnabled(orgId, autoChargeEnabled);
|
||||
}
|
||||
const cfg = await getOrgBillingConfig(orgId);
|
||||
return NextResponse.json({ config: cfg });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update payment mode") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/app/api/admin/billing/orgs/route.ts
Normal file
80
src/app/api/admin/billing/orgs/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling, getOrgOpenBalances } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/orgs
|
||||
*
|
||||
* Returns the orgs known to the platform via tenant labels, with
|
||||
* their billing-address-on-file status and open balance summary.
|
||||
* Powers the generate form's org dropdown and the billing landing
|
||||
* page's open-balance table.
|
||||
*
|
||||
* Each entry:
|
||||
* {
|
||||
* zitadelOrgId: string,
|
||||
* tenantCount: number,
|
||||
* hasBillingAddress: boolean,
|
||||
* companyName: string | null,
|
||||
* openCount: number,
|
||||
* overdueCount: number,
|
||||
* totalOpenChf: number
|
||||
* }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Org membership is derived from tenant labels — there's no
|
||||
// separate "orgs" table on the portal. listTenants reads from
|
||||
// K8s, which is the source of truth.
|
||||
const tenants = await listTenants();
|
||||
const orgIdToTenants = new Map<string, string[]>();
|
||||
for (const t of tenants) {
|
||||
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!oid) continue;
|
||||
if (!orgIdToTenants.has(oid)) orgIdToTenants.set(oid, []);
|
||||
orgIdToTenants.get(oid)!.push(t.metadata.name);
|
||||
}
|
||||
|
||||
const balances = await getOrgOpenBalances();
|
||||
const balanceMap = new Map(balances.map((b) => [b.zitadelOrgId, b]));
|
||||
|
||||
// Hydrate billing-address presence + company name per org.
|
||||
const results = await Promise.all(
|
||||
[...orgIdToTenants.entries()].map(async ([orgId, tenantNames]) => {
|
||||
const billing = await getOrgBilling(orgId).catch(() => null);
|
||||
const bal = balanceMap.get(orgId);
|
||||
return {
|
||||
zitadelOrgId: orgId,
|
||||
tenantCount: tenantNames.length,
|
||||
tenantNames,
|
||||
hasBillingAddress: !!billing,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
openCount: bal?.openCount ?? 0,
|
||||
overdueCount: bal?.overdueCount ?? 0,
|
||||
totalOpenChf: bal?.totalOpenChf ?? 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort: orgs with overdue first, then open, then by name.
|
||||
results.sort((a, b) => {
|
||||
if (a.overdueCount !== b.overdueCount) {
|
||||
return b.overdueCount - a.overdueCount;
|
||||
}
|
||||
if (a.openCount !== b.openCount) {
|
||||
return b.openCount - a.openCount;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
59
src/app/api/admin/billing/pricing/route.ts
Normal file
59
src/app/api/admin/billing/pricing/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getPlatformPricing, updatePlatformPricing } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/pricing
|
||||
* Returns the single-row platform pricing config.
|
||||
*
|
||||
* PUT /api/admin/billing/pricing
|
||||
* Updates one or more pricing fields. Missing fields are left
|
||||
* unchanged.
|
||||
*
|
||||
* Both endpoints are platform-role only.
|
||||
*/
|
||||
|
||||
const updateSchema = z.object({
|
||||
tenantMonthlyFeeChf: z.number().min(0).max(99_999_999).optional(),
|
||||
tenantSetupFeeChf: z.number().min(0).max(99_999_999).optional(),
|
||||
threemaMessageChf: z.number().min(0).max(1000).optional(),
|
||||
vatRateChli: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const pricing = await getPlatformPricing();
|
||||
return NextResponse.json(pricing);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid pricing payload", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const updated = await updatePlatformPricing(parsed.data);
|
||||
return NextResponse.json(updated);
|
||||
} catch (e) {
|
||||
console.error("Failed to update platform pricing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Update failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal file
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { removeSkillPricing } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/billing/skill-pricing/[skill]
|
||||
* Remove pricing for a skill. Toggle events continue to be
|
||||
* recorded; the skill simply becomes free starting from the next
|
||||
* generated invoice. Historical invoices already issued are
|
||||
* unaffected (they carry frozen line amounts).
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ skill: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { skill } = await params;
|
||||
try {
|
||||
await removeSkillPricing(skill);
|
||||
return NextResponse.json({ message: "Removed." });
|
||||
} catch (e) {
|
||||
console.error("Failed to remove skill pricing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Remove failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
80
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listSkillPricing, setSkillPricing } from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/skill-pricing
|
||||
* List all configured skill prices.
|
||||
*
|
||||
* PUT /api/admin/billing/skill-pricing
|
||||
* Upsert a daily price for a single skill. Body:
|
||||
* { skillId: string, dailyPriceChf: number }
|
||||
*
|
||||
* Both endpoints are platform-only.
|
||||
*
|
||||
* Note on skillId validation: we accept any package id that exists
|
||||
* in PACKAGE_CATALOG. The PIN to "skills only" is enforced at the
|
||||
* UI layer, not here, so admins can price a non-skill package in
|
||||
* an emergency without code changes.
|
||||
*/
|
||||
|
||||
const upsertSchema = z.object({
|
||||
skillId: z.string().min(1).max(100),
|
||||
dailyPriceChf: z.number().min(0).max(1_000_000),
|
||||
// Optional with default 0 so existing API callers keep working.
|
||||
// Setup fee fires once per (tenant, skill); see billing.ts.
|
||||
setupFeeChf: z.number().min(0).max(1_000_000).optional().default(0),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const rows = await listSkillPricing();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = upsertSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid payload", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
// Validate the skill id exists in PACKAGE_CATALOG. Returns null
|
||||
// for unknown ids; we reject those rather than persist a row that
|
||||
// would never match a real toggle event.
|
||||
const pkg = getPackageDef(parsed.data.skillId);
|
||||
if (!pkg) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown package id: ${parsed.data.skillId}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const row = await setSkillPricing(
|
||||
parsed.data.skillId,
|
||||
parsed.data.dailyPriceChf,
|
||||
parsed.data.setupFeeChf
|
||||
);
|
||||
return NextResponse.json(row);
|
||||
} catch (e) {
|
||||
console.error("Failed to upsert skill pricing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Upsert failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import { runMonthlyIssuance } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/cron/issue-monthly
|
||||
*
|
||||
* Admin-side manual trigger for the issuance sweep — same business
|
||||
* logic as /api/cron/issue-monthly, different auth (session-based
|
||||
* platform role check) and the option to override the target
|
||||
* year/month from the request body.
|
||||
*
|
||||
* Body (all optional):
|
||||
* { year?: number, month?: number }
|
||||
*
|
||||
* Default target is the previous local month — matching what the
|
||||
* automated cron would do. Override is useful for catching up after
|
||||
* a failed run or re-billing a past month after fixing data.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
year: z.number().int().min(2000).max(3000).optional(),
|
||||
month: z.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
(parsed.data.year && !parsed.data.month) ||
|
||||
(parsed.data.month && !parsed.data.year)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "year and month must both be provided, or neither" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runMonthlyIssuance({
|
||||
triggeredBy: user.id,
|
||||
year: parsed.data.year,
|
||||
month: parsed.data.month,
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Issuance sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/cron/runs/route.ts
Normal file
27
src/app/api/admin/cron/runs/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getLastSuccessfulCronRuns,
|
||||
listRecentCronRuns,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/cron/runs
|
||||
*
|
||||
* Returns recent cron run history plus per-kind "last successful"
|
||||
* summary for the admin /admin/cron dashboard.
|
||||
*
|
||||
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const [recent, lastSuccess] = await Promise.all([
|
||||
listRecentCronRuns(30),
|
||||
getLastSuccessfulCronRuns(),
|
||||
]);
|
||||
return NextResponse.json({ recent, lastSuccess });
|
||||
}
|
||||
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import { runReminderSweep } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/cron/send-reminders
|
||||
*
|
||||
* Admin-side manual trigger for the reminder sweep. Same logic
|
||||
* as the machine path; session-based platform-role auth.
|
||||
*/
|
||||
export async function POST() {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runReminderSweep({
|
||||
triggeredBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Reminder sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
getLitellmHealth,
|
||||
getGlobalSpend,
|
||||
getPerKeySpend,
|
||||
getPerTeamSpend,
|
||||
} from "@/lib/litellm";
|
||||
|
||||
@@ -28,6 +29,17 @@ async function checkVllmHealth(): Promise<{
|
||||
/**
|
||||
* GET /api/admin/health
|
||||
* Returns system health overview for the admin panel.
|
||||
*
|
||||
* Slice 2 spend layout
|
||||
* --------------------
|
||||
* - `spend.global` — total across all teams (LiteLLM-reported)
|
||||
* - `spend.perTenant[name]` — per-tenant CHF, derived from the per-key
|
||||
* spend map keyed by `litellmKeyAlias`. Only
|
||||
* populated for tenants whose status carries
|
||||
* an alias (post-Slice-2 reconciled CRs).
|
||||
* - `spend.perOrg[teamId]` — company-level total (= LiteLLM team total).
|
||||
* Useful for the admin overview to see
|
||||
* spend-per-customer at a glance.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -36,17 +48,17 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [tenants, litellm, vllm, globalSpend, perTeamSpend] =
|
||||
const [tenants, litellm, vllm, globalSpend, perKeySpend, perTeamSpend] =
|
||||
await Promise.allSettled([
|
||||
listTenants(),
|
||||
getLitellmHealth(),
|
||||
checkVllmHealth(),
|
||||
getGlobalSpend(),
|
||||
getPerKeySpend(),
|
||||
getPerTeamSpend(),
|
||||
]);
|
||||
|
||||
const allTenants =
|
||||
tenants.status === "fulfilled" ? tenants.value : [];
|
||||
const allTenants = tenants.status === "fulfilled" ? tenants.value : [];
|
||||
|
||||
// Count tenants by phase
|
||||
const phaseCounts: Record<string, number> = {};
|
||||
@@ -57,15 +69,27 @@ export async function GET() {
|
||||
phaseCounts[phase] = (phaseCounts[phase] || 0) + 1;
|
||||
}
|
||||
|
||||
// Build per-tenant spend map (tenantName → spend)
|
||||
const spendMap: Record<string, number> = {};
|
||||
// Build per-tenant spend map (tenantName → spend) from the per-key map.
|
||||
// Tenants without a `litellmKeyAlias` in status are skipped — they
|
||||
// simply won't appear in this map until they've been reconciled by
|
||||
// the Slice-2 operator.
|
||||
const keySpend =
|
||||
perKeySpend.status === "fulfilled" ? perKeySpend.value : new Map();
|
||||
const tenantSpend: Record<string, number> = {};
|
||||
for (const t of allTenants) {
|
||||
const alias = t.status?.litellmKeyAlias;
|
||||
if (alias && keySpend.has(alias)) {
|
||||
tenantSpend[t.metadata.name] = keySpend.get(alias)!;
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-org spend map (teamId → spend). Multiple tenants of the
|
||||
// same org share a teamId, so the same number appears for each.
|
||||
const teamSpend =
|
||||
perTeamSpend.status === "fulfilled" ? perTeamSpend.value : new Map();
|
||||
for (const t of allTenants) {
|
||||
const teamId = t.status?.litellmTeamId;
|
||||
if (teamId && teamSpend.has(teamId)) {
|
||||
spendMap[t.metadata.name] = teamSpend.get(teamId)!;
|
||||
}
|
||||
const orgSpend: Record<string, number> = {};
|
||||
for (const [teamId, spend] of teamSpend.entries()) {
|
||||
orgSpend[teamId] = spend;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -76,7 +100,8 @@ export async function GET() {
|
||||
spend: {
|
||||
global:
|
||||
globalSpend.status === "fulfilled" ? globalSpend.value : 0,
|
||||
perTenant: spendMap,
|
||||
perTenant: tenantSpend,
|
||||
perOrg: orgSpend,
|
||||
},
|
||||
services: {
|
||||
litellm:
|
||||
|
||||
75
src/app/api/admin/openclaw/route.ts
Normal file
75
src/app/api/admin/openclaw/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOpenClawDefaults, setOpenClawDefaults } from "@/lib/k8s";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Platform-wide default OpenClaw image tag (admin-only).
|
||||
*
|
||||
* GET — read the current default tag from the
|
||||
* `pieced-openclaw-config` ConfigMap. Can be empty string if no
|
||||
* default is configured; the operator uses its built-in fallback
|
||||
* in that case.
|
||||
*
|
||||
* PATCH — update the tag. Send "" to clear. The operator watches
|
||||
* this ConfigMap and re-enqueues all tenants without a per-tenant
|
||||
* override on change, so existing tenants roll forward to the new
|
||||
* default automatically. Tenants WITH an override are unaffected.
|
||||
*
|
||||
* Tag-only by design — see operator notes.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
defaultTag: z.string().trim().max(256),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await getOpenClawDefaults());
|
||||
} catch (e: any) {
|
||||
console.error("Failed to read openclaw defaults:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to read defaults") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await setOpenClawDefaults({
|
||||
defaultTag: parsed.data.defaultTag,
|
||||
});
|
||||
return NextResponse.json(next);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to update openclaw defaults:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update defaults") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,41 @@ import {
|
||||
updateTenantRequestStatus,
|
||||
clearEncryptedSecrets,
|
||||
} from "@/lib/db";
|
||||
import { createTenant } from "@/lib/k8s";
|
||||
import { sendApprovalEmail } from "@/lib/email";
|
||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||
import { decryptSecrets } from "@/lib/crypto";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||
import {
|
||||
getDefaultSoulMd,
|
||||
getDefaultAgentsMd,
|
||||
generateToolsMd,
|
||||
} from "@/lib/workspace-defaults";
|
||||
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/approve
|
||||
* Approve a tenant request:
|
||||
* 1. Decrypt stored package secrets (if any)
|
||||
* 2. Write each package's secrets to OpenBao at secret/data/tenants/{tenant-name}/{package}
|
||||
* 3. Null the encrypted_secrets column
|
||||
* 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
* 5. Create PiecedTenant CR
|
||||
* 6. Update request status, notify customer.
|
||||
* Also supports re-approving a previously rejected request (clears admin notes).
|
||||
*
|
||||
* Approve a request. Two paths depending on request_type:
|
||||
*
|
||||
* Provision (the original purpose):
|
||||
* 1. Decrypt stored package secrets (if any)
|
||||
* 2. Write each package's secrets to OpenBao
|
||||
* 3. Null the encrypted_secrets column
|
||||
* 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
* 5. Create PiecedTenant CR
|
||||
* 6. Update request status, notify customer.
|
||||
* Supports re-approving a previously rejected request (clears admin notes).
|
||||
*
|
||||
* Resume (Bug 37a):
|
||||
* 1. PATCH spec.suspend=false on the existing PiecedTenant CR.
|
||||
* 2. Clear the `pieced.ch/resume-request-pending` annotation so the
|
||||
* operator knows the request is settled (and doesn't pause its
|
||||
* 60-day TTL forever — though now that the tenant isn't suspended,
|
||||
* the timer is moot).
|
||||
* 3. Mark request approved, notify customer.
|
||||
* No CR creation, no secret materialisation, no workspace files.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -59,15 +73,68 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Resume request: short path. Just patch the existing tenant, clear
|
||||
// the annotation, mark approved.
|
||||
if (tenantRequest.requestType === "resume") {
|
||||
if (!tenantRequest.tenantName) {
|
||||
// Shouldn't happen — resume requests are created with tenant_name
|
||||
// set. Defensive 500 if it does.
|
||||
return NextResponse.json(
|
||||
{ error: "Resume request has no tenant_name" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||
// Best-effort — annotation cleanup is also done by the operator
|
||||
// when it sees suspend=false on the next reconcile (it clears
|
||||
// status.suspendedAt), but explicitly clearing here keeps the
|
||||
// CR clean.
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
tenantRequest.tenantName,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"post-approve annotation clear failed; not blocking",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
await updateTenantRequestStatus(id, "approved", { adminNotes });
|
||||
|
||||
await sendResumeApprovalEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName
|
||||
).catch((e) => console.error("resume approval email failed:", e));
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Resume approved. Tenant is reactivating.",
|
||||
tenantName: tenantRequest.tenantName,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Resume approval failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to approve resume") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isReApproval = tenantRequest.status === "rejected";
|
||||
|
||||
// Derive tenant name from company name: lowercase, alphanumeric + hyphens
|
||||
const tenantName =
|
||||
tenantRequest.companyName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`;
|
||||
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
|
||||
// Slice 4: for personal accounts the slug is replaced by the literal
|
||||
// "p-" prefix so no PII is embedded in the K8s namespace name.
|
||||
const tenantName = deriveTenantName(
|
||||
tenantRequest.isPersonal ? "personal" : "company",
|
||||
tenantRequest.companyName,
|
||||
tenantRequest.id
|
||||
);
|
||||
|
||||
try {
|
||||
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)
|
||||
@@ -98,20 +165,96 @@ export async function POST(
|
||||
"TOOLS.md": toolsMd,
|
||||
};
|
||||
|
||||
// Step 4: Create the PiecedTenant CR
|
||||
// Step 4: Create the PiecedTenant CR.
|
||||
// displayName precedence:
|
||||
// 1. customer-chosen instance name (Slice 3 multi-tenant)
|
||||
// 2. for personal accounts, the contact name (avoids exposing the
|
||||
// synthetic "{name} (Personal)" company name in the OpenClaw UI)
|
||||
// 3. company name otherwise
|
||||
const displayName =
|
||||
tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0
|
||||
? tenantRequest.instanceName.trim()
|
||||
: tenantRequest.isPersonal
|
||||
? tenantRequest.contactName || "Assistant"
|
||||
: tenantRequest.companyName;
|
||||
|
||||
// Phase 9b: split the customer's initial channel-user ids into
|
||||
// (a) ids the operator needs in spec.channelUsers (telegram,
|
||||
// discord, …) — passed straight into createTenant
|
||||
// (b) Threema ids that ALSO need a relay route registered so
|
||||
// inbound messages reach this tenant. Threema is in (a)
|
||||
// AND (b): spec.channelUsers tells the operator the id is
|
||||
// authorized; the relay's route maps inbound traffic from
|
||||
// that id to this tenant.
|
||||
const initialChannelUsers = tenantRequest.channelUsers ?? {};
|
||||
// Strip channels the customer didn't actually enable (defensive
|
||||
// — the wizard already filters this, but the row could carry
|
||||
// stale data if the customer edited their request post-submit).
|
||||
const filteredChannelUsers: Record<string, string[]> = {};
|
||||
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
|
||||
if (!packages.includes(channel)) continue;
|
||||
const cleaned = (ids ?? [])
|
||||
.map((s) => (s ?? "").trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (cleaned.length > 0) {
|
||||
filteredChannelUsers[channel] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
await createTenant(
|
||||
tenantName,
|
||||
{
|
||||
displayName: tenantRequest.companyName,
|
||||
displayName,
|
||||
agentName: tenantRequest.agentName,
|
||||
packages,
|
||||
workspaceFiles,
|
||||
...(Object.keys(filteredChannelUsers).length > 0
|
||||
? { channelUsers: filteredChannelUsers }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
||||
// Bug 7: stamp the personal flag on the CR so callers (notably
|
||||
// the tenant detail page) can hide assignment-related UI
|
||||
// without an extra DB join. Slice 4 already tracks this on the
|
||||
// request row; the CR label is the same fact at the K8s layer.
|
||||
// Legacy tenants approved before this change won't carry the
|
||||
// label — operators can backfill with `kubectl label`.
|
||||
...(tenantRequest.isPersonal
|
||||
? { "pieced.ch/personal": "true" }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
// Threema: register relay routes for each id the customer
|
||||
// entered. Best-effort — a route failure doesn't unwind the
|
||||
// tenant creation (admin can retry from the tenant page later).
|
||||
// The Threema package itself isn't enabled on the tenant until
|
||||
// the customer toggles it from the tenant detail page (which
|
||||
// also mints the per-tenant token); the routes here pre-warm
|
||||
// the relay so the first toggle works without re-typing the id.
|
||||
if (
|
||||
packages.includes("threema") &&
|
||||
filteredChannelUsers.threema &&
|
||||
filteredChannelUsers.threema.length > 0
|
||||
) {
|
||||
for (const tid of filteredChannelUsers.threema) {
|
||||
try {
|
||||
const res = await createRelayRoute(tenantName, tid);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update request status — clear admin notes on re-approval
|
||||
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
||||
adminNotes: isReApproval ? null : adminNotes,
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||
import { sendRejectionEmail } from "@/lib/email";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/reject
|
||||
* Reject a tenant request and notify the customer.
|
||||
*
|
||||
* For resume requests (Bug 37a): also clears the
|
||||
* `pieced.ch/resume-request-pending` annotation on the tenant CR.
|
||||
* The operator's 60-day TTL then resumes counting from the original
|
||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||
* a fresh resume request later if circumstances change, but that
|
||||
* starts a new pending row and re-stamps the annotation.
|
||||
*
|
||||
* Phase 9b: provision rejections that have a linked paid setup
|
||||
* invoice (setup_invoice_id) trigger an automatic full refund via
|
||||
* the existing refundInvoice flow. The refund creates a credit
|
||||
* note + Stripe refund + customer email — same paper trail any
|
||||
* post-payment refund would have. Best-effort: a refund failure
|
||||
* does NOT block the rejection (admin can re-refund manually via
|
||||
* the invoice detail page if needed), but it's logged and surfaced
|
||||
* in the response so admin sees what happened.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user: SessionUser;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -37,16 +61,106 @@ export async function POST(
|
||||
adminNotes,
|
||||
});
|
||||
|
||||
// Notify customer
|
||||
await sendRejectionEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName,
|
||||
adminNotes
|
||||
);
|
||||
// Resume rejection: clear the annotation so the operator's TTL
|
||||
// resumes. Best-effort — failure is logged, not propagated.
|
||||
if (
|
||||
tenantRequest.requestType === "resume" &&
|
||||
tenantRequest.tenantName
|
||||
) {
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
tenantRequest.tenantName,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"post-reject annotation clear failed; operator's TTL will pause until annotation removed by admin",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: refund the setup-fee invoice if one is linked. Only
|
||||
// applies to provision rejections; resume requests never have a
|
||||
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
|
||||
// the request was created before Phase 9b shipped, or the setup
|
||||
// fee was 0).
|
||||
const refundSummary: {
|
||||
attempted: boolean;
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
} = { attempted: false, succeeded: false };
|
||||
if (
|
||||
tenantRequest.requestType === "provision" &&
|
||||
tenantRequest.setupInvoiceId
|
||||
) {
|
||||
refundSummary.attempted = true;
|
||||
try {
|
||||
// refundInvoice expects an explicit CHF amount (no "full"
|
||||
// sentinel). Compute the remaining refundable amount as
|
||||
// total minus what's already been refunded. For a fresh
|
||||
// setup-fee invoice this is just totalChf, but the formula
|
||||
// is robust if admin had partially refunded earlier (rare
|
||||
// but possible — same invoice could in theory get a manual
|
||||
// partial refund, then a rejection).
|
||||
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(
|
||||
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
|
||||
);
|
||||
}
|
||||
const remaining = Math.round(
|
||||
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
|
||||
) / 100;
|
||||
if (remaining <= 0) {
|
||||
refundSummary.succeeded = true; // nothing to refund — treat as success
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tenantRequest.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: adminNotes
|
||||
? `Tenant request rejected: ${adminNotes}`
|
||||
: "Tenant request rejected",
|
||||
refundedBy: user.id,
|
||||
});
|
||||
refundSummary.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refundSummary.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify customer. Resume requests get a different email — the
|
||||
// tenant already exists; copy needs to mention "stays suspended" and
|
||||
// the 60-day retention deadline. Provision rejections use the
|
||||
// original onboarding-rejection wording.
|
||||
if (tenantRequest.requestType === "resume") {
|
||||
await sendResumeRejectionEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName,
|
||||
adminNotes
|
||||
);
|
||||
} else {
|
||||
await sendRejectionEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName,
|
||||
adminNotes
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Request rejected.",
|
||||
request: updated,
|
||||
refund: refundSummary,
|
||||
});
|
||||
}
|
||||
|
||||
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
recordSkillEvents,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { listOrgUsers } from "@/lib/zitadel";
|
||||
import { sendSkillActivationApprovalEmail } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/skills/pending/[id]/approve
|
||||
*
|
||||
* Atomic-ish approval. Ordering:
|
||||
* 1. Load + sanity-check the request (must be pending).
|
||||
* 2. Patch the tenant CR to include the skill in spec.packages.
|
||||
* 3. Record the skill_event (kind=enabled) for billing.
|
||||
* 4. Flip the request row to 'approved'.
|
||||
* 5. Best-effort approval email to the requester.
|
||||
*
|
||||
* Step 2 is the irreversible one — if it succeeds but step 4 fails
|
||||
* we end up with a skill enabled in K8s but a still-pending request
|
||||
* row. That's a manual cleanup task; we log loudly so admin notices
|
||||
* via the queue page (the request would reappear there).
|
||||
*
|
||||
* The request must be in 'pending' status. Approving an already-
|
||||
* approved/rejected request returns 409.
|
||||
*
|
||||
* Body (optional): { adminNotes?: string }
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let admin;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
admin = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const adminNotes =
|
||||
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
|
||||
? body.adminNotes
|
||||
: null;
|
||||
|
||||
// 1. Load + sanity-check.
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Patch the tenant CR — add the skill if not already present.
|
||||
// Defensive: if the tenant was deleted or the skill was somehow
|
||||
// added by another path, we still proceed without duplicate.
|
||||
let tenant;
|
||||
try {
|
||||
tenant = await getTenant(req.tenantName);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tenant ${req.tenantName} not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
|
||||
const alreadyEnabled = currentPackages.has(req.skillId);
|
||||
if (!alreadyEnabled) {
|
||||
currentPackages.add(req.skillId);
|
||||
try {
|
||||
await patchTenantSpec(req.tenantName, {
|
||||
packages: [...currentPackages],
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record skill event (only if we actually added it — re-adding
|
||||
// would skew the day-count). Best-effort.
|
||||
if (!alreadyEnabled) {
|
||||
try {
|
||||
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to record skill_event after approve (request ${id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Flip request to approved.
|
||||
const updated = await updateSkillActivationRequestStatus(id, "approved", {
|
||||
reviewedBy: admin.id,
|
||||
adminNotes,
|
||||
});
|
||||
if (!updated) {
|
||||
// Race: another admin tab flipped it between our read and now.
|
||||
// The K8s patch already happened so we don't roll back; log so
|
||||
// the human notices.
|
||||
console.error(
|
||||
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Request status changed during approval; the skill may have been enabled. Check the queue.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Email the requester (best-effort). Look up their email via
|
||||
// ZITADEL since we only stored the userId on the request.
|
||||
try {
|
||||
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||
if (requester?.email) {
|
||||
const def = getPackageDef(req.skillId);
|
||||
await sendSkillActivationApprovalEmail({
|
||||
to: requester.email,
|
||||
contactName: requester.displayName || requester.email,
|
||||
skillName: def?.name ?? req.skillId,
|
||||
tenantName: req.tenantName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to send approval email for request ${id}:`, e);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
129
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
129
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { listOrgUsers } from "@/lib/zitadel";
|
||||
import { sendSkillActivationRejectionEmail } from "@/lib/email";
|
||||
import { deletePackageSecrets } from "@/lib/openbao";
|
||||
|
||||
/**
|
||||
* POST /api/admin/skills/pending/[id]/reject
|
||||
*
|
||||
* Reject a pending activation request with a required reason that
|
||||
* is shown to the customer (mirroring the tenant-request rejection
|
||||
* flow). The skill is NOT added to the tenant spec — it was never
|
||||
* there in the first place — so the customer's enable attempt is
|
||||
* effectively cancelled. They can try again from their tenant
|
||||
* settings after seeing the reason (a new pending row will be
|
||||
* created by their next toggle).
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* reason: string (1..1000 chars, required),
|
||||
* adminNotes?: string (optional, not shown to customer)
|
||||
* }
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
reason: z.string().min(1).max(1000),
|
||||
adminNotes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let admin;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
admin = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
|
||||
reviewedBy: admin.id,
|
||||
rejectionReason: parsed.data.reason,
|
||||
adminNotes: parsed.data.adminNotes ?? null,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request status changed during rejection." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup: if the package needed customer-provided secrets, the
|
||||
// user submitted them BEFORE the gate fired (handleSubmitSecrets
|
||||
// in PackageCard writes to OpenBao then PATCHes). Those secrets
|
||||
// are now orphaned — the package never made it into spec, won't
|
||||
// be re-attempted unless the user retries with fresh credentials.
|
||||
// Best-effort delete: keep the OpenBao path clean, avoid stale
|
||||
// creds lurking. Idempotent (404 is fine). Failure is logged but
|
||||
// not propagated — the rejection itself already succeeded.
|
||||
//
|
||||
// We deliberately skip customProvisioning packages here. Those
|
||||
// mint platform-side credentials via a dedicated endpoint and
|
||||
// need symmetric deprovisioning (POST /[pkg.id] → DELETE
|
||||
// /[pkg.id]). Calling deletePackageSecrets wouldn't revoke them
|
||||
// — admin handles that path manually if the rejected request had
|
||||
// already minted resources.
|
||||
const def = getPackageDef(req.skillId);
|
||||
if (def?.requiresSecrets && !def.customProvisioning) {
|
||||
try {
|
||||
await deletePackageSecrets(req.tenantName, req.skillId);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after reject:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Email the requester with the reason — best-effort.
|
||||
try {
|
||||
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||
if (requester?.email) {
|
||||
const def = getPackageDef(req.skillId);
|
||||
await sendSkillActivationRejectionEmail({
|
||||
to: requester.email,
|
||||
contactName: requester.displayName || requester.email,
|
||||
skillName: def?.name ?? req.skillId,
|
||||
tenantName: req.tenantName,
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to send rejection email for request ${id}:`, e);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
22
src/app/api/admin/skills/pending/route.ts
Normal file
22
src/app/api/admin/skills/pending/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listPendingSkillActivationRequests } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/skills/pending
|
||||
*
|
||||
* List all pending skill-activation requests across all tenants
|
||||
* and orgs. Powers the admin queue at /admin/skills/pending.
|
||||
*
|
||||
* Platform-role only. Returns up to 500 rows oldest-first so the
|
||||
* queue UI shows the oldest requests at the top (FIFO).
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const rows = await listPendingSkillActivationRequests();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenant, deleteTenant } from "@/lib/k8s";
|
||||
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
|
||||
import {
|
||||
markTenantRequestDeletedByTenantName,
|
||||
removeAllAssignmentsForTenant,
|
||||
recordTenantDeleted,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants/[name]/delete
|
||||
* Delete a PiecedTenant CR. The operator handles cleanup
|
||||
* (namespace, vault, litellm team, etc.).
|
||||
*
|
||||
* Slice 6: also cascades the tenant_user_assignments rows so a
|
||||
* future tenant with the same name (won't happen given UUID-suffix
|
||||
* naming, but defense in depth) doesn't inherit stale assignments.
|
||||
*
|
||||
* Also marks the associated tenant_request as "deleted" so the
|
||||
* customer can re-submit the onboarding wizard.
|
||||
*/
|
||||
@@ -31,10 +40,23 @@ export async function POST(
|
||||
try {
|
||||
await deleteTenant(name);
|
||||
|
||||
// Mark the associated tenant_request as "deleted" so the customer
|
||||
// sees the wizard again instead of a stale "active" status
|
||||
// Best-effort DB cleanups. Both errors are logged but not surfaced —
|
||||
// the K8s deletion has already started, and the row state is just
|
||||
// for portal display.
|
||||
await markTenantRequestDeletedByTenantName(name).catch((e) =>
|
||||
console.error("Failed to update tenant request after delete:", e)
|
||||
console.error("Failed to mark tenant request deleted:", e)
|
||||
);
|
||||
await removeAllAssignmentsForTenant(name).catch((e) =>
|
||||
console.error("Failed to clean up tenant assignments:", e)
|
||||
);
|
||||
|
||||
// Billing — Phase 1: stamp deletion timestamp on the lifecycle
|
||||
// row so the final invoice covering the deletion month can
|
||||
// prorate correctly. Idempotent at the DB layer; a missing
|
||||
// lifecycle row (e.g. pre-Phase-1 tenants that haven't been
|
||||
// backfilled yet) makes this a no-op.
|
||||
await recordTenantDeleted(name).catch((e) =>
|
||||
console.error("billing: failed to stamp tenant deletion:", e)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
78
src/app/api/admin/tenants/[name]/openclaw-image/route.ts
Normal file
78
src/app/api/admin/tenants/[name]/openclaw-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Per-tenant OpenClaw image override (admin-only).
|
||||
*
|
||||
* Why admin-only: customers cannot pick OpenClaw versions. This
|
||||
* exists so the platform team can A/B-test new releases on specific
|
||||
* tenants without rolling them out fleet-wide. The endpoint enforces
|
||||
* `user.isPlatform`; even owners of the tenant's org cannot use it.
|
||||
*
|
||||
* PATCH body shapes:
|
||||
* - { tag: "2026.4.22" } → use this tag
|
||||
* - { tag: "" } or empty body → clear override (revert to platform
|
||||
* default)
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
tag: z.string().trim().max(256).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tag = parsed.data.tag ?? "";
|
||||
const isClearing = tag === "";
|
||||
|
||||
// Merge-patch semantics: openClawImage: null removes the field
|
||||
// from the spec; openClawImage: { tag } sets it.
|
||||
const spec: any = isClearing
|
||||
? { openClawImage: null }
|
||||
: { openClawImage: { tag } };
|
||||
|
||||
try {
|
||||
const updated = await patchTenantSpec(name, spec);
|
||||
return NextResponse.json({
|
||||
message: isClearing
|
||||
? "Override cleared; tenant follows platform default."
|
||||
: "Override set.",
|
||||
openClawImage: updated.spec.openClawImage ?? null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Failed to set tenant openclaw image:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update tenant image") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { recordSuspensionEvent } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,32 @@ export async function POST(
|
||||
|
||||
try {
|
||||
const updated = await patchTenantSpec(name, { suspend });
|
||||
|
||||
// Billing — Phase 1: record the transition. Mirrors the same
|
||||
// hook in the customer-side suspend route so admin actions
|
||||
// also produce events. Best-effort; logging failures don't
|
||||
// block the response.
|
||||
try {
|
||||
const orgId =
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||
if (orgId) {
|
||||
await recordSuspensionEvent(
|
||||
name,
|
||||
orgId,
|
||||
suspend ? "suspended" : "resumed"
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`billing: failed to record suspension event for ${name}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: suspend ? "Tenant suspended." : "Tenant resumed.",
|
||||
tenant: updated,
|
||||
|
||||
27
src/app/api/billing/auto-charge/route.ts
Normal file
27
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* POST /api/billing/auto-charge — RETIRED.
|
||||
*
|
||||
* Auto-pay is no longer a customer-toggleable setting. A saved
|
||||
* card on file is the consent to auto-bill; customers manage their
|
||||
* card via update/remove on /settings/billing, nothing else. The
|
||||
* auto_charge_enabled flag is now an admin-only pause used during
|
||||
* disputes, set from /admin/billing/orgs.
|
||||
*
|
||||
* This route is kept as an explicit 410 (Gone) so any stale client
|
||||
* that still POSTs here fails loudly rather than silently toggling
|
||||
* a flag the customer shouldn't control. The old behaviour lived
|
||||
* here through Phase 9b-2.
|
||||
*/
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Auto-pay can no longer be disabled. A saved card is required for service. " +
|
||||
"Contact support if you need to switch to bank-transfer billing.",
|
||||
code: "auto_pay_not_toggleable",
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
75
src/app/api/billing/current/route.ts
Normal file
75
src/app/api/billing/current/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { computeInvoiceDraft } from "@/lib/billing";
|
||||
import { listInvoices } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/current
|
||||
*
|
||||
* Running total for the current calendar month — what the
|
||||
* customer will be billed if no further activity happens. Uses
|
||||
* the same compute pipeline as the final invoice (LiteLLM spend,
|
||||
* Threema usage, skill day-counting, proration) so the number
|
||||
* the customer sees matches what they'll eventually receive
|
||||
* within the limits of intra-month drift.
|
||||
*
|
||||
* If an invoice has ALREADY been issued for the current month
|
||||
* (e.g. cron ran early, admin manually generated), we return
|
||||
* that issued invoice instead — no point showing a draft that
|
||||
* duplicates a real invoice.
|
||||
*
|
||||
* Returns:
|
||||
* { issued: Invoice } // current-month invoice exists
|
||||
* { draft: InvoiceDraft } // still accruing
|
||||
* { error: ... } // org missing billing config
|
||||
*
|
||||
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
|
||||
* DB queries per skill. Sub-second typically. No caching; called
|
||||
* on demand from the customer billing page.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Resolve current calendar month from UTC. Billing is UTC-day
|
||||
// based throughout (see billing.ts iterDays comment), so the
|
||||
// running total inherits that same semantics.
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth() + 1; // 1-12
|
||||
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
|
||||
|
||||
// 1. Has the current month already been invoiced?
|
||||
const existing = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
periodMonth,
|
||||
limit: 1,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({ issued: existing[0] });
|
||||
}
|
||||
|
||||
// 2. Otherwise compute the draft. Falls through to error if the
|
||||
// org doesn't have a billing config yet (no Address on file).
|
||||
try {
|
||||
const draft = await computeInvoiceDraft({
|
||||
zitadelOrgId: user.orgId,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e: any) {
|
||||
// Most likely: org_billing row missing. We surface a 200 with a
|
||||
// soft error code rather than 500 — the customer-side widget
|
||||
// displays a helpful "complete your billing details" message
|
||||
// instead of a stack trace.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e?.message ?? "Could not compute running total.",
|
||||
code: e?.code ?? "COMPUTE_FAILED",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceByNumberForOrg,
|
||||
getOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
createCheckoutSessionForInvoice,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/invoices/[invoiceNumber]/pay
|
||||
*
|
||||
* Initiates a Stripe Checkout Session for an open invoice. Returns
|
||||
* `{ url }` — the browser is expected to navigate to that URL,
|
||||
* where Stripe hosts the payment UI.
|
||||
*
|
||||
* Authorization: caller must belong to the invoice's org (the DB
|
||||
* query enforces this — wrong-org returns 404, indistinguishable
|
||||
* from a non-existent invoice).
|
||||
*
|
||||
* Preconditions enforced server-side:
|
||||
* - Invoice exists for caller's org
|
||||
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
|
||||
* all reject — already-paid invoices in particular must not
|
||||
* create a second Checkout Session, even though Stripe would
|
||||
* deduplicate the actual charge)
|
||||
*
|
||||
* The Stripe Customer for the org is lazily ensured here — first
|
||||
* card click on an org creates the customer; subsequent clicks
|
||||
* reuse the persisted stripe_customer_id.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const inv = detail.invoice;
|
||||
if (inv.status !== "open" && inv.status !== "overdue") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
inv.status === "paid"
|
||||
? "This invoice has already been paid."
|
||||
: `This invoice cannot be paid online (status: ${inv.status}).`,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// We need org_billing for the customer creation address. The
|
||||
// invoice has a SNAPSHOT but that's frozen at issue time; for
|
||||
// creating/updating the Stripe customer we want the current
|
||||
// address (which may have been corrected since the invoice).
|
||||
// Snapshot is still authoritative on the invoice PDF and total.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
if (!orgBilling) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing details are not configured for your organization." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const customerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: orgBilling.companyName,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
address: {
|
||||
line1: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createCheckoutSessionForInvoice({
|
||||
invoice: inv,
|
||||
customerId,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
|
||||
e
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card payment.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices/[invoiceNumber]/pdf
|
||||
*
|
||||
* Customer-facing PDF download. Same Uint8Array.from() variance
|
||||
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
|
||||
* for the rationale.
|
||||
*
|
||||
* Authorization: looks up the invoice by number with org scope
|
||||
* baked into the query, then re-fetches the PDF blob by id. A
|
||||
* customer can't probe another org's invoice numbers — they get
|
||||
* 404 either way.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
const pdf = await getInvoicePdf(detail.invoice.id);
|
||||
if (!pdf) {
|
||||
return new NextResponse("PDF not available", { status: 404 });
|
||||
}
|
||||
const body = Uint8Array.from(pdf.data);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices/[invoiceNumber]
|
||||
*
|
||||
* Customer-scoped detail lookup by invoice number (the human-
|
||||
* readable YYYY-NNNNN format the customer sees on the PDF). The
|
||||
* org filter is part of the DB query — a customer probing another
|
||||
* org's invoice number gets the same 404 as a non-existent one.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
39
src/app/api/billing/invoices/route.ts
Normal file
39
src/app/api/billing/invoices/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices
|
||||
*
|
||||
* Customer-scoped list of invoices for the caller's org. Returns
|
||||
* a flat array of Invoice headers (no line items — those are
|
||||
* fetched separately by /[invoiceNumber]).
|
||||
*
|
||||
* Status filter is implicit: we return every invoice the
|
||||
* customer's org has, all statuses (issued/paid/overdue/void)
|
||||
* because the customer wants a single billing-history view.
|
||||
*
|
||||
* Before returning we run syncOverdueInvoices() so the displayed
|
||||
* status reflects the current date — issued invoices past their
|
||||
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
|
||||
* a separate cron for this transition.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Personal accounts have an org too — they share the same shape;
|
||||
// their invoices show up under that synthetic org id.
|
||||
try {
|
||||
await syncOverdueInvoices();
|
||||
} catch (e) {
|
||||
// Non-fatal — display stale status rather than 500.
|
||||
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
|
||||
}
|
||||
const invoices = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
limit: 200,
|
||||
});
|
||||
return NextResponse.json(invoices);
|
||||
}
|
||||
128
src/app/api/billing/route.ts
Normal file
128
src/app/api/billing/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Org-scoped billing API (Bug 35).
|
||||
*
|
||||
* GET — return the current billing record for the caller's org, or
|
||||
* 404 if none has been captured yet. The /settings/billing page
|
||||
* renders an empty form on 404 (first-time edit) and a pre-filled
|
||||
* form on 200.
|
||||
*
|
||||
* PUT — upsert the billing record. Required for any subsequent tenant
|
||||
* provisioning unless the caller is on a personal org. Validation:
|
||||
* - All address fields required.
|
||||
* - VAT number required for company orgs (where `user.isPersonal`
|
||||
* is false). Optional for personal orgs.
|
||||
* - billing_email validated as RFC-5322-ish.
|
||||
*
|
||||
* Authorization:
|
||||
* - GET: any authenticated user in the org. We expose only their
|
||||
* own org's billing — orgId is scoped from the session.
|
||||
* - PUT: owners and platform admins (canMutate check). Customers
|
||||
* in `user` role cannot edit billing.
|
||||
*/
|
||||
|
||||
const billingSchema = z.object({
|
||||
companyName: z.string().min(1).max(200),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
postalCode: z.string().min(1).max(20),
|
||||
city: z.string().min(1).max(100),
|
||||
country: z.string().min(2).max(3), // ISO 3166-1 alpha-2 or alpha-3
|
||||
vatNumber: z
|
||||
.string()
|
||||
.max(50)
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v && v.trim() !== "" ? v.trim() : null)),
|
||||
billingEmail: z.string().email().max(200),
|
||||
notes: z
|
||||
.string()
|
||||
.max(2000)
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v && v.trim() !== "" ? v.trim() : null)),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
if (!billing) {
|
||||
// 404 carries semantic meaning here — "no record yet". Callers
|
||||
// (settings page, wizard) treat this as the empty-form state.
|
||||
return NextResponse.json(
|
||||
{ error: "No billing record for this org" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = billingSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Company orgs (B2B) require companyName AND VAT. Personal orgs
|
||||
// (B2C — private individuals) need neither; their /settings/billing
|
||||
// form hides both fields and we don't ask the API to enforce them.
|
||||
if (!user.isPersonal) {
|
||||
const missing: Record<string, string[]> = {};
|
||||
if (!parsed.data.companyName || parsed.data.companyName.trim().length === 0) {
|
||||
missing.companyName = ["Required for companies"];
|
||||
}
|
||||
if (!parsed.data.vatNumber) {
|
||||
missing.vatNumber = ["Required for companies"];
|
||||
}
|
||||
if (Object.keys(missing).length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Company name and VAT number are required for company accounts.",
|
||||
details: { fieldErrors: missing },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: parsed.data.companyName,
|
||||
streetAddress: parsed.data.streetAddress,
|
||||
postalCode: parsed.data.postalCode,
|
||||
city: parsed.data.city,
|
||||
country: parsed.data.country,
|
||||
vatNumber: parsed.data.vatNumber,
|
||||
billingEmail: parsed.data.billingEmail,
|
||||
notes: parsed.data.notes,
|
||||
});
|
||||
return NextResponse.json({ billing });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to upsert org billing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to save billing") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/billing/saved-card/route.ts
Normal file
46
src/app/api/billing/saved-card/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
|
||||
import { detachPaymentMethod } from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* DELETE /api/billing/saved-card
|
||||
*
|
||||
* Phase 9. Remove the saved card for the caller's org. Detaches
|
||||
* the PaymentMethod in Stripe (so it can't be charged again) and
|
||||
* clears the four display columns + the pm_id reference locally.
|
||||
*
|
||||
* Idempotent: calling on an org with no saved card returns 200
|
||||
* (the desired end-state is already reached).
|
||||
*
|
||||
* Auth: any signed-in member of the org. Same reasoning as the
|
||||
* setup endpoint — card removal is a customer-visible action; it
|
||||
* doesn't leak anything, and a non-owner needing to remove a
|
||||
* stolen-card-on-file shouldn't be blocked by role gating.
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const cfg = await getOrgBillingConfig(user.orgId);
|
||||
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
|
||||
// Already empty — no-op, return success.
|
||||
return NextResponse.json({ removed: false });
|
||||
}
|
||||
// Stripe detach first. If it fails for a real reason (network,
|
||||
// 500 from Stripe), we don't clear the DB — admin can retry.
|
||||
// 404 is treated as success by detachPaymentMethod (PM already
|
||||
// gone), so we proceed to clear the DB regardless.
|
||||
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
|
||||
await clearSavedPaymentMethod(user.orgId);
|
||||
return NextResponse.json({ removed: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to remove card") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/billing/setup-card/route.ts
Normal file
75
src/app/api/billing/setup-card/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import {
|
||||
createSetupCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/setup-card
|
||||
*
|
||||
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
|
||||
* flow. Creates a Checkout session in setup mode and returns its
|
||||
* URL — the caller redirects the browser. On completion, the
|
||||
* webhook handler saves the resulting PaymentMethod's display
|
||||
* fields against this org's billing config.
|
||||
*
|
||||
* Auth: any signed-in member of the org. We don't owner-gate this
|
||||
* because non-owners might legitimately need to update payment
|
||||
* (e.g., for a team they administer). The actual card data is
|
||||
* collected by Stripe, not us — there's nothing to leak from
|
||||
* misuse here.
|
||||
*
|
||||
* Requires an existing billing snapshot (org_billing row). If
|
||||
* absent, returns 400 — the customer hasn't set their billing
|
||||
* address yet, and Stripe needs the address for the customer
|
||||
* object.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
if (!orgBilling) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing address required before saving a card." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Ensure the Stripe customer exists. Idempotent — if we
|
||||
// already created one for this org (e.g. from a prior
|
||||
// "Pay by Card" Checkout), it's reused.
|
||||
const customerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: orgBilling.companyName,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
address: {
|
||||
line1: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
},
|
||||
});
|
||||
// Base URL for redirect targets — must be the public-facing
|
||||
// origin since Stripe redirects the browser back. Behind an
|
||||
// ingress (Cedric's setup) request.url is the internal pod
|
||||
// address ("0.0.0.0:3000" / cluster.svc), useless for the
|
||||
// browser. Same env-var pattern as the invoice pay endpoint.
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const session = await createSetupCheckoutSession({
|
||||
customerId,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card setup") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getCreditNoteByNumber,
|
||||
getCreditNoteByNumberForOrg,
|
||||
getCreditNotePdf,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/credit-notes/[number]/pdf
|
||||
*
|
||||
* Phase 7. Customer-facing PDF download for a credit note. Returns
|
||||
* the binary PDF with Content-Disposition: inline so the browser
|
||||
* renders it in-tab (matching the invoice download behaviour). The
|
||||
* customer's email links here.
|
||||
*
|
||||
* Authorization:
|
||||
* - The caller must be authenticated.
|
||||
* - For customer-org callers, the credit note must belong to their
|
||||
* org (orgId-scoped lookup).
|
||||
* - Platform admins can fetch any credit note (cross-org lookup).
|
||||
*
|
||||
* Returns 404 in both "doesn't exist" and "exists but not yours"
|
||||
* cases — leak-safe identical to invoice lookup.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { number } = await params;
|
||||
// URL-decoded number — the route param comes URL-encoded.
|
||||
const decodedNumber = decodeURIComponent(number);
|
||||
const cn = user.isPlatform
|
||||
? await getCreditNoteByNumber(decodedNumber)
|
||||
: await getCreditNoteByNumberForOrg(decodedNumber, user.orgId);
|
||||
if (!cn) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const pdf = await getCreditNotePdf(cn.id);
|
||||
if (!pdf) {
|
||||
// The credit note exists but the PDF was never attached. Most
|
||||
// likely a render failure during issuance — the credit note
|
||||
// row is still authoritative, the PDF needs re-rendering.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Credit note exists but its PDF has not been rendered. Please contact support.",
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
return new NextResponse(new Uint8Array(pdf.data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
42
src/app/api/cron/issue-monthly/route.ts
Normal file
42
src/app/api/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/cron/issue-monthly
|
||||
*
|
||||
* Machine entry point for the monthly issuance sweep. Authentication
|
||||
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
|
||||
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
|
||||
*
|
||||
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
|
||||
* https://app.pieced.ch/api/cron/issue-monthly
|
||||
*
|
||||
* The sweep targets the calendar month that ended just before
|
||||
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
|
||||
* time bills May; running it on July 5th bills June; etc. The
|
||||
* uniqueness constraint on (org, period_start) makes re-runs
|
||||
* harmless — already-issued orgs are counted as skipped.
|
||||
*
|
||||
* Returns the summary {success, failure, skipped} JSON. The
|
||||
* CronJob doesn't look at the response body (just the status
|
||||
* code) but having a useful one helps debugging via curl.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runMonthlyIssuance({
|
||||
triggeredBy: "cron",
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Issuance sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/cron/send-reminders/route.ts
Normal file
33
src/app/api/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/cron/send-reminders
|
||||
*
|
||||
* Machine entry point for the daily reminder sweep. Same auth
|
||||
* (bearer token in CRON_BEARER_TOKEN) and the same response
|
||||
* contract as /api/cron/issue-monthly.
|
||||
*
|
||||
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
|
||||
* past their due date and haven't received the corresponding
|
||||
* reminder level yet; sends one email per invoice per run.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runReminderSweep({
|
||||
triggeredBy: "cron",
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Reminder sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
65
src/app/api/onboarding/[id]/dismiss/route.ts
Normal file
65
src/app/api/onboarding/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { dismissTenantRequest, getTenantRequestById } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/[id]/dismiss
|
||||
*
|
||||
* Customer-side acknowledgement of a rejected or cancelled request
|
||||
* (Bug 13). Sets `dismissed_at = now()` so the row stops appearing
|
||||
* in the dashboard's `listActiveTenantRequestsByOrgId` query. The
|
||||
* row itself is preserved for audit.
|
||||
*
|
||||
* Authorization mirrors the GET / DELETE / PATCH endpoints on this
|
||||
* resource: customer owners (or platform staff) of the row's org.
|
||||
*
|
||||
* Idempotent: dismissing an already-dismissed request returns 200
|
||||
* with no change. We refuse to dismiss non-terminal rows (pending,
|
||||
* approved, provisioning, active) — those are still actionable, and
|
||||
* "hiding" them would stash live state from the customer.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const tr = await getTenantRequestById(id);
|
||||
if (!tr) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (tr.status !== "rejected" && tr.status !== "cancelled") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Only rejected or cancelled requests can be dismissed. Active requests stay visible.",
|
||||
code: "not_dismissable",
|
||||
currentStatus: tr.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await dismissTenantRequest(id);
|
||||
return NextResponse.json({ message: "Dismissed.", id });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to dismiss request:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to dismiss request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
274
src/app/api/onboarding/[id]/route.ts
Normal file
274
src/app/api/onboarding/[id]/route.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
updateTenantRequestEditableFields,
|
||||
} from "@/lib/db";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser, TenantRequest } from "@/types";
|
||||
|
||||
/**
|
||||
* Customer-side controls for a single tenant_request row.
|
||||
*
|
||||
* - DELETE /api/onboarding/[id] → cancel a still-pending request
|
||||
* - PATCH /api/onboarding/[id] → edit fields of a still-pending
|
||||
* request (Bug 6)
|
||||
*
|
||||
* Both endpoints share the same authorization check: the caller must
|
||||
* be a customer owner (or platform staff) of the request's org. We
|
||||
* also enforce status === 'pending' on the row — once an admin has
|
||||
* acted on it, the customer can no longer mutate it from the portal.
|
||||
*
|
||||
* Reading these is via the existing GET /api/onboarding?id=... handler.
|
||||
*/
|
||||
|
||||
async function loadAuthorized(
|
||||
id: string
|
||||
): Promise<
|
||||
| { error: NextResponse }
|
||||
| { req: TenantRequest; user: SessionUser }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
const tr = await getTenantRequestById(id);
|
||||
if (!tr) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
// Customers may only read their own org's requests; platform users
|
||||
// may read any. Same scope as `GET /api/onboarding?id=...`.
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
return { req: tr, user };
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/onboarding/[id]
|
||||
*
|
||||
* Customer cancels a still-pending request. Status flips to 'cancelled';
|
||||
* the row is preserved for audit. The customer can dismiss the
|
||||
* cancelled card afterwards (Bug 13 reuse — same dismissal mechanism).
|
||||
*
|
||||
* Once admin has approved/provisioned/rejected, this endpoint refuses
|
||||
* (409). Cancelling a tenant that's already running goes through the
|
||||
* subscription-suspend flow on the tenant detail page, not here.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const loaded = await loadAuthorized(id);
|
||||
if ("error" in loaded) return loaded.error;
|
||||
const tr = loaded.req!;
|
||||
|
||||
if (tr.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.",
|
||||
code: "not_pending",
|
||||
currentStatus: tr.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTenantRequestStatus(id, "cancelled");
|
||||
|
||||
// Phase 9b: a 'pending' provision request has already had its
|
||||
// setup fee charged (the order-time Checkout completed before
|
||||
// the webhook flipped it to 'pending'). Cancelling it must
|
||||
// refund that payment, exactly as an admin rejection does.
|
||||
// Resume requests never carry a setup_invoice_id, so this only
|
||||
// fires for provision orders. Best-effort: a refund failure is
|
||||
// logged + surfaced but doesn't block the cancellation (admin
|
||||
// can refund manually from the invoice page).
|
||||
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
|
||||
attempted: false,
|
||||
succeeded: false,
|
||||
};
|
||||
if (tr.requestType === "provision" && tr.setupInvoiceId) {
|
||||
refund.attempted = true;
|
||||
try {
|
||||
const inv = await getInvoiceById(tr.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
|
||||
}
|
||||
const remaining =
|
||||
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
|
||||
if (remaining <= 0) {
|
||||
refund.succeeded = true; // nothing left to refund
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tr.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: "Order cancelled by customer",
|
||||
refundedBy: loaded.user!.id,
|
||||
});
|
||||
refund.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refund.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Customer cancels their own pending resume request: clear the
|
||||
// operator-side annotation so the 60-day TTL resumes counting.
|
||||
// Best-effort — the operator handles missing annotation gracefully.
|
||||
if (tr.requestType === "resume" && tr.tenantName) {
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
tr.tenantName,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"post-cancel annotation clear failed; not blocking",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Request cancelled.", id, refund });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to cancel request:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to cancel request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/onboarding/[id]
|
||||
*
|
||||
* Customer edits a still-pending request. Validation is the same as on
|
||||
* POST /api/onboarding (shared schema). Only customer-input fields are
|
||||
* editable; status/tenant_name/admin_notes/etc. are server-managed.
|
||||
*
|
||||
* Note on company-level fields
|
||||
* ----------------------------
|
||||
* For a follow-up instance (org has prior approved rows), the POST
|
||||
* handler intentionally ignores the wizard's billingAddress and uses
|
||||
* the on-file value instead. We mirror that here: company-level fields
|
||||
* (companyName, contactName, contactEmail, billingAddress) on a
|
||||
* follow-up edit are NOT updated through this endpoint. The customer
|
||||
* should use a future settings page (Bug 11) for those. For now,
|
||||
* editing only mutates per-instance fields — agent name, instance
|
||||
* name, packages, soulMd, agentsMd, billingNotes, packageSecrets.
|
||||
*
|
||||
* For the FIRST instance (no prior approved rows), billingAddress IS
|
||||
* editable here, since the customer is still defining their company's
|
||||
* billing data.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const loaded = await loadAuthorized(id);
|
||||
if ("error" in loaded) return loaded.error;
|
||||
const tr = loaded.req!;
|
||||
|
||||
if (tr.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Only pending requests can be edited.",
|
||||
code: "not_pending",
|
||||
currentStatus: tr.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = onboardingSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
// Re-encrypt package secrets if present in the patch body. When the
|
||||
// user re-opens the wizard to edit, the secrets array is populated
|
||||
// afresh from the wizard (we never decrypt and return existing
|
||||
// secrets — that'd be a security regression). If the user didn't
|
||||
// touch any secret-bearing package, the wizard sends no
|
||||
// packageSecrets and we leave the existing encrypted blob alone.
|
||||
let encryptedSecrets: Buffer | null | undefined;
|
||||
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {
|
||||
try {
|
||||
encryptedSecrets = await encryptSecrets(input.packageSecrets);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to encrypt package secrets:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to secure credentials. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only first-instance edits get billingAddress; follow-ups inherit
|
||||
// company billing from the on-file approved row.
|
||||
const isFirstInstance = !tr.tenantName; // approximation; covers the
|
||||
// "no prior approved row for this org" case the POST handler treats
|
||||
// identically. A more rigorous check would call
|
||||
// getMostRecentApprovedRequestForOrg, but in practice an org with
|
||||
// an approved row for some other tenant has a tenantName on those
|
||||
// rows, not on the pending one being edited — so the simple check
|
||||
// here is fine for the only state the endpoint accepts (pending).
|
||||
|
||||
try {
|
||||
const updated = await updateTenantRequestEditableFields(id, {
|
||||
instanceName: input.instanceName,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress: isFirstInstance ? input.billingAddress : undefined,
|
||||
billingNotes: input.billingNotes,
|
||||
encryptedSecrets,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ message: "Request updated.", id });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to edit request:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to edit request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,186 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
getTenantRequestByOrgId,
|
||||
deleteTenantRequest,
|
||||
createTenantRequestPendingPayment,
|
||||
deletePendingPaymentRequest,
|
||||
getTenantRequestById,
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canUserSeeTenant,
|
||||
canSeeInflightRequests,
|
||||
} from "@/lib/visibility";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import type { OnboardingInput } from "@/types";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import {
|
||||
createSetupFeeCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
|
||||
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||
import type {
|
||||
InvoiceBillingSnapshot,
|
||||
OnboardingInput,
|
||||
PiecedTenant,
|
||||
TenantRequest,
|
||||
} from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
agentName: z.string().min(1).max(50),
|
||||
soulMd: z.string().max(10_000).optional(),
|
||||
agentsMd: z.string().max(10_000).optional(),
|
||||
packages: z.array(z.string()).optional(),
|
||||
packageSecrets: z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional(),
|
||||
billingAddress: z.object({
|
||||
company: z.string().optional(),
|
||||
street: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
}),
|
||||
billingNotes: z.string().max(2_000).optional(),
|
||||
});
|
||||
/**
|
||||
* Helper: shape a TenantRequest row for client consumption.
|
||||
* Hides server-only fields (encryptedSecrets, internal db ids).
|
||||
*/
|
||||
/**
|
||||
* Helper: shape a TenantRequest row for client consumption.
|
||||
* Hides server-only fields (encryptedSecrets, internal db ids).
|
||||
*
|
||||
* Slice 7 / Bug 6: surfaces enough fields for the customer-side edit
|
||||
* flow to pre-fill the wizard. soulMd, agentsMd, billingAddress,
|
||||
* billingNotes were previously kept off the public shape because the
|
||||
* pre-Slice-3 dashboard didn't render them. Edit needs them.
|
||||
*
|
||||
* Bug 13: surfaces dismissedAt so the dashboard can distinguish
|
||||
* "freshly rejected, show prominently" from "rejected and acknowledged,
|
||||
* keep hidden" without an extra API call.
|
||||
*/
|
||||
function publicRequestShape(r: TenantRequest) {
|
||||
return {
|
||||
id: r.id,
|
||||
instanceName: r.instanceName,
|
||||
agentName: r.agentName,
|
||||
soulMd: r.soulMd,
|
||||
agentsMd: r.agentsMd,
|
||||
packages: r.packages,
|
||||
billingAddress: r.billingAddress,
|
||||
billingNotes: r.billingNotes,
|
||||
status: r.status,
|
||||
adminNotes: r.adminNotes,
|
||||
tenantName: r.tenantName,
|
||||
dismissedAt: r.dismissedAt ?? null,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function publicTenantShape(t: PiecedTenant) {
|
||||
return {
|
||||
name: t.metadata.name,
|
||||
displayName: t.spec.displayName,
|
||||
phase: t.status?.phase ?? "Pending",
|
||||
suspended: t.spec.suspend ?? false,
|
||||
packages: t.spec.packages ?? [],
|
||||
creationTimestamp: t.metadata.creationTimestamp,
|
||||
conditions: t.status?.conditions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/onboarding
|
||||
* Check the current onboarding state for the logged-in user's org.
|
||||
*
|
||||
* Two response shapes depending on the `?id=` query:
|
||||
*
|
||||
* - With `?id=<requestId>`: returns the single request's status plus
|
||||
* the linked tenant's phase if approved. Used by ProvisioningStatus
|
||||
* to poll a specific request. The id is validated against the
|
||||
* caller's orgId so admins-and-only-admins can read across orgs.
|
||||
*
|
||||
* - Without `id`: returns lists of all in-flight requests and active
|
||||
* tenants for the caller's org. Used by the dashboard to render the
|
||||
* multi-tenant view.
|
||||
*
|
||||
* Slice 3 note: this replaces the old single-state response shape
|
||||
* (`{ state: "...", request: {...} }`). Pre-Slice-3 callers will see
|
||||
* the new shape and need to be updated. The only known caller is
|
||||
* `<ProvisioningStatus>`, updated in lockstep.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if there's already a running tenant for this org
|
||||
const allTenants = await listTenants();
|
||||
const myTenant = allTenants.find(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const requestedId = req.nextUrl.searchParams.get("id");
|
||||
|
||||
if (myTenant) {
|
||||
if (requestedId) {
|
||||
const tr = await getTenantRequestById(requestedId);
|
||||
if (!tr) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Customers may only read their own org's requests; platform
|
||||
// admins/operators may read any.
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Slice 6: a `user`-role customer doesn't see in-flight requests
|
||||
// even within their own org — they can't act on them and showing
|
||||
// the row would be a permanent "pending" state with no exit. Owner
|
||||
// and platform skip this gate.
|
||||
if (!canSeeInflightRequests(user)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let tenant: PiecedTenant | null = null;
|
||||
if (tr.tenantName) {
|
||||
tenant = (await getTenant(tr.tenantName)) ?? null;
|
||||
// If a request is already linked to a tenant CR and the caller
|
||||
// can't see that tenant (assignment scope), don't expose it via
|
||||
// the request endpoint either. canSeeInflightRequests above
|
||||
// already shortcuts this for `user`-role, but defense in depth.
|
||||
if (tenant && !(await canUserSeeTenant(user, tenant))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
state: "active",
|
||||
tenantName: myTenant.metadata.name,
|
||||
phase: myTenant.status?.phase ?? "Unknown",
|
||||
request: publicRequestShape(tr),
|
||||
tenant: tenant ? publicTenantShape(tenant) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's a pending request
|
||||
const request = await getTenantRequestByOrgId(user.orgId);
|
||||
// List view: requests + tenants for this org, filtered by visibility.
|
||||
// For owner/platform, this returns the same data as pre-Slice-6.
|
||||
// For user-role, requests is forced to [] and tenants is narrowed to
|
||||
// assignments.
|
||||
const [requests, allTenants] = await Promise.all([
|
||||
listActiveTenantRequestsByOrgId(user.orgId),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
if (!request || request.status === "deleted") {
|
||||
return NextResponse.json({ state: "no_request" });
|
||||
}
|
||||
const visibleTenants = await listVisibleTenants(user, allTenants);
|
||||
const visibleRequests = canSeeInflightRequests(user) ? requests : [];
|
||||
|
||||
return NextResponse.json({
|
||||
state: request.status,
|
||||
request: {
|
||||
id: request.id,
|
||||
agentName: request.agentName,
|
||||
packages: request.packages,
|
||||
status: request.status,
|
||||
adminNotes: request.adminNotes,
|
||||
tenantName: request.tenantName,
|
||||
createdAt: request.createdAt,
|
||||
},
|
||||
requests: visibleRequests.map(publicRequestShape),
|
||||
tenants: visibleTenants.map(publicTenantShape),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding
|
||||
* Submit the onboarding wizard. Creates a tenant_request with status "pending".
|
||||
* The actual PiecedTenant CR is NOT created yet — admin approval required.
|
||||
*
|
||||
* If packageSecrets are provided (for packages requiring credentials like
|
||||
* Telegram, Discord, Email), they are encrypted with AES-256-GCM and stored
|
||||
* as a BYTEA blob. They are decrypted only during admin approval to write
|
||||
* to OpenBao.
|
||||
* Always creates a NEW tenant_request row, regardless of how many other
|
||||
* rows already exist for this org. The pre-Slice-3 409 ("you already
|
||||
* have a request") is gone — multi-tenant is the design now.
|
||||
*
|
||||
* For additional instances in an existing company, the customer's prior
|
||||
* approved row is used to seed billing/contact info, so the wizard
|
||||
* doesn't need to re-collect data already on file. The wizard *does*
|
||||
* still send a billingAddress payload (the field is required by the
|
||||
* schema), but in practice the client can pre-fill it from
|
||||
* `getMostRecentApprovedRequestForOrg`.
|
||||
*
|
||||
* Encrypted package secrets, if provided, are AES-256-GCM-sealed and
|
||||
* stored as a BYTEA blob. They are decrypted only during admin approval
|
||||
* to write to OpenBao.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
@@ -90,6 +188,15 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Slice 5: only owners (or platform users) may create new instances.
|
||||
// A `user`-role member of an existing org cannot self-provision.
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only the organization owner can create new instances." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = onboardingSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
@@ -99,40 +206,53 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing request
|
||||
const existing = await getTenantRequestByOrgId(user.orgId);
|
||||
if (existing && existing.status !== "deleted") {
|
||||
return NextResponse.json(
|
||||
{ error: "Onboarding request already submitted.", request: existing },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// If previous request was deleted, remove it so a fresh one can be created
|
||||
if (existing && existing.status === "deleted") {
|
||||
await deleteTenantRequest(existing.id);
|
||||
}
|
||||
|
||||
// Check for existing tenant
|
||||
const allTenants = await listTenants();
|
||||
const myTenant = allTenants.find(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
|
||||
if (myTenant) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "You already have a tenant provisioned.",
|
||||
tenantName: myTenant.metadata.name,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const input: OnboardingInput & {
|
||||
packageSecrets?: Record<string, Record<string, string>>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
} = parsed.data;
|
||||
|
||||
// Look up an existing approved request for this org to inherit
|
||||
// company-level billing data. For brand-new orgs (first registration),
|
||||
// there is no prior row and we use the form-supplied billingAddress
|
||||
// verbatim. For follow-up requests, we ignore the form-supplied
|
||||
// company line in favour of the recorded company name.
|
||||
const prior = await getMostRecentApprovedRequestForOrg(user.orgId);
|
||||
|
||||
// Slice 4: detect personal-account orgs by the canonical " (Personal)"
|
||||
// suffix on the ZITADEL org name. Set at registration, stable for the
|
||||
// lifetime of the org. Persisted on the row so admin views and the
|
||||
// approve handler don't have to re-derive it.
|
||||
//
|
||||
// If any prior row has is_personal set, prefer that — it's the same
|
||||
// org and the value can't change. (The prior-row check is defensive;
|
||||
// the org-name check should agree.)
|
||||
const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName);
|
||||
|
||||
// Bug 5: personal accounts are 1-instance by design. If there's
|
||||
// already an active tenant or an in-flight request for this user's
|
||||
// org, reject the submission outright. Server-side only check;
|
||||
// matching UI guards live on /dashboard (button hidden) and
|
||||
// /dashboard/new (server-redirect to /dashboard).
|
||||
if (isPersonal) {
|
||||
const [allTenants, activeRequests] = await Promise.all([
|
||||
listTenants(),
|
||||
listActiveTenantRequestsByOrgId(user.orgId),
|
||||
]);
|
||||
const ownTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
if (ownTenants.length > 0 || activeRequests.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Personal accounts are limited to one instance. Cancel your existing request or contact support to change plan.",
|
||||
code: "personal_account_at_capacity",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt package secrets if provided
|
||||
let encryptedSecrets: Buffer | undefined;
|
||||
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {
|
||||
@@ -147,34 +267,363 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
// The audit copy of company name on this request stays inherited
|
||||
// from the first request in the org — it's a historical snapshot
|
||||
// of the company name at the time the request was created, and
|
||||
// org_billing is now the canonical source for current values.
|
||||
//
|
||||
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
|
||||
// They identify whoever submitted THIS specific request (drives
|
||||
// admin display, support ticket routing, and email greetings).
|
||||
// The previous "prior?.contactName ?? user.name" pattern locked
|
||||
// the contact to whoever first onboarded the org, which broke for
|
||||
// any subsequent submission by a different user — admin saw the
|
||||
// wrong name, support emails went to the wrong person, and the
|
||||
// actual submitter had no way to correct it because the wizard
|
||||
// doesn't expose a contact-name input. The fix is simply to use
|
||||
// the current session user every time.
|
||||
const companyName = prior?.companyName ?? user.orgName;
|
||||
const contactName = user.name;
|
||||
const contactEmail = user.email;
|
||||
|
||||
// Bug 35: org-scoped billing.
|
||||
//
|
||||
// Resolution rules:
|
||||
// 1. If org_billing exists, use it (synthesise a BillingAddress
|
||||
// shape for the audit copy on tenant_requests). Wizard's
|
||||
// submitted billingAddress is ignored — the org has billing
|
||||
// on file, the wizard skipped that step.
|
||||
// 2. If no org_billing AND wizard supplied billingAddress, use
|
||||
// the wizard's data and save to org_billing for next time.
|
||||
// VAT is enforced by billingAddressSchema (required for
|
||||
// everyone).
|
||||
// 3. If no org_billing AND no wizard billingAddress: reject.
|
||||
// Billing is required for all customers regardless of
|
||||
// personal/company org structure — we're a commercial
|
||||
// product. Personal accounts (sole proprietors, individuals)
|
||||
// are still subject to billing capture.
|
||||
//
|
||||
// The synthetic BillingAddress for case 1 collapses fields that
|
||||
// org_billing has more granularly; good enough for audit, since
|
||||
// /settings/billing is the authoritative editor going forward.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
let billingAddress: TenantRequest["billingAddress"];
|
||||
let billingNotes = input.billingNotes ?? prior?.billingNotes;
|
||||
|
||||
if (orgBilling) {
|
||||
billingAddress = {
|
||||
company: orgBilling.companyName,
|
||||
street: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
vatNumber: orgBilling.vatNumber ?? undefined,
|
||||
};
|
||||
} else if (input.billingAddress) {
|
||||
// Wizard supplied billing — re-validate the strict shape (the
|
||||
// outer onboardingSchema marks it optional now, so we can't rely
|
||||
// on its enforcement of the inner required fields).
|
||||
const billingCheck = billingAddressSchema.safeParse(input.billingAddress);
|
||||
if (!billingCheck.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid billing address",
|
||||
details: billingCheck.error.flatten(),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Company orgs (B2B) require companyName AND vatNumber.
|
||||
// Personal orgs (B2C — private individuals) require neither;
|
||||
// the wizard hides both fields for them and the API doesn't
|
||||
// enforce.
|
||||
if (!isPersonal) {
|
||||
const missing: Record<string, string[]> = {};
|
||||
if (
|
||||
!billingCheck.data.company ||
|
||||
billingCheck.data.company.trim().length === 0
|
||||
) {
|
||||
missing["billingAddress.company"] = ["Required for companies"];
|
||||
}
|
||||
if (
|
||||
!billingCheck.data.vatNumber ||
|
||||
billingCheck.data.vatNumber.length === 0
|
||||
) {
|
||||
missing["billingAddress.vatNumber"] = ["Required for companies"];
|
||||
}
|
||||
if (Object.keys(missing).length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Company name and VAT number are required for company accounts.",
|
||||
details: { fieldErrors: missing },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
billingAddress = billingCheck.data;
|
||||
|
||||
// Persist to org_billing. For personal customers (B2C, no
|
||||
// company line), fall back to their display name from the
|
||||
// session — invoices addressed to their actual name rather than
|
||||
// an opaque org id like "personal-3f2a8b1c". For companies the
|
||||
// wizard's company field is filled.
|
||||
const personalDisplayName = (user.name || user.email || "").trim();
|
||||
try {
|
||||
await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName:
|
||||
(billingCheck.data.company || "").trim() ||
|
||||
(isPersonal ? personalDisplayName : user.orgName) ||
|
||||
user.orgName,
|
||||
streetAddress: billingCheck.data.street,
|
||||
postalCode: billingCheck.data.postalCode,
|
||||
city: billingCheck.data.city,
|
||||
country: billingCheck.data.country,
|
||||
// Personal: undefined (no VAT). Company: enforced non-empty
|
||||
// by the check above.
|
||||
vatNumber: isPersonal ? null : billingCheck.data.vatNumber!,
|
||||
billingEmail: contactEmail,
|
||||
notes: billingNotes ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-fatal — the tenant_request still gets created with the
|
||||
// billingAddress audit copy. The customer can re-save via
|
||||
// /settings/billing if this failed.
|
||||
console.warn(
|
||||
"failed to save org_billing on first capture; tenant_request still created with audit copy",
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No billing supplied AND no org_billing record. Required for
|
||||
// everyone — commercial product, no personal-orgs-skip
|
||||
// shortcut. Customer must complete the wizard's billing step
|
||||
// or set up /settings/billing first.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Billing information is required. Please complete the billing step or set it up at /settings/billing.",
|
||||
details: {
|
||||
fieldErrors: {
|
||||
billingAddress: ["Required"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Look up the setup fee. If it's 0 we skip the Checkout flow
|
||||
// entirely and create a normal pending request (same as the
|
||||
// pre-Phase-9b behaviour).
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||
|
||||
// ZERO-FEE PATH ---------------------------------------------------
|
||||
// No payment to collect. Create the request directly in 'pending'
|
||||
// status (same as the pre-Phase-9b flow) and notify admin. The
|
||||
// wizard treats this response identically to its previous
|
||||
// success path.
|
||||
if (setupFeeChf <= 0) {
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName,
|
||||
instanceName: input.instanceName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress,
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
}
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
// PAID-FEE PATH ---------------------------------------------------
|
||||
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
|
||||
// Checkout sessions don't block retries). Build the setup-fee
|
||||
// invoice, then start a Checkout session. The wizard follows the
|
||||
// returned URL; on completion the webhook flips the row to
|
||||
// 'pending' and admin sees it in their queue.
|
||||
const tenantRequest = await createTenantRequestPendingPayment({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName: user.orgName,
|
||||
contactName: user.name,
|
||||
contactEmail: user.email,
|
||||
companyName,
|
||||
instanceName: input.instanceName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress: input.billingAddress,
|
||||
billingNotes: input.billingNotes,
|
||||
billingAddress,
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
|
||||
// Notify admin about the new request
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName
|
||||
// Derive the future tenant_name — needed on the invoice line so
|
||||
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
|
||||
// already-paid setup fee once the K8s tenant exists. The name is
|
||||
// request-id-suffix-derived, so abandoned Checkout retries each
|
||||
// get unique names.
|
||||
const derivedTenantName = deriveTenantName(
|
||||
isPersonal ? "personal" : "company",
|
||||
companyName,
|
||||
tenantRequest.id
|
||||
);
|
||||
|
||||
// Re-fetch orgBilling here: the variable at the top of POST was
|
||||
// captured BEFORE the upsertOrgBilling call upstream (which fires
|
||||
// when the wizard collected the address on first onboarding). For
|
||||
// a brand-new user that initial fetch returned null; only by
|
||||
// re-fetching now do we get the row we just wrote. Existing
|
||||
// customers get the same orgBilling back either way.
|
||||
const billingForOrder = await getOrgBilling(user.orgId);
|
||||
if (!billingForOrder) {
|
||||
console.error(
|
||||
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Billing record missing. Please re-save your billing details." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const billingSnapshot: InvoiceBillingSnapshot = {
|
||||
companyName: billingForOrder.companyName,
|
||||
contactName: billingForOrder.contactName ?? null,
|
||||
streetAddress: billingForOrder.streetAddress,
|
||||
postalCode: billingForOrder.postalCode,
|
||||
city: billingForOrder.city,
|
||||
country: billingForOrder.country,
|
||||
vatNumber: billingForOrder.vatNumber ?? null,
|
||||
billingEmail: billingForOrder.billingEmail,
|
||||
notes: billingForOrder.notes ?? null,
|
||||
};
|
||||
|
||||
// Locale for the invoice + PDF — pick from the org's country
|
||||
// using the same heuristic the auto-cron uses.
|
||||
const c = (billingSnapshot.country ?? "").toUpperCase();
|
||||
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
|
||||
? "de"
|
||||
: ["FR", "BE", "LU"].includes(c)
|
||||
? "fr"
|
||||
: c === "IT"
|
||||
? "it"
|
||||
: "en";
|
||||
|
||||
let setupInvoice;
|
||||
try {
|
||||
setupInvoice = await createTenantSetupFeeInvoice({
|
||||
zitadelOrgId: user.orgId,
|
||||
tenantName: derivedTenantName,
|
||||
billingSnapshot,
|
||||
locale: invoiceLocale,
|
||||
paymentMethod: "card",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
console.error("Failed to create setup-fee invoice:", e);
|
||||
// Roll back the pending_payment row so the customer can retry
|
||||
// without an orphan record.
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to prepare setup-fee invoice. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the Checkout session. The Stripe customer must exist
|
||||
// before this — ensureStripeCustomerForOrg returns the existing
|
||||
// one (idempotent) since the saved-card setup already created it.
|
||||
let checkoutUrl: string;
|
||||
try {
|
||||
const stripeCustomerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: billingSnapshot.companyName,
|
||||
billingEmail: billingSnapshot.billingEmail,
|
||||
address: {
|
||||
line1: billingSnapshot.streetAddress,
|
||||
postalCode: billingSnapshot.postalCode,
|
||||
city: billingSnapshot.city,
|
||||
country: billingSnapshot.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createSetupFeeCheckoutSession({
|
||||
invoice: setupInvoice,
|
||||
customerId: stripeCustomerId,
|
||||
baseUrl,
|
||||
tenantRequestId: tenantRequest.id,
|
||||
});
|
||||
checkoutUrl = url;
|
||||
} catch (e) {
|
||||
console.error("Failed to create setup-fee Checkout session:", e);
|
||||
// Roll back BOTH the pending_payment row and the setup invoice
|
||||
// we already created. The invoice was issued in 'open' status
|
||||
// but no payment will ever arrive (Checkout never started), so
|
||||
// void it to keep the ledger clean — an open invoice with no
|
||||
// route to payment would otherwise linger and show up in
|
||||
// arrears reports. Void (not delete) preserves the audit trail
|
||||
// and the void reason. Best-effort: a void failure is logged
|
||||
// but doesn't change the 500 we return.
|
||||
await voidInvoice({
|
||||
invoiceId: setupInvoice.id,
|
||||
reason: "Order abandoned before payment (Checkout could not be started)",
|
||||
voidedBy: user.id,
|
||||
}).catch((ve) =>
|
||||
console.error(
|
||||
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
|
||||
ve
|
||||
)
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start payment. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Don't notify admin yet — the request is invisible to admin
|
||||
// until the webhook flips it to 'pending'. Notification happens
|
||||
// there.
|
||||
return NextResponse.json(
|
||||
{ message: "Request submitted.", request: tenantRequest },
|
||||
{
|
||||
message: "Redirecting to payment.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
checkoutUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,43 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { registerCustomer } from "@/lib/zitadel";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { checkDuplicateDomain } from "@/lib/db";
|
||||
import { generatePersonalOrgName } from "@/lib/personal-org";
|
||||
import type { RegistrationInput } from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const registrationSchema = z.object({
|
||||
companyName: z.string().min(2).max(100),
|
||||
givenName: z.string().min(1).max(100),
|
||||
familyName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
|
||||
});
|
||||
/**
|
||||
* Registration schema.
|
||||
*
|
||||
* Slice 4 changes
|
||||
* ---------------
|
||||
* - `companyName` is no longer always required. It's required when
|
||||
* `isPersonal` is false/absent, ignored when `isPersonal` is true.
|
||||
* - `isPersonal` flag distinguishes personal accounts. The server
|
||||
* derives the ZITADEL org name from a generated opaque ID
|
||||
* (`personal-{8hex}`) — see `lib/personal-org.ts` for the format
|
||||
* spec. Customers cannot rename their own org, so the marker is
|
||||
* stable.
|
||||
* - Personal accounts skip the duplicate-domain check entirely. Their
|
||||
* row is also excluded from future domain checks (see
|
||||
* `lib/domain-check.ts::findDuplicateInDb`).
|
||||
*/
|
||||
const registrationSchema = z
|
||||
.object({
|
||||
companyName: z.string().min(2).max(100).optional(),
|
||||
givenName: z.string().min(1).max(100),
|
||||
familyName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
|
||||
isPersonal: z.boolean().optional().default(false),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.isPersonal || (data.companyName && data.companyName.trim().length >= 2),
|
||||
{
|
||||
message: "Company name is required for company registrations",
|
||||
path: ["companyName"],
|
||||
}
|
||||
);
|
||||
|
||||
/** 3 registrations per IP per hour */
|
||||
const RATE_LIMIT = 3;
|
||||
@@ -53,31 +80,44 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const input: RegistrationInput = parsed.data;
|
||||
const isPersonal = input.isPersonal === true;
|
||||
|
||||
// --- Duplicate-domain check ---
|
||||
// --- Duplicate-domain check (skipped for personal accounts) ---
|
||||
//
|
||||
// Block if another active tenant_request or ZITADEL org already exists
|
||||
// for this corporate email domain. Public domains (gmail, gmx, etc.)
|
||||
// are exempted by checkDuplicateDomain.
|
||||
//
|
||||
// We return a structured `code: "duplicate_domain"` with the matched
|
||||
// domain so the client can render the localized message via
|
||||
// register.duplicateDomain (with {domain} interpolation). The fallback
|
||||
// English string is included for non-i18n clients (curl, monitoring).
|
||||
const dup = await checkDuplicateDomain(input.email);
|
||||
if (dup.blocked && dup.domain) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`,
|
||||
code: "duplicate_domain",
|
||||
domain: dup.domain,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
// Personal accounts are explicitly allowed to use any email domain
|
||||
// (including corporate). Their tenant_request rows are excluded
|
||||
// from this check by lib/domain-check.ts, so a personal account
|
||||
// doesn't block a later real-company registration on the same
|
||||
// domain.
|
||||
if (!isPersonal) {
|
||||
const dup = await checkDuplicateDomain(input.email);
|
||||
if (dup.blocked && dup.domain) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`,
|
||||
code: "duplicate_domain",
|
||||
domain: dup.domain,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Determine the ZITADEL org name ---
|
||||
//
|
||||
// For company: use the customer-supplied companyName (already
|
||||
// validated to be present + ≥2 chars by the schema refinement).
|
||||
// For personal: a fresh opaque ID like "personal-3f2a8b1c". The
|
||||
// user's actual display name is per-user (`session.user.name`),
|
||||
// so the GUI shows that instead — see `displayOrgNameFor()`.
|
||||
// This keeps personal orgs collision-free (Bug 9: two people
|
||||
// named "Eva Müller" both being able to register).
|
||||
const orgName = isPersonal
|
||||
? generatePersonalOrgName()
|
||||
: input.companyName!.trim();
|
||||
|
||||
const result = await registerCustomer({
|
||||
companyName: input.companyName,
|
||||
companyName: orgName,
|
||||
email: input.email,
|
||||
givenName: input.givenName,
|
||||
familyName: input.familyName,
|
||||
@@ -88,6 +128,7 @@ export async function POST(request: NextRequest) {
|
||||
{
|
||||
orgId: result.orgId,
|
||||
userId: result.userId,
|
||||
isPersonal,
|
||||
message:
|
||||
"Registration successful. You will receive an invitation email to set your password.",
|
||||
},
|
||||
|
||||
90
src/app/api/settings/billing/route.ts
Normal file
90
src/app/api/settings/billing/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/settings/billing — read the caller's org_billing row.
|
||||
* Returns null if the org hasn't configured billing yet — the
|
||||
* form renders empty and the PUT will create on first save.
|
||||
*
|
||||
* PUT /api/settings/billing — upsert the row.
|
||||
*
|
||||
* Authorization: caller must have role "owner" in their org.
|
||||
* Non-owners get 403 (they shouldn't have reached the page UI
|
||||
* anyway, which hides the link, but the API enforces too — a
|
||||
* non-owner who hits this directly with curl gets refused).
|
||||
*
|
||||
* Personal accounts are inherently their own owner (single-user
|
||||
* org), so user.roles.includes("owner") returns true and they
|
||||
* can manage their own billing.
|
||||
*/
|
||||
|
||||
const upsertSchema = z.object({
|
||||
companyName: z.string().trim().min(1).max(200),
|
||||
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
|
||||
// never send this (the UI hides the field); orgs may set or leave
|
||||
// it empty.
|
||||
contactName: z.string().trim().max(200).optional().nullable(),
|
||||
streetAddress: z.string().trim().min(1).max(200),
|
||||
postalCode: z.string().trim().min(1).max(20),
|
||||
city: z.string().trim().min(1).max(100),
|
||||
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
|
||||
country: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(2)
|
||||
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
|
||||
vatNumber: z.string().trim().max(40).optional().nullable(),
|
||||
billingEmail: z.string().trim().email().max(200),
|
||||
notes: z.string().trim().max(2000).optional().nullable(),
|
||||
});
|
||||
|
||||
function requireOwner(user: { roles: string[] } | null) {
|
||||
if (!user) return false;
|
||||
return user.roles.includes("owner");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!requireOwner(user as any)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!requireOwner(user as any)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = upsertSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const billing = await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: data.companyName,
|
||||
contactName: data.contactName ?? null,
|
||||
streetAddress: data.streetAddress,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
country: data.country.toUpperCase(),
|
||||
vatNumber: data.vatNumber ?? null,
|
||||
billingEmail: data.billingEmail,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
81
src/app/api/settings/profile/route.ts
Normal file
81
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getHumanUserDetail,
|
||||
updateHumanUserProfile,
|
||||
} from "@/lib/zitadel";
|
||||
|
||||
/**
|
||||
* GET /api/settings/profile — read the caller's ZITADEL profile.
|
||||
* Returns first/last/display name and email. Used by the settings
|
||||
* page server component to populate the form.
|
||||
*
|
||||
* PUT /api/settings/profile — update first + last name. Email is
|
||||
* NOT mutable here — changing email needs verification flow that
|
||||
* ZITADEL's own self-service UI already provides; we don't
|
||||
* duplicate that.
|
||||
*
|
||||
* Authorization: any authenticated user can edit their own profile.
|
||||
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
|
||||
* service, but only against the caller's own userId. There is no
|
||||
* userId field on the request — it's always derived from the
|
||||
* session, so the route can't be abused to edit other users.
|
||||
*/
|
||||
|
||||
const updateSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(100),
|
||||
lastName: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
return NextResponse.json({ profile });
|
||||
} catch (e: any) {
|
||||
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
|
||||
// as 502 — the portal couldn't reach its identity provider, which
|
||||
// is operationally different from a 4xx on the caller's input.
|
||||
console.error("getHumanUserDetail failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not load profile from identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await updateHumanUserProfile({
|
||||
userId: user.id,
|
||||
givenName: parsed.data.firstName,
|
||||
familyName: parsed.data.lastName,
|
||||
});
|
||||
return NextResponse.json({
|
||||
displayName: result.displayName,
|
||||
changeDate: result.changeDate,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("updateHumanUserProfile failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not update profile in identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/skills/pricing/route.ts
Normal file
23
src/app/api/skills/pricing/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listSkillPricing } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/skills/pricing
|
||||
*
|
||||
* Returns the platform-wide skill pricing (daily price + setup fee
|
||||
* per skill) for display in the customer's cost-disclosure dialog
|
||||
* before they enable a priced skill. Any logged-in user can read
|
||||
* this — pricing isn't org-specific and is effectively public
|
||||
* information for anyone who'd be considering activation.
|
||||
*
|
||||
* Empty array means no skill is currently priced.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const rows = await listSkillPricing();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
74
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
74
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { deletePackageSecrets } from "@/lib/openbao";
|
||||
|
||||
/**
|
||||
* POST /api/skills/requests/[id]/withdraw
|
||||
*
|
||||
* The owner of a pending activation request can cancel it. This
|
||||
* doesn't touch K8s (the skill was never enabled) — it just flips
|
||||
* the row to 'withdrawn' so the user's UI clears the pending
|
||||
* state and they can try a different skill or retry later.
|
||||
*
|
||||
* Authorization: only the original requester OR a platform admin
|
||||
* can withdraw a request. We deliberately don't allow other org
|
||||
* members to cancel each other's requests in v1 — the partial
|
||||
* unique index would let one user repeatedly cancel another's
|
||||
* pending request.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (!user.isPlatform && req.zitadelUserId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
|
||||
reviewedBy: user.id,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request status changed during withdraw." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup: same logic as reject — the user submitted secrets
|
||||
// before the gate fired, and those are now orphaned in OpenBao.
|
||||
// Best-effort delete; failure logged but not propagated. Skip
|
||||
// customProvisioning packages (their deprovisioning is a
|
||||
// separate, dedicated endpoint).
|
||||
const def = getPackageDef(req.skillId);
|
||||
if (def?.requiresSecrets && !def.customProvisioning) {
|
||||
try {
|
||||
await deletePackageSecrets(req.tenantName, req.skillId);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after withdraw:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
40
src/app/api/skills/requests/route.ts
Normal file
40
src/app/api/skills/requests/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listSkillActivationRequestsForTenant } from "@/lib/db";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
|
||||
/**
|
||||
* GET /api/skills/requests?tenant=<name>
|
||||
*
|
||||
* Returns pending and most-recent-rejected skill activation
|
||||
* requests for the named tenant. Used by the tenant settings page
|
||||
* to render the "Manual review pending" or "Activation rejected"
|
||||
* inline states on PackageCard.
|
||||
*
|
||||
* Authorization: the caller must be able to see the tenant (owner
|
||||
* of its org, assigned user, or platform admin).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tenantName = searchParams.get("tenant");
|
||||
if (!tenantName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing tenant parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const tenant = await getTenant(tenantName).catch(() => null);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!canUserSeeTenant(user, tenant)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const requests = await listSkillActivationRequestsForTenant(tenantName);
|
||||
return NextResponse.json(requests);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user