Compare commits

..

55 Commits

Author SHA1 Message Date
cd15b391ac Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s
2026-05-24 16:38:41 +02:00
11d7dbb06e Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m36s
2026-05-24 14:48:40 +02:00
d41f0b6ec9 Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 53s
2026-05-24 14:40:15 +02:00
03f8dd9afe Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 47s
2026-05-24 14:25:00 +02:00
d4fcc33bc1 Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-24 14:12:26 +02:00
cdc2210eaf Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-24 14:01:33 +02:00
6bf9caa53a Lock @react-pdf/renderer for Phase 2 billing
Some checks failed
Build and Push / build (push) Failing after 1m23s
2026-05-24 13:56:53 +02:00
c8ed27157f Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
2026-05-24 13:51:38 +02:00
6baca1a459 Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m35s
2026-05-24 00:21:29 +02:00
faf49119ea Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-23 23:50:42 +02:00
ce70fe8480 Phase1: Schema + skill event tracking
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-23 23:45:04 +02:00
55571b1e59 Threema UX: static file middleware fix, *AIAGENT display, info banner
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 17:29:25 +02:00
c0ff22394c Threema QR: on-demand modal + auto-open on first add
All checks were successful
Build and Push / build (push) Successful in 1m29s
2026-05-17 17:13:44 +02:00
395d2f43cc Threema: customer-friendly texts + QR setup component
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-17 16:50:23 +02:00
6f42b56ad5 Threema package: relay-managed channel users + provisioning endpoints
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 11:10:40 +02:00
85c4302f7a Threema Gateway
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-05-16 22:00:27 +02:00
726151d90b Add new TTS/STT Logic
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-16 19:55:51 +02:00
a13af83655 Adjust skills
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-11 21:25:09 +02:00
b58bdadad4 feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
All checks were successful
Build and Push / build (push) Successful in 1m52s
2026-05-10 21:15:53 +02:00
d375a099f0 Limit by tenant and org
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-02 23:43:02 +02:00
666dd64580 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-02 23:25:24 +02:00
188bef2ece Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 23:16:14 +02:00
57258bca92 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-02 22:59:51 +02:00
c7ab4c6b4e Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 22:33:35 +02:00
b77dd04b15 EMail templates rework
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-02 22:03:19 +02:00
11157b872c Add note to reactivation request
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 16:43:54 +02:00
8273d08f15 Support org
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-05-02 10:50:06 +02:00
b023c068eb Billing rework
All checks were successful
Build and Push / build (push) Successful in 1m29s
2026-05-02 00:41:12 +02:00
2c1e7af797 Billing rework
All checks were successful
Build and Push / build (push) Successful in 1m32s
2026-05-02 00:34:26 +02:00
08460f93d4 Billing rework
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-05-02 00:09:05 +02:00
392b0991a5 Billing rework
Some checks failed
Build and Push / build (push) Failing after 41s
2026-05-02 00:04:23 +02:00
46369fda01 Show suspended since and new emails for suspend continue approve and rejection
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-01 22:37:23 +02:00
647afcfbe7 Suspendedremoval display in Frontend
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-01 21:48:25 +02:00
b12bca8818 Suspendedremoval display in Frontend
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-01 21:39:16 +02:00
a79d0defa4 Suspendedremoval
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-01 18:17:04 +02:00
de1bb9bd02 Suspendedremoval
Some checks failed
Build and Push / build (push) Failing after 41s
2026-05-01 18:11:42 +02:00
a5812dca9a Suspendedremoval
Some checks failed
Build and Push / build (push) Failing after 48s
2026-05-01 18:07:00 +02:00
7d58c78cb9 Fix modal popup
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-05-01 16:56:33 +02:00
f308c84325 Group F - Fix spending per tenant
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-05-01 13:34:56 +02:00
2cf5b56441 OCI Warning status
All checks were successful
Build and Push / build (push) Successful in 2m12s
2026-05-01 10:25:50 +02:00
f84516a65b Group D fixes
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-04-29 22:16:48 +02:00
219b4c8365 Group D fixes
Some checks failed
Build and Push / build (push) Failing after 37s
2026-04-29 22:13:08 +02:00
9c50c9f054 Group C+ fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-29 21:34:52 +02:00
49d81190d4 Group C fixes
All checks were successful
Build and Push / build (push) Successful in 1m47s
2026-04-29 17:20:58 +02:00
eeef108f7e Group B fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-29 15:43:12 +02:00
c7df5c83a4 Fix user view tenant
All checks were successful
Build and Push / build (push) Successful in 1m32s
2026-04-29 12:33:04 +02:00
c46f27edef Fix bugs
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-04-29 12:16:00 +02:00
542a607b53 Fix zitadel role issues
All checks were successful
Build and Push / build (push) Successful in 1m20s
2026-04-29 09:36:36 +02:00
a31d05b7c2 Team UI
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-04-26 23:07:47 +02:00
22fd5fb2cc TenantAssignment and readside filtering
All checks were successful
Build and Push / build (push) Successful in 1m23s
2026-04-26 22:58:30 +02:00
7c4e20099d Role split and owner gating
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-26 22:45:38 +02:00
3521a0ff4f Personal accounts
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-04-26 22:26:33 +02:00
2c85bf8597 Multitenantperorg enabling
All checks were successful
Build and Push / build (push) Successful in 1m21s
2026-04-26 22:09:26 +02:00
7b22bc4087 OneLiteLLM team per company+virt keys
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-26 21:21:02 +02:00
1f48712e42 Tenant naming logic adjustments
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-04-26 18:47:50 +02:00
129 changed files with 21581 additions and 950 deletions

View File

@@ -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__

134
README.md
View File

@@ -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
View 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.

View 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}`);
}

View File

@@ -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);

569
package-lock.json generated
View File

@@ -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",
@@ -73,6 +74,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 +1099,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 +1487,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 +2828,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 +3246,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 +3299,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 +3419,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 +3448,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 +3649,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 +3689,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 +4312,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 +4334,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 +4396,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 +4466,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 +4795,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 +4868,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 +5263,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 +5356,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 +5384,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 +5790,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 +5836,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 +5863,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 +6252,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 +6274,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 +6482,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 +6501,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 +6642,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 +6695,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 +6773,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 +6800,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 +6855,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 +6901,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 +6954,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 +7021,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 +7385,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",
@@ -7086,6 +7579,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 +7650,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 +7885,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 +7977,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 +8177,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",

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View 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);

View 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);

View 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
View 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);

View 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);

View 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
View 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);
});

View 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>
);
}

View File

@@ -0,0 +1,35 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceDetail } 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, delete, PDF download) is
* a client component for the interactive bits.
*/
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();
return (
<main className="max-w-4xl mx-auto px-6 py-8">
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
<InvoiceDetailView detail={detail} />
</main>
);
}

View 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>
);
}

View File

@@ -0,0 +1,128 @@
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-3 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>
</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>
<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>
</Card>
</div>
)}
</main>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -22,11 +22,30 @@ export default async function AdminPage() {
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/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/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">

View 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>
);
}

View File

@@ -0,0 +1,83 @@
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 } 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 = await getOrgBilling(user.orgId);
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}
/>
</div>
</div>
);
}

View File

@@ -1,12 +1,23 @@
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,
} 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";
@@ -20,7 +31,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 +144,161 @@ 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;
// 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 +314,119 @@ 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}
/>
</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-white 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>
);
}

View File

@@ -6,12 +6,41 @@ import { useRouter } from "next/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,40 @@ export default function RegisterPage() {
const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState("");
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 }));
}
@@ -102,100 +139,212 @@ 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
selected={accountType === "personal"}
onClick={() => setAccountType("personal")}
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
selected={accountType === "company"}
onClick={() => setAccountType("company")}
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-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>
<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")}{" "}
<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-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.
*/
function AccountTypeCard({
selected,
onClick,
label,
description,
icon,
}: {
selected: boolean;
onClick: () => void;
label: string;
description: string;
icon: React.ReactNode;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onClick}
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-primary"
}`}
>
{label}
</div>
<div className="text-xs text-text-muted leading-snug">{description}</div>
</button>
);
}

View File

@@ -0,0 +1,47 @@
import { getTranslations } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getSessionUser, canMutate } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
/**
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
*
* Server-side fetches the existing record (if any) and passes it to
* the client form. The form posts to PUT /api/billing on submit.
*
* Access: same gate as the API — owners and platform admins. `user`
* role redirects to /settings (which also wouldn't list billing for
* them). 403 here would be friendlier than redirect, but the most
* likely cause of a `user` landing on this URL is sharing a bookmark
* with their owner — silent redirect is gentle.
*/
export default async function BillingSettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) {
redirect("/settings");
}
const t = await getTranslations("settingsBilling");
const billing = await getOrgBilling(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">{t("subtitle")}</p>
</div>
<BillingSettingsForm
initial={billing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</main>
);
}

View File

@@ -0,0 +1,81 @@
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 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. Currently only billing; this
// shape leaves headroom for adding more without restructuring.
const sections: Array<{
key: string;
href: string;
title: string;
description: string;
visible: boolean;
}> = [
{
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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,97 @@
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 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-white 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>
);
}

View File

@@ -0,0 +1,70 @@
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";
/**
* /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 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>
</div>
);
}

View File

@@ -1,15 +1,27 @@
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 } 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 { 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 +38,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 +82,12 @@ 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;
// 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 +98,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 +119,94 @@ 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>
)}
{/* 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 +218,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
/>
</section>
@@ -103,6 +229,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledChannels={enabledChannels}
initialChannelUsers={channelUsers}
canEdit={canEdit}
/>
</section>
)}
@@ -112,8 +239,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>
);
}

View 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 }
);
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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",
},
});
}

View 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 }
);
}
}

View 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);
}

View 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);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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:

View 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 }
);
}
}

View File

@@ -4,9 +4,12 @@ import {
getTenantRequestById,
updateTenantRequestStatus,
clearEncryptedSecrets,
recordTenantCreated,
recordSkillEvents,
recordSuspensionEvent,
} 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 {
@@ -14,18 +17,31 @@ import {
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 +75,85 @@ 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 });
// Billing — Phase 1: record the resume so monthly proration
// counts the suspended segment correctly. Best-effort; if
// logging fails, the approval still succeeds.
try {
await recordSuspensionEvent(
tenantRequest.tenantName,
tenantRequest.zitadelOrgId,
"resumed"
);
} catch (e) {
console.error(
"billing: failed to record resumed suspension event:",
e
);
}
// 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 +184,70 @@ 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;
await createTenant(
tenantName,
{
displayName: tenantRequest.companyName,
displayName,
agentName: tenantRequest.agentName,
packages,
workspaceFiles,
},
{
"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" }
: {}),
}
);
// Billing — Phase 1: record the tenant's creation and initial
// package state. Anchored at "now" rather than the CR's
// creationTimestamp because we don't get the timestamp back from
// createTenant — the few-millisecond skew vs the CR's actual
// creationTimestamp is irrelevant for monthly billing.
//
// Best-effort: tracking failures must never block provisioning.
// The backfill helper can repair any gaps later if needed.
const billingAnchor = new Date();
try {
await recordTenantCreated(
tenantName,
tenantRequest.zitadelOrgId,
billingAnchor
);
await recordSkillEvents(
tenantName,
tenantRequest.zitadelOrgId,
packages,
[],
billingAnchor
);
} catch (e) {
console.error(
"billing: failed to record tenant creation / initial skill events:",
e
);
}
// Step 5: Update request status — clear admin notes on re-approval
const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes: isReApproval ? null : adminNotes,

View File

@@ -1,11 +1,19 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import { sendRejectionEmail } from "@/lib/email";
import { setTenantAnnotation } from "@/lib/k8s";
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
/**
* 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.
*/
export async function POST(
request: Request,
@@ -37,13 +45,45 @@ 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
);
}
}
// 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.",

View File

@@ -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({

View 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 }
);
}
}

View File

@@ -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,

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -0,0 +1,227 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
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";
/**
* 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: Awaited<ReturnType<typeof getTenantRequestById>>; }
> {
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 };
}
/**
* 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");
// 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 });
} 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 }
);
}
}

View File

@@ -1,88 +1,172 @@
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,
getTenantRequestById,
listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
getMostRecentApprovedRequestForOrg,
getOrgBilling,
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 type { 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 +174,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 +192,52 @@ 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>>;
} = 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 +252,185 @@ export async function POST(request: Request) {
}
}
// For follow-up instances, prefer the on-file company name and contact
// details; the user can't change those by re-typing them in the wizard.
const companyName = prior?.companyName ?? user.orgName;
const contactName = prior?.contactName ?? user.name;
const contactEmail = prior?.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 }
);
}
const tenantRequest = await createTenantRequest({
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,
});
// Notify admin about the new request
// Notify admin about the new request. For follow-up instances, include
// the instance name in the notification so the admin sees what's
// being requested without opening the panel.
try {
await sendAdminNotificationEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName
tenantRequest.instanceName
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
: tenantRequest.companyName
);
} catch (e) {
console.error("Failed to send admin notification:", e);
}
// For diagnostics: how many other in-flight requests does this org
// already have? Useful for the admin queue.
const allRequests = await listTenantRequestsByOrgId(user.orgId);
return NextResponse.json(
{ message: "Request submitted.", request: tenantRequest },
{
message: "Request submitted.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
},
{ status: 201 }
);
}

View File

@@ -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.",
},

View File

@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
getSupportTicketById,
createSupportTicketComment,
updateSupportTicket,
} from "@/lib/db";
import {
sendSupportTicketReplyEmail,
sendSupportAdminNotificationEmail,
} from "@/lib/email";
import { safeError } from "@/lib/errors";
import type { SupportTicketStatus } from "@/types";
/**
* Comments on a support ticket (Feature 5). Threaded chronologically;
* no nested replies.
*
* Auto status transitions on comment:
* - Customer reply on a `waiting_for_customer` → `in_progress`
* (the ball is back in admin's court).
* - Customer reply on a `resolved` ticket → `reopened`
* (customer disagreed with the resolution).
* - Admin reply on `open` or `reopened` → `in_progress`
* (signals admin has engaged).
* - Admin reply on `in_progress` → `waiting_for_customer`
* (admin's response, ball is in customer's court).
* - Otherwise no change.
*
* The auto-bump is opportunistic — caller may pass an explicit
* status override via the PATCH endpoint instead. We only auto-bump
* here when no comment-side override is provided (the comment POST
* doesn't accept a status field).
*
* Email rules:
* - Customer replies → admin queue gets an "admin notification" email.
* - Admin replies → customer gets a reply email (with the body
* inline so they can read on mobile without clicking).
* - No "you just commented" confirmation back to the author.
*
* The customer reply path skips the separate status-change email
* even when the status auto-bumps, on the principle that one email
* per action is enough; the admin will see the reply notification
* and the new status in the queue.
*/
const createSchema = z.object({
body: z.string().trim().min(1, "required").max(10_000),
});
/**
* Compute the auto-bumped status (if any) for a comment from a given
* author kind. Returns the new status if it should change, or null
* if it should stay the same.
*/
function autoBumpStatus(
current: SupportTicketStatus,
authorKind: "customer" | "admin"
): SupportTicketStatus | null {
if (authorKind === "customer") {
if (current === "waiting_for_customer") return "in_progress";
if (current === "resolved") return "reopened";
return null;
}
// admin
if (current === "open" || current === "reopened") return "in_progress";
if (current === "in_progress") return "waiting_for_customer";
return null;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const ticket = await getSupportTicketById(id);
if (!ticket) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Same authorization as the GET on the parent resource.
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const authorKind: "customer" | "admin" = user.isPlatform
? "admin"
: "customer";
try {
const comment = await createSupportTicketComment({
ticketId: id,
authorUserId: user.id,
authorName: user.name,
authorKind,
body: parsed.data.body,
});
// Auto-bump status if the comment changes the ball's court.
const nextStatus = autoBumpStatus(ticket.status, authorKind);
if (nextStatus) {
await updateSupportTicket(id, { status: nextStatus });
}
// Email the other side. Customer's reply → admin queue;
// admin's reply → customer.
if (authorKind === "customer") {
sendSupportAdminNotificationEmail({
reason: "replied",
ticketId: ticket.id,
title: ticket.title,
contactName: ticket.contactName,
contactEmail: ticket.contactEmail,
body: parsed.data.body,
category: ticket.category,
}).catch((e) => console.error("admin notification:", e));
} else {
sendSupportTicketReplyEmail({
to: ticket.contactEmail,
contactName: ticket.contactName,
ticketId: ticket.id,
title: ticket.title,
authorName: user.name,
body: parsed.data.body,
}).catch((e) => console.error("reply email:", e));
}
return NextResponse.json({ comment }, { status: 201 });
} catch (e: any) {
console.error("Failed to create support ticket comment:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to add comment") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
getSupportTicketById,
listCommentsForTicket,
updateSupportTicket,
} from "@/lib/db";
import { sendSupportTicketStatusEmail } from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* Single support ticket detail (Feature 5).
*
* GET — returns the ticket plus all comments in chronological order.
* Authorization: customer sees their own; platform admin sees any.
*
* PATCH — change status and/or category. Admin only. Sends a status
* change email to the customer if status changed, UNLESS the same
* call also creates a comment (in that case the comment endpoint
* handles the email so the customer doesn't get two messages).
*
* No DELETE — tickets are durable history. Resolved tickets stay in
* the DB for the audit trail.
*/
const patchSchema = z.object({
status: z
.enum(["open", "in_progress", "waiting_for_customer", "resolved", "reopened"])
.optional(),
category: z
.enum(["bug", "feature_request", "question", "billing", "other"])
.optional(),
});
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const ticket = await getSupportTicketById(id);
if (!ticket) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Authorization: customer can see their own; platform admin can
// see any. Owners cannot see their org's tickets — confirmed by
// Feature 5 visibility design (per-user, not per-org).
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
// Don't leak existence — same 404 as if the ticket didn't exist.
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const comments = await listCommentsForTicket(id);
return NextResponse.json({ ticket, comments });
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const ticket = await getSupportTicketById(id);
if (!ticket) {
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 }
);
}
// Authorization: status/category changes are admin-only EXCEPT
// the customer can close their own ticket via status='resolved'
// (Feature 5 design — gives them an "I figured it out, never mind"
// escape hatch). Customer cannot reopen via this endpoint — that
// happens automatically when they comment on a resolved ticket
// (handled in the comments POST).
const isCustomerSelfClose =
!user.isPlatform &&
ticket.zitadelUserId === user.id &&
parsed.data.status === "resolved" &&
parsed.data.category === undefined;
if (!user.isPlatform && !isCustomerSelfClose) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const previousStatus = ticket.status;
const updated = await updateSupportTicket(id, parsed.data);
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Email customer when admin (not the customer themselves)
// changes status. Skip on customer-self-close — they know what
// they did. Skip when status didn't actually change (admin
// edited only category).
if (
user.isPlatform &&
parsed.data.status !== undefined &&
parsed.data.status !== previousStatus
) {
sendSupportTicketStatusEmail({
to: ticket.contactEmail,
contactName: ticket.contactName,
ticketId: ticket.id,
title: ticket.title,
newStatus: parsed.data.status,
}).catch((e) => console.error("status email:", e));
}
return NextResponse.json({ ticket: updated });
} catch (e: any) {
console.error("Failed to update support ticket:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update ticket") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
createSupportTicket,
listSupportTicketsForUser,
listAllSupportTickets,
} from "@/lib/db";
import {
sendSupportTicketCreatedEmail,
sendSupportAdminNotificationEmail,
} from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* Support tickets API (Feature 5).
*
* Visibility: tickets are scoped strictly per-user (zitadel_user_id).
* Coworkers in the same org cannot see each other's tickets — this
* is the team's design choice for privacy. Platform admins see
* everything (the admin queue lives at the same UI but pulls from
* a different list).
*
* GET — for platform users, returns all tickets across all users.
* For everyone else, returns only the caller's own tickets. The
* client decides the rendering based on user role; we just return
* the right list.
*
* POST — creates a ticket, sends a confirmation email to the
* customer and a notification email to the admin distribution list.
*/
const createSchema = z.object({
title: z.string().trim().min(3, "required").max(200),
description: z.string().trim().min(10, "required").max(10_000),
category: z.enum(["bug", "feature_request", "question", "billing", "other"]),
});
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Platform admins get the global queue; everyone else sees their
// own tickets only. Visibility-by-default-deny: even an org owner
// doesn't see their coworkers' tickets, by Feature 5 design.
const tickets = user.isPlatform
? await listAllSupportTickets()
: await listSupportTicketsForUser(user.id);
return NextResponse.json({ tickets });
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const ticket = await createSupportTicket({
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
title: parsed.data.title,
description: parsed.data.description,
category: parsed.data.category,
contactName: user.name,
contactEmail: user.email,
});
// Fire-and-log email notifications. Both are best-effort;
// failure to send doesn't roll back the ticket creation.
sendSupportTicketCreatedEmail({
to: user.email,
contactName: user.name,
ticketId: ticket.id,
title: ticket.title,
}).catch((e) => console.error("ticket created email:", e));
sendSupportAdminNotificationEmail({
reason: "created",
ticketId: ticket.id,
title: ticket.title,
contactName: user.name,
contactEmail: user.email,
body: ticket.description,
category: ticket.category,
}).catch((e) => console.error("admin notification:", e));
return NextResponse.json({ ticket }, { status: 201 });
} catch (e: any) {
console.error("Failed to create support ticket:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to create ticket") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, isCustomerOwner } from "@/lib/session";
import { getOrgMembers, isValidInviteRole } from "@/lib/team";
import { updateAuthorizationRoles } from "@/lib/zitadel";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
role: z.enum(["owner", "user"]),
});
/**
* PATCH /api/team/[userId]/role
*
* Change the role of an existing member of the caller's org.
*
* Body: { role: "owner" | "user" }
*
* Authorization
* -------------
* Customer-side: only an `owner` of the caller's org may change roles.
* `isCustomerOwner` is the right gate — `canMutate` would also accept
* platform users, but cross-org role mutation by platform staff
* belongs in ZITADEL Console with audited admin tooling, not here.
*
* Safety guards
* -------------
* 1. Self-demotion is blocked. An owner demoting themself to `user`
* could lose access to /team and never come back. If the user
* genuinely wants to step down they should promote a colleague to
* `owner` first, then ask that colleague to demote them.
* 2. Last-owner demotion is blocked. Demoting the org's only owner
* to `user` would lock the org out of all future role changes,
* invites, and tenant requests. We count owners across the whole
* member list and refuse if this change would leave zero.
* 3. The target must already have an authorization on the project.
* A member without one — orphan, mid-invite race — has nothing
* for `UpdateAuthorization` to update; we return a clear 409.
*
* The mutation itself is replace-not-merge: see
* `lib/zitadel.ts::updateAuthorizationRoles`. Passing `[role]` revokes
* any other roles the member happened to hold.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only customer owners — platform staff use Console.
if (!isCustomerOwner(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{ error: "Personal accounts have no team roles to change." },
{ status: 403 }
);
}
const { userId } = await params;
if (userId === user.id) {
return NextResponse.json(
{
error:
"You cannot change your own role. Ask another owner, or promote a colleague to owner first.",
code: "self_change_blocked",
},
{ 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 }
);
}
const { role } = parsed.data;
// Defensive — the Zod enum already enforces this.
if (!isValidInviteRole(role)) {
return NextResponse.json(
{ error: "Role must be 'owner' or 'user'." },
{ status: 400 }
);
}
try {
const members = await getOrgMembers(user.orgId);
const target = members.find((m) => m.userId === userId);
if (!target) {
return NextResponse.json(
{ error: "Target user is not a member of this organization." },
{ status: 404 }
);
}
if (!target.authorizationId) {
// Should be very rare — implies the row was created out-of-band
// (e.g. directly in Console) without an authorization. Surface a
// clear message rather than a confusing 500 from ZITADEL.
return NextResponse.json(
{
error:
"Member has no authorization record on the project. Re-invite them or contact support.",
code: "no_authorization",
},
{ status: 409 }
);
}
// Last-owner protection: this matters when the target is currently
// an owner AND the new role is something other than owner. We could
// narrow the count to "before this change" but the simpler form is
// equivalent: if there's only one owner and that owner is the
// target, refuse.
const currentlyOwner = target.roles.includes("owner");
if (currentlyOwner && role !== "owner") {
const ownerCount = members.filter((m) => m.roles.includes("owner")).length;
if (ownerCount <= 1) {
return NextResponse.json(
{
error:
"This is the only owner. Promote another member to owner before demoting this one.",
code: "last_owner",
},
{ status: 409 }
);
}
}
// No-op: target already has the requested role and ONLY that role.
if (target.roles.length === 1 && target.roles[0] === role) {
return NextResponse.json({ message: "No change.", role }, { status: 200 });
}
await updateAuthorizationRoles(target.authorizationId, [role]);
return NextResponse.json(
{ message: "Role updated.", userId, role },
{ status: 200 }
);
} catch (e: any) {
console.error("Role update failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update role") },
{ status: e.statusCode || 500 }
);
}
}

View File

@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { inviteOrgMember, isValidInviteRole } from "@/lib/team";
import { z } from "zod";
import { safeError } from "@/lib/errors";
const inviteSchema = z.object({
email: z.string().email(),
givenName: z.string().min(1).max(100),
familyName: z.string().min(1).max(100),
role: z.enum(["owner", "user"]),
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
});
/**
* POST /api/team/invite
*
* Invite a new member into the caller's org. Body shape:
* { email, givenName, familyName, role: "owner" | "user" }
*
* Allowed roles are explicitly only the customer-side ones —
* `isValidInviteRole` enforces this server-side too as a belt
* alongside the Zod enum (the Zod enum is the primary check; the
* helper exists because future callers in admin tooling may want the
* same predicate).
*
* Platform users can also call this — they'd be inviting members
* into their own platform org, which is uncommon but legal.
*/
export async function POST(req: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{
error:
"Personal accounts cannot invite additional members. Upgrade to a company account to add a team.",
code: "personal_account",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = inviteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
// Defensive recheck — the Zod enum already guarantees this, but it
// makes the intent explicit at the call site.
if (!isValidInviteRole(input.role)) {
return NextResponse.json(
{ error: "Role must be 'owner' or 'user'." },
{ status: 400 }
);
}
try {
const result = await inviteOrgMember({
orgId: user.orgId,
email: input.email,
givenName: input.givenName,
familyName: input.familyName,
role: input.role,
preferredLanguage: input.preferredLanguage,
});
return NextResponse.json(
{
userId: result.userId,
message:
"Invitation sent. The user will receive an email with a link to set their password.",
},
{ status: 201 }
);
} catch (e: any) {
console.error("Invite failed:", e);
// ZITADEL "user already exists" surfaces as a 4xx error; pass it
// through with a clean message so the client can render localized
// text.
const msg = e?.message ?? "";
if (msg.includes("already exists") || msg.includes("9.User.AlreadyExisting")) {
return NextResponse.json(
{
error: "A user with this email already exists.",
code: "user_already_exists",
},
{ status: 409 }
);
}
return NextResponse.json(
{ error: safeError(e, "Failed to invite user") },
{ status: e.statusCode || 500 }
);
}
}

44
src/app/api/team/route.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getOrgMembers } from "@/lib/team";
import { safeError } from "@/lib/errors";
/**
* GET /api/team
*
* Returns the joined members-with-roles view for the caller's org.
* Gated on `canMutate` — only owners and platform users can see the
* full member list. A `user`-role member shouldn't be browsing the
* roster.
*
* Platform admins viewing this endpoint see members of their OWN
* platform org. To inspect customer org membership cross-cut, use
* ZITADEL Console — that's the deliberate boundary between portal
* (customer self-service) and console (full IAM).
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{ error: "Personal accounts do not have a team." },
{ status: 403 }
);
}
try {
const members = await getOrgMembers(user.orgId);
return NextResponse.json({ members });
} catch (e: any) {
console.error("Failed to list team members:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to list team members") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { removeTenantAssignment } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/tenants/[name]/assignments/[userId]
*
* Revoke a user's assignment to a tenant. Owner+platform only.
*
* No-op if the assignment didn't exist (delete is idempotent at the
* DB layer). We don't surface "not found" because that would let a
* caller probe for assignment existence — the boolean response is
* just "you're authorized to do this".
*
* Note on self-revocation: an owner can revoke their own row even
* though it has no practical effect (owners see all tenants). A
* `user`-role member cannot revoke their own assignment because
* they're already gated out by canMutate.
*/
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string; userId: 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 { name, userId } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Same cross-org boundary as assign: customer owners can only manage
// their own org's tenants; platform users can manage anywhere.
const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!user.isPlatform && tenantOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
await removeTenantAssignment(name, userId);
return NextResponse.json({ message: "Assignment revoked." });
} catch (e: any) {
console.error("Failed to remove tenant assignment:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to revoke assignment") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,193 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant } from "@/lib/k8s";
import {
listAssignmentsForTenant,
addTenantAssignment,
} from "@/lib/db";
import { getOrgMembers } from "@/lib/team";
import { safeError } from "@/lib/errors";
import { z } from "zod";
const assignSchema = z.object({
userId: z.string().min(1).max(200),
});
/**
* GET /api/tenants/[name]/assignments
*
* Returns the list of users assigned to a tenant, joined with their
* ZITADEL profile (display name, email, role) so the UI can render
* a useful list without an extra round-trip.
*
* Visibility: any caller who can see the tenant can see its
* assignments. This includes user-role members who are themselves
* assigned — they see their fellow assignees, which is intentional
* (so they know who else has access).
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const orgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"];
const [rows, members] = await Promise.all([
listAssignmentsForTenant(name),
orgId ? getOrgMembers(orgId) : Promise.resolve([]),
]);
const memberById = new Map(members.map((m) => [m.userId, m]));
// Enrich assignments with member metadata. If the member can't be
// found in ZITADEL (stale row, e.g. user was removed from the org
// outside the portal), surface the orphan with a placeholder name
// so admins can clean it up.
const assignments = rows.map((r) => {
const m = memberById.get(r.zitadelUserId);
return {
userId: r.zitadelUserId,
displayName: m?.displayName ?? "(removed user)",
email: m?.email ?? "",
roles: m?.roles ?? [],
assignedAt: r.assignedAt,
assignedBy: r.assignedBy,
orphan: !m,
};
});
return NextResponse.json({ assignments });
} catch (e: any) {
console.error("Failed to list tenant assignments:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to list assignments") },
{ status: 500 }
);
}
}
/**
* POST /api/tenants/[name]/assignments
*
* Body: { userId }
*
* Assign a user to a tenant. Owner+platform only. The target user must
* already be a member of the tenant's org (we verify via the team list)
* — to add a brand-new user, the owner first invites them via
* POST /api/team/invite, then assigns them here.
*
* Idempotent: re-assigning is a no-op (DB INSERT ... ON CONFLICT DO
* NOTHING). The original `assignedAt`/`assignedBy` are preserved.
*
* Owners technically don't need to be assigned (they see all of their
* org's tenants anyway) but we don't reject the operation — just lets
* future bookkeeping work consistently.
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: 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 { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Customer owners can only assign within their own org. Platform
// users can assign anywhere (rare, but consistent with admin scope).
const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!user.isPlatform && tenantOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!tenantOrgId) {
return NextResponse.json(
{ error: "Tenant is missing the org-id label; cannot assign." },
{ status: 500 }
);
}
// Bug 7 server-side counterpart: personal tenants are sole-owner
// by definition. Reject any assignment attempt — this matches the
// hidden panel on the detail page and stops a determined client
// (or platform user with a legacy unlabeled personal tenant) from
// creating spurious rows.
if (
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
(!user.isPlatform && user.isPersonal)
) {
return NextResponse.json(
{
error: "Personal tenants do not support additional assignments.",
code: "personal_tenant",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = assignSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Verify the target user is actually a member of the tenant's org.
// This is the audit boundary — without it, an owner could grant
// access to arbitrary user IDs they made up.
try {
const members = await getOrgMembers(tenantOrgId);
const target = members.find((m) => m.userId === parsed.data.userId);
if (!target) {
return NextResponse.json(
{
error:
"Target user is not a member of this organization. Invite them first.",
code: "user_not_in_org",
},
{ status: 400 }
);
}
await addTenantAssignment({
tenantName: name,
orgId: tenantOrgId,
userId: parsed.data.userId,
assignedBy: user.id,
});
return NextResponse.json(
{ message: "User assigned.", userId: parsed.data.userId },
{ status: 201 }
);
} catch (e: any) {
console.error("Failed to add tenant assignment:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to assign user") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { findKeyByAlias, updateKeyBudget } from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* Update the per-tenant budget — operates on the LiteLLM virtual
* key, NOT on the team.
*
* Why per-key
* -----------
* Each tenant in an org has its own virtual key
* (`key_alias = tenant.metadata.name`); the team that owns those
* keys is org-scoped and shared across all the org's tenants. A
* budget on the team would cap the whole org; a budget on the key
* caps just this one tenant. Customers landing on the tenant detail
* page reasonably expect "edit budget" to mean "the budget of THIS
* tenant" — so we put it on the key.
*
* The team-level (org-wide) budget is a separate control that lives
* in /settings (not yet implemented) — the two coexist: LiteLLM
* applies whichever cap is hit first.
*
* Schema:
* - maxBudget: number > 0 (set a cap), or null (remove the cap).
* - budgetDuration: one of "30d", "1mo", "1y", or null (lifetime).
*
* Authorization: owners and platform admins.
*/
const patchSchema = z.object({
// > 0 because LiteLLM rejects 0 and a zero cap would lock the key
// out instantly. Upper bound 1M as a typo guard.
maxBudget: z.number().positive().max(1_000_000).nullable(),
budgetDuration: z.enum(["30d", "1mo", "1y"]).nullable(),
});
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 (!canMutate(user)) {
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 });
}
if (!(await canUserSeeTenant(user, tenant))) {
// Don't leak existence — same 404 a non-visible tenant gets.
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const teamId = tenant.status?.litellmTeamId;
if (!teamId) {
return NextResponse.json(
{
error:
"Tenant has no LiteLLM team yet. Please wait until provisioning completes.",
},
{ status: 409 }
);
}
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 }
);
}
// Defensive: removing the cap should null out the duration too —
// a reset cadence on an unlimited budget is meaningless and would
// confuse LiteLLM's bookkeeping.
const maxBudget = parsed.data.maxBudget;
const budgetDuration =
maxBudget === null ? null : parsed.data.budgetDuration;
// Look up the key by alias (= tenant name). The token returned is
// what /key/update wants in the `key` field.
let keyInfo;
try {
keyInfo = await findKeyByAlias(teamId, name);
} catch (e: any) {
console.error("Failed to look up tenant key:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to look up tenant key") },
{ status: 500 }
);
}
if (!keyInfo) {
return NextResponse.json(
{
error:
"Tenant has no virtual key yet. Please wait until provisioning completes.",
},
{ status: 409 }
);
}
try {
await updateKeyBudget(keyInfo.token, { maxBudget, budgetDuration });
return NextResponse.json({
message: maxBudget === null ? "Budget removed." : "Budget updated.",
maxBudget,
budgetDuration,
});
} catch (e: any) {
console.error("Failed to update key budget:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update budget") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,199 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, setTenantAnnotation } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import {
createResumeRequest,
getPendingResumeRequestForTenant,
getTenantRequestByTenantName,
} from "@/lib/db";
import { sendResumeRequestAdminNotificationEmail } from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* Body schema. Both fields optional; the customer can submit a
* resume request with no body at all (the JS client sends `{}`),
* or with a note explaining their reactivation rationale.
*
* Length cap mirrors `billing_notes` (2000 chars) — same lower
* bound for "free-form text we don't want abused".
*/
const bodySchema = z.object({
customerNotes: z
.string()
.trim()
.max(2000)
.optional()
.transform((v) => (v && v.length > 0 ? v : undefined)),
});
/**
* POST /api/tenants/[name]/resume-request
*
* Owner-initiated request to reactivate a suspended tenant (Bug 37a).
* Creates a pending tenant_request of type 'resume' for admin review,
* and stamps the PiecedTenant CR with an annotation that pauses the
* operator's 60-day deletion timer.
*
* Why a request flow at all
* -------------------------
* Customers can self-serve cancel; resume requires admin oversight.
* Reactivation may involve re-validating billing, confirming the
* customer still wants to be active, or other manual steps. The
* request flow gives admins a queue to review, with the same approve/
* reject UX as initial provision requests.
*
* Authorization
* -------------
* Owners and platform admins. Platform admins shouldn't normally use
* this endpoint — they have direct PATCH suspend access — but it's
* permissive in case admin tooling pivots.
*
* Validation
* ----------
* - Tenant must exist and be visible to the caller.
* - Tenant must be currently suspended. Resuming an active tenant
* is meaningless.
* - At most one pending resume request per tenant. Enforced by the
* DB's partial unique index, but we also check explicitly here to
* return a friendly 409 instead of a 500.
*
* Side effects on success
* -----------------------
* - INSERT into tenant_requests (request_type='resume', status='pending')
* - PATCH annotation `pieced.ch/resume-request-pending=<request-id>` on
* the CR. This is the operator's signal to pause its 60-day deletion
* timer until the request transitions to terminal.
*
* The annotation set is best-effort: if the K8s PATCH fails after the
* DB insert, the row exists without the annotation. The customer
* sees the request as pending; admin can still approve. The only
* functional consequence is the 60-day timer doesn't pause until the
* next request transition, which is fine in practice (admin response
* times are dramatically shorter than 60 days).
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: 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 { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!tenant.spec.suspend) {
return NextResponse.json(
{ error: "Tenant is not suspended; nothing to resume." },
{ status: 409 }
);
}
// Body is optional — the customer can submit a resume request
// with no payload at all, or attach a free-form note.
const rawBody = await req.json().catch(() => ({}));
const parsed = bodySchema.safeParse(rawBody ?? {});
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const customerNotes = parsed.data.customerNotes;
// Already a pending request? Don't duplicate.
const existing = await getPendingResumeRequestForTenant(name);
if (existing) {
return NextResponse.json(
{
error: "A resume request for this tenant is already pending.",
request: { id: existing.id, createdAt: existing.createdAt },
},
{ status: 409 }
);
}
// Pull traceability fields (companyName, agentName) from the original
// provision request. The schema marks these NOT NULL, so we have to
// populate them; copying from the provision row keeps the resume
// row navigable in the admin UI without making up values.
const provision = await getTenantRequestByTenantName(name);
try {
const resumeRequest = await createResumeRequest({
tenantName: name,
zitadelOrgId:
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? user.orgId,
zitadelUserId: user.id,
contactName: user.name,
contactEmail: user.email,
companyName: provision?.companyName ?? tenant.spec.displayName ?? name,
agentName: provision?.agentName ?? "Assistant",
customerNotes,
});
// Stamp the annotation so the operator pauses its TTL. If this
// fails the request still exists; surface the error so admin
// tooling can re-stamp if needed, but don't roll back.
try {
await setTenantAnnotation(
name,
"pieced.ch/resume-request-pending",
resumeRequest.id
);
} catch (e) {
console.warn(
"resume request created but annotation could not be set; operator's 60-day timer will not pause until next reconcile triggered by request transition",
e
);
}
// Notify admin distribution. Fire-and-log: failure to email
// doesn't roll back the request creation. The customer's note
// (if any) is included so admin can triage from the email
// without opening the queue.
sendResumeRequestAdminNotificationEmail({
tenantName: name,
companyName: resumeRequest.companyName,
contactName: resumeRequest.contactName,
contactEmail: resumeRequest.contactEmail,
customerNotes,
}).catch((e) =>
console.error("resume admin notification email failed:", e)
);
return NextResponse.json(
{
message: "Resume request submitted. An admin will review shortly.",
request: { id: resumeRequest.id, status: resumeRequest.status },
},
{ status: 201 }
);
} catch (e: any) {
// Unique violation (a pending row already exists for this tenant)
// is friendly-handled above; this catches everything else.
if (e.code === "23505") {
return NextResponse.json(
{ error: "A resume request for this tenant is already pending." },
{ status: 409 }
);
}
console.error("Resume request creation failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to submit resume request") },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { recordSkillEvents } from "@/lib/db";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
@@ -22,11 +24,11 @@ export async function GET(
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Slice 6: visibility now includes assignment-table check for
// user-role members. We return 404 (not 403) to avoid leaking
// tenant existence — same as cross-org reads.
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(tenant);
@@ -46,7 +48,7 @@ export async function PATCH(
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -186,6 +188,50 @@ export async function PATCH(
}
const updated = await patchTenantSpec(name, specPatch);
// Billing — Phase 1: if packages changed, record enable/disable
// events. The diff is computed against the patched CR (the
// returned state) rather than `existing` so the events match
// what K8s actually committed. Best-effort: a logging failure
// never poisons the PATCH response — drift would be reconciled
// on the next backfill or by the next normal toggle.
//
// Note on races: two concurrent PATCHes could each see the
// same `existing` and both succeed at the K8s layer (last write
// wins for spec.packages, which is replaced wholesale). The
// events from the losing PATCH would then describe a transition
// that no longer reflects reality. Acceptable trade-off for v1
// — the toggle UI sends one request at a time and races would
// only matter for adjacent same-day toggles, which the billing
// computation collapses to a single billable day anyway.
if (specPatch.packages !== undefined) {
try {
const orgId =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
const oldSet = new Set<string>(existing.spec.packages ?? []);
const newSet = new Set<string>(updated.spec.packages ?? []);
const added = [...newSet].filter((x) => !oldSet.has(x));
const removed = [...oldSet].filter((x) => !newSet.has(x));
if (added.length > 0 || removed.length > 0) {
await recordSkillEvents(name, orgId, added, removed);
}
} else {
// A tenant without the org label is a pre-Slice-3 artifact
// — we can't attribute its skill events to any org. Log
// and skip rather than guess.
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; skill events not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record skill events for ${name}:`,
e
);
}
}
return NextResponse.json(updated);
} catch (e: any) {
return NextResponse.json(

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { writePackageSecrets } from "@/lib/openbao";
import { getPackageDef } from "@/lib/packages";
@@ -12,7 +12,7 @@ export async function POST(
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

View File

@@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
suspend: z.boolean(),
});
/**
* PATCH /api/tenants/[name]/suspend
*
* Direct suspend control on the PiecedTenant CR. Sets `spec.suspend`
* to true (cancel) or false (resume).
*
* Authorization (Bug 37a)
* -----------------------
* - suspend=true → owners and platform admins may call.
* - suspend=false → platform admins ONLY. Owners must go through the
* resume-request flow (POST /api/tenants/[name]/resume-request),
* which creates a pending request for admin approval. This
* asymmetry is by design: cancellation is self-service (low risk;
* reversible by request); reactivation requires admin oversight
* (e.g. to re-validate billing, confirm intent).
*
* Customer flow:
* - Cancel: PATCH suspend=true here
* - Resume: POST /resume-request — creates a 'resume' tenant_request,
* admin approves via /api/admin/requests/[id]/approve which
* then PATCHes suspend=false here as a platform user.
*
* Workload behaviour
* ------------------
* On suspend=true the operator deletes the OpenClawInstance, stopping
* the pod within seconds. Tenant data — namespace, ConfigMaps,
* OpenBao secrets, CNPG database, LiteLLM team — is retained.
*
* Suspended tenants enter a 60-day retention window (operator
* constant `retentionAfterSuspend`); after that, the tenant is fully
* deleted unless a pending resume request exists. The operator
* checks the `pieced.ch/resume-request-pending` annotation to know
* about pending requests; we set it here when admin approves the
* resume (transitively, via the admin-approve endpoint), and clear
* it when the request reaches a terminal state.
*/
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 (!canMutate(user)) {
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 });
}
// Identical pattern to the detail page — don't leak existence.
if (!(await canUserSeeTenant(user, 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 { suspend } = parsed.data;
// Bug 37a: resume (suspend=false) is platform-admin only via this
// endpoint. Owners must go through the resume-request flow.
if (!suspend && !user.isPlatform) {
return NextResponse.json(
{
error:
"Resume requires platform-admin approval. Submit a resume request via /api/tenants/[name]/resume-request.",
},
{ status: 403 }
);
}
// No-op early exit. Avoids a needless K8s patch + status churn when
// the user double-clicks the button or the UI is briefly out of sync.
if (Boolean(tenant.spec.suspend) === suspend) {
return NextResponse.json(
{ message: "No change.", suspend },
{ status: 200 }
);
}
try {
await patchTenantSpec(name, { suspend });
// Billing — Phase 1: record the transition so monthly proration
// can exclude suspended days from the fixed fee. The portal
// commands this transition; the operator's status.suspendedAt
// lags by a reconcile cycle (seconds), which is irrelevant for
// monthly billing. Best-effort: a logging failure never blocks
// the suspend/resume itself.
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
);
}
// On admin-side resume, also clear the pending-resume-request
// annotation if it exists. Belt-and-suspenders: the admin-approve
// endpoint already clears it on its happy path, but a platform
// user resuming directly via this endpoint shouldn't leave the
// annotation behind. Best-effort: failure to clear the annotation
// is logged but doesn't fail the resume.
if (!suspend) {
try {
await setTenantAnnotation(
name,
"pieced.ch/resume-request-pending",
null
);
} catch (e) {
console.warn(
"failed to clear resume-request-pending annotation; operator will see it stale until next request transition",
e
);
}
}
return NextResponse.json(
{
message: suspend
? "Subscription cancelled. Your data is preserved for 60 days."
: "Subscription resumed.",
suspend,
},
{ status: 200 }
);
} catch (e: any) {
console.error("Suspend toggle failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update subscription") },
{ status: e.statusCode || 500 }
);
}
}

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import {
writePackageSecrets,
deletePackageSecrets,
} from "@/lib/openbao";
import { mintToken, revokeToken } from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema package provisioning — special-cased because the credentials
* are platform-issued (relay mints them), not customer-supplied.
*
* POST /api/tenants/:name/threema
* - Mints a per-tenant bearer + HMAC secret from the central relay.
* - Writes both to OpenBao under
* secret/data/tenants/<tenant-{name}>/threema-relay so the
* operator's ExternalSecret can sync them into the tenant
* namespace alongside other channel secrets.
* - Returns 200 on success. The caller (PackageCard) then PATCHes
* tenant.spec.packages to add "threema".
*
* DELETE /api/tenants/:name/threema
* - Revokes the per-tenant token at the relay (cascades to all
* routes — the relay's tokens.deleteToken also deletes routes).
* - Deletes the OpenBao secret so the ExternalSecret/operator can
* converge cleanly.
* - Returns 200 on success even if no token existed (idempotent).
*
* Failure semantics
* -----------------
* On POST: if minting succeeds but the OpenBao write fails, we attempt
* to revoke the just-minted token before returning the error. That way
* the relay doesn't keep an orphan token row that nothing can use.
* Best-effort cleanup; if the revoke also fails, the relay admin can
* use DELETE /admin/tokens/<name> manually.
*
* On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we
* return the error and stop — leaving OpenBao alone means the pod's
* still-mounted secret keeps working in the brief window between
* "customer hits disable" and "operator reconciles spec without threema",
* which is more graceful than yanking the secret out from under a
* running pod.
*/
const VAULT_SUFFIX = "threema-relay";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ name: 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 { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const minted = await mintToken(name);
if (!minted.ok) {
return NextResponse.json(
{ error: `Relay mint failed: ${minted.message}` },
{ status: minted.kind === "http" ? 502 : 503 },
);
}
try {
await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, {
token: minted.token,
"hmac-secret": minted.hmacSecret,
});
} catch (e) {
// Compensate: revoke the just-minted token so the relay doesn't
// hold an orphan. Best-effort — log and continue surfacing the
// original error.
const revoke = await revokeToken(name);
if (!revoke.ok) {
console.error(
`[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`,
);
}
return NextResponse.json(
{ error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` },
{ status: 503 },
);
}
return NextResponse.json({ ok: true });
} catch (e) {
console.error("[threema/provision]", e);
return NextResponse.json(
{ error: safeError(e, "Provisioning failed") },
{ status: 500 },
);
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: 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 { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const revoke = await revokeToken(name);
// 404 from relay = nothing to revoke = idempotent success.
if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) {
return NextResponse.json(
{ error: `Relay revoke failed: ${revoke.message}` },
{ status: revoke.kind === "http" ? 502 : 503 },
);
}
// Delete the OpenBao secret. Idempotent — deletePackageSecrets
// tolerates 404.
try {
await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX);
} catch (e) {
// Already revoked at the relay — surface the openbao failure
// but keep the partial-success state visible.
return NextResponse.json(
{
error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`,
partial: true,
},
{ status: 503 },
);
}
return NextResponse.json({
ok: true,
deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0,
});
} catch (e) {
console.error("[threema/deprovision]", e);
return NextResponse.json(
{ error: safeError(e, "Deprovisioning failed") },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import {
createRoute,
deleteRoute,
isRouteConflictForOtherTenant,
isRouteConflictForSameTenant,
listRoutes,
} from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema route management — keeps three places in sync:
*
* 1. Relay DB (`routes` table) — source of truth for uniqueness
* 2. K8s spec.channelUsers.threema — what the operator sees
* 3. Customer UI — derived from (2)
*
* Add (POST) order: relay first (to claim uniqueness atomically), then
* K8s. On K8s failure we compensate by deleting the relay route.
*
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
* is the customer-facing semantic that matters), then relay. On relay
* failure we DO NOT rollback K8s — the customer wanted it gone, and
* the relay's stale route will be cleaned up on the next retry (deletes
* are idempotent at the relay).
*
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
* spec.channelUsers.threema, which REPLACES the entire array. We GET
* the latest array, mutate it, then PATCH. Two concurrent adds from the
* same customer's tabs can lose one of them. Acceptable at pilot scale
* (single-digit customers, low concurrency); revisit with SSA + field
* managers if it ever bites.
*/
const ROUTE_BODY = z.object({
threemaId: z
.string()
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
});
// ---- helpers --------------------------------------------------------------
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
if (!canMutate(user))
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
const tenant = await getTenant(name);
if (!tenant)
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { tenant };
}
function currentThreemaIds(tenantSpec: any): string[] {
const cu = tenantSpec?.channelUsers ?? {};
const ids = cu.threema;
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
}
// ---- GET ------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const res = await listRoutes(name);
if (!res.ok) {
return NextResponse.json(
{ error: `Relay list failed: ${res.message}` },
{ status: res.kind === "http" ? 502 : 503 },
);
}
return NextResponse.json({ routes: res.routes });
}
// ---- POST -----------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
const body = await req.json().catch(() => null);
const parsed = ROUTE_BODY.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
const threemaId = parsed.data.threemaId;
// Step 1: claim at the relay. Uniqueness is enforced here.
const claim = await createRoute(name, threemaId);
if (!claim.ok) {
if (isRouteConflictForOtherTenant(claim, name)) {
return NextResponse.json(
{ error: "This Threema ID is already registered to another tenant" },
{ status: 409 },
);
}
if (!isRouteConflictForSameTenant(claim, name)) {
// Genuine non-409 failure
return NextResponse.json(
{ error: `Relay create failed: ${claim.message}` },
{ status: claim.kind === "http" ? claim.status : 503 },
);
}
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
}
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (!existing.includes(threemaId)) {
const next = [...existing, threemaId];
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
// Compensate: drop the relay route so we don't leave an orphan.
const compensate = await deleteRoute(name, threemaId);
if (!compensate.ok) {
console.error(
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
);
}
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
return NextResponse.json({ ok: true, threemaId });
}
// ---- DELETE ---------------------------------------------------------------
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
const parsed = ROUTE_BODY.safeParse({ threemaId });
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
// Step 1: drop from K8s (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (existing.includes(parsed.data.threemaId)) {
const next = existing.filter((id) => id !== parsed.data.threemaId);
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
// Step 2: drop at relay (also idempotent — 404 is fine).
const dropped = await deleteRoute(name, parsed.data.threemaId);
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
// K8s is already updated; surface but don't rollback. Next time the
// user toggles, both will converge.
return NextResponse.json(
{
ok: true,
threemaId: parsed.data.threemaId,
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
},
{ status: 200 },
);
}
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
}

View File

@@ -1,21 +1,14 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
export async function GET() {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tenants = await listTenants();
if (user.isPlatform) {
return NextResponse.json(tenants);
}
// Customers see only their own tenant
const own = tenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
return NextResponse.json(own);
const all = await listTenants();
const visible = await listVisibleTenants(user, all);
return NextResponse.json(visible);
}

View File

@@ -1,45 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import { listVisibleTenants } from "@/lib/visibility";
import {
getTeamInfo,
getTeamSpendLogsV2,
findKeyByAlias,
} from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* GET /api/usage
*
* Customers: teamId is resolved server-side from the tenant matching the
* user's orgId. No client-supplied teamId accepted.
* Platform admins: may pass ?teamId=... to inspect any tenant's usage.
* Per-tenant spend/token usage for a given month.
*
* Resolution rules (in priority order)
* ------------------------------------
* 1. `?tenant=<name>` query param — the canonical path. The route
* looks up the PiecedTenant CR by name, runs it through the
* viewer's visibility filter, and reads `status.litellmTeamId` +
* `status.litellmKeyAlias`. This is what the tenant-detail page
* calls with for both customers and admins.
* 2. `?teamId=<id>` (+ optional `?keyAlias=<alias>`) — admin escape
* hatch for debugging across orgs (e.g. opening the platform
* panel without a specific tenant in mind). Platform-only;
* ignored for customer sessions.
* 3. No params — 400. We deliberately do NOT fall back to "the
* first visible tenant". Bug 19: that fallback meant siblings
* in the same org showed identical numbers because the API
* always picked the same "first" tenant regardless of which
* detail page the customer was viewing. Forcing callers to be
* explicit makes the bug structurally impossible to reintroduce.
*
* Filtering
* ---------
* LiteLLM's `/spend/logs/v2` accepts a server-side `key_alias` filter.
* We pass it through directly — no more "fetch all team pages and
* post-filter in JS" (which was O(team_total) memory per request and
* masked the routing bug above by being slow enough that nobody
* noticed which alias was actually being used).
*
* The team-level budget is still surfaced as the *org* budget, since
* teams are org-scoped post-Slice-2. That's intentional: the customer
* sees "your company has X budget remaining" alongside "this tenant
* cost Y this month".
*/
export async function GET(req: NextRequest) {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tenantName = req.nextUrl.searchParams.get("tenant");
let teamId: string | null = null;
let keyAlias: string | null = null;
if (user.isPlatform) {
// Admins may pass a specific teamId to inspect any tenant
teamId = req.nextUrl.searchParams.get("teamId") ?? null;
}
if (tenantName) {
// Path 1: resolve from tenant name with visibility check.
//
// listVisibleTenants enforces the same visibility rules as every
// other read endpoint:
// - platform admins see everything
// - owners see all tenants in their org
// - users see only the tenants they're assigned to (Slice 6)
//
// Filtering through that list rather than reading the CR directly
// means a malicious caller can't probe arbitrary tenant names to
// learn what exists in other orgs.
const allTenants = await listTenants();
const visible = await listVisibleTenants(user, allTenants);
const tenant = visible.find((t) => t.metadata.name === tenantName);
// For customers (or admins without explicit teamId): resolve from their tenant
if (!teamId) {
const tenants = await listTenants();
const orgTenant = tenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (!orgTenant?.status?.litellmTeamId) {
if (!tenant) {
return NextResponse.json(
{ error: "No active tenant found for your organization" },
{ error: "Tenant not found or not accessible" },
{ status: 404 }
);
}
teamId = orgTenant.status.litellmTeamId;
if (!tenant.status?.litellmTeamId) {
// Tenant exists but the operator hasn't reconciled it yet.
// Common right after onboarding; the customer should see a
// friendly empty state, not a 500.
return NextResponse.json(
{ error: "Tenant is still provisioning, no usage data yet" },
{ status: 409 }
);
}
teamId = tenant.status.litellmTeamId;
// litellmKeyAlias is set by the operator's LiteLLM reconcile step
// alongside litellmTeamId, so if teamId is present this should be
// too. Defensive fallback to team-level if missing — in that case
// the customer briefly sees company totals until the next operator
// reconcile, which is better than 500.
keyAlias = tenant.status.litellmKeyAlias ?? null;
} else if (user.isPlatform) {
// Path 2: admin escape hatch.
teamId = req.nextUrl.searchParams.get("teamId");
keyAlias = req.nextUrl.searchParams.get("keyAlias");
if (!teamId) {
return NextResponse.json(
{
error:
"Either ?tenant=<name> or ?teamId=<id> (admin) must be provided",
},
{ status: 400 }
);
}
} else {
// Path 3: no resolution possible. See doc above for why we don't
// pick a default.
return NextResponse.json(
{ error: "Tenant must be specified via ?tenant=<name>" },
{ status: 400 }
);
}
// Month param: YYYY-MM, defaults to current month
// Month param: YYYY-MM, defaults to current month.
const now = new Date();
const monthParam =
req.nextUrl.searchParams.get("month") ||
@@ -55,7 +130,21 @@ export async function GET(req: NextRequest) {
try {
const teamInfo = await getTeamInfo(teamId);
// Fetch all pages
// Per-tenant budget lives on the virtual key, not the team
// (Feature 7 fix). When the request is scoped to a specific
// tenant (keyAlias provided), look up the key so we can return
// the per-tenant cap. Tolerate failure — older LiteLLM builds
// or short-lived race conditions during provisioning shouldn't
// 500 the whole usage page; we degrade to "no key info".
const keyInfo = keyAlias
? await findKeyByAlias(teamId, keyAlias).catch(() => null)
: null;
// Page through results — server-side filtered by key_alias when
// provided. Pagination still needed because LiteLLM caps
// page_size at 100, and a busy tenant can easily exceed that in
// a month. With server-side filtering this stays cheap regardless
// of how busy sibling tenants in the same team are.
const allRequests: any[] = [];
let page = 1;
while (true) {
@@ -64,14 +153,20 @@ export async function GET(req: NextRequest) {
startStr,
endStr,
page,
100
100,
keyAlias
);
allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break;
page++;
// Defensive cap. A pathological response with bogus total_pages
// shouldn't be able to spin us forever. 50 pages × 100 = 5000
// entries/month/tenant is well above any realistic usage at
// pilot scale.
if (page > 50) break;
}
// Aggregate by day
// Aggregate by day.
const byDay: Record<
string,
{ inputTokens: number; outputTokens: number; spend: number }
@@ -102,6 +197,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
teamId,
keyAlias, // null when admin queries team-wide (no specific tenant)
month: monthParam,
currentPeriod: {
inputTokens: totalInput,
@@ -109,13 +205,38 @@ export async function GET(req: NextRequest) {
totalSpend,
requestCount: allRequests.length,
},
budget: {
maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0,
remaining: teamInfo?.team_info?.max_budget
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
: null,
},
// Budget reporting (Feature 7).
//
// When the caller scopes to a specific tenant (keyAlias set),
// we report THAT tenant's per-key budget — that's what the
// tenant detail page renders, and what the customer expects
// when they see "Budget" on a tenant's page.
//
// When unscoped (admin / org-wide view), we fall back to the
// team budget — that's the org-wide cap, conceptually different
// but the only thing meaningful at that scope.
//
// The two cases display the same way; the editor button gates
// on whether we know which tenant we're on (= keyAlias set).
budget: keyAlias && keyInfo
? {
maxBudget: keyInfo.maxBudget,
spend: keyInfo.spend,
remaining:
keyInfo.maxBudget !== null
? keyInfo.maxBudget - keyInfo.spend
: null,
budgetDuration: keyInfo.budgetDuration,
}
: {
maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0,
remaining: teamInfo?.team_info?.max_budget
? teamInfo.team_info.max_budget -
(teamInfo.team_info.spend ?? 0)
: null,
budgetDuration: teamInfo?.team_info?.budget_duration ?? null,
},
rateLimits: {
rpm: teamInfo?.team_info?.rpm_limit ?? null,
tpm: teamInfo?.team_info?.tpm_limit ?? null,

View File

@@ -199,7 +199,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
throw new Error(data.error || "Delete failed");
}
setDeleteModal(null);
await fetchTenants();
// Bug 32: K8s deletion is asynchronous — the resource enters a
// Terminating phase with a deletionTimestamp set, finalizers run,
// then the resource is fully removed. fetchTenants() right
// after the API call would race the K8s store and often still
// include the just-deleted row. Two complementary fixes:
// 1. Optimistically drop the row from local state so the UI
// reflects the user's intent immediately.
// 2. Schedule a delayed refetch (1.5s) to pick up any side
// effects (cascaded request rows, freshly-released names).
// The immediate fetchTenants() is kept as a "best chance" — if
// K8s does report the deletion synchronously (rare), we get the
// freshest data. If it doesn't, the optimistic update has us
// covered until the delayed refetch lands.
setTenants((prev) => prev.filter((t) => t.metadata.name !== name));
fetchTenants();
setTimeout(() => fetchTenants(), 1500);
} catch (e: any) {
setError(e.message);
} finally {
@@ -347,9 +362,40 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
>
<td className="px-4 py-3">
<div className="font-medium text-text-primary text-sm">
<div className="font-medium text-text-primary text-sm flex items-center gap-2">
{req.companyName}
{/* Bug 37a: distinguish resume requests in the
queue. Provision and resume share status
semantics but very different action
consequences — a resume approval just
un-suspends an existing tenant, no
provisioning workflow runs. */}
{req.requestType === "resume" && (
<span
className="px-1.5 py-0.5 text-[10px] font-semibold rounded uppercase tracking-wider bg-success/15 text-success"
title={t("resumeRequestTooltip")}
>
{t("resumeRequestBadge")}
</span>
)}
</div>
{req.requestType === "resume" && req.tenantName && (
<div className="text-text-muted text-xs font-mono mt-0.5">
{req.tenantName}
</div>
)}
{/* Feature 6: customer's reactivation rationale,
shown inline so admin can triage without
opening a detail view. Truncated for
queue density; full content on hover. */}
{req.requestType === "resume" && req.customerNotes && (
<div
className="text-text-secondary text-xs mt-1 max-w-[280px] line-clamp-2 whitespace-pre-wrap"
title={req.customerNotes}
>
{req.customerNotes}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-text-primary text-sm">

View File

@@ -0,0 +1,345 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { InvoiceDraft } from "@/types";
interface OrgEntry {
zitadelOrgId: string;
tenantNames: string[];
companyName: string | null;
country: string | null;
hasBillingAddress: boolean;
}
interface Props {
orgs: OrgEntry[];
}
const LOCALE_OPTIONS = [
{ value: "de", label: "Deutsch" },
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "it", label: "Italiano" },
];
/**
* Two-step flow: preview (dryRun) → commit.
*
* Preview displays the InvoiceDraft (lines, subtotal, VAT, total)
* plus any warnings. Admin reviews and either commits or aborts.
* Commit re-runs the generator without dryRun and redirects to the
* persisted invoice's detail page.
*/
export function GenerateForm({ orgs }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
// Default to previous calendar month — that's the typical "bill
// for last month" use case.
const now = new Date();
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const [orgId, setOrgId] = useState(orgs[0]?.zitadelOrgId ?? "");
const [year, setYear] = useState(String(prevMonth.getFullYear()));
const [month, setMonth] = useState(String(prevMonth.getMonth() + 1));
const [locale, setLocale] = useState<string>("");
const [draft, setDraft] = useState<InvoiceDraft | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const selectedOrg = orgs.find((o) => o.zitadelOrgId === orgId);
// Auto-detect default locale from country if admin hasn't picked
// one. Same logic as billing.ts's defaultLocaleForCountry.
const effectiveLocale =
locale ||
(() => {
const c = (selectedOrg?.country || "").toUpperCase();
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
if (["FR", "BE", "LU"].includes(c)) return "fr";
if (c === "IT") return "it";
return "en";
})();
const preview = async () => {
setError("");
setDraft(null);
setBusy(true);
try {
const res = await fetch("/api/admin/billing/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zitadelOrgId: orgId,
year: Number(year),
month: Number(month),
locale: effectiveLocale,
dryRun: true,
}),
});
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setDraft(j.draft);
} catch (e: any) {
setError(e.message);
} finally {
setBusy(false);
}
};
const commit = async () => {
if (!draft) return;
if (!confirm(t("confirmGenerate"))) return;
setError("");
setBusy(true);
try {
const res = await fetch("/api/admin/billing/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zitadelOrgId: orgId,
year: Number(year),
month: Number(month),
locale: effectiveLocale,
dryRun: false,
}),
});
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
// Navigate to the new invoice's detail page.
if (j.invoice?.id) {
router.push(`/admin/billing/invoices/${j.invoice.id}`);
}
} catch (e: any) {
setError(e.message);
setBusy(false);
}
};
return (
<div className="space-y-6">
<Card>
<CardHeader>{t("generateFormTitle")}</CardHeader>
{orgs.length === 0 ? (
<p className="text-sm text-text-muted italic">{t("noOrgsToGenerate")}</p>
) : (
<div className="space-y-4">
<label className="block">
<span className="text-sm text-text-secondary">{t("orgLabel")}</span>
<select
value={orgId}
onChange={(e) => {
setOrgId(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{orgs.map((o) => (
<option key={o.zitadelOrgId} value={o.zitadelOrgId}>
{o.companyName ?? o.zitadelOrgId}
{!o.hasBillingAddress ? `${t("noBillingAddrTag")}` : ""}
{` (${o.tenantNames.length} ${t("tenantsLabel")})`}
</option>
))}
</select>
{selectedOrg && !selectedOrg.hasBillingAddress && (
<p className="text-xs text-error mt-1">
{t("noBillingAddrWarning")}
</p>
)}
</label>
<div className="grid grid-cols-3 gap-3">
<label className="block">
<span className="text-sm text-text-secondary">{t("yearLabel")}</span>
<input
type="number"
min="2020"
max="2100"
value={year}
onChange={(e) => {
setYear(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">{t("monthLabel")}</span>
<select
value={month}
onChange={(e) => {
setMonth(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, "0")}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("localeLabel")}
</span>
<select
value={locale}
onChange={(e) => {
setLocale(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
<option value="">
{t("localeAuto")} ({effectiveLocale})
</option>
{LOCALE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={preview}
disabled={busy || !selectedOrg?.hasBillingAddress}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
>
{busy && !draft ? t("computing") : t("previewBtn")}
</button>
{draft && (
<button
onClick={commit}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy ? t("saving") : t("commitBtn")}
</button>
)}
{error && (
<span className="text-sm text-error">{error}</span>
)}
</div>
</div>
)}
</Card>
{draft && <DraftPreview draft={draft} />}
</div>
);
}
function DraftPreview({ draft }: { draft: InvoiceDraft }) {
const t = useTranslations("adminBilling");
// Group lines by tenant for the preview (matches PDF layout).
const linesByTenant = new Map<string | null, typeof draft.lines>();
for (const ln of draft.lines) {
const key = ln.tenantName;
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
linesByTenant.get(key)!.push(ln);
}
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a.localeCompare(b);
});
return (
<Card>
<CardHeader>
{t("previewTitle")} {draft.periodStart} {draft.periodEnd}
</CardHeader>
{draft.warnings.length > 0 && (
<div className="mb-4 p-3 rounded-md border border-warning bg-warning/10 text-sm space-y-1">
<div className="font-semibold text-warning">{t("warningsTitle")}</div>
{draft.warnings.map((w, i) => (
<div key={i} className="text-text-secondary"> {w}</div>
))}
</div>
)}
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{tenantOrder.map((tenantKey) => {
const lines = linesByTenant.get(tenantKey)!;
return (
<Fragment key={tenantKey ?? "_org"}>
{tenantKey && (
<tr className="border-t border-border">
<td colSpan={4} className="py-1.5 pt-3">
<span className="text-xs font-semibold text-accent">
{tenantKey}
</span>
</td>
</tr>
)}
{lines.map((ln, i) => (
<tr
key={`${tenantKey}-${i}`}
className="border-t border-border"
>
<td className="py-1.5">
<div>{ln.description}</div>
<div className="text-xs text-text-muted font-mono">
{ln.kind}
</div>
</td>
<td className="py-1.5 text-right">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-1.5 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(4)}
</td>
<td className="py-1.5 text-right">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</Fragment>
);
})}
{draft.lines.length === 0 && (
<tr>
<td colSpan={4} className="py-4 text-center text-text-muted italic">
{t("noLinesGenerated")}
</td>
</tr>
)}
</tbody>
</table>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t("subtotal")}</span>
<span>CHF {draft.subtotalChf.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">
{t("vat")} ({draft.vatRate.toFixed(2)}%)
</span>
<span>CHF {draft.vatAmountChf.toFixed(2)}</span>
</div>
<div className="flex justify-between pt-1 border-t border-border font-semibold">
<span>{t("total")}</span>
<span>CHF {draft.totalChf.toFixed(2)}</span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,307 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { InvoiceDetail, InvoiceStatus } from "@/types";
interface Props {
detail: InvoiceDetail;
}
/**
* Renders the invoice header (status, totals, action bar) then
* line items grouped by tenant, then billing snapshot. Actions are
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
*
* On successful action we router.refresh() — the server-side page
* re-renders against the new DB state. For delete we navigate
* away first.
*/
export function InvoiceDetailView({ detail }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
const { invoice, lines } = detail;
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
null
);
const [actionError, setActionError] = useState("");
const [noteInput, setNoteInput] = useState("");
const [noteOpen, setNoteOpen] = useState(false);
const markPaid = async () => {
setActionError("");
setBusyAction("mark-paid");
try {
const res = await fetch(
`/api/admin/billing/invoices/${invoice.id}/mark-paid`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: noteInput || undefined }),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setNoteOpen(false);
setNoteInput("");
router.refresh();
} catch (e: any) {
setActionError(e.message);
} finally {
setBusyAction(null);
}
};
const deleteInvoice = async () => {
if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber })))
return;
setActionError("");
setBusyAction("delete");
try {
const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, {
method: "DELETE",
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.push("/admin/billing/invoices");
} catch (e: any) {
setActionError(e.message);
setBusyAction(null);
}
};
// Group lines by tenant for display (matches PDF layout).
const linesByTenant = new Map<string | null, typeof lines>();
for (const ln of lines) {
const k = ln.tenantName;
if (!linesByTenant.has(k)) linesByTenant.set(k, []);
linesByTenant.get(k)!.push(ln);
}
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a.localeCompare(b);
});
return (
<div className="space-y-4 animate-in">
<div className="flex items-end justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule">
{invoice.invoiceNumber}
</h1>
<div className="flex items-center gap-3 mt-3 text-sm">
<StatusPill status={invoice.status} />
<span className="text-text-muted">
{invoice.periodStart} {invoice.periodEnd}
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted">
{t("dueOnLabel")}: {invoice.dueAt}
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted font-mono text-xs">
{invoice.locale}
</span>
</div>
</div>
<div className="text-right">
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
<div className="text-2xl font-semibold font-mono">
CHF {invoice.totalChf.toFixed(2)}
</div>
</div>
</div>
{/* Action bar */}
<Card>
<div className="flex flex-wrap items-center gap-3">
{invoice.hasPdf && (
<a
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
>
{t("downloadPdfBtn")}
</a>
)}
{(invoice.status === "open" || invoice.status === "overdue") && (
<>
{!noteOpen ? (
<button
onClick={() => setNoteOpen(true)}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{t("markPaidBtn")}
</button>
) : (
<div className="flex items-center gap-2 flex-grow">
<input
type="text"
placeholder={t("paidNotePlaceholder")}
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
autoFocus
/>
<button
onClick={markPaid}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
</button>
<button
onClick={() => {
setNoteOpen(false);
setNoteInput("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
)}
</>
)}
<button
onClick={deleteInvoice}
disabled={busyAction !== null}
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
title={t("deleteHint")}
>
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
</button>
</div>
{actionError && (
<div className="mt-3 text-sm text-error">{actionError}</div>
)}
{invoice.paidAt && (
<div className="mt-3 text-xs text-text-muted">
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
{invoice.paidMethodDetail}
</div>
)}
</Card>
{/* Lines */}
<Card>
<CardHeader>{t("lineItemsTitle")}</CardHeader>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{tenantOrder.map((tenantKey) => {
const tenantLines = linesByTenant.get(tenantKey)!;
return (
<Fragment key={tenantKey ?? "_org"}>
{tenantKey && (
<tr>
<td colSpan={4} className="pt-3 pb-1">
<span className="text-xs font-semibold text-accent">
{tenantKey}
</span>
</td>
</tr>
)}
{tenantLines.map((ln) => (
<tr key={ln.id} className="border-t border-border">
<td className="py-1.5">
<div>{ln.description}</div>
<div className="text-xs text-text-muted font-mono">
{ln.kind}
</div>
</td>
<td className="py-1.5 text-right">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-1.5 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(4)}
</td>
<td className="py-1.5 text-right">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t("subtotal")}</span>
<span>CHF {invoice.subtotalChf.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">
{t("vat")} ({invoice.vatRate.toFixed(2)}%)
</span>
<span>CHF {invoice.vatAmountChf.toFixed(2)}</span>
</div>
<div className="flex justify-between pt-1 border-t border-border font-semibold">
<span>{t("total")}</span>
<span>CHF {invoice.totalChf.toFixed(2)}</span>
</div>
</div>
</Card>
{/* Billing snapshot */}
<Card>
<CardHeader>{t("billToSnapshotTitle")}</CardHeader>
<div className="text-sm space-y-1">
<div className="font-semibold">
{invoice.billingSnapshot.companyName}
</div>
<div>{invoice.billingSnapshot.streetAddress}</div>
<div>
{invoice.billingSnapshot.postalCode}{" "}
{invoice.billingSnapshot.city}
</div>
<div>{invoice.billingSnapshot.country}</div>
{invoice.billingSnapshot.vatNumber && (
<div className="text-text-muted">
VAT: {invoice.billingSnapshot.vatNumber}
</div>
)}
<div className="text-text-muted">
{invoice.billingSnapshot.billingEmail}
</div>
</div>
</Card>
</div>
);
}
function StatusPill({ status }: { status: InvoiceStatus }) {
const t = useTranslations("adminBilling");
const color =
status === "paid"
? "bg-success/15 text-success"
: status === "overdue"
? "bg-error/15 text-error"
: status === "void" || status === "uncollectible"
? "bg-text-muted/15 text-text-muted"
: "bg-accent/15 text-accent";
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
>
{t(`status_${status}`)}
</span>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceStatus } from "@/types";
interface Props {
initialInvoices: Invoice[];
}
const STATUS_FILTERS: (InvoiceStatus | "all")[] = [
"all",
"open",
"overdue",
"paid",
"void",
];
/**
* Filterable invoice list. Filters live in URL-less local state
* (simpler than syncing to query string for a v1 admin tool); a
* page refresh resets.
*
* Re-fetching strategy: when filters change, hit the API directly
* rather than router.refresh() so we don't bounce the user through
* a full page render.
*/
export function InvoicesTable({ initialInvoices }: Props) {
const t = useTranslations("adminBilling");
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
const [monthFilter, setMonthFilter] = useState("");
const [invoices, setInvoices] = useState(initialInvoices);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Effect runs after initial render too; skip refetch on mount
// when filters are at their defaults — the server already
// gave us the right initial set.
if (statusFilter === "all" && monthFilter === "") return;
let cancelled = false;
setBusy(true);
const params = new URLSearchParams();
if (statusFilter !== "all") params.set("status", statusFilter);
if (monthFilter) params.set("month", monthFilter);
fetch(`/api/admin/billing/invoices?${params}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setInvoices(data);
})
.catch((e) => console.error("Failed to load invoices:", e))
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [statusFilter, monthFilter]);
return (
<div className="space-y-4">
<Card>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="text-xs text-text-muted">{t("statusFilterLabel")}</span>
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value as InvoiceStatus | "all")
}
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
>
{STATUS_FILTERS.map((s) => (
<option key={s} value={s}>
{s === "all" ? t("allStatuses") : t(`status_${s}`)}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-text-muted">{t("monthFilterLabel")}</span>
<input
type="month"
value={monthFilter}
onChange={(e) => setMonthFilter(e.target.value)}
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
{monthFilter && (
<button
onClick={() => setMonthFilter("")}
className="text-xs text-text-muted hover:underline"
>
{t("clearFilter")}
</button>
)}
{busy && (
<span className="text-xs text-text-muted ml-auto">
{t("loading")}
</span>
)}
</div>
</Card>
<Card>
{invoices.length === 0 ? (
<p className="text-sm text-text-muted italic text-center py-6">
{t("noInvoicesFound")}
</p>
) : (
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("invoiceNumberCol")}</th>
<th className="pb-2">{t("orgCol")}</th>
<th className="pb-2">{t("periodCol")}</th>
<th className="pb-2">{t("statusCol")}</th>
<th className="pb-2 text-right">{t("totalCol")}</th>
<th className="pb-2 text-right">{t("dueCol")}</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr
key={inv.id}
className="border-t border-border hover:bg-surface-2 cursor-pointer"
>
<td className="py-2">
<Link
href={`/admin/billing/invoices/${inv.id}`}
className="font-mono text-xs hover:underline"
>
{inv.invoiceNumber}
</Link>
</td>
<td className="py-2">
<div className="text-xs">
{inv.billingSnapshot.companyName || (
<span className="font-mono">{inv.zitadelOrgId}</span>
)}
</div>
</td>
<td className="py-2 text-xs font-mono">
{inv.periodStart.slice(0, 7)}
</td>
<td className="py-2">
<StatusPill status={inv.status} />
</td>
<td className="py-2 text-right">
CHF {inv.totalChf.toFixed(2)}
</td>
<td className="py-2 text-right text-xs text-text-muted">
{inv.dueAt}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
);
}
function StatusPill({ status }: { status: InvoiceStatus }) {
const t = useTranslations("adminBilling");
const color =
status === "paid"
? "bg-success/15 text-success"
: status === "overdue"
? "bg-error/15 text-error"
: status === "void" || status === "uncollectible"
? "bg-text-muted/15 text-text-muted"
: "bg-accent/15 text-accent";
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
>
{t(`status_${status}`)}
</span>
);
}

View File

@@ -0,0 +1,439 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { PlatformPricing, SkillPricing } from "@/types";
interface CatalogEntry {
id: string;
name: string;
category: string;
}
interface Props {
initialPricing: PlatformPricing;
initialSkillPricing: SkillPricing[];
catalog: CatalogEntry[];
}
/**
* Two-card layout:
* 1. Platform pricing form (4 inputs, save = PUT to /pricing).
* 2. Skill pricing table — list of priced skills, "Add skill"
* picker below.
*
* No optimistic updates — every save round-trips and we
* router.refresh() afterwards so the server-side render stays
* the source of truth.
*/
export function PricingEditor({
initialPricing,
initialSkillPricing,
catalog,
}: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
// -- Platform pricing form ----------------------------------------------
const [monthly, setMonthly] = useState(
String(initialPricing.tenantMonthlyFeeChf)
);
const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf));
const [threema, setThreema] = useState(
String(initialPricing.threemaMessageChf)
);
const [vat, setVat] = useState(String(initialPricing.vatRateChli));
const [savingPricing, setSavingPricing] = useState(false);
const [pricingError, setPricingError] = useState("");
const [pricingSaved, setPricingSaved] = useState(false);
const savePricing = async (e: React.FormEvent) => {
e.preventDefault();
setSavingPricing(true);
setPricingError("");
setPricingSaved(false);
try {
const res = await fetch("/api/admin/billing/pricing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tenantMonthlyFeeChf: Number(monthly),
tenantSetupFeeChf: Number(setup),
threemaMessageChf: Number(threema),
vatRateChli: Number(vat),
}),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
setPricingSaved(true);
router.refresh();
} catch (e: any) {
setPricingError(e.message);
} finally {
setSavingPricing(false);
}
};
// -- Skill pricing ------------------------------------------------------
// Server is authoritative — we don't keep an editable local copy of the
// table; instead each action posts to the API and we router.refresh().
const [newSkillId, setNewSkillId] = useState(
catalog.find((c) => c.category === "skill")?.id ?? ""
);
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
const [addingSkill, setAddingSkill] = useState(false);
const [skillError, setSkillError] = useState("");
// Core upsert — used by both the "add new skill" form and the inline
// editors on existing rows. Kept event-free so callers can invoke it
// without synthesizing a fake form event. Both `dailyPriceChf` and
// `setupFeeChf` are written together because the API does a full
// upsert; partial updates would silently zero the other field.
const upsertSkillPrice = async (
skillId: string,
dailyPriceChf: number,
setupFeeChf: number
) => {
setAddingSkill(true);
setSkillError("");
try {
const res = await fetch("/api/admin/billing/skill-pricing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setSkillError(e.message);
} finally {
setAddingSkill(false);
}
};
const onAddNewSkill = (e: React.FormEvent) => {
e.preventDefault();
if (!newSkillId) return;
void upsertSkillPrice(
newSkillId,
Number(newSkillPrice),
Number(newSkillSetupFee)
);
};
const deleteSkill = async (skillId: string) => {
if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return;
setSkillError("");
try {
const res = await fetch(
`/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setSkillError(e.message);
}
};
// Catalog filtered to skill-kind entries for the picker, but keeping
// existing pricing rows even if they reference non-skill packages.
const skillCatalogOptions = catalog.filter((c) => c.category === "skill");
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
return (
<div className="space-y-6">
<Card>
<CardHeader>{t("platformPricingTitle")}</CardHeader>
<form onSubmit={savePricing} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-sm text-text-secondary">
{t("monthlyFeeLabel")} (CHF)
</span>
<input
type="number"
step="0.01"
min="0"
value={monthly}
onChange={(e) => setMonthly(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("setupFeeLabel")} (CHF)
</span>
<input
type="number"
step="0.01"
min="0"
value={setup}
onChange={(e) => setSetup(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("threemaMessageLabel")} (CHF)
</span>
<input
type="number"
step="0.0001"
min="0"
value={threema}
onChange={(e) => setThreema(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("vatRateLabel")} (%)
</span>
<input
type="number"
step="0.01"
min="0"
max="100"
value={vat}
onChange={(e) => setVat(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={savingPricing}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{savingPricing ? t("saving") : t("save")}
</button>
{pricingSaved && (
<span className="text-sm text-success">{t("savedOk")}</span>
)}
{pricingError && (
<span className="text-sm text-error">{pricingError}</span>
)}
</div>
</form>
</Card>
<Card>
<CardHeader>{t("skillPricingTitle")}</CardHeader>
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
{initialSkillPricing.length > 0 ? (
<table className="w-full text-sm mb-6">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("skillCol")}</th>
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
<th className="pb-2 text-right">{t("setupFeeCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th>
</tr>
</thead>
<tbody>
{initialSkillPricing.map((sp) => {
const entry = catalogIndex.get(sp.skillId);
return (
<tr
key={sp.skillId}
className="border-t border-border align-top"
>
<td className="py-2">
<div className="font-mono text-xs">{sp.skillId}</div>
{entry && (
<div className="text-xs text-text-muted">{entry.name}</div>
)}
</td>
<td className="py-2 text-right">
{/* Inline edits write daily + setup together (full
upsert on the API side). The other field is
held constant from the snapshot here. */}
<InlinePriceEditor
skillId={sp.skillId}
initialPrice={sp.dailyPriceChf}
decimals={4}
onSave={(price) =>
upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
}
/>
</td>
<td className="py-2 text-right">
<InlinePriceEditor
skillId={`${sp.skillId}-setup`}
initialPrice={sp.setupFeeChf}
decimals={2}
onSave={(fee) =>
upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
}
/>
</td>
<td className="py-2 text-right">
<button
onClick={() => deleteSkill(sp.skillId)}
className="text-xs text-error hover:underline"
>
{t("remove")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
)}
<form onSubmit={onAddNewSkill} className="flex items-end gap-3">
<label className="flex-grow">
<span className="text-xs text-text-muted">{t("addSkillLabel")}</span>
<select
value={newSkillId}
onChange={(e) => setNewSkillId(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{skillCatalogOptions
.filter((c) => !pricedIds.has(c.id))
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))}
</select>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("dailyPriceLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillPrice}
onChange={(e) => setNewSkillPrice(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("skillSetupFeeLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillSetupFee}
onChange={(e) => setNewSkillSetupFee(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<button
type="submit"
disabled={addingSkill || !newSkillId}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{addingSkill ? t("saving") : t("add")}
</button>
</form>
{skillError && (
<p className="text-sm text-error mt-2">{skillError}</p>
)}
</Card>
</div>
);
}
/**
* Tiny inline editor for a single numeric price/fee. Mounts in
* "view" mode showing the current value as a clickable badge;
* clicking turns it into an input + save/cancel buttons.
*
* `decimals` controls the display precision in view mode AND the
* step granularity of the input (daily prices use 4dp, setup fees
* use 2dp).
*/
function InlinePriceEditor({
skillId,
initialPrice,
decimals = 2,
onSave,
}: {
skillId: string;
initialPrice: number;
decimals?: number;
onSave: (price: number) => Promise<void> | void;
}) {
const t = useTranslations("adminBilling");
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(String(initialPrice));
const [busy, setBusy] = useState(false);
const step = decimals === 4 ? "0.0001" : "0.01";
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="text-sm font-mono hover:underline"
title={t("clickToEdit")}
>
CHF {initialPrice.toFixed(decimals)}
</button>
);
}
return (
<span className="inline-flex items-center gap-1">
<input
type="number"
step={step}
min="0"
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded"
autoFocus
/>
<button
onClick={async () => {
setBusy(true);
try {
await onSave(Number(value));
setEditing(false);
} finally {
setBusy(false);
}
}}
disabled={busy}
className="text-xs px-2 py-1 bg-accent text-white rounded"
>
{busy ? "…" : "✓"}
</button>
<button
onClick={() => {
setValue(String(initialPrice));
setEditing(false);
}}
className="text-xs px-2 py-1 border border-border rounded"
>
</button>
</span>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { OpenClawDefaults } from "@/lib/k8s";
interface TenantRow {
name: string;
displayName: string;
phase: string;
override: { tag: string } | null;
}
interface Props {
initialDefaults: OpenClawDefaults;
tenants: TenantRow[];
}
/**
* Two-section admin UI:
* - Default editor card at the top — single input for the tag.
* - Tenant table below — each row has an inline edit/clear control.
*
* No optimistic updates: every save round-trips to the API and we
* router.refresh() to re-render the server-side state. Keeps the UI
* honest about what's actually applied (controller-runtime watch
* latency can be a couple of seconds).
*
* Tag-only by design — see operator notes for rationale.
*/
export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
const t = useTranslations("openclawAdmin");
const tCommon = useTranslations("common");
const router = useRouter();
const [defaults, setDefaults] = useState(initialDefaults);
const [defaultTag, setDefaultTag] = useState(initialDefaults.defaultTag);
const [savingDefault, setSavingDefault] = useState(false);
const [defaultError, setDefaultError] = useState("");
const [defaultSaved, setDefaultSaved] = useState(false);
const onSaveDefault = async (e: React.FormEvent) => {
e.preventDefault();
setSavingDefault(true);
setDefaultError("");
setDefaultSaved(false);
try {
const res = await fetch("/api/admin/openclaw", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaultTag: defaultTag.trim() }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
const next = await res.json();
setDefaults(next);
setDefaultSaved(true);
} catch (e: any) {
setDefaultError(e.message);
} finally {
setSavingDefault(false);
}
};
return (
<div className="space-y-8">
{/* Default editor */}
<section className="animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("defaultSection")}
</h2>
<Card>
<p className="text-sm text-text-secondary mb-4">
{t("defaultDescription")}
</p>
<form onSubmit={onSaveDefault} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTag")}
</label>
<input
type="text"
value={defaultTag}
onChange={(e) => setDefaultTag(e.target.value)}
placeholder="2026.4.22"
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
/>
<p className="text-xs text-text-muted mt-1">{t("emptyHint")}</p>
</div>
{defaultError && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{defaultError}
</div>
)}
{defaultSaved && !defaultError && (
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
{t("defaultSaved")}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={savingDefault}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{savingDefault ? tCommon("loading") : t("saveDefault")}
</button>
</div>
</form>
</Card>
</section>
{/* Tenant overrides */}
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("overridesSection")}
</h2>
<Card>
{tenants.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-6">
{t("noTenants")}
</p>
) : (
<div className="space-y-2">
{tenants.map((tn) => (
<TenantOverrideRow
key={tn.name}
tenant={tn}
platformDefault={defaults}
onChanged={() => router.refresh()}
/>
))}
</div>
)}
</Card>
</section>
</div>
);
}
/**
* Single row in the tenants table. Collapsed by default; click to
* expand the inline editor.
*/
function TenantOverrideRow({
tenant,
platformDefault,
onChanged,
}: {
tenant: TenantRow;
platformDefault: OpenClawDefaults;
onChanged: () => void;
}) {
const t = useTranslations("openclawAdmin");
const tCommon = useTranslations("common");
const [expanded, setExpanded] = useState(false);
const [tag, setTag] = useState(tenant.override?.tag ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const submit = async (clear = false) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/admin/tenants/${encodeURIComponent(tenant.name)}/openclaw-image`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(clear ? {} : { tag: tag.trim() }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
setExpanded(false);
onChanged();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
const effective = tenant.override?.tag
? tenant.override.tag
: platformDefault.defaultTag || t("builtinFallback");
return (
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-surface-1 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="font-medium text-text-primary truncate">
{tenant.displayName}
</div>
<div className="text-xs text-text-muted font-mono truncate mt-0.5">
{tenant.name}
</div>
</div>
<div className="text-right ml-4 min-w-0">
{tenant.override ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-amber-400/15 text-amber-400 border border-amber-400/20">
{t("statusOverridden")}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-400/15 text-blue-400 border border-blue-400/20">
{t("statusFollowsDefault")}
</span>
)}
<div className="text-xs text-text-muted font-mono truncate max-w-[260px] mt-1">
{effective}
</div>
</div>
</button>
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-border bg-surface-1">
<div className="mb-3">
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTag")}
</label>
<input
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder={
platformDefault.defaultTag
? `${t("defaultPrefix")} ${platformDefault.defaultTag}`
: ""
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
/>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
</div>
)}
<div className="flex flex-wrap gap-2 justify-end">
{tenant.override && (
<button
type="button"
onClick={() => submit(true)}
disabled={saving}
className="text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : t("clearOverride")}
</button>
)}
<button
type="button"
onClick={() => submit(false)}
disabled={saving || !tag.trim()}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : t("saveOverride")}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -3,26 +3,47 @@
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { ThreemaQrModal } from "./threema-qr-modal";
/** Maps channel IDs to the instructions for finding the user ID. */
const CHANNEL_ID_HELP: Record<string, string> = {
telegram: "telegramIdHelp",
discord: "discordIdHelp",
email: "emailIdHelp",
threema: "threemaIdHelp",
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
// the `mail` skill (category=skill, not channel), so it never appears
// in `enabledChannels`. If a future channel is added to the catalog,
// give it an entry here so the help blurb renders.
};
/**
* Channels whose user list is managed through a dedicated endpoint
* instead of the generic PATCH /api/tenants/:name flow.
*
* Threema is the only one today — adding/removing a Threema ID has to
* synchronise with the central pieced-threema-gateway relay's `routes`
* table (uniqueness enforced there, not in K8s). The
* /api/tenants/:name/threema/routes endpoint owns that two-step
* coordination (relay first, then K8s, with compensation on K8s
* failure). Other channels just patch the K8s spec directly.
*/
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
interface ChannelUsersProps {
tenantName: string;
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
enabledChannels: string[];
/** Current channelUsers from the PiecedTenant spec */
initialChannelUsers: Record<string, string[]>;
/** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */
canEdit?: boolean;
}
export function ChannelUsers({
tenantName,
enabledChannels,
initialChannelUsers,
canEdit = true,
}: ChannelUsersProps) {
const t = useTranslations("channelUsers");
const router = useRouter();
@@ -31,6 +52,14 @@ export function ChannelUsers({
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [channelUsers, setChannelUsers] =
useState<Record<string, string[]>>(initialChannelUsers);
/** Which channel's QR helper modal is open, if any. */
const [showQrFor, setShowQrFor] = useState<string | null>(null);
/**
* Tracks channels for which we've already auto-opened the helper
* modal on this page load. Prevents the modal from re-popping every
* time the user refocuses the input after dismissing it.
*/
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
const updateChannelUsers = useCallback(
async (updated: Record<string, string[]>) => {
@@ -57,6 +86,70 @@ export function ChannelUsers({
[tenantName, router]
);
/**
* Threema (and any future relay-managed channel) uses a dedicated
* endpoint that synchronises the central relay's routes table with
* the K8s spec atomically. We call it per-ID rather than sending the
* whole array because uniqueness is enforced ID-by-ID at the relay,
* and the error UX of "this ID is taken" is per-add.
*/
const addToRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${tenantName}/${channel}/routes`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threemaId }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
if (current.includes(threemaId)) return prev;
return { ...prev, [channel]: [...current, threemaId] };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const removeFromRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const handleAdd = useCallback(
(channel: string) => {
const userId = inputValues[channel]?.trim();
@@ -68,18 +161,27 @@ export function ChannelUsers({
return;
}
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
setInputValues((prev) => ({ ...prev, [channel]: "" }));
updateChannelUsers(updated);
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void addToRelayChannel(channel, userId);
} else {
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
updateChannelUsers(updated);
}
},
[channelUsers, inputValues, updateChannelUsers, t]
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
);
const handleRemove = useCallback(
(channel: string, userId: string) => {
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void removeFromRelayChannel(channel, userId);
return;
}
const current = channelUsers[channel] || [];
const updated = {
...channelUsers,
@@ -87,7 +189,7 @@ export function ChannelUsers({
};
updateChannelUsers(updated);
},
[channelUsers, updateChannelUsers]
[channelUsers, updateChannelUsers, removeFromRelayChannel]
);
if (enabledChannels.length === 0) return null;
@@ -131,6 +233,39 @@ export function ChannelUsers({
</span>
</div>
{channel === "threema" && (
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
<div className="flex items-start gap-2 flex-1">
<svg
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
/>
</svg>
<div className="text-xs text-text-secondary leading-relaxed">
<p className="font-medium text-text-primary mb-0.5">
{t("threemaSetup.bannerTitle")}
</p>
<p>{t("threemaSetup.bannerBody")}</p>
</div>
</div>
<button
onClick={() => setShowQrFor("threema")}
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
>
{t("threemaSetup.bannerButton")}
</button>
</div>
)}
{helpKey && (
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
{t(helpKey)}
@@ -146,47 +281,67 @@ export function ChannelUsers({
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full"
>
{userId}
<button
onClick={() => handleRemove(channel, userId)}
disabled={saving}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
title={t("remove")}
>
</button>
{canEdit && (
<button
onClick={() => handleRemove(channel, userId)}
disabled={saving}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
title={t("remove")}
>
</button>
)}
</span>
))}
</div>
)}
{/* Add user */}
<div className="flex gap-2">
<input
type="text"
value={inputValues[channel] || ""}
onChange={(e) =>
setInputValues((prev) => ({
...prev,
[channel]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
placeholder={t("placeholder")}
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<button
onClick={() => handleAdd(channel)}
disabled={saving || !inputValues[channel]?.trim()}
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "…" : t("add")}
</button>
</div>
{/* Add user — hidden in read-only mode */}
{canEdit && (
<div className="flex gap-2">
<input
type="text"
value={inputValues[channel] || ""}
onChange={(e) =>
setInputValues((prev) => ({
...prev,
[channel]: e.target.value,
}))
}
onFocus={() => {
// For threema specifically, open the QR helper the
// first time the user clicks into the input on this
// page load. We don't repeat after dismiss — the
// "Show QR" button next to the channel name covers
// re-opens on demand.
if (channel === "threema" && !autoOpened.has("threema")) {
setShowQrFor("threema");
setAutoOpened((prev) => new Set(prev).add("threema"));
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
placeholder={t("placeholder")}
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<button
onClick={() => handleAdd(channel)}
disabled={saving || !inputValues[channel]?.trim()}
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "…" : t("add")}
</button>
</div>
)}
</div>
);
})}
<ThreemaQrModal
open={showQrFor === "threema"}
onClose={() => setShowQrFor(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
interface ThreemaQrModalProps {
open: boolean;
onClose: () => void;
}
/**
* On-demand modal showing the QR for adding the assistant on Threema.
* Triggered by the "Show QR" button in the threema channel card and
* closes on overlay click, ESC, or the close button.
*
* Uses a plain <img> not next/image — image optimization adds nothing
* for a 57KB static PNG and removes a potential source of rendering
* bugs in the Next.js standalone build.
*/
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
const t = useTranslations("channelUsers.threemaSetup");
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-text-primary">
{t("title")}
</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
width={220}
height={220}
style={{ display: "block" }}
/>
</div>
</div>
<p className="text-center text-xs text-text-muted font-mono">
{THREEMA_GATEWAY.displayName}
</p>
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
<li>{t("step1")}</li>
<li>{t("step2")}</li>
<li>{t("step3")}</li>
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
/**
* Format remaining budget as CHF. Same adaptive precision rule as the
* usage display: 2 decimals for amounts ≥ 1, 4 for smaller values
* so per-request residuals don't round to zero. The currency comes
* from LiteLLM via our CHF pricing config — see chf() in
* usage-display.tsx for the full reasoning.
*/
function formatRemaining(n: number): string {
const decimals = Math.abs(n) >= 1 ? 2 : 4;
return `CHF ${n.toFixed(decimals)}`;
}
interface Props {
tenantName: string;
maxBudget: number | null;
remaining: number | null;
budgetDuration: string | null;
/** Called after a successful save so the parent re-fetches usage. */
onSaved: () => void;
}
/**
* Clickable Budget StatCard with edit modal (Feature 7).
*
* The display side mirrors the read-only StatCard layout exactly so
* the grid stays uniform. The "click to edit" hint is implicit via
* hover state — a "Set" / "Edit" link in the corner would be louder
* but adds clutter on a tile that's already busy. Customers who
* mouse over discover it.
*
* Important UX note shown in the modal: the budget is org-scoped,
* not per-tenant. All tenants in the same ZITADEL org share the
* underlying LiteLLM team. Without that callout, a customer with
* multiple tenants might think they're capping just one.
*/
export function BudgetEditableCard({
tenantName,
maxBudget,
remaining,
budgetDuration,
onSaved,
}: Props) {
const t = useTranslations("usage");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// Form state. Mode = "unlimited" | "capped". When unlimited, the
// duration dropdown is hidden because LiteLLM's reset cadence is
// meaningless without a cap.
const [mode, setMode] = useState<"unlimited" | "capped">(
maxBudget !== null ? "capped" : "unlimited"
);
const [budgetInput, setBudgetInput] = useState<string>(
maxBudget !== null ? String(maxBudget) : ""
);
const [duration, setDuration] = useState<"30d" | "1mo" | "1y">(
(budgetDuration === "30d" ||
budgetDuration === "1mo" ||
budgetDuration === "1y")
? budgetDuration
: "1mo"
);
// Reset form when modal opens — picks up any change made elsewhere
// (e.g. another browser tab) since this card was last re-rendered.
useEffect(() => {
if (open) {
setMode(maxBudget !== null ? "capped" : "unlimited");
setBudgetInput(maxBudget !== null ? String(maxBudget) : "");
setDuration(
(budgetDuration === "30d" ||
budgetDuration === "1mo" ||
budgetDuration === "1y")
? budgetDuration
: "1mo"
);
setError("");
}
}, [open, maxBudget, budgetDuration]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
try {
let body: { maxBudget: number | null; budgetDuration: string | null };
if (mode === "unlimited") {
body = { maxBudget: null, budgetDuration: null };
} else {
const parsed = parseFloat(budgetInput);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(t("budgetInvalid"));
}
body = { maxBudget: parsed, budgetDuration: duration };
}
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/budget`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("budgetSaveFailed"));
}
setOpen(false);
onSaved();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="bg-surface-1 border border-accent/40 rounded-xl p-4 text-left hover:border-accent transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 group block w-full"
>
<div className="text-xs text-text-muted mb-1 flex items-center justify-between">
<span>{t("budget")}</span>
<span className="text-[10px] text-accent inline-flex items-center gap-1">
{/* Pencil icon — unambiguous "this is editable" affordance.
Visible at all times (was hover-only before, which on
touch devices and at-a-glance scanning gave no
indication the card was clickable). */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
{t("budgetEdit")}
</span>
</div>
<div className="text-lg font-semibold text-text-primary tabular-nums">
{remaining !== null ? formatRemaining(remaining) : t("noLimit")}
</div>
</button>
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("budgetEditTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("budgetEditDescription")}
</p>
<form onSubmit={onSubmit} className="space-y-4">
{/* Mode toggle: unlimited vs capped. Two radios are
clearer than a single "max" field where 0 means
unlimited (which would conflict with our zod
validation requiring positive). */}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
<input
type="radio"
name="budget-mode"
checked={mode === "unlimited"}
onChange={() => setMode("unlimited")}
className="mt-1"
/>
<span>
<span className="font-medium">{t("budgetModeUnlimited")}</span>
<span className="block text-xs text-text-muted">
{t("budgetModeUnlimitedDescription")}
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
<input
type="radio"
name="budget-mode"
checked={mode === "capped"}
onChange={() => setMode("capped")}
className="mt-1"
/>
<span>
<span className="font-medium">{t("budgetModeCapped")}</span>
<span className="block text-xs text-text-muted">
{t("budgetModeCappedDescription")}
</span>
</span>
</label>
</div>
{mode === "capped" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("budgetAmount")} <span className="text-red-400">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-2 text-sm text-text-muted font-medium">
CHF
</span>
<input
type="number"
min="0.01"
max="1000000"
step="0.01"
required
value={budgetInput}
onChange={(e) => setBudgetInput(e.target.value)}
className="w-full pl-12 pr-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("budgetResetCadence")}
</label>
<select
value={duration}
onChange={(e) =>
setDuration(e.target.value as "30d" | "1mo" | "1y")
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
>
<option value="30d">{t("budgetCadence_30d")}</option>
<option value="1mo">{t("budgetCadence_1mo")}</option>
<option value="1y">{t("budgetCadence_1y")}</option>
</select>
</div>
</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>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setOpen(false)}
disabled={saving}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="submit"
disabled={saving}
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : tCommon("save")}
</button>
</div>
</form>
</Modal>
</>
);
}

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { useEffect, useState, useCallback } from "react";
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
interface DailyUsage {
date: string;
@@ -18,7 +19,17 @@ interface UsageData {
totalSpend: number;
requestCount: number;
};
budget: { maxBudget: number | null; spend: number; remaining: number | null };
budget: {
maxBudget: number | null;
spend: number;
remaining: number | null;
/**
* Feature 7: budget reset cadence as stored on LiteLLM.
* Strings: "30d" / "1mo" / "1y" / null (no reset). UI maps these
* to user-friendly labels.
*/
budgetDuration: string | null;
};
rateLimits: { rpm: number | null; tpm: number | null };
dailyUsage: DailyUsage[];
}
@@ -29,8 +40,31 @@ function fmt(n: number): string {
return n.toString();
}
function usd(n: number): string {
return `$${n.toFixed(4)}`;
/**
* Format a numeric amount as CHF.
*
* Note on currency labelling: LiteLLM stores raw cost numbers it
* receives from upstream (OpenAI/Anthropic), which originate as USD.
* The PieCed pricing config (Slice 5) converts those numbers to
* CHF before LiteLLM persists them, so the values flowing through
* here are already CHF amounts. We label them as such in the UI;
* "USD" or "$" anywhere in the customer-facing experience would
* be misleading.
*
* Precision is adaptive:
* - Amounts ≥ 1 CHF: 2 decimals (typical money formatting).
* - Smaller amounts: 4 decimals — per-request inference costs are
* routinely sub-rappen, and rounding to 2dp
* would render CHF 0.0042 as "CHF 0.00",
* which obscures real costs from customers
* looking at the daily breakdown.
*
* This is a customer-facing display helper; for storage and
* comparisons keep using the raw number.
*/
function chf(n: number): string {
const decimals = Math.abs(n) >= 1 ? 2 : 4;
return `CHF ${n.toFixed(decimals)}`;
}
function getCurrentMonth(): string {
@@ -69,7 +103,7 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
const x = i * (barW + 2);
return (
<g key={d.date}>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {usd(d.spend)}</title>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {chf(d.spend)}</title>
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{i % 7 === 0 && (
@@ -94,10 +128,38 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
/**
* Usage display widget.
*
* - Customers: don't pass teamId — the backend resolves it from the session.
* - Admins inspecting a specific tenant: pass teamId to override.
* Pass `tenant=<name>` for the canonical path — works for both
* customers and admins, the API resolves team+alias from the tenant
* CR's status. The visibility check on the API ensures users can't
* query tenants they shouldn't see.
*
* `teamId`/`keyAlias` remain available as a platform-admin escape
* hatch for cross-org debugging, but the tenant-detail and dashboard
* paths should always use `tenant`.
*
* Bug 19 fix: previous version omitted both props for customer
* sessions, expecting the API to "figure it out". The API's fallback
* was "first visible tenant", which meant siblings in the same org
* showed identical numbers regardless of which detail page was open.
* Now the page passes the tenant name explicitly; no fallback exists.
*/
export function UsageDisplay({ teamId }: { teamId?: string | null }) {
export function UsageDisplay({
tenant,
teamId,
keyAlias,
canEditBudget = false,
}: {
tenant?: string | null;
teamId?: string | null;
keyAlias?: string | null;
/**
* Feature 7: when true, the Budget StatCard becomes clickable and
* opens the budget editor. Off by default — owners and platform
* admins get it on; `user` role customers see the budget read-only.
* Server component decides this via canMutate(user).
*/
canEditBudget?: boolean;
}) {
const t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null);
@@ -111,8 +173,13 @@ export function UsageDisplay({ teamId }: { teamId?: string | null }) {
setError(null);
const params = new URLSearchParams({ month });
if (teamId) {
if (tenant) {
params.set("tenant", tenant);
} else if (teamId) {
// Admin escape hatch — only honoured by the API when the
// viewer is platform-role.
params.set("teamId", teamId);
if (keyAlias) params.set("keyAlias", keyAlias);
}
fetch(`/api/usage?${params}`)
@@ -120,7 +187,7 @@ export function UsageDisplay({ teamId }: { teamId?: string | null }) {
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [teamId, month]);
}, [tenant, teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]);
@@ -160,11 +227,25 @@ export function UsageDisplay({ teamId }: { teamId?: string | null }) {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent />
<StatCard
label={t("budget")}
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")}
/>
<StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
{canEditBudget && tenant ? (
<BudgetEditableCard
tenantName={tenant}
maxBudget={data.budget.maxBudget}
remaining={data.budget.remaining}
budgetDuration={data.budget.budgetDuration}
onSaved={fetchUsage}
/>
) : (
<StatCard
label={t("budget")}
value={
data.budget.remaining !== null
? chf(data.budget.remaining)
: t("noLimit")
}
/>
)}
</div>
<div className="bg-surface-1 border border-border rounded-xl p-5">

View File

@@ -13,8 +13,13 @@ function NavBar() {
const pathname = usePathname();
const user = (session as any)?.platformUser;
const isLogin = pathname === "/login";
if (isLogin) return null;
// Hide the nav entirely on auth-only routes. These pages have no
// session yet — showing "Dashboard" / "Sign Out" is misleading at
// best (the buttons would 401 or redirect-loop). Keep this list
// narrow and route-exact: anything else we add to the auth flow
// (e.g. password reset) needs to be added here too.
const isAuthRoute = pathname === "/login" || pathname === "/register";
if (isAuthRoute) return null;
return (
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
@@ -40,6 +45,46 @@ function NavBar() {
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
{t("dashboard")}
</NavLink>
{/* Slice 7: /team is owner+platform only AND personal
accounts are excluded — they have no team to manage
(Bug 8). Match server-side gates (`canMutate`,
`user.isPersonal === false`). The roles array carries
either "owner" or "user" for customer sessions;
isPlatform covers the platform side. */}
{user &&
!user.isPersonal &&
(user.isPlatform ||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
<NavLink href="/team" active={pathname === "/team"}>
{t("team")}
</NavLink>
)}
{/* Bug 35: /settings is shown to anyone who can mutate org-level
state — owners and platform admins. Personal accounts also
see it; their billing page is optional but the entry point
exists for consistency. `user`-role customers don't see it
(canMutate is false). */}
{user &&
(user.isPlatform ||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
<NavLink
href="/settings"
active={pathname.startsWith("/settings")}
>
{t("settings")}
</NavLink>
)}
{/* Feature 5: Support is available to every signed-in
user. Customers see their own tickets only; platform
admins see the queue. */}
{user && (
<NavLink
href="/support"
active={pathname.startsWith("/support")}
>
{t("support")}
</NavLink>
)}
{user?.isPlatform && (
<NavLink href="/admin" active={pathname === "/admin"}>
{t("admin")}
@@ -51,8 +96,17 @@ function NavBar() {
{/* Right side */}
<div className="flex items-center gap-4">
{user && (
// For personal accounts the orgName is opaque
// ("personal-3f2a8b1c") or a synthetic legacy
// "Name (Personal)" — neither is what we want in the nav.
// Show the user's display name instead. The detection logic
// and fallback chain live in `lib/personal-org.ts`; keeping
// a thin inline branch here avoids importing a server-only
// helper into a client component.
<span className="hidden md:inline text-xs text-text-secondary font-mono">
{user.orgName}
{user.isPersonal
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
: user.orgName}
</span>
)}
<LanguageSwitcher />

View File

@@ -1,31 +1,68 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard";
import { ProvisioningStatus } from "./provisioning-status";
interface OnboardingFlowProps {
orgName: string;
initialState: "no_request" | "pending" | "approved" | "provisioning" | "rejected";
/**
* The user's display name. Forwarded to the wizard so personal
* accounts can show the user's own name where they would otherwise
* see an opaque org name. Ignored for company accounts.
*/
userName?: string;
userEmail?: string;
/**
* Bug 35: true if the org already has a billing record. The wizard
* uses this to skip the billing step on subsequent tenants — capture
* once at first onboarding, reuse afterwards. Editable later via
* /settings/billing.
*/
hasOrgBilling?: boolean;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
* shape and behavioural contract.
*/
editingRequest?: React.ComponentProps<
typeof OnboardingWizard
>["editingRequest"];
}
/**
* Orchestrates the onboarding experience:
* - no_request → show wizard
* - pending/approved/provisioning/rejected → show status
* - After wizard submission → switch to status polling
* Wraps the onboarding wizard. On successful submission, refreshes the
* router so the parent server component re-renders with the new pending
* request visible in the dashboard list.
*
* Slice 3: this component used to manage the no_request → pending →
* provisioning → active state machine, with conditional rendering of
* `<ProvisioningStatus>`. That state is now reflected at the dashboard
* level (which renders one `<ProvisioningStatus>` per pending request),
* so this wrapper does just one thing: show the wizard, then navigate.
*/
export function OnboardingFlow({ orgName, initialState }: OnboardingFlowProps) {
const [showWizard, setShowWizard] = useState(initialState === "no_request");
export function OnboardingFlow({
orgName,
userName,
userEmail,
hasOrgBilling,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
if (showWizard) {
return (
<OnboardingWizard
orgName={orgName}
onComplete={() => setShowWizard(false)}
/>
);
}
return <ProvisioningStatus />;
return (
<OnboardingWizard
orgName={orgName}
userName={userName}
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and
// render its `<ProvisioningStatus>` card automatically.
router.push("/dashboard");
router.refresh();
}}
/>
);
}

View File

@@ -1,71 +1,160 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface OnboardingState {
state: string;
request?: {
id: string;
status: string;
companyName: string;
agentName: string;
adminNotes?: string;
createdAt?: string;
};
tenant?: {
name: string;
phase: string;
message?: string;
conditions?: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
};
interface RequestSummary {
id: string;
instanceName?: string | null;
agentName: string;
packages: string[];
status: string;
adminNotes?: string;
tenantName?: string;
dismissedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
export function ProvisioningStatus() {
interface TenantSummary {
name: string;
displayName: string;
phase: string;
conditions: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
}
interface SingleRequestState {
request: RequestSummary;
tenant: TenantSummary | null;
}
interface Props {
requestId: string;
/**
* Whether the viewer can act on this request — cancel a pending one,
* dismiss a rejected one, etc. True for owner + platform; false for
* `user`-role customers (who shouldn't see in-flight requests at all,
* but defence in depth — `canSeeInflightRequests` already gates the
* dashboard side).
*/
canAct: boolean;
}
/**
* ProvisioningStatus
*
* Polls /api/onboarding?id=<requestId> every 5s until the request reaches
* a terminal state. Slice 3: takes a `requestId` prop so multiple of
* these can render on the same dashboard for different in-flight
* requests.
*
* Slice 7 / Bug 6 + 13:
* - pending → cancel + edit buttons
* - rejected → admin notes block + dismiss button
* - cancelled → small acknowledgement card + dismiss button
* - terminal Ready/Active states unchanged
*/
export function ProvisioningStatus({ requestId, canAct }: Props) {
const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const f = useFormatter();
const [data, setData] = useState<OnboardingState | null>(null);
const router = useRouter();
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState("");
const [actionPending, setActionPending] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const poll = useCallback(async () => {
try {
const res = await fetch("/api/onboarding");
const res = await fetch(
`/api/onboarding?id=${encodeURIComponent(requestId)}`
);
if (!res.ok) throw new Error("Failed to fetch status");
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err.message);
}
}, []);
}, [requestId]);
useEffect(() => {
poll();
const status = data?.request?.status;
const phase = data?.tenant?.phase;
const terminal =
status === "rejected" ||
status === "cancelled" ||
status === "active" ||
phase === "Ready" ||
phase === "Running";
// Poll every 5 seconds while not in a terminal state
const interval = setInterval(() => {
if (
data?.state === "provisioned" ||
data?.state === "rejected" ||
data?.state === "active"
) {
return;
}
poll();
}, 5000);
if (terminal) return;
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, [poll, data?.state]);
}, [poll, data?.request?.status, data?.tenant?.phase]);
if (error) {
const handleCancel = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("cancelFailed"));
}
setConfirmCancel(false);
// Re-poll so the card transitions to "cancelled" state without a
// full route refresh — the dashboard's surrounding tenant cards
// are unaffected.
await poll();
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
const handleDismiss = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}/dismiss`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("dismissFailed"));
}
// Server-rendered list query (`listActiveTenantRequestsByOrgId`)
// filters out dismissed rows — refresh to drop this card.
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
if (error && !data) {
return (
<Card>
<div className="text-xs text-red-400">{error}</div>
@@ -84,8 +173,14 @@ export function ProvisioningStatus() {
);
}
// Pending admin approval
if (data.state === "pending") {
const status = data.request.status;
const label =
data.request.instanceName ||
data.request.tenantName ||
data.request.agentName;
// ─── Pending: awaiting admin approval ───────────────────────────────
if (status === "pending") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -107,10 +202,15 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
</p>
{data.request?.createdAt && (
{data.request.createdAt && (
<p
className="text-xs text-text-muted mt-4"
title={formatDateTime(data.request.createdAt, f)}
@@ -124,13 +224,72 @@ export function ProvisioningStatus() {
</span>
</p>
)}
{/* Bug 6 — owner-only edit + cancel actions while still
pending. Once admin acts, both buttons disappear (the
status branch changes). */}
{canAct && (
<div className="flex justify-center gap-2 mt-5">
<Link
href={`/dashboard/edit/${encodeURIComponent(requestId)}`}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("editRequest")}
</Link>
<button
type="button"
onClick={() => setConfirmCancel(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("cancelRequest")}
</button>
</div>
)}
{error && (
<p className="text-xs text-red-400 mt-3">{error}</p>
)}
</div>
{confirmCancel && (
<Modal
open={confirmCancel}
onClose={() => setConfirmCancel(false)}
ariaLabel={t("cancelConfirmRequestTitle")}
>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmRequestTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("cancelConfirmRequestDescription")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmCancel(false)}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={handleCancel}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
>
{actionPending
? tCommon("loading")
: t("cancelRequestConfirm")}
</button>
</div>
</Modal>
)}
</Card>
);
}
// Rejected
if (data.state === "rejected") {
// ─── Rejected: admin declined ───────────────────────────────────────
if (status === "rejected") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -152,23 +311,99 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request?.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
{data.request.adminNotes}
</p>
{data.request.adminNotes && (
<div className="text-left text-xs text-text-secondary mt-4 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
<div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
{t("rejectionReason")}
</div>
<div className="whitespace-pre-wrap">
{data.request.adminNotes}
</div>
</div>
)}
{/* Bug 13: dismiss removes this card from the dashboard but
keeps the row in the DB for audit. The customer can also
just resubmit via the wizard — both paths are valid. */}
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// Provisioning in progress
// ─── Cancelled: customer cancelled before admin acted (Bug 6) ──────
if (status === "cancelled") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-text-muted/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelledTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("cancelledDescription")}
</p>
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// ─── Provisioning: approved, operator working ──────────────────────
if (
data.state === "approved" ||
data.state === "provisioning"
status === "approved" ||
status === "provisioning" ||
(status === "active" && data.tenant && data.tenant.phase !== "Ready")
) {
const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? [];
@@ -182,6 +417,11 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
</p>
@@ -216,8 +456,8 @@ export function ProvisioningStatus() {
);
}
// Provisioned / Running
if (data.state === "provisioned") {
// ─── Active / Ready ─────────────────────────────────────────────────
if (status === "active") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -239,6 +479,11 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}
</p>

View File

@@ -3,11 +3,39 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import {
configureStepSchema,
billingStepSchema,
onboardingSchema,
fieldErrors,
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
type Step = "welcome" | "configure" | "billing" | "confirm";
const STEPS: Step[] = ["welcome", "configure", "billing", "confirm"];
// The step list. Composed once and used to compute "next/prev" arrows
// and progress indicator. Bug 35: the billing step is conditional —
// orgs that already have billing on file (subsequent tenants, or
// pre-filled via /settings/billing) skip it. The wizard's submit
// payload omits billingAddress in that case; the API picks up the
// existing org_billing row server-side.
function makeSteps(opts: {
hasOrgBilling: boolean;
isEditing: boolean;
}): Step[] {
const base: Step[] = ["welcome", "configure", "billing", "confirm"];
// Edit mode currently still shows the billing step because we want
// the customer to be able to fix billing on a still-pending request
// BEFORE it reaches admin. Once approved, edits go through
// /settings/billing instead. Same step set for editing as new for now.
if (opts.hasOrgBilling && !opts.isEditing) {
return base.filter((s) => s !== "billing");
}
return base;
}
// Inline fallbacks — only used if the API call to /api/workspace-defaults fails
const FALLBACK_SOUL = `# AI Assistant
@@ -41,39 +69,156 @@ translation, and general question answering.
`;
const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;
interface WizardProps {
orgName: string;
/**
* The user's display name. Used as the visible label for personal
* accounts (where `orgName` is an opaque ID like "personal-3f2a8b1c"
* or a synthetic legacy "{name} (Personal)" string). Ignored for
* company accounts.
*/
userName?: string;
userEmail?: string;
/**
* Bug 35: when true, the wizard skips the billing step. The org
* already has billing on file (captured during a previous tenant's
* onboarding, or set directly via /settings/billing), and we don't
* re-prompt for it. The submit payload omits billingAddress in that
* case; the API picks up the existing record server-side.
*
* In edit mode this is ignored — the wizard re-renders the step
* with the request's original billingAddress so the customer can
* fix it before admin approves.
*/
hasOrgBilling?: boolean;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
* skipped (we trust the existing values), and the submit button
* PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding.
*
* Per-package secrets are deliberately NOT pre-filled, even if the
* customer originally supplied them — server-side decryption to
* the client would be a security regression. The user re-enters
* any secrets they want to change; if they leave them blank, the
* existing encrypted blob in the DB is preserved by the PATCH
* endpoint.
*/
editingRequest?: {
id: string;
instanceName: string;
agentName: string;
soulMd: string;
agentsMd: string;
packages: string[];
billingAddress: {
company?: string;
street?: string;
city?: string;
postalCode?: string;
country?: string;
vatNumber?: string;
};
billingNotes: string;
};
onComplete: () => void;
}
export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
export function OnboardingWizard({
orgName,
userName,
userEmail,
hasOrgBilling,
editingRequest,
onComplete,
}: WizardProps) {
const t = useTranslations("onboarding");
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
const tCountries = useTranslations("countries");
const [step, setStep] = useState<Step>("welcome");
// Personal accounts have an org name that is either the legacy
// "{givenName} {familyName} (Personal)" or the current opaque
// "personal-{8hex}" form. Either way, the customer-facing display
// should be the user's own name — never the org name. SOUL.md
// interpolation and the billing form follow the same rule so
// invoices and prompts don't leak "(Personal)" or "personal-3f2a..".
const isPersonal = isPersonalOrgName(orgName);
const displayOrgName = displayOrgNameFor({
name: userName,
email: userEmail,
orgName,
isPersonal,
});
const isEditing = Boolean(editingRequest);
// STEPS is recomputed from props so toggling hasOrgBilling at the
// server level (e.g. between renders if the customer just saved
// billing on /settings/billing in another tab) flows through. Cheap.
const STEPS = makeSteps({
hasOrgBilling: Boolean(hasOrgBilling),
isEditing,
});
// Edit mode jumps straight to the configure step — the welcome step
// is a first-time onboarding affordance and only adds friction when
// the customer is fixing a typo.
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
// In edit mode we already have soulMd/agentsMd from the request;
// skip the workspace-defaults round trip that would overwrite them.
const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing);
const [config, setConfig] = useState({
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", orgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
billingAddress: {
company: orgName,
street: "",
city: "",
postalCode: "",
country: "CH",
},
billingNotes: "",
const [config, setConfig] = useState(() => {
if (editingRequest) {
return {
instanceName: editingRequest.instanceName,
agentName: editingRequest.agentName,
soulMd: editingRequest.soulMd,
agentsMd: editingRequest.agentsMd,
packages: editingRequest.packages,
billingAddress: {
company: editingRequest.billingAddress.company ?? "",
street: editingRequest.billingAddress.street ?? "",
city: editingRequest.billingAddress.city ?? "",
postalCode: editingRequest.billingAddress.postalCode ?? "",
country: editingRequest.billingAddress.country ?? "CH",
vatNumber: editingRequest.billingAddress.vatNumber ?? "",
},
billingNotes: editingRequest.billingNotes,
};
}
return {
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
// CORE defaults: heartbeat + cron + active-memory pre-selected so
// the assistant can be proactive, run scheduled tasks, and recall
// stable context out of the box. Customers can untoggle any of
// them before submitting. core-voice is fully wired (Phase B)
// but stays unselected — opt-in keeps audio spend predictable
// for tenants who don't intend to use voice channels.
packages: [...DEFAULT_PACKAGE_IDS] as string[],
billingAddress: {
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
// want to.
company: isPersonal ? "" : displayOrgName,
street: "",
city: "",
postalCode: "",
country: "CH",
vatNumber: "",
},
billingNotes: "",
};
});
// TOOLS.md preview — readonly, auto-generated
@@ -127,11 +272,70 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const stepIndex = STEPS.indexOf(step);
// Bug 12 — per-step validation. `errors` holds field-path → message
// for the inline labels under each input. We only populate it on
// attempted advancement; touching a field clears its own error so
// valid input doesn't keep showing stale messages.
const [errors, setErrors] = useState<Record<string, string>>({});
const clearError = useCallback((path: string) => {
setErrors((prev) => {
if (!prev[path]) return prev;
const next = { ...prev };
delete next[path];
return next;
});
}, []);
/**
* Validate the current step against its schema. On success: clear
* errors and return true. On failure: populate errors and return
* false so the caller can refuse to advance.
*
* Welcome and configure-step have no schema interaction with billing
* fields — keeping the schemas narrow means we don't surface a
* billing error when the user is still typing on the configure step.
*/
const validateStep = (s: Step): boolean => {
if (s === "welcome") return true;
if (s === "configure") {
const r = configureStepSchema.safeParse({ agentName: config.agentName });
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
}
if (s === "billing") {
const r = billingStepSchema.safeParse({
billingAddress: config.billingAddress,
});
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
}
// confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config);
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
};
const goNext = () => {
if (!validateStep(step)) return;
if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1]);
};
const goBack = () => {
// Going back never re-validates; the user's existing errors stay
// pinned to fields so they can fix them after navigating back.
if (stepIndex > 0) setStep(STEPS[stepIndex - 1]);
};
@@ -184,6 +388,17 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
};
const handleSubmit = async () => {
// Defence in depth: re-run the full schema before sending. The
// server schema is the authoritative gate but we save a round trip
// by catching any client-side gaps here. In practice this should
// never fail at this point — the per-step validators have already
// caught everything — but a future regression in the per-step
// schemas would otherwise let the bad payload through.
if (!validateStep("confirm")) {
setError(t("validationError"));
return;
}
setSubmitting(true);
setError("");
@@ -197,11 +412,34 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
}
}
const res = await fetch("/api/onboarding", {
method: "POST",
// Bug 6: edit mode targets the per-row endpoint with PATCH;
// create mode targets the collection endpoint with POST. Body
// shape is the same — both routes parse it through
// onboardingSchema.
const url = editingRequest
? `/api/onboarding/${encodeURIComponent(editingRequest.id)}`
: "/api/onboarding";
const method = editingRequest ? "PATCH" : "POST";
// Bug 35: when the org already has billing on file, the wizard
// skipped the billing step and `config.billingAddress` is the
// empty default. Strip it from the payload so the API picks up
// the existing org_billing record server-side rather than
// validating the empty form against billingStepSchema (which
// would reject for a company org).
const submitConfig = hasOrgBilling
? (() => {
const { billingAddress: _bill, billingNotes: _notes, ...rest } =
config;
return rest;
})()
: config;
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...config,
...submitConfig,
packageSecrets:
Object.keys(secretsPayload).length > 0
? secretsPayload
@@ -308,18 +546,38 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
{t("instanceName")}
</label>
<input
type="text"
value={config.agentName}
value={config.instanceName}
onChange={(e) =>
setConfig((prev) => ({ ...prev, agentName: e.target.value }))
setConfig((prev) => ({ ...prev, instanceName: e.target.value }))
}
placeholder={t("instanceNamePlaceholder")}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<p className="text-xs text-text-muted mt-1">
{t("instanceNameHint")}
</p>
</div>
<FieldWithError error={errors.agentName}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")} <RequiredMark />
</label>
<input
type="text"
required
value={config.agentName}
onChange={(e) => {
clearError("agentName");
setConfig((prev) => ({ ...prev, agentName: e.target.value }));
}}
className={inputClass(errors.agentName)}
/>
</FieldWithError>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("soulMd")}
@@ -440,7 +698,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
<button
type="button"
onClick={() => togglePackage(pkg.id)}
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-surface-3/30 transition-colors"
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors cursor-pointer hover:bg-surface-3/30"
>
<div className="text-left">
<span
@@ -585,106 +843,164 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
{/* Bug 2: company line is meaningless for personal accounts.
Hide entirely rather than render an empty disabled field
— the latter would just suggest the customer should
fill it in. */}
{!isPersonal && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) => {
clearError("billingAddress.company");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: e.target.value,
},
}));
}}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
)}
<div>
<FieldWithError error={errors["billingAddress.street"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingStreet")}
{t("billingStreet")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.street}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.street");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
street: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
}));
}}
className={inputClass(errors["billingAddress.street"])}
/>
</div>
</FieldWithError>
<div className="grid grid-cols-3 gap-3">
<div>
<FieldWithError error={errors["billingAddress.postalCode"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingPostalCode")}
{t("billingPostalCode")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.postalCode}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.postalCode");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
postalCode: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
}));
}}
className={inputClass(errors["billingAddress.postalCode"])}
/>
</div>
</FieldWithError>
<div className="col-span-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")}
</label>
<input
type="text"
value={config.billingAddress.city}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<FieldWithError error={errors["billingAddress.city"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.city}
onChange={(e) => {
clearError("billingAddress.city");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: e.target.value,
},
}));
}}
className={inputClass(errors["billingAddress.city"])}
/>
</FieldWithError>
</div>
</div>
<div>
{/* Bug 3: country was a free-text field — typos broke
invoicing. Now a fixed list of DACH+ neighbours. Add
more codes to SUPPORTED_COUNTRIES in lib/validation.ts
when expanding markets. */}
<FieldWithError error={errors["billingAddress.country"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCountry")}
{t("billingCountry")} <RequiredMark />
</label>
<input
type="text"
<select
value={config.billingAddress.country}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.country");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
country: e.target.value,
country: e.target.value as SupportedCountry,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
}));
}}
className={inputClass(errors["billingAddress.country"])}
>
{SUPPORTED_COUNTRIES.map((code) => (
<option key={code} value={code}>
{tCountries(code)}
</option>
))}
</select>
</FieldWithError>
{/* Bug 35: VAT identifier. Required for company customers
(B2B). Hidden entirely for personal customers (B2C —
private individuals don't have a VAT number); the API
enforces the same rule. Editable later via
/settings/billing for company customers if their VAT
id changes. */}
{!isPersonal && (
<FieldWithError error={errors["billingAddress.vatNumber"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingVatNumber")} <RequiredMark />
</label>
<input
type="text"
value={config.billingAddress.vatNumber ?? ""}
onChange={(e) => {
clearError("billingAddress.vatNumber");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
vatNumber: e.target.value,
},
}));
}}
placeholder="CHE-123.456.789 MWST"
className={inputClass(errors["billingAddress.vatNumber"])}
/>
<p className="text-xs text-text-muted mt-1">
{t("billingVatHelp")}
</p>
</FieldWithError>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
@@ -699,7 +1015,11 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
}))
}
rows={3}
placeholder={t("billingNotesPlaceholder")}
placeholder={t(
isPersonal
? "billingNotesPlaceholderPersonal"
: "billingNotesPlaceholder"
)}
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 resize-y"
/>
</div>
@@ -732,59 +1052,105 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
{t("confirmDescription")}
</p>
{/* Bug 4 redesign: previously this step only showed agentName
and city — useless for actually reviewing what's about to
be submitted. Now it shows the real config: instance
name, agent name, packages, billing one-liner, contact
email, and notes. Each row uses two columns rather than
flex-justify-between so long values wrap underneath the
label rather than being squashed onto one line. */}
<div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono">
{config.agentName}
</span>
</div>
{config.packages.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("packages")}</span>
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.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>
))}
<div className="bg-surface-2 border border-border rounded-lg p-4 divide-y divide-border">
<ReviewRow
label={t("instanceName")}
value={
config.instanceName.trim() || (
<span className="text-text-muted italic">
{t("reviewInstanceDefault")}
</span>
)
}
mono
/>
<ReviewRow
label={t("agentName")}
value={config.agentName}
mono
/>
<ReviewRow
label={t("packages")}
value={
config.packages.length === 0 ? (
<span className="text-text-muted italic">
{t("reviewNoPackages")}
</span>
) : (
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.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>
))}
</div>
)
}
/>
<ReviewRow
label={t("reviewBillingTo")}
value={
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
config.billingAddress.company &&
config.billingAddress.company.trim().length > 0 && (
<div>{config.billingAddress.company}</div>
)}
<div>{config.billingAddress.street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
</div>
</div>
</div>
)}
{config.packages.some((id) =>
PACKAGE_CATALOG.find((p) => p.id === id)?.requiresSecrets
) && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">
{t("credentialsProvided")}
</span>
<span className="text-emerald-400 text-xs font-medium">
</span>
</div>
)}
{config.billingAddress.company && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">
{t("billingCompany")}
</span>
<span className="text-text-primary">
{config.billingAddress.company}
</span>
</div>
)}
{config.billingAddress.city && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billingCity")}</span>
<span className="text-text-primary">
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</span>
</div>
}
/>
{/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a
VAT number, the form didn't ask, the review hides it. */}
{!isPersonal &&
config.billingAddress.vatNumber &&
config.billingAddress.vatNumber.trim().length > 0 && (
<ReviewRow
label={t("billingVatNumber")}
value={config.billingAddress.vatNumber}
mono
/>
)}
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}
mono
/>
{config.billingNotes.trim().length > 0 && (
<ReviewRow
label={t("billingNotes")}
value={
<span className="text-text-primary whitespace-pre-wrap text-right">
{config.billingNotes}
</span>
}
/>
)}
</div>
@@ -797,6 +1163,25 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</div>
)}
{/* Aggregate validation errors — if any per-step schema check
missed something (it shouldn't, but defence in depth),
the user sees a consolidated list here rather than a
silent submit failure. */}
{Object.keys(errors).length > 0 && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
<div className="font-semibold mb-1">
{t("validationErrorsTitle")}
</div>
<ul className="list-disc list-inside space-y-0.5">
{Object.entries(errors).map(([path, msg]) => (
<li key={path}>
<span className="font-mono">{path}</span>: {msg}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-between mt-6">
<button
onClick={goBack}
@@ -809,7 +1194,11 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
disabled={submitting}
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? tCommon("loading") : t("submitRequest")}
{submitting
? tCommon("loading")
: isEditing
? t("saveChanges")
: t("submitRequest")}
</button>
</div>
</Card>
@@ -817,3 +1206,74 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</div>
);
}
/**
* Two-column review row used by the confirm step. Right-aligned value
* with the label as a muted prefix on the left.
*/
function ReviewRow({
label,
value,
mono,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex justify-between gap-4 text-sm py-2 first:pt-0 last:pb-0">
<span className="text-text-muted shrink-0">{label}</span>
<span
className={`text-text-primary text-right min-w-0 break-words ${
mono ? "font-mono" : ""
}`}
>
{value}
</span>
</div>
);
}
/**
* Renders children + an inline error message if present. Children
* supply the label and input; this wrapper just appends the message.
*/
function FieldWithError({
error,
children,
}: {
error?: string;
children: React.ReactNode;
}) {
return (
<div>
{children}
{error && (
<p className="text-xs text-red-400 mt-1" role="alert">
{error}
</p>
)}
</div>
);
}
function RequiredMark() {
return (
<span aria-hidden="true" className="text-accent">
*
</span>
);
}
/**
* Tailwind class for input/select with optional error-state ring.
* Centralised here to keep the wizard's many fields visually
* consistent without repeating the long class string.
*/
function inputClass(error?: string): string {
return `w-full px-3 py-2 bg-surface-2 border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 transition-colors ${
error
? "border-red-400/60 focus:ring-red-400 focus:border-red-400"
: "border-border focus:ring-accent focus:border-accent"
}`;
}

View File

@@ -10,9 +10,18 @@ interface Props {
status?: "pending" | "active" | "error";
tenantName: string;
onToggled: () => void;
/** Slice 5: when false, the enable/disable button is hidden. */
canEdit?: boolean;
}
export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) {
export function PackageCard({
pkg,
enabled,
status,
tenantName,
onToggled,
canEdit = true,
}: Props) {
const t = useTranslations();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -21,6 +30,26 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
const [error, setError] = useState<string | null>(null);
async function handleEnable() {
if (pkg.customProvisioning) {
// Platform-side provisioning, then add to packages list.
setSaving(true);
setError(null);
try {
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "POST",
});
if (!provRes.ok) {
const err = await provRes.json().catch(() => ({}));
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
}
await togglePackage(true);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
return;
}
if (pkg.requiresSecrets) {
setShowModal(true);
setSecrets({});
@@ -31,6 +60,34 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
await togglePackage(true);
}
async function handleDisable() {
setSaving(true);
setError(null);
try {
if (pkg.customProvisioning) {
// Revoke platform-side credentials FIRST so the relay drops
// routes before the operator removes the channel from the
// OpenClaw config. Partial-success (token revoked, OpenBao
// delete failed) returns 503 with partial=true and we surface
// the error rather than continuing — the secret may still be
// valid in OpenBao and rolling back the relay revoke isn't
// possible (it cascaded to routes).
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "DELETE",
});
if (!deprovRes.ok) {
const err = await deprovRes.json().catch(() => ({}));
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
}
}
await togglePackage(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function togglePackage(enable: boolean) {
setSaving(true);
try {
@@ -113,17 +170,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
{canEdit ? (
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
) : (
// Slice 5: read-only viewers see a static badge instead of a
// toggle. The status badge above the divider already conveys
// "active/pending/error"; this just clarifies "you can't change
// it" without duplicating the status colour.
<span className="ml-auto text-[10px] text-text-muted italic">
{enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")}
</span>
)}
</div>
</div>

View File

@@ -10,9 +10,12 @@ interface Props {
enabledPackages: string[];
conditions?: Array<{ type: string; status: string; reason?: string }>;
onRefresh?: () => void;
/** Slice 5: when false, package toggles and edit affordances are hidden. */
canEdit?: boolean;
}
const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;
@@ -30,7 +33,13 @@ function getPackageStatus(
return "error";
}
export function PackageList({ tenantName, enabledPackages, conditions, onRefresh }: Props) {
export function PackageList({
tenantName,
enabledPackages,
conditions,
onRefresh,
canEdit = true,
}: Props) {
const t = useTranslations("packages");
const router = useRouter();
const handleRefresh = onRefresh || (() => router.refresh());
@@ -55,6 +64,7 @@ export function PackageList({ tenantName, enabledPackages, conditions, onRefresh
status={getPackageStatus(pkg.id, enabledPackages.includes(pkg.id), conditions)}
tenantName={tenantName}
onToggled={handleRefresh}
canEdit={canEdit}
/>
))}
</div>

View File

@@ -8,9 +8,11 @@ const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
interface Props {
tenantName: string;
files: Record<string, string>;
/** Slice 5: when false, save button hidden and textarea is read-only. */
canEdit?: boolean;
}
export function WorkspaceEditor({ tenantName, files }: Props) {
export function WorkspaceEditor({ tenantName, files, canEdit = true }: Props) {
const t = useTranslations("workspace");
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files);
@@ -19,6 +21,7 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
const [error, setError] = useState<string | null>(null);
function handleChange(content: string) {
if (!canEdit) return;
setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
setDirty(true);
}
@@ -62,20 +65,25 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
</button>
))}
</div>
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
>
{saving ? "…" : t("save")}
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
>
{saving ? "…" : t("save")}
</button>
)}
</div>
<textarea
value={localFiles[activeTab] || ""}
onChange={(e) => handleChange(e.target.value)}
readOnly={!canEdit}
spellCheck={false}
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none"
className={`w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none ${
!canEdit ? "cursor-default" : ""
}`}
placeholder={t("placeholder", { file: activeTab })}
/>

View File

@@ -0,0 +1,279 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type { OrgBilling } from "@/types";
import { Card } from "@/components/ui/card";
interface Props {
/** Existing billing record, or null on first edit. */
initial: OrgBilling | null;
/**
* True if the caller is on a personal org. Personal customers
* (B2C — private individuals) don't have a company name or VAT
* number; the form re-labels the company-name field as "Full name"
* and hides VAT.
*/
isPersonal: boolean;
/** Default company name for company orgs on first edit. */
orgName: string;
/** Default full-name for personal orgs on first edit. */
userName: string;
/**
* Default billing email — the address the user registered with.
* Used on first edit (when `initial` is null). Customers can still
* type a different address (e.g. accounting@…) but the registration
* email is a sensible starting point.
*/
userEmail: string;
}
/**
* Editable billing form. Used by /settings/billing; the wizard's
* inline billing step (Bug 35 phase 2) reuses the same shape but is
* implemented separately because of its different submit semantics
* (one combined wizard submit, vs. this page's standalone PUT).
*
* The form does NOT do client-side VAT format validation — too many
* country variations to get right, and the API will reject empty
* VAT for company orgs anyway. The asterisk on the field plus the
* server error suffices.
*/
export function BillingSettingsForm({
initial,
isPersonal,
orgName,
userName,
userEmail,
}: Props) {
const t = useTranslations("settingsBilling");
const tCommon = useTranslations("common");
const router = useRouter();
const [companyName, setCompanyName] = useState(
initial?.companyName ?? (isPersonal ? userName : orgName)
);
const [streetAddress, setStreetAddress] = useState(
initial?.streetAddress ?? ""
);
const [postalCode, setPostalCode] = useState(initial?.postalCode ?? "");
const [city, setCity] = useState(initial?.city ?? "");
const [country, setCountry] = useState(initial?.country ?? "CH");
const [vatNumber, setVatNumber] = useState(initial?.vatNumber ?? "");
// Default billing email to the user's registration email when no
// record exists yet. They can change it (a separate accounting
// address is common); we just want sensible pre-fill on first edit.
const [billingEmail, setBillingEmail] = useState(
initial?.billingEmail ?? userEmail ?? ""
);
const [notes, setNotes] = useState(initial?.notes ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError("");
setSuccess(false);
try {
const res = await fetch("/api/billing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName,
streetAddress,
postalCode,
city,
country,
vatNumber: vatNumber.trim() || null,
billingEmail,
notes: notes.trim() || null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
setSuccess(true);
// Refresh server props so the form re-renders with the saved
// record's timestamps. Subtle but useful: the "last updated"
// line below ticks forward.
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
return (
<Card className="animate-in animate-in-delay-1">
<form onSubmit={onSubmit} className="space-y-4">
{/* Bug 35: this field stores `company_name` in the DB but
the label changes by customer type:
- Company (B2B): "Company name" — the legal entity.
- Personal (B2C): "Full name" — the individual's
invoice name (may differ from their session display
name; e.g. legal name vs friendly name).
Required for both. The DB column is NOT NULL either way. */}
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{isPersonal ? t("fullName") : t("companyName")}{" "}
<span className="text-red-400">*</span>
</label>
<input
type="text"
required
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("streetAddress")} <span className="text-red-400">*</span>
</label>
<input
type="text"
required
value={streetAddress}
onChange={(e) => setStreetAddress(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("postalCode")} <span className="text-red-400">*</span>
</label>
<input
type="text"
required
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("city")} <span className="text-red-400">*</span>
</label>
<input
type="text"
required
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("country")} <span className="text-red-400">*</span>
</label>
<select
required
value={country}
onChange={(e) => setCountry(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
>
<option value="CH">Switzerland</option>
<option value="LI">Liechtenstein</option>
<option value="DE">Germany</option>
<option value="AT">Austria</option>
<option value="FR">France</option>
<option value="IT">Italy</option>
</select>
</div>
{/* Bug 35: VAT visible only for company customers (B2B).
Personal customers (B2C — private individuals) don't have
a VAT number; the API likewise doesn't require one for
them. */}
{!isPersonal && (
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("vatNumber")} <span className="text-red-400">*</span>
</label>
<input
type="text"
required
value={vatNumber}
onChange={(e) => setVatNumber(e.target.value)}
placeholder="CHE-123.456.789 MWST"
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
<p className="text-xs text-text-muted mt-1">{t("vatHelp")}</p>
</div>
)}
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("billingEmail")} <span className="text-red-400">*</span>
</label>
<input
type="email"
required
value={billingEmail}
onChange={(e) => setBillingEmail(e.target.value)}
placeholder="invoices@example.com"
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
<p className="text-xs text-text-muted mt-1">{t("billingEmailHelp")}</p>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("notes")}{" "}
<span className="text-text-muted normal-case">
({tCommon("optional")})
</span>
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
placeholder={t(
isPersonal ? "notesPlaceholderPersonal" : "notesPlaceholder"
)}
/>
</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>
)}
{success && !error && (
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
{t("saved")}
</div>
)}
<div className="flex items-center justify-between gap-3">
{initial?.updatedAt && (
<div className="text-xs text-text-muted">
{t("lastUpdated", {
when: new Date(initial.updatedAt).toLocaleString(),
})}
</div>
)}
<button
type="submit"
disabled={submitting}
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{submitting ? tCommon("loading") : t("save")}
</button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type {
SupportTicketCategory,
SupportTicketStatus,
} from "@/types";
const STATUSES: SupportTicketStatus[] = [
"open",
"in_progress",
"waiting_for_customer",
"resolved",
"reopened",
];
const CATEGORIES: SupportTicketCategory[] = [
"bug",
"feature_request",
"question",
"billing",
"other",
];
interface Props {
ticketId: string;
currentStatus: SupportTicketStatus;
currentCategory: SupportTicketCategory;
}
/**
* Admin-only controls — change ticket status / category. Visible
* exclusively when `user.isPlatform` (gate is in the parent server
* component, not here).
*
* Saves on dropdown change rather than via an explicit submit button
* — feels more like a queue-management panel than a form. Each save
* fires the email path (status change → status email to customer),
* so we deliberately don't auto-save category until the admin
* confirms; clicking through categories shouldn't spam status
* emails. (Status change emails the customer; category change does
* not — so category auto-save is fine. Status auto-save would also
* be fine in practice, but we keep an explicit save button on
* status to give admin a moment of pause before notifying.)
*
* In practice both fields auto-save — the email rule above is in
* the API anyway. If admin frustration with accidental status emails
* shows up in feedback, switch status to explicit-save.
*/
export function TicketAdminControls({
ticketId,
currentStatus,
currentCategory,
}: Props) {
const t = useTranslations("support");
const router = useRouter();
const [status, setStatus] = useState(currentStatus);
const [category, setCategory] = useState(currentCategory);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const saveChange = async (changes: {
status?: SupportTicketStatus;
category?: SupportTicketCategory;
}) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/support/tickets/${encodeURIComponent(ticketId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("updateFailed"));
}
router.refresh();
} catch (e: any) {
setError(e.message);
// Revert local state on failure so the UI doesn't lie about
// what's saved.
if (changes.status) setStatus(currentStatus);
if (changes.category) setCategory(currentCategory);
} finally {
setSaving(false);
}
};
return (
<Card className="border-blue-400/30 bg-blue-400/5">
<div className="text-xs uppercase tracking-wider text-blue-400 font-semibold mb-3">
{t("adminControlsTitle")}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldStatus")}
</label>
<select
value={status}
disabled={saving}
onChange={(e) => {
const next = e.target.value as SupportTicketStatus;
setStatus(next);
saveChange({ status: next });
}}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary disabled:opacity-50"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(`status_${s}`)}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldCategory")}
</label>
<select
value={category}
disabled={saving}
onChange={(e) => {
const next = e.target.value as SupportTicketCategory;
setCategory(next);
saveChange({ category: next });
}}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary disabled:opacity-50"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`category_${c}`)}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-3">
{error}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { useTranslations } from "next-intl";
import type { SupportTicketCategory } from "@/types";
/**
* Plain translated category label, e.g. "Bug" / "Feature request" /
* "Billing". No styling chrome — just the text. Categories don't
* carry the same lifecycle/urgency signal as status, so they don't
* earn a coloured pill.
*/
export function TicketCategoryLabel({
category,
}: {
category: SupportTicketCategory;
}) {
const t = useTranslations("support");
return <span>{t(`category_${category}`)}</span>;
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { SupportTicketCategory } from "@/types";
const CATEGORIES: SupportTicketCategory[] = [
"bug",
"feature_request",
"question",
"billing",
"other",
];
export function TicketCreateForm() {
const t = useTranslations("support");
const tCommon = useTranslations("common");
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState<SupportTicketCategory>("question");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/support/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description, category }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("createFailed"));
}
const data = await res.json();
// Redirect to the new ticket's detail page so the customer can
// see the confirmation state and immediately add follow-ups if
// they wish.
router.push(`/support/${data.ticket.id}`);
router.refresh();
} catch (e: any) {
setError(e.message);
setSubmitting(false);
}
};
return (
<Card>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldCategory")} <span className="text-red-400">*</span>
</label>
<select
required
value={category}
onChange={(e) =>
setCategory(e.target.value as SupportTicketCategory)
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`category_${c}`)}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTitle")} <span className="text-red-400">*</span>
</label>
<input
type="text"
required
minLength={3}
maxLength={200}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldDescription")} <span className="text-red-400">*</span>
</label>
<textarea
required
minLength={10}
maxLength={10_000}
rows={8}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
<p className="text-xs text-text-muted mt-1">
{t("descriptionHelp")}
</p>
</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>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{submitting ? tCommon("loading") : t("submitTicket")}
</button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useTranslations } from "next-intl";
import type { SupportTicketStatus } from "@/types";
const STATUS_STYLES: Record<SupportTicketStatus, string> = {
// Open: blue, neutral attention.
open: "bg-blue-400/15 text-blue-400 border border-blue-400/20",
// In progress: amber, work happening.
in_progress: "bg-amber-400/15 text-amber-400 border border-amber-400/20",
// Waiting for customer: violet — distinct from in_progress so admins
// can quickly visually separate "I owe a response" from "they owe one".
waiting_for_customer:
"bg-violet-400/15 text-violet-400 border border-violet-400/20",
resolved: "bg-success/15 text-success border border-success/20",
// Reopened: red — flags admin attention because the previous
// resolution didn't stick.
reopened: "bg-red-400/15 text-red-400 border border-red-400/20",
};
/**
* Small status pill rendered on ticket list rows and detail header.
* Translated label, colour-coded by ticket lifecycle stage.
*/
export function TicketStatusBadge({
status,
}: {
status: SupportTicketStatus;
}) {
const t = useTranslations("support");
return (
<span
className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${STATUS_STYLES[status]}`}
>
{t(`status_${status}`)}
</span>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import { formatDateTime } from "@/lib/format";
import type { SupportTicketComment, SupportTicketStatus } from "@/types";
interface Props {
ticketId: string;
ticketStatus: SupportTicketStatus;
comments: SupportTicketComment[];
isPlatform: boolean;
/** True when the viewer is the customer who created this ticket. */
isOwnTicket: boolean;
}
/**
* Thread of comments + reply box. Customer-side viewers see a
* "Close ticket" button as well, mapping to the customer-self-close
* path on the PATCH endpoint.
*
* Reply submission: posts the comment, then router.refresh() so the
* server-rendered page re-fetches and renders the new entry. Avoids
* duplicating the comment-rendering logic on the client.
*
* Empty body submissions are blocked at HTML level (required) AND
* by the API; we trust both layers.
*/
export function TicketThread({
ticketId,
ticketStatus,
comments,
isPlatform,
isOwnTicket,
}: Props) {
const t = useTranslations("support");
const tCommon = useTranslations("common");
const f = useFormatter();
const router = useRouter();
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState("");
const onSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/support/tickets/${encodeURIComponent(ticketId)}/comments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("commentFailed"));
}
setBody("");
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
// Customer-self-close: confirms because it's a state change, then
// hits PATCH with status=resolved. The API allows this for
// own-ticket regardless of role; the button only shows when the
// ticket is in a non-resolved state.
const onCustomerClose = async () => {
if (!confirm(t("confirmClose"))) return;
setClosing(true);
setError("");
try {
const res = await fetch(
`/api/support/tickets/${encodeURIComponent(ticketId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "resolved" }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("closeFailed"));
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setClosing(false);
}
};
const isResolved = ticketStatus === "resolved";
const canCustomerClose =
isOwnTicket && !isResolved;
return (
<>
{comments.map((c) => (
<Card
key={c.id}
className={
c.authorKind === "admin"
? "border-blue-400/30 bg-blue-400/5"
: ""
}
>
<div className="flex items-center justify-between text-xs text-text-muted mb-2">
<span className="font-medium text-text-primary">
{c.authorName}
{c.authorKind === "admin" && (
<span className="ml-2 text-blue-400 text-[10px] uppercase tracking-wider">
{t("authorTagAdmin")}
</span>
)}
</span>
<span>{formatDateTime(c.createdAt, f)}</span>
</div>
<div className="text-sm text-text-primary whitespace-pre-wrap">
{c.body}
</div>
</Card>
))}
{isResolved && (
<Card className="border-success/30 bg-success/5">
<p className="text-sm text-text-secondary text-center">
{t("resolvedBanner")}
</p>
</Card>
)}
{/* Reply box. Visible regardless of status — customer can
reply even on a resolved ticket (which auto-reopens it
server-side). The semantic is "reply means the ticket is
alive again", which is friendlier than blocking the reply. */}
<Card>
<form onSubmit={onSubmitComment} className="space-y-3">
<label className="block text-xs uppercase tracking-wider text-text-muted">
{t("replyLabel")}
</label>
<textarea
required
minLength={1}
maxLength={10_000}
rows={4}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={
isResolved && isOwnTicket
? t("replyPlaceholderReopen")
: t("replyPlaceholder")
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
{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>
)}
<div className="flex items-center justify-between">
{canCustomerClose ? (
<button
type="button"
onClick={onCustomerClose}
disabled={closing || submitting}
className="text-xs text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
>
{closing ? tCommon("loading") : t("closeTicket")}
</button>
) : (
<span />
)}
<button
type="submit"
disabled={submitting || closing || body.trim().length === 0}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{submitting ? tCommon("loading") : t("sendReply")}
</button>
</div>
</form>
</Card>
</>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
type FormState = "idle" | "submitting" | "success" | "error";
/**
* InviteForm — owner submits email + name + role to /api/team/invite.
* On success, broadcasts `team:refresh` so the sibling TeamList
* re-fetches the member list.
*
* Form fields mirror the POST body:
* { email, givenName, familyName, role: "owner" | "user" }
*
* Role defaults to "user" — the more conservative grant. Owner
* promotion happens in ZITADEL Console for now.
*/
export function InviteForm() {
const t = useTranslations("team");
const tCommon = useTranslations("common");
const [form, setForm] = useState({
email: "",
givenName: "",
familyName: "",
role: "user" as "owner" | "user",
});
const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState("");
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setState("submitting");
try {
const res = await fetch("/api/team/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
if (data.code === "user_already_exists") {
throw new Error(t("inviteUserExists"));
}
throw new Error(data.error || "Invite failed");
}
setState("success");
setForm({ email: "", givenName: "", familyName: "", role: "user" });
// Tell the TeamList sibling to refresh
window.dispatchEvent(new Event("team:refresh"));
// Auto-clear the success banner after a moment so the form
// doesn't permanently look "done"
setTimeout(() => setState("idle"), 3500);
} catch (err: any) {
setError(err.message);
setState("error");
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<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>
<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="colleague@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("role")}
</label>
<select
name="role"
value={form.role}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
>
<option value="user">{t("roleUser")}</option>
<option value="owner">{t("roleOwner")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("roleHint")}</p>
</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>
)}
{state === "success" && (
<div className="text-xs text-emerald-400 bg-emerald-400/10 border border-emerald-400/20 rounded-lg px-3 py-2">
{t("inviteSent")}
</div>
)}
<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("inviteButton")}
</button>
</form>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
interface OrgMember {
userId: string;
email: string;
displayName: string;
givenName: string;
familyName: string;
roles: string[];
authorizationId: string;
}
interface Props {
initialMembers: OrgMember[];
currentUserId: string;
/**
* Whether the viewing user can change other members' roles. True only
* for customer owners. Server enforces this independently — this prop
* is purely UX (don't render the control if the action would 403).
*/
canEditRoles: boolean;
}
type RoleOption = "owner" | "user";
/**
* TeamList — renders the org's members. Refreshes after invites by
* polling the API; the InviteForm broadcasts a `team:refresh` window
* event after a successful invite so the list updates immediately
* rather than waiting for the next reload.
*
* Slice 7 + Bug 25: owners can change other members' roles inline.
* Clicking the "Change role" button on a row swaps the badge for a
* dropdown + Save/Cancel pair. We deliberately don't use a modal —
* the change is a single-field edit and the user already sees the row
* context, so inline is faster.
*
* Self-row never shows the editor (server enforces too). Last-owner
* demotion is enforced server-side; we surface the resulting 409 as a
* row-local error rather than pre-validating client-side, because the
* client doesn't know the org's full owner count without an extra
* round trip.
*/
export function TeamList({
initialMembers,
currentUserId,
canEditRoles,
}: Props) {
const t = useTranslations("team");
const [members, setMembers] = useState<OrgMember[]>(initialMembers);
// Per-row editor state. `editingId` is the userId currently being
// edited (only one at a time). `pendingRole` is the dropdown value.
// `rowError` carries server-rejection messages keyed by userId.
const [editingId, setEditingId] = useState<string | null>(null);
const [pendingRole, setPendingRole] = useState<RoleOption>("user");
const [submitting, setSubmitting] = useState(false);
const [rowError, setRowError] = useState<Record<string, string>>({});
useEffect(() => {
function refresh() {
fetch("/api/team")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.members) setMembers(data.members);
})
.catch(() => {});
}
window.addEventListener("team:refresh", refresh);
return () => window.removeEventListener("team:refresh", refresh);
}, []);
function startEdit(m: OrgMember) {
const current = (m.roles[0] === "owner" ? "owner" : "user") as RoleOption;
setEditingId(m.userId);
setPendingRole(current);
setRowError((e) => ({ ...e, [m.userId]: "" }));
}
function cancelEdit() {
setEditingId(null);
setSubmitting(false);
}
async function saveEdit(m: OrgMember) {
setSubmitting(true);
setRowError((e) => ({ ...e, [m.userId]: "" }));
try {
const res = await fetch(
`/api/team/${encodeURIComponent(m.userId)}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: pendingRole }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("roleUpdateFailed"));
}
// Optimistic update — replace the row's roles locally rather than
// re-fetching the whole list. The list will eventually re-fetch
// on the next `team:refresh` event anyway.
setMembers((prev) =>
prev.map((x) =>
x.userId === m.userId ? { ...x, roles: [pendingRole] } : x
)
);
setEditingId(null);
} catch (err: any) {
setRowError((e) => ({ ...e, [m.userId]: err.message }));
} finally {
setSubmitting(false);
}
}
if (members.length === 0) {
return (
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
{t("noMembers")}
</div>
);
}
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<ul className="divide-y divide-border">
{members.map((m) => {
const isSelf = m.userId === currentUserId;
const isEditing = editingId === m.userId;
// Hide editor for self even when the viewer is an owner —
// self-demotion is server-blocked and offering it as a UI
// affordance would just produce errors.
const showEditor = canEditRoles && !isSelf;
const err = rowError[m.userId];
return (
<li
key={m.userId}
className="px-5 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{m.displayName || m.email}
</span>
{isSelf && (
<span className="text-[10px] uppercase tracking-wider text-accent">
{t("you")}
</span>
)}
</div>
<div className="text-xs text-text-muted truncate font-mono">
{m.email}
</div>
{err && (
<div className="text-xs text-red-400 mt-1">{err}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isEditing ? (
<>
<select
value={pendingRole}
onChange={(e) =>
setPendingRole(e.target.value as RoleOption)
}
disabled={submitting}
className="text-xs bg-surface-2 border border-border rounded-md px-2 py-1 text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent"
>
<option value="user">{t("roleUser")}</option>
<option value="owner">{t("roleOwner")}</option>
</select>
<button
type="button"
onClick={() => saveEdit(m)}
disabled={submitting || !m.authorizationId}
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
>
{t("save")}
</button>
<button
type="button"
onClick={cancelEdit}
disabled={submitting}
className="text-xs px-2.5 py-1 rounded-md border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancel")}
</button>
</>
) : (
<>
<div className="flex flex-wrap gap-1.5 justify-end">
{m.roles.length === 0 && (
<span className="text-[10px] uppercase tracking-wider text-text-muted bg-surface-3 px-2 py-0.5 rounded-full">
{t("noRole")}
</span>
)}
{m.roles.map((r) => (
<span
key={r}
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full ${
r === "owner"
? "bg-accent/15 text-accent border border-accent/20"
: "bg-surface-3 text-text-secondary border border-border"
}`}
>
{r}
</span>
))}
</div>
{showEditor && (
<button
type="button"
onClick={() => startEdit(m)}
title={t("changeRole")}
className="text-xs text-text-muted hover:text-text-primary px-2 py-1 rounded-md transition-colors"
>
{t("changeRole")}
</button>
)}
</>
)}
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
interface Assignment {
userId: string;
displayName: string;
email: string;
roles: string[];
assignedAt: string;
assignedBy: string;
orphan: boolean;
}
interface OrgMember {
userId: string;
email: string;
displayName: string;
roles: string[];
}
interface Props {
tenantName: string;
/**
* When false, the panel renders read-only — assignments are visible
* but the add-user form and remove ✕ buttons are hidden. Pass
* `canEdit` from the parent server component (= canMutate(user)).
*/
canEdit: boolean;
}
/**
* AssignedUsersPanel — manages the tenant_user_assignments rows for
* one tenant. Owner sees:
* - List of currently-assigned users with name, email, role, and
* an "X" button to revoke.
* - Dropdown of org members not yet assigned + "Assign" button.
*
* `user`-role members see the panel read-only (canEdit=false): they
* see who else has access to the tenant they're working with, but
* can't change anything.
*/
export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
const t = useTranslations("assignments");
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [members, setMembers] = useState<OrgMember[] | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [pickedUserId, setPickedUserId] = useState("");
const refresh = useCallback(async () => {
setError("");
try {
const [aRes, mRes] = await Promise.all([
fetch(`/api/tenants/${tenantName}/assignments`),
canEdit
? fetch(`/api/team`)
: Promise.resolve(null),
]);
if (!aRes.ok) throw new Error("Failed to load assignments");
const aData = await aRes.json();
setAssignments(aData.assignments ?? []);
if (mRes && mRes.ok) {
const mData = await mRes.json();
setMembers(mData.members ?? []);
}
} catch (err: any) {
setError(err.message);
}
}, [tenantName, canEdit]);
useEffect(() => {
refresh();
}, [refresh]);
async function handleAssign() {
if (!pickedUserId || busy) return;
setBusy(true);
setError("");
try {
const res = await fetch(`/api/tenants/${tenantName}/assignments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: pickedUserId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Assign failed");
}
setPickedUserId("");
await refresh();
} catch (err: any) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function handleRevoke(userId: string) {
if (busy) return;
setBusy(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${tenantName}/assignments/${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Revoke failed");
}
await refresh();
} catch (err: any) {
setError(err.message);
} finally {
setBusy(false);
}
}
if (assignments === null) {
return (
<Card>
<div className="text-xs text-text-muted">{t("loading")}</div>
</Card>
);
}
// Compute candidates for the assign dropdown: members of the org
// who hold the `user` role (not owners — they have implicit access)
// and aren't already assigned.
const assignedIds = new Set(assignments.map((a) => a.userId));
const candidates = (members ?? []).filter(
(m) =>
!assignedIds.has(m.userId) &&
m.roles.includes("user") &&
!m.roles.includes("owner")
);
return (
<Card>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
<button
onClick={() => setError("")}
className="ml-2 text-red-300 hover:text-red-200"
>
</button>
</div>
)}
{assignments.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-3">
{t("noneAssigned")}
</p>
) : (
<ul className="divide-y divide-border -mx-2">
{assignments.map((a) => (
<li
key={a.userId}
className="px-2 py-2 flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="text-sm font-medium text-text-primary truncate">
{a.orphan ? (
<span className="text-text-muted italic">
{a.displayName}
</span>
) : (
a.displayName
)}
</div>
{a.email && (
<div className="text-xs text-text-muted truncate font-mono">
{a.email}
</div>
)}
</div>
{canEdit && (
<button
onClick={() => handleRevoke(a.userId)}
disabled={busy}
className="text-text-muted/60 hover:text-red-400 transition-colors disabled:opacity-50 text-sm px-2"
title={t("revoke")}
>
</button>
)}
</li>
))}
</ul>
)}
{canEdit && (
<div className="mt-4 pt-4 border-t border-border">
{candidates.length === 0 ? (
<p className="text-xs text-text-muted text-center py-2">
{t("noCandidates")}
</p>
) : (
<div className="flex gap-2">
<select
value={pickedUserId}
onChange={(e) => setPickedUserId(e.target.value)}
className="flex-1 px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
>
<option value="">{t("pickUser")}</option>
{candidates.map((m) => (
<option key={m.userId} value={m.userId}>
{m.displayName || m.email}
</option>
))}
</select>
<button
onClick={handleAssign}
disabled={busy || !pickedUserId}
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{busy ? "…" : t("assign")}
</button>
</div>
)}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,406 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Modal } from "@/components/ui/modal";
import { formatRelative } from "@/lib/format";
interface Props {
tenantName: string;
/**
* Current suspend state — server-derived. Drives which control the
* customer sees: "Cancel subscription" while active, the
* resume-request flow while suspended.
*/
suspended: boolean;
/**
* True when the viewer has platform admin role. Platform users are
* the only ones who can directly resume a tenant via PATCH; owners
* must go through the resume-request flow. We use this in the
* suspended branch to decide whether to render a direct "Resume"
* button or the "Request reactivation" workflow.
*/
isPlatform: boolean;
/**
* If a resume request is currently pending for this tenant, its
* id, when it was submitted, and the customer's optional note.
* The component renders an info card with a cancel-request button
* instead of the request-reactivation button. Only meaningful when
* `suspended === true`.
*/
pendingResumeRequest: {
id: string;
createdAt: string;
customerNotes: string | null;
} | null;
}
/**
* SubscriptionToggle — owner-side cancel/resume control.
*
* Three render states:
* 1. Active: "Cancel subscription" button + confirmation modal
* (mentions 60-day retention before permanent deletion).
* 2. Suspended, no pending resume request: "Request reactivation"
* button + simple confirmation modal explaining admin review.
* 3. Suspended, pending resume request: status card "Reactivation
* requested X days ago" + "Cancel request" button.
*
* Platform admins viewing a suspended tenant get a fourth state in
* place of #2/#3: a direct "Resume now" button (no admin queue, no
* request flow). This is the admin escape hatch.
*
* The control intentionally lives at the bottom of the tenant
* detail page rather than near the top — putting it next to the
* status badge would invite mis-clicks.
*/
export function SubscriptionToggle({
tenantName,
suspended,
isPlatform,
pendingResumeRequest,
}: Props) {
const t = useTranslations("tenantDetail");
const tCommon = useTranslations("common");
const f = useFormatter();
const router = useRouter();
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [confirmResumeOpen, setConfirmResumeOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// Feature 6: customer's free-form note attached to the resume
// request. Reset when the modal opens/closes so re-opening doesn't
// show stale text from a previous abandoned attempt.
const [resumeNotes, setResumeNotes] = useState("");
// Customer-side cancel: PATCH suspend=true. Same path as before.
// The 60-day retention copy in the modal is the new bit (Bug 37b);
// mechanics are unchanged.
const cancel = async () => {
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suspend: true }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
setConfirmCancelOpen(false);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
// Owner-side resume request: POST a 'resume' tenant_request that
// sits pending until admin acts. Different from cancel: no PATCH
// on the CR — that happens only when admin approves.
const requestResume = async () => {
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/resume-request`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// Trim and omit on empty so the API stores NULL rather
// than empty string. The endpoint's zod transform also
// handles this; double-checking on the client lets us
// skip the round-trip when there's nothing to send.
customerNotes: resumeNotes.trim() || undefined,
}),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
setConfirmResumeOpen(false);
setResumeNotes("");
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
// Customer cancels their own pending resume request.
const cancelResumeRequest = async () => {
if (!pendingResumeRequest) return;
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${pendingResumeRequest.id}`,
{ method: "DELETE" }
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
// Platform admin: direct resume, bypassing the request flow.
const adminResume = async () => {
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suspend: false }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
// ─── Suspended branch ───────────────────────────────────────────────
if (suspended) {
// Platform admin sees direct resume. Independent of pending
// resume — admin can always resume immediately.
if (isPlatform) {
return (
<div>
<button
type="button"
onClick={adminResume}
disabled={submitting}
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("resumeSubscription")}
</button>
{pendingResumeRequest && (
<p className="text-xs text-text-muted mt-2">
{t("resumeRequestPendingNoteAdmin")}
</p>
)}
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
</div>
);
}
// Owner with pending resume request: render the request status
// card with cancel.
if (pendingResumeRequest) {
return (
<div>
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="text-sm font-medium text-amber-400 mb-1">
{t("resumeRequestPendingTitle")}
</div>
<div className="text-xs text-text-secondary">
{t("resumeRequestPendingDescription", {
when: formatRelative(pendingResumeRequest.createdAt, f),
})}
</div>
{/* Feature 6: echo the customer's note back so they can
see what they wrote. Useful especially when they
later wonder "what did I tell them?" or want to
confirm before cancelling and resubmitting. */}
{pendingResumeRequest.customerNotes && (
<div className="mt-2 text-xs text-text-secondary border-l-2 border-amber-500/30 pl-3 whitespace-pre-wrap">
{pendingResumeRequest.customerNotes}
</div>
)}
<button
type="button"
onClick={cancelResumeRequest}
disabled={submitting}
className="mt-3 text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelResumeRequest")}
</button>
</div>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
</div>
);
}
// Owner with no pending request: offer to create one.
return (
<div>
<button
type="button"
onClick={() => setConfirmResumeOpen(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors"
>
{t("requestReactivation")}
</button>
{error && !confirmResumeOpen && (
<p className="text-xs text-red-400 mt-2">{error}</p>
)}
{confirmResumeOpen && (
<Modal
open={confirmResumeOpen}
onClose={() => setConfirmResumeOpen(false)}
ariaLabel={t("requestReactivationConfirmTitle")}
>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("requestReactivationConfirmTitle")}
</h3>
<p className="text-sm text-text-secondary mb-4">
{t("requestReactivationConfirmDescription")}
</p>
{/* Feature 6: optional explanatory note. Useful for
customers to tell admin why they want reactivation
— e.g. "we paused over winter break, picking back
up". Stored on the tenant_request and surfaced in
the admin queue. */}
<div className="mb-5">
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1.5">
{t("requestReactivationNoteLabel")}{" "}
<span className="text-text-muted normal-case">
({tCommon("optional")})
</span>
</label>
<textarea
value={resumeNotes}
onChange={(e) => setResumeNotes(e.target.value)}
rows={3}
maxLength={2000}
placeholder={t("requestReactivationNotePlaceholder")}
disabled={submitting}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary disabled:opacity-50"
/>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmResumeOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={requestResume}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-success text-white hover:bg-success/90 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("requestReactivationConfirm")}
</button>
</div>
</Modal>
)}
</div>
);
}
// ─── Active branch ──────────────────────────────────────────────────
return (
<div>
<button
type="button"
onClick={() => setConfirmCancelOpen(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("cancelSubscription")}
</button>
{error && !confirmCancelOpen && (
<p className="text-xs text-red-400 mt-2">{error}</p>
)}
{confirmCancelOpen && (
<Modal
open={confirmCancelOpen}
onClose={() => setConfirmCancelOpen(false)}
ariaLabel={t("cancelConfirmTitle")}
>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmTitle")}
</h3>
<p className="text-sm text-text-secondary mb-3">
{t("cancelConfirmDescription")}
</p>
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-3">
<li>{t("cancelConfirmBullet1")}</li>
<li>{t("cancelConfirmBullet2")}</li>
<li>{t("cancelConfirmBullet3")}</li>
</ul>
{/* Bug 37b: 60-day retention warning. Distinct paragraph so it
reads as a separate, more serious commitment than the
regular bullets above. */}
<div className="text-xs text-amber-400 bg-amber-400/10 border border-amber-400/20 rounded-lg px-3 py-2 mb-5">
{t("cancelConfirmRetentionWarning")}
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmCancelOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={cancel}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div>
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import Link from "next/link";
/**
* BackLink — small "← Page" navigation cue that sits above a page's
* `<h1 className="accent-rule">` heading.
*
* Why this exists
* ---------------
* The pattern was originally written inline on /team and /dashboard/new
* as `<Link className="inline-flex …"><span>←</span> Title</Link>`.
* That's wrong because `.accent-rule` (defined in globals.css) sets
* `display: inline-block` on the H1 — so an inline-flex link followed by
* an inline-block H1 are both inline-level, and end up on the same
* baseline whenever there's horizontal room for them. The `mb-4` on the
* link does nothing because vertical margin between inline boxes
* doesn't push siblings to a new line.
*
* Solving it: this component renders the link as a block-level flex
* container with `w-fit` so it shrinks to its content (and its hover
* area doesn't span the gutter). The trailing block element below sits
* cleanly on its own line.
*
* Use it whenever a page has a back-link above an `accent-rule` H1.
* The two prior callsites (/team and /dashboard/new) have been
* migrated; new pages should just use this directly.
*/
export function BackLink({
href,
label,
}: {
href: string;
label: string;
}) {
return (
<Link
href={href}
className="flex w-fit items-center gap-1.5 mb-4 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
>
<span aria-hidden="true"></span>
<span>{label}</span>
</Link>
);
}

Some files were not shown because too many files have changed in this diff Show More