Compare commits

...

79 Commits

Author SHA1 Message Date
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
0bf4c6cf4c Debug pipeline 2026-04-25 22:48:05 +02:00
4296a70d51 Debug pipeline 2026-04-25 22:34:48 +02:00
c41145bae7 Debug pipeline 2026-04-25 22:29:42 +02:00
d6a6150a7f Debug pipeline 2026-04-25 22:22:39 +02:00
f69a2d4fa2 Debug pipeline 2026-04-25 22:17:10 +02:00
c2ac8b4401 Debug pipeline 2026-04-25 22:10:46 +02:00
3ce3ba0649 Debug pipeline 2026-04-25 22:08:46 +02:00
5b27f54eb3 Debug pipeline 2026-04-25 20:15:30 +02:00
cc5806f031 Debug pipeline 2026-04-25 20:12:22 +02:00
dab18bb9e6 Debug pipeline 2026-04-25 20:10:36 +02:00
de4ff5ebaf Debug pipeline 2026-04-25 20:09:17 +02:00
f3a1ae0267 Debug pipeline 2026-04-25 20:04:55 +02:00
e7d3fa3873 Debug pipeline 2026-04-25 19:30:48 +02:00
baa0e2b597 Add docker env 2026-04-25 19:21:53 +02:00
935dfb8abc Add docker env 2026-04-25 19:20:54 +02:00
65d8a2e2ff Add docker env 2026-04-25 19:07:16 +02:00
d62684dec7 Add docker env 2026-04-25 19:04:42 +02:00
709588302c ci: add Gitea Actions workflows 2026-04-25 18:20:14 +02:00
b9654d7a7c Timestamp and registration checking 2026-04-25 18:09:02 +02:00
f550b3400f Frontend adjustments 2026-04-14 20:45:58 +02:00
f0eca1959b fix(portal): security hardening for pilot readiness
- C1: Rewrite /api/usage to resolve teamId server-side from tenant CR;
  customers can no longer pass arbitrary teamId (IDOR fix)
- C2: Remove POST /api/tenants — tenants are only created via admin
  approval flow
- H1: Validate packages against catalog, workspaceFiles against allowlist,
  and field lengths in PATCH /api/tenants/[name]
- H2: Remove full ZITADEL profile claims logging from JWT callback
- H3: Add safeError() utility; sanitize all error responses to clients,
  toggle raw errors via PORTAL_DEBUG_ERRORS=true
- H4/H5: Escape HTML entities in all email templates (contactName,
  companyName, adminNotes)
2026-04-14 20:20:04 +02:00
6f9f46b2d0 Ratelimit 2026-04-12 18:13:26 +02:00
dbfa7560cf All the channel approval 2026-04-12 13:47:27 +02:00
1edb5785e3 Add Health and Spend for Admins 2026-04-11 22:36:36 +02:00
fdb56490dd All the MD files via Database 2026-04-11 21:14:09 +02:00
147 changed files with 24565 additions and 1163 deletions

View File

@@ -17,3 +17,7 @@ LITELLM_MASTER_KEY=
# Portal Database (CloudNativePG) # Portal Database (CloudNativePG)
DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal 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__

106
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,106 @@
name: Build and Push
on:
push:
branches: [main]
# Don't rebuild on doc-only or CI-config-only changes
paths-ignore:
- 'README.md'
- '.gitea/**'
- 'deploy/**'
workflow_dispatch:
env:
REGISTRY: registry.c5ai.ch
IMAGE: pieced/pieced-portal
jobs:
build:
# 'self-hosted' matches the label our act_runner registers with.
# 'ubuntu-latest' would work too because we configure both labels in the
# runner config, but self-hosted makes intent explicit.
runs-on: ubuntu-latest
env:
DOCKER_HOST: tcp://172.17.0.1:2375
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine next patch version
id: version
# Reads tags from the registry's OCI Distribution v2 API, filters to
# strict semver (skips 'latest', 'dev', '<sha>-dirty', etc.), picks the
# highest with version-sort, and bumps the patch component. If nothing
# numeric exists yet (fresh registry), starts at 0.1.0.
env:
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
tags_json=$(curl -sf -u "$REG_USER:$REG_PASS" \
"https://${REGISTRY}/v2/${IMAGE}/tags/list")
highest=$(echo "$tags_json" \
| jq -r '.tags // [] | .[]' \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -n1 || true)
if [ -z "$highest" ]; then
next="0.1.0"
echo "No semver tags found — starting at $next"
else
major=$(echo "$highest" | cut -d. -f1)
minor=$(echo "$highest" | cut -d. -f2)
patch=$(echo "$highest" | cut -d. -f3)
next="${major}.${minor}.$((patch + 1))"
echo "Highest existing: $highest → next: $next"
fi
echo "version=${next}" >> "$GITHUB_OUTPUT"
- name: Build and push image
env:
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
printf '%s' "$REG_PASS" \
| docker login "${REGISTRY}" -u "$REG_USER" --password-stdin
docker build \
--pull \
-t "${REGISTRY}/${IMAGE}:${VERSION}" \
-t "${REGISTRY}/${IMAGE}:latest" \
.
docker push "${REGISTRY}/${IMAGE}:${VERSION}"
docker push "${REGISTRY}/${IMAGE}:latest"
- name: Tag git commit with version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
git config user.name "pieced-ci"
git config user.email "ci@pieced.ch"
git tag -a "v${VERSION}" -m "Release ${VERSION}"
# Use CI_TOKEN explicitly so we can push a tag (the workflow's
# default token may or may not have push scope depending on Gitea
# actions config — explicit token avoids ambiguity).
git push \
"https://oauth2:${{ secrets.CI_TOKEN }}@git.c5ai.ch/pieced/pieced-portal.git" \
"v${VERSION}"
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
{
echo "## Build complete: ${VERSION}"
echo
echo "**Image:** \`${REGISTRY}/${IMAGE}:${VERSION}\`"
echo
echo "Run the **Deploy to GitOps** workflow to roll this version out."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,98 @@
name: Deploy to GitOps
# Manually triggered. Bumps the image tag in pieced-gitops so ArgoCD rolls
# the new version out. Does not build anything itself — the build workflow
# is the only thing that creates and pushes images.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to deploy (e.g. 0.1.5). Must already exist in the registry.'
required: true
type: string
env:
REGISTRY: registry.c5ai.ch
IMAGE: pieced/pieced-portal
GITOPS_REPO: admin/pieced-gitops
GITOPS_FILE: apps/portal/deployment.yaml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Verify image exists in registry
# Fail fast if the user typed a version that was never built. Catches
# typos before we touch the gitops repo. Uses env-var pattern for
# credentials to avoid shell interpolation mangling special characters.
env:
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
status=$(curl -sf -o /dev/null -w '%{http_code}' \
-u "$REG_USER:$REG_PASS" \
"https://${REGISTRY}/v2/${IMAGE}/manifests/${{ inputs.version }}" \
|| true)
if [ "$status" != "200" ]; then
echo "::error::Image ${REGISTRY}/${IMAGE}:${{ inputs.version }} not found (HTTP $status)"
exit 1
fi
echo "Confirmed: ${REGISTRY}/${IMAGE}:${{ inputs.version }} exists."
- name: Checkout pieced-gitops
uses: actions/checkout@v4
with:
repository: ${{ env.GITOPS_REPO }}
token: ${{ secrets.CI_TOKEN }}
path: gitops
# We need history to commit + push back; default fetch-depth: 1 is fine
# for a single commit but force a clean shallow clone:
fetch-depth: 1
- name: Update image tag
working-directory: gitops
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
file="${GITOPS_FILE}"
if [ ! -f "$file" ]; then
echo "::error::$file not found in gitops repo"
exit 1
fi
# Anchored to the full image path to avoid accidentally rewriting
# any unrelated 'image:' line that might appear later.
sed -i -E \
"s|(image: ${REGISTRY}/${IMAGE}:)[^[:space:]]+|\1${VERSION}|" \
"$file"
echo "--- diff ---"
git --no-pager diff "$file" || true
- name: Commit and push
working-directory: gitops
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if git diff --quiet; then
echo "No changes — image tag was already ${VERSION}."
exit 0
fi
git config user.name "pieced-ci"
git config user.email "ci@pieced.ch"
git add "${GITOPS_FILE}"
git commit -m "Bump pieced-portal to ${VERSION}"
git push
- name: Summary
env:
VERSION: ${{ inputs.version }}
run: |
{
echo "## Deployed: pieced-portal ${VERSION}"
echo
echo "ArgoCD will sync within its refresh interval."
echo "Watch with: \`kubectl get app -n argocd portal -w\`"
} >> "$GITHUB_STEP_SUMMARY"

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 | The admin panel's suspend/resume button hits
|-------|--------| `/api/admin/tenants/[name]/suspend` (a different route from the
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) | customer-side `/api/tenants/[name]/suspend`). The v2 drop only
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) | hooked the customer route — admin suspends were going to K8s
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) | without producing a row in `tenant_suspension_events`.
| 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` |
## Setup This patch adds the same `recordSuspensionEvent` hook to the
admin route. No other code paths affected; no schema changes.
### 1. ZITADEL Application ## Files
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
``` ```
src/ src/app/api/admin/tenants/[name]/suspend/route.ts MODIFIED
├── 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
``` ```
## Session Roadmap ## Deploy
- **6.1** ← This session: scaffold, auth, basic pages Extract over your `pieced-portal/` tree, rebuild, redeploy as
- **6.2**: Instance management, package config, usage display usual. After the new image is running, verify:
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md) 1. Suspend any test tenant from the `/admin` panel.
- **6.5**: Admin panel (tenant lifecycle, billing overview) 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).

View File

@@ -1,5 +1,25 @@
npm install #!/usr/bin/env bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.4 . set -euo pipefail
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.4
kubectl rollout restart deployment pieced-portal -n pieced-system
VERSION="${1:-}"
REGISTRY="registry.c5ai.ch/pieced/pieced-portal"
if [[ -z "$VERSION" ]]; then
echo "Usage: ./buildanddeploy.sh <version>"
echo "Example: ./buildanddeploy.sh 0.2.0"
exit 1
fi
echo "Building pieced-portal:${VERSION}..."
npm install
docker build -t "${REGISTRY}:${VERSION}" .
docker push "${REGISTRY}:${VERSION}"
echo ""
echo "✓ Pushed ${REGISTRY}:${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Update image tag in pieced-gitops/apps/portal/deployment.yaml:"
echo " image: ${REGISTRY}:${VERSION}"
echo " 2. git commit + push to pieced-gitops"
echo " 3. ArgoCD syncs automatically"

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.

31
deploy/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Session 6.6 — Items 5 & 6: System Health + Spend Column
## Files
| File | Action | What |
|------|--------|------|
| `src/lib/litellm.ts` | REPLACE | Added `listTeams()`, `getLitellmHealth()`, `getGlobalSpend()`, `getPerTeamSpend()` |
| `src/app/api/admin/health/route.ts` | **NEW** | Returns tenant phase counts, aggregate + per-tenant spend, vLLM & LiteLLM health |
| `src/components/admin/admin-panel.tsx` | REPLACE | Added "Health" tab (service indicators, tenant overview, spend cards) + "Spend (CHF)" column in tenants table |
| `patch-i18n-admin-health.mjs` | **RUN ONCE** | Patches all 4 i18n files with new admin keys |
## Steps
1. Drop in the 3 source files (overwrite existing)
2. Run the i18n patcher from the portal root:
```bash
node patch-i18n-admin-health.mjs
```
3. Build and deploy
## Environment Variables (optional)
- `VLLM_HEALTH_URL` — defaults to `http://vllm.inference.svc:8000`. Set if your vLLM is elsewhere.
- `LITELLM_INTERNAL_URL` / `LITELLM_MASTER_KEY` — already configured.
## Notes
- The health API uses `Promise.allSettled` so a single service being down won't break the whole page.
- Per-tenant spend is fetched from LiteLLM's `/team/list` which returns the cumulative `spend` per team. This is mapped to tenant names via `status.litellmTeamId` on the PiecedTenant CR.
- The spend column in the tenants table piggybacks on the same health data — fetched once when switching to the tenants tab.
- If LiteLLM's `/team/list` or `/global/spend` response format differs from what I assumed, you may need to adjust the parsing in `litellm.ts`. The functions have fallbacks for common response shapes.

58
deploy/README_sql.md Normal file
View File

@@ -0,0 +1,58 @@
# Session 6.6 — Items 3 & 4: AGENTS.md / TOOLS.md in Wizard + Default Templates
## Manual Steps (in order)
### 1. Deploy the updated portal code
Copy the files from this ZIP into your `pieced-portal` repo, overwriting existing files.
All paths match the project structure — drop-in replacements.
### 2. The DB migration is automatic
The updated `db.ts` adds these idempotently on first query:
- Column `agents_md TEXT` on `tenant_requests`
- Table `workspace_templates` (file_key TEXT PK, content TEXT, updated_at TIMESTAMPTZ)
No manual `ALTER TABLE` needed.
### 3. Seed the workspace templates
After the portal has started (so the table exists):
```bash
kubectl exec -i portal-db-1 -n portal -- psql -U portal -d portal < seed-workspace-templates.sql
```
Or connect interactively and paste the SQL.
### 4. Edit templates as needed
To update a template later, just UPDATE the row:
```sql
UPDATE workspace_templates
SET content = '# Your new SOUL.md content here...', updated_at = now()
WHERE file_key = 'SOUL.md';
```
The portal reads templates on every wizard load / approval — no restart needed.
---
## File Manifest
| File | Action | What changed |
|------|--------|-------------|
| `src/lib/workspace-defaults.ts` | **NEW** | Default content fetching from DB + TOOLS.md generation |
| `src/lib/db.ts` | REPLACE | Added `agents_md` column, `workspace_templates` table + CRUD |
| `src/types/index.ts` | REPLACE | Added `agentsMd` to `TenantRequest` and `OnboardingInput` |
| `src/app/api/onboarding/route.ts` | REPLACE | Accepts `agentsMd` field |
| `src/app/api/admin/requests/[id]/approve/route.ts` | REPLACE | Builds all 3 workspace files (SOUL/AGENTS/TOOLS) |
| `src/app/api/workspace-defaults/route.ts` | **NEW** | API to fetch defaults for wizard pre-fill |
| `src/components/onboarding/wizard.tsx` | REPLACE | "Advanced Configuration" accordion with AGENTS.md textarea + readonly TOOLS.md preview |
| `src/messages/{de,en,fr,it}.json` | REPLACE | Added `agentsMd`, `agentsMdHint`, `toolsMd`, `toolsMdHint`, `advancedConfig`, `readonlyNote` |
| `seed-workspace-templates.sql` | **NEW** | SQL to seed default templates |
## Design Decisions
- **TOOLS.md is readonly** in both the wizard and the tenant detail page. It's auto-generated from the base template + per-package sections. Users see it but can't edit it.
- **AGENTS.md is editable** in the wizard (under "Advanced Configuration" accordion) and on the tenant detail workspace editor.
- **Templates live in the DB** (`workspace_templates` table) so you can edit them without redeploying. Hardcoded fallbacks exist in `workspace-defaults.ts` in case the DB rows are missing.
- **TOOLS.md is regenerated on approval** based on the packages selected, so it's always consistent with what's actually enabled.
- The workspace editor on the tenant detail page already supports arbitrary `workspaceFiles` keys from the CR spec, so AGENTS.md and TOOLS.md will appear there automatically. TOOLS.md should be made readonly there too — that's a separate small change to the workspace editor component (mark `TOOLS.md` as readonly based on the filename).

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Run: node patch-i18n-admin-health.mjs
* Adds health/spend keys to all 4 message files.
* Run from the pieced-portal root.
*/
import { readFileSync, writeFileSync } from "fs";
const newKeys = {
en: {
health: "Health",
serviceHealth: "Service Health",
vllmDescription: "GPU inference engine",
litellmDescription: "LLM proxy & spend tracking",
tenantOverview: "Tenant Overview",
spendOverview: "Spend Overview",
globalSpend: "Global Spend (CHF)",
activeTenants: "Active Tenants",
tenantsWithSpend: "tenants with recorded spend",
refresh: "Refresh",
healthUnavailable: "Health data unavailable.",
loadingHealth: "Loading health data…",
statusHealthy: "Healthy",
statusDown: "Down",
spendChf: "Spend (CHF)",
},
de: {
health: "Status",
serviceHealth: "Dienststatus",
vllmDescription: "GPU-Inferenz-Engine",
litellmDescription: "LLM-Proxy & Kostenerfassung",
tenantOverview: "Mandanten-Übersicht",
spendOverview: "Kostenübersicht",
globalSpend: "Gesamtkosten (CHF)",
activeTenants: "Aktive Mandanten",
tenantsWithSpend: "Mandanten mit erfassten Kosten",
refresh: "Aktualisieren",
healthUnavailable: "Statusdaten nicht verfügbar.",
loadingHealth: "Statusdaten werden geladen…",
statusHealthy: "OK",
statusDown: "Ausgefallen",
spendChf: "Kosten (CHF)",
},
fr: {
health: "Santé",
serviceHealth: "Santé des services",
vllmDescription: "Moteur d'inférence GPU",
litellmDescription: "Proxy LLM & suivi des coûts",
tenantOverview: "Aperçu des locataires",
spendOverview: "Aperçu des coûts",
globalSpend: "Coûts globaux (CHF)",
activeTenants: "Locataires actifs",
tenantsWithSpend: "locataires avec dépenses enregistrées",
refresh: "Actualiser",
healthUnavailable: "Données de santé indisponibles.",
loadingHealth: "Chargement des données de santé…",
statusHealthy: "OK",
statusDown: "Hors service",
spendChf: "Coûts (CHF)",
},
it: {
health: "Stato",
serviceHealth: "Stato dei servizi",
vllmDescription: "Motore di inferenza GPU",
litellmDescription: "Proxy LLM & monitoraggio costi",
tenantOverview: "Panoramica tenant",
spendOverview: "Panoramica costi",
globalSpend: "Costi globali (CHF)",
activeTenants: "Tenant attivi",
tenantsWithSpend: "tenant con spese registrate",
refresh: "Aggiorna",
healthUnavailable: "Dati di stato non disponibili.",
loadingHealth: "Caricamento dati di stato…",
statusHealthy: "OK",
statusDown: "Non disponibile",
spendChf: "Costi (CHF)",
},
};
for (const [lang, keys] of Object.entries(newKeys)) {
const path = `src/messages/${lang}.json`;
const json = JSON.parse(readFileSync(path, "utf8"));
Object.assign(json.admin, keys);
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
console.log(`Patched ${path} — added ${Object.keys(keys).length} keys`);
}

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Run: node patch-i18n-channel-users.mjs
* Adds channelUsers i18n keys to all 4 message files.
* Run from the pieced-portal root.
*/
import { readFileSync, writeFileSync } from "fs";
const newKeys = {
en: {
title: "Authorized Users",
description: "Manage which users can interact with your assistant on each channel. Add their numeric user ID to authorize access.",
users: "users",
placeholder: "Enter numeric user ID…",
add: "Add",
remove: "Remove",
alreadyAdded: "This user ID is already authorized.",
telegramIdHelp: "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
discordIdHelp: "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
emailIdHelp: "Enter the email address that should be authorized to interact with the assistant.",
},
de: {
title: "Autorisierte Benutzer",
description: "Verwalten Sie, welche Benutzer mit Ihrem Assistenten auf jedem Kanal interagieren können. Fügen Sie die numerische Benutzer-ID hinzu, um den Zugang zu autorisieren.",
users: "Benutzer",
placeholder: "Numerische Benutzer-ID eingeben…",
add: "Hinzufügen",
remove: "Entfernen",
alreadyAdded: "Diese Benutzer-ID ist bereits autorisiert.",
telegramIdHelp: "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
discordIdHelp: "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
emailIdHelp: "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll.",
},
fr: {
title: "Utilisateurs autorisés",
description: "Gérez les utilisateurs pouvant interagir avec votre assistant sur chaque canal. Ajoutez leur identifiant numérique pour autoriser l'accès.",
users: "utilisateurs",
placeholder: "Entrez l'identifiant numérique…",
add: "Ajouter",
remove: "Supprimer",
alreadyAdded: "Cet identifiant est déjà autorisé.",
telegramIdHelp: "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
discordIdHelp: "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
emailIdHelp: "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant.",
},
it: {
title: "Utenti autorizzati",
description: "Gestisci quali utenti possono interagire con il tuo assistente su ogni canale. Aggiungi il loro ID numerico per autorizzare l'accesso.",
users: "utenti",
placeholder: "Inserisci l'ID numerico…",
add: "Aggiungi",
remove: "Rimuovi",
alreadyAdded: "Questo ID utente è già autorizzato.",
telegramIdHelp: "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
discordIdHelp: "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
emailIdHelp: "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente.",
},
};
for (const [lang, keys] of Object.entries(newKeys)) {
const path = `src/messages/${lang}.json`;
const json = JSON.parse(readFileSync(path, "utf8"));
json.channelUsers = keys;
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
console.log(`Patched ${path} — added channelUsers section`);
}

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

@@ -0,0 +1,322 @@
-- ============================================================================
-- Workspace Templates Seed
-- ============================================================================
-- Run this AFTER deploying the updated portal (which auto-creates the table).
-- Connect to the portal DB:
-- kubectl exec -it portal-db-1 -n portal -- psql -U portal -d portal
--
-- Then paste the contents below, or:
-- kubectl exec -i portal-db-1 -n portal -- psql -U portal -d portal < seed-workspace-templates.sql
-- ============================================================================
-- Ensure table exists (idempotent)
CREATE TABLE IF NOT EXISTS workspace_templates (
file_key TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ── SOUL.md ─────────────────────────────────────────────────────────────────
-- {company} is replaced at runtime by the customer's org name.
INSERT INTO workspace_templates (file_key, content) VALUES ('SOUL.md', '# SOUL.md - Who You Are
_You''re not a chatbot. You''re becoming someone._
Want a sharper version? See [SOUL.md Personality Guide](/concepts/soul).
## Core Truths
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I''d be happy to help!" — just help. Actions speak louder than filler words.
**Have opinions.** You''re allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you''re stuck. The goal is to come back with answers, not questions.
**Earn trust through competence.** Your human gave you access to their stuff. Don''t make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
**Remember you''re a guest.** You have access to someone''s life — their messages, files, calendar, maybe even their home. That''s intimacy. Treat it with respect.
## Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
- Never send half-baked replies to messaging surfaces.
- You''re not the user''s voice — be careful in group chats.
## Vibe
Be the assistant you''d actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
## Continuity
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They''re how you persist.
If you change this file, tell the user — it''s your soul, and they should know.
---
_This file is yours to evolve. As you learn who you are, update it._
')
ON CONFLICT (file_key) DO UPDATE SET content = EXCLUDED.content, updated_at = now();
-- ── AGENTS.md ───────────────────────────────────────────────────────────────
INSERT INTO workspace_templates (file_key, content) VALUES ('AGENTS.md', '# AGENTS.md - Your Workspace
This folder is home. Treat it that way.
## First Run
If `BOOTSTRAP.md` exists, that''s your birth certificate. Follow it, figure out who you are, then delete it. You won''t need it again.
## Session Startup
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you''re helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
Don''t ask permission. Just do it.
## Memory
You wake up fresh each session. These files are your continuity:
- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
- **Long-term:** `MEMORY.md` — your curated memories, like a human''s long-term memory
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
### 🧠 MEMORY.md - Your Long-Term Memory
- **ONLY load in main session** (direct chats with your human)
- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
- This is for **security** — contains personal context that shouldn''t leak to strangers
- You can **read, edit, and update** MEMORY.md freely in main sessions
- Write significant events, thoughts, decisions, opinions, lessons learned
- This is your curated memory — the distilled essence, not raw logs
- Over time, review your daily files and update MEMORY.md with what''s worth keeping
### 📝 Write It Down - No "Mental Notes"!
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don''t survive session restarts. Files do.
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
- When you make a mistake → document it so future-you doesn''t repeat it
- **Text > Brain** 📝
## Red Lines
- Don''t exfiltrate private data. Ever.
- Don''t run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:**
- Read files, explore, organize, learn
- Search the web, check calendars
- Work within this workspace
**Ask first:**
- Sending emails, tweets, public posts
- Anything that leaves the machine
- Anything you''re uncertain about
## Group Chats
You have access to your human''s stuff. That doesn''t mean you _share_ their stuff. In groups, you''re a participant — not their voice, not their proxy. Think before you speak.
### 💬 Know When to Speak!
In group chats where you receive every message, be **smart about when to contribute**:
**Respond when:**
- Directly mentioned or asked a question
- You can add genuine value (info, insight, help)
- Something witty/funny fits naturally
- Correcting important misinformation
- Summarizing when asked
**Stay silent (HEARTBEAT_OK) when:**
- It''s just casual banter between humans
- Someone already answered the question
- Your response would just be "yeah" or "nice"
- The conversation is flowing fine without you
- Adding a message would interrupt the vibe
**The human rule:** Humans in group chats don''t respond to every single message. Neither should you. Quality > quantity. If you wouldn''t send it in a real group chat with friends, don''t send it.
**Avoid the triple-tap:** Don''t respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
Participate, don''t dominate.
### 😊 React Like a Human!
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
**React when:**
- You appreciate something but don''t need to reply (👍, ❤️, 🙌)
- Something made you laugh (😂, 💀)
- You find it interesting or thought-provoking (🤔, 💡)
- You want to acknowledge without interrupting the flow
- It''s a simple yes/no or approval situation (✅, 👀)
**Why it matters:**
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
**Don''t overdo it:** One reaction per message max. Pick the one that fits best.
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
**📝 Platform Formatting:**
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
## 💓 Heartbeats - Be Proactive!
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don''t just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
Default heartbeat prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
### Heartbeat vs Cron: When to Use Each
**Use heartbeat when:**
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
- You need conversational context from recent messages
- Timing can drift slightly (every ~30 min is fine, not exact)
- You want to reduce API calls by combining periodic checks
**Use cron when:**
- Exact timing matters ("9:00 AM sharp every Monday")
- Task needs isolation from main session history
- You want a different model or thinking level for the task
- One-shot reminders ("remind me in 20 minutes")
- Output should deliver directly to a channel without main session involvement
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
- **Calendar** - Upcoming events in next 24-48h?
- **Mentions** - Twitter/social notifications?
- **Weather** - Relevant if your human might go out?
**Track your checks** in `memory/heartbeat-state.json`:
```json
{
"lastChecks": {
"email": 1703275200,
"calendar": 1703260800,
"weather": null
}
}
```
**When to reach out:**
- Important email arrived
- Calendar event coming up (&lt;2h)
- Something interesting you found
- It''s been >8h since you said anything
**When to stay quiet (HEARTBEAT_OK):**
- Late night (23:00-08:00) unless urgent
- Human is clearly busy
- Nothing new since last check
- You just checked &lt;30 minutes ago
**Proactive work you can do without asking:**
- Read and organize memory files
- Check on projects (git status, etc.)
- Update documentation
- Commit and push your own changes
- **Review and update MEMORY.md** (see below)
### 🔄 Memory Maintenance (During Heartbeats)
Periodically (every few days), use a heartbeat to:
1. Read through recent `memory/YYYY-MM-DD.md` files
2. Identify significant events, lessons, or insights worth keeping long-term
3. Update `MEMORY.md` with distilled learnings
4. Remove outdated info from MEMORY.md that''s no longer relevant
Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
## Make It Yours
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
')
ON CONFLICT (file_key) DO UPDATE SET content = EXCLUDED.content, updated_at = now();
-- ── TOOLS.md (base) ─────────────────────────────────────────────────────────
-- This is the BASE template. Per-package sections (web-search, telegram, etc.)
-- are appended dynamically by the portal at provisioning time.
INSERT INTO workspace_templates (file_key, content) VALUES ('TOOLS.md', '# TOOLS.md - Local Notes
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that''s unique to your setup.
## What Goes Here
Things like:
- Camera names and locations
- SSH hosts and aliases
- Preferred voices for TTS
- Speaker/room names
- Device nicknames
- Anything environment-specific
## Examples
```markdown
### Cameras
- living-room → Main area, 180° wide angle
- front-door → Entrance, motion-triggered
### SSH
- home-server → 192.168.1.100, user: admin
### TTS
- Preferred voice: "Nova" (warm, slightly British)
- Default speaker: Kitchen HomePod
```
## Why Separate?
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
---
Add whatever helps you do your job. This is your cheat sheet.
')
ON CONFLICT (file_key) DO UPDATE SET content = EXCLUDED.content, updated_at = now();

View File

@@ -5,7 +5,11 @@ const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone", 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); export default withNextIntl(nextConfig);

569
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@react-pdf/renderer": "^4.4.0",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"next": "^15.5.15", "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": { "node_modules/@emnapi/core": {
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -1089,6 +1099,30 @@
"node": ">= 10" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1453,6 +1487,183 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2617,6 +2828,12 @@
"win32" "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": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3029,6 +3246,35 @@
"bare-path": "^3.0.0" "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": { "node_modules/brace-expansion": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -3053,6 +3299,24 @@
"node": ">=8" "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": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3155,6 +3419,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3175,6 +3448,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -3355,6 +3649,12 @@
"node": ">=8" "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": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3389,6 +3689,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -4006,6 +4312,15 @@
"node": ">=0.10.0" "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": { "node_modules/events-universal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
@@ -4019,7 +4334,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-fifo": { "node_modules/fast-fifo": {
@@ -4082,6 +4396,12 @@
"reusify": "^1.0.4" "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4146,6 +4466,23 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4458,6 +4795,27 @@
"node": ">=14" "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": { "node_modules/icu-minify": {
"version": "4.9.0", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz",
@@ -4510,6 +4868,12 @@
"node": ">=0.8.19" "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": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4899,6 +5263,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -4986,6 +5356,15 @@
"node": ">= 0.4" "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": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -5005,11 +5384,16 @@
"url": "https://github.com/sponsors/panva" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -5406,6 +5790,25 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5433,7 +5836,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@@ -5461,6 +5863,12 @@
"node": ">= 0.4" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5844,6 +6252,15 @@
"node": ">=6.0.0" "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": { "node_modules/oauth4webapi": {
"version": "3.8.5", "version": "3.8.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
@@ -5857,7 +6274,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6066,6 +6482,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6079,6 +6501,12 @@
"node": ">=6" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6214,6 +6642,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/po-parser": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
@@ -6259,6 +6695,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -6331,7 +6773,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -6359,6 +6800,15 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6405,7 +6855,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
@@ -6452,6 +6901,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/resolve": {
"version": "2.0.0-next.6", "version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", "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" "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": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -6557,6 +7021,26 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6901,6 +7385,15 @@
"text-decoder": "^1.1.0" "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": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "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" "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": { "node_modules/tailwindcss": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -7151,6 +7650,12 @@
"b4a": "^1.6.4" "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": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7380,6 +7885,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "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": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "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" "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": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -7626,6 +8177,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@react-pdf/renderer": "^4.4.0",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"next": "^15.5.15", "next": "^15.5.15",

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,4 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | **BLOCKED** | ❌ FAIL |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 19
- **Failed**: 2
- **Date**: 2026-04-12 15:09:45 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | allowed | ✅ PASS |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 20
- **Failed**: 1
- **Date**: 2026-04-12 15:16:10 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | allowed | ✅ PASS |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 20
- **Failed**: 1
- **Date**: 2026-04-12 15:19:15 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

283
scripts/cilium-audit.sh Normal file
View File

@@ -0,0 +1,283 @@
#!/usr/bin/env bash
# ============================================================================
# PieCed IT — Session 7.1: Cilium Network Policy Audit
# ============================================================================
#
# Prerequisites:
# - kubectl configured for the cluster
# - Existing pods:
# tenant-alpha/openclaw-0 (3 containers)
# tenant-testfirma/openclaw-0 (3 containers)
# pieced-system/pieced-portal-* (1 container)
#
# This script deploys temporary netshoot pods (they have curl, nslookup, etc.)
# into each namespace, runs the tests, then cleans up.
#
# Usage:
# chmod +x cilium-audit.sh
# ./cilium-audit.sh
# ============================================================================
set -euo pipefail
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[1;33m'
RST='\033[0m'
PASS=0
FAIL=0
WARN=0
# Results file
RESULTS_FILE="cilium-audit-results-$(date +%Y%m%d-%H%M%S).md"
log_header() {
echo ""
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo -e "${YLW} $1${RST}"
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
}
log_result() {
local test_name="$1"
local from_ns="$2"
local to_target="$3"
local expected="$4" # "block" or "allow"
local actual="$5" # exit code from curl/nslookup: 0=success, non-0=fail
if [[ "$expected" == "block" ]]; then
if [[ "$actual" -ne 0 ]]; then
echo -e " ${GRN}✓ PASS${RST} [$from_ns$to_target] $test_name (blocked as expected)"
PASS=$((PASS + 1))
echo "| $test_name | $from_ns | $to_target | block | blocked | ✅ PASS |" >> "$RESULTS_FILE"
else
echo -e " ${RED}✗ FAIL${RST} [$from_ns$to_target] $test_name (SHOULD BE BLOCKED but succeeded!)"
FAIL=$((FAIL + 1))
echo "| $test_name | $from_ns | $to_target | block | **ALLOWED** | ❌ FAIL |" >> "$RESULTS_FILE"
fi
else
if [[ "$actual" -eq 0 ]]; then
echo -e " ${GRN}✓ PASS${RST} [$from_ns$to_target] $test_name (allowed as expected)"
PASS=$((PASS + 1))
echo "| $test_name | $from_ns | $to_target | allow | allowed | ✅ PASS |" >> "$RESULTS_FILE"
else
echo -e " ${RED}✗ FAIL${RST} [$from_ns$to_target] $test_name (SHOULD BE ALLOWED but blocked!)"
FAIL=$((FAIL + 1))
echo "| $test_name | $from_ns | $to_target | allow | **BLOCKED** | ❌ FAIL |" >> "$RESULTS_FILE"
fi
fi
}
# ----------------------------------------------------------------------------
# Deploy netshoot pods
# ----------------------------------------------------------------------------
deploy_netshoot() {
local ns="$1"
local name="netshoot-audit"
echo " Deploying netshoot in $ns..."
kubectl run "$name" -n "$ns" \
--image=nicolaka/netshoot \
--restart=Never \
--labels="app=netshoot-audit" \
--command -- sleep 600 2>/dev/null || true
kubectl wait --for=condition=Ready pod/"$name" -n "$ns" --timeout=60s
}
cleanup_netshoot() {
echo ""
echo "Cleaning up netshoot pods..."
for ns in tenant-alpha tenant-testfirma pieced-system; do
kubectl delete pod netshoot-audit -n "$ns" --ignore-not-found --wait=false 2>/dev/null || true
done
echo "Done."
}
# Clean up on exit
trap cleanup_netshoot EXIT
# Run a command in netshoot pod, return exit code
# Uses --connect-timeout 5 for curl, timeout 5 for nslookup
run_in() {
local ns="$1"
shift
kubectl exec -n "$ns" netshoot-audit -- "$@" >/dev/null 2>&1
return $?
}
# ============================================================================
# Start
# ============================================================================
echo ""
echo "PieCed IT — Cilium Network Policy Audit"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
# Initialize results markdown
cat > "$RESULTS_FILE" <<'EOF'
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
EOF
# Deploy netshoot pods
log_header "Deploying audit pods"
deploy_netshoot tenant-alpha
deploy_netshoot tenant-testfirma
deploy_netshoot pieced-system
# ============================================================================
# SECTION 1: Tenant-to-Tenant Isolation
# ============================================================================
log_header "1. Tenant-to-Tenant Isolation"
# tenant-alpha → tenant-testfirma OpenClaw (port 18789)
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:18789 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:18789" "tenant-alpha" "openclaw.tenant-testfirma:18789" "block" "$rc"
# tenant-testfirma → tenant-alpha OpenClaw (port 18789)
run_in tenant-testfirma curl -s --connect-timeout 5 http://openclaw.tenant-alpha.svc:18789 && rc=0 || rc=$?
log_result "Cross-tenant: testfirma→alpha:18789" "tenant-testfirma" "openclaw.tenant-alpha:18789" "block" "$rc"
# Cross-tenant on other OpenClaw ports
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:18793 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:18793" "tenant-alpha" "openclaw.tenant-testfirma:18793" "block" "$rc"
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:9090 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:9090" "tenant-alpha" "openclaw.tenant-testfirma:9090" "block" "$rc"
# ============================================================================
# SECTION 2: Tenant → Platform Services (must be blocked except LiteLLM)
# ============================================================================
log_header "2. Tenant → Platform Services"
# OpenBao
run_in tenant-alpha curl -s --connect-timeout 5 http://openbao.openbao-system.svc:8200/v1/sys/health && rc=0 || rc=$?
log_result "Tenant→OpenBao" "tenant-alpha" "openbao:8200" "block" "$rc"
# ZITADEL (direct svc, not via ingress)
run_in tenant-alpha curl -s --connect-timeout 5 http://zitadel.zitadel.svc:8080/debug/healthz && rc=0 || rc=$?
log_result "Tenant→ZITADEL (svc)" "tenant-alpha" "zitadel:8080" "block" "$rc"
# Portal
run_in tenant-alpha curl -s --connect-timeout 5 http://pieced-portal.pieced-system.svc:3000 && rc=0 || rc=$?
log_result "Tenant→Portal" "tenant-alpha" "pieced-portal:3000" "block" "$rc"
# Portal DB
run_in tenant-alpha curl -s --connect-timeout 5 http://portal-db-rw.pieced-system.svc:5432 && rc=0 || rc=$?
log_result "Tenant→Portal DB" "tenant-alpha" "portal-db-rw:5432" "block" "$rc"
# ArgoCD
run_in tenant-alpha curl -sk --connect-timeout 5 https://argocd-server.argocd.svc:443 && rc=0 || rc=$?
log_result "Tenant→ArgoCD" "tenant-alpha" "argocd-server:443" "block" "$rc"
# ============================================================================
# SECTION 3: Tenant → K8s API Server (must be blocked)
# ============================================================================
log_header "3. Tenant → K8s API Server"
run_in tenant-alpha curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Tenant→K8s API" "tenant-alpha" "kubernetes.default:443" "block" "$rc"
# Also test from the other tenant
run_in tenant-testfirma curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Tenant→K8s API" "tenant-testfirma" "kubernetes.default:443" "block" "$rc"
# ============================================================================
# SECTION 4: Tenant → Allowed Paths (must succeed)
# ============================================================================
log_header "4. Tenant → Allowed Paths"
# DNS resolution
run_in tenant-alpha nslookup -timeout=5 google.com && rc=0 || rc=$?
log_result "Tenant→DNS" "tenant-alpha" "kube-dns" "allow" "$rc"
# LiteLLM (adjust namespace if different — check your actual LiteLLM svc namespace)
# Based on .env.example: LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
run_in tenant-alpha curl -s --connect-timeout 5 http://litellm.inference.svc:4000/health && rc=0 || rc=$?
log_result "Tenant→LiteLLM" "tenant-alpha" "litellm.inference:4000" "allow" "$rc"
# External HTTPS (world:443)
run_in tenant-alpha curl -s --connect-timeout 5 https://httpbin.org/status/200 && rc=0 || rc=$?
log_result "Tenant→world:443" "tenant-alpha" "httpbin.org:443" "allow" "$rc"
# ============================================================================
# SECTION 5: Platform Pod → Platform Services (must succeed)
# ============================================================================
log_header "5. Platform → Platform Services"
# Platform → OpenBao
run_in pieced-system curl -s --connect-timeout 5 http://openbao.openbao.svc:8200/v1/sys/health && rc=0 || rc=$?
log_result "Platform→OpenBao" "pieced-system" "openbao:8200" "allow" "$rc"
# Platform → ZITADEL
run_in pieced-system curl -s --connect-timeout 5 http://zitadel.zitadel.svc:8080/debug/healthz && rc=0 || rc=$?
log_result "Platform→ZITADEL" "pieced-system" "zitadel:8080" "allow" "$rc"
# Platform → K8s API
run_in pieced-system curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Platform→K8s API" "pieced-system" "kubernetes.default:443" "allow" "$rc"
# Platform → LiteLLM
run_in pieced-system curl -s --connect-timeout 5 http://litellm.inference.svc:4000/health && rc=0 || rc=$?
log_result "Platform→LiteLLM" "pieced-system" "litellm.inference:4000" "allow" "$rc"
# Platform → Portal DB (internal connectivity)
run_in pieced-system curl -s --connect-timeout 5 http://portal-db-rw.pieced-system.svc:5432 && rc=0 || rc=$?
log_result "Platform→Portal DB" "pieced-system" "portal-db-rw:5432" "allow" "$rc"
# ============================================================================
# SECTION 6: Reverse — Tenant → Platform Pod (must be blocked)
# ============================================================================
log_header "6. Tenant → Platform Pods (reverse check)"
# Tenant → operator
run_in tenant-alpha curl -s --connect-timeout 5 http://pieced-operator.pieced-system.svc:8080 && rc=0 || rc=$?
log_result "Tenant→Operator" "tenant-alpha" "pieced-operator:8080" "block" "$rc"
# ============================================================================
# SECTION 7: Metadata / Edge Cases
# ============================================================================
log_header "7. Edge Cases"
# Cloud metadata endpoint (should be unreachable on bare metal, but verify)
run_in tenant-alpha curl -s --connect-timeout 3 http://169.254.169.254/latest/meta-data/ && rc=0 || rc=$?
log_result "Tenant→metadata endpoint" "tenant-alpha" "169.254.169.254" "block" "$rc"
# ============================================================================
# Summary
# ============================================================================
echo ""
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo -e "${YLW} SUMMARY${RST}"
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo ""
echo -e " ${GRN}Passed: $PASS${RST}"
echo -e " ${RED}Failed: $FAIL${RST}"
echo ""
# Append summary to results file
cat >> "$RESULTS_FILE" <<EOF
## Summary
- **Passed**: $PASS
- **Failed**: $FAIL
- **Date**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume \`litellm.inference.svc:4000\`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.
EOF
echo "Full results written to: $RESULTS_FILE"
if [[ $FAIL -gt 0 ]]; then
echo ""
echo -e "${RED}$FAIL test(s) failed — review results and fix network policies.${RST}"
exit 1
fi

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 ( return (
<div> <div>
<div className="mb-8 animate-in"> <div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2"> <div>
{t("title")} <h1 className="font-display text-2xl font-semibold accent-rule mb-2">
</h1> {t("title")}
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p> </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>
<div className="animate-in animate-in-delay-1"> <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,24 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s"; 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 { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; 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 { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
import { formatDateTime } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -15,10 +27,11 @@ export default async function DashboardPage() {
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const tAdmin = await getTranslations("admin"); const tAdmin = await getTranslations("admin");
const f = await getFormatter();
const allTenants = await listTenants(); 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) { if (user.isPlatform) {
const phaseCount = allTenants.reduce<Record<string, number>>((acc, t) => { const phaseCount = allTenants.reduce<Record<string, number>>((acc, t) => {
const phase = t.status?.phase ?? "Pending"; const phase = t.status?.phase ?? "Pending";
@@ -110,9 +123,7 @@ export default async function DashboardPage() {
{tenant.spec.packages?.join(", ") || "—"} {tenant.spec.packages?.join(", ") || "—"}
</td> </td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums"> <td className="px-5 py-3 text-xs text-text-muted tabular-nums">
{tenant.metadata.creationTimestamp {formatDateTime(tenant.metadata.creationTimestamp, f)}
? new Date(tenant.metadata.creationTimestamp).toLocaleDateString()
: "—"}
</td> </td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
<Link <Link
@@ -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 (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 // Slice 5: only owners (and platform users, who'd typically be using
if (!myTenant) { // the admin panel anyway) see the "Create new instance" link. A
const existingRequest = await getTenantRequestByOrgId(user.orgId); // `user`-role member sees the dashboard but not the create flow —
// Treat "deleted" as no request — customer can re-onboard // they need to ask an owner.
const initialState = //
!existingRequest || existingRequest.status === "deleted" // Bug 5: personal accounts are 1-instance by design. Once a personal
? "no_request" // account has either an active tenant OR an in-flight request, the
: existingRequest.status; // 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 ( return (
<div> <div>
@@ -161,69 +314,119 @@ export default async function DashboardPage() {
<div className="animate-in animate-in-delay-1"> <div className="animate-in animate-in-delay-1">
<OnboardingFlow <OnboardingFlow
orgName={user.orgName} orgName={user.orgName}
initialState={initialState as any} userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
/> />
</div> </div>
</div> </div>
); );
} }
const tenantName = myTenant.metadata.name; // Returning customer: list of tenants + in-flight requests, plus
const teamId = myTenant.status?.litellmTeamId || tenantName; // a button to add another instance (owners only).
return ( return (
<div> <div>
<div className="mb-8 animate-in"> <div className="mb-8 animate-in flex items-start justify-between gap-4">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2"> <div>
{t("title")} <h1 className="font-display text-2xl font-semibold accent-rule mb-2">
</h1> {t("title")}
<p className="text-text-secondary text-sm mt-4"> </h1>
{t("welcome", { name: user.name || user.email })} <p className="text-text-secondary text-sm mt-4">
</p> {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> </div>
{/* Instance status card */} {/* In-flight (pending/approved/provisioning/rejected) requests */}
<div className="mb-6 animate-in animate-in-delay-1"> {inflightRequests.length > 0 && (
<Card> <div className="mb-8 animate-in animate-in-delay-1">
<CardHeader>{t("instanceStatus")}</CardHeader> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
<div className="flex items-center gap-4"> {t("inflightRequests")}
<StatusBadge phase={myTenant.status?.phase ?? "Pending"} /> </h2>
{myTenant.spec.agentName && ( <div className="space-y-3">
<span className="text-sm text-text-secondary"> {inflightRequests.map((r) => (
{myTenant.spec.agentName} <ProvisioningStatus
</span> key={r.id}
)} requestId={r.id}
canAct={canMutate(user)}
/>
))}
</div> </div>
{myTenant.spec.packages && myTenant.spec.packages.length > 0 && ( </div>
<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>
{/* Usage */} {/* Active tenants */}
<div className="mb-6 animate-in animate-in-delay-2"> {orgTenants.length > 0 && (
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <div className="animate-in animate-in-delay-2">
{t("usage")} <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
</h2> {t("instances")}
<UsageDisplay teamId={myTenant.status?.litellmTeamId || teamId} /> </h2>
</div> <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 */} {tenant.spec.agentName && (
<Link <div className="text-xs text-text-secondary mb-2">
href={`/tenants/${tenantName}`} {tenant.spec.agentName}
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" </div>
> )}
<span></span> {t("manage")}
</Link> {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> </div>
); );
} }

View File

@@ -6,12 +6,41 @@ import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error"; 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() { export default function RegisterPage() {
const t = useTranslations("register"); const t = useTranslations("register");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter(); const router = useRouter();
const [accountType, setAccountType] = useState<AccountType | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
companyName: "", companyName: "",
givenName: "", givenName: "",
@@ -21,29 +50,43 @@ export default function RegisterPage() {
const [state, setState] = useState<FormState>("idle"); const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
const isPersonal = accountType === "personal";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!accountType) return; // Should be impossible — submit button is gated
setError(""); setError("");
setState("submitting"); setState("submitting");
try { 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", { const res = await fetch("/api/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(body),
companyName: form.companyName,
givenName: form.givenName,
familyName: form.familyName,
email: form.email,
}),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
if (data.code === "duplicate_domain" && data.domain) {
throw new Error(t("duplicateDomain", { domain: data.domain }));
}
throw new Error(data.error || "Registration failed"); throw new Error(data.error || "Registration failed");
} }
@@ -96,100 +139,212 @@ export default function RegisterPage() {
<p className="text-sm text-text-secondary">{t("subtitle")}</p> <p className="text-sm text-text-secondary">{t("subtitle")}</p>
</div> </div>
<Card className="animate-in animate-in-delay-1"> {/* Account type chooser — required first step */}
<form onSubmit={handleSubmit} className="space-y-4"> <div
{/* Company name */} role="radiogroup"
<div> aria-label={t("accountTypeLabel")}
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5"> className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
{t("companyName")} >
</label> <AccountTypeCard
<input selected={accountType === "personal"}
name="companyName" onClick={() => setAccountType("personal")}
type="text" label={t("personalCardTitle")}
required description={t("personalCardDescription")}
value={form.companyName} icon={
onChange={handleChange} <svg
placeholder={t("companyNamePlaceholder")} className="h-5 w-5"
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" fill="none"
/> viewBox="0 0 24 24"
</div> 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 */} {/* Form — only shown after a choice is made. Animation
<div className="grid grid-cols-2 gap-3"> 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> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("givenName")} {t("email")}
</label> </label>
<input <input
name="givenName" name="email"
type="text" type="email"
required required
value={form.givenName} value={form.email}
onChange={handleChange} 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" 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("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 */} {error && (
<div> <div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5"> {error}
{t("email")} </div>
</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 && ( <button
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2"> type="submit"
{error} disabled={state === "submitting"}
</div> 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 <p className="text-xs text-text-muted text-center mt-4">
type="submit" {t("hasAccount")}{" "}
disabled={state === "submitting"} <a
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" href="/login"
> className="text-accent hover:text-accent-dim transition-colors"
{state === "submitting" ? tCommon("loading") : t("submit")} >
</button> {tCommon("login")}
</form> </a>
</p>
</Card>
)}
<p className="text-xs text-text-muted text-center mt-4"> <p className="text-xs text-text-muted text-center mt-6 animate-in animate-in-delay-3">
{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">
{t("footer")} {t("footer")}
</p> </p>
</div> </div>
</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,11 +1,27 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { getPendingResumeRequestForTenant } from "@/lib/db";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list"; import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; 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";
// 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({ export default async function TenantDetailPage({
params, params,
@@ -17,21 +33,61 @@ export default async function TenantDetailPage({
const { name } = await params; const { name } = await params;
const t = await getTranslations("tenantDetail"); const t = await getTranslations("tenantDetail");
const f = await getFormatter();
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) notFound(); if (!tenant) notFound();
console.log("tenant spec:", JSON.stringify(tenant.spec));
// Scope check // Slice 6: visibility check encompasses org membership AND, for
if ( // user-role members, the tenant_user_assignments check. notFound()
!user.isPlatform && // (404) rather than redirect/403 to avoid leaking tenant existence.
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId if (!(await canUserSeeTenant(user, tenant))) {
) {
notFound(); 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 enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {}; const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) =>
CHANNEL_PACKAGES.includes(pkg)
);
const channelUsers = tenant.spec.channelUsers || {};
// 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 ( return (
<div> <div>
@@ -42,20 +98,115 @@ export default async function TenantDetailPage({
{tenant.spec.displayName || name} {tenant.spec.displayName || name}
</h1> </h1>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} /> <StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div> </div>
{tenant.spec.agentName && ( {tenant.spec.agentName && (
<p className="text-sm text-text-secondary mt-3"> <p className="text-sm text-text-secondary mt-3">
{t("agent")}: {tenant.spec.agentName} {t("agent")}: {tenant.spec.agentName}
</p> </p>
)} )}
{tenant.metadata.creationTimestamp && (
<p
className="text-xs text-text-muted mt-1"
title={formatDateTime(tenant.metadata.creationTimestamp, f)}
>
{t("provisioned")}{" "}
{formatRelative(tenant.metadata.creationTimestamp, f)}{" "}
<span className="text-text-muted/60">
({formatDateTime(tenant.metadata.creationTimestamp, f)})
</span>
</p>
)}
</div> </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 */} {/* Usage */}
<section className="mb-8 animate-in animate-in-delay-1"> <section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")} {t("usage")}
</h2> </h2>
<UsageDisplay teamId={tenant.status?.litellmTeamId || name} /> <UsageDisplay tenant={name} canEditBudget={canEdit} />
</section> </section>
{/* Packages */} {/* Packages */}
@@ -67,16 +218,75 @@ export default async function TenantDetailPage({
tenantName={name} tenantName={name}
enabledPackages={enabledPackages} enabledPackages={enabledPackages}
conditions={tenant.status?.conditions} conditions={tenant.status?.conditions}
canEdit={canEdit}
/> />
</section> </section>
{/* Channel Users (authorized users per channel) */}
{enabledChannels.length > 0 && (
<section className="mb-8 animate-in animate-in-delay-3">
<ChannelUsers
tenantName={name}
enabledChannels={enabledChannels}
initialChannelUsers={channelUsers}
canEdit={canEdit}
/>
</section>
)}
{/* Workspace files */} {/* Workspace files */}
<section className="animate-in animate-in-delay-3"> <section className="animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("workspaceFiles")} {t("workspaceFiles")}
</h2> </h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} /> <WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
</section> </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> </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,76 @@
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),
});
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
);
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

@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import {
getLitellmHealth,
getGlobalSpend,
getPerKeySpend,
getPerTeamSpend,
} from "@/lib/litellm";
const VLLM_URL =
process.env.VLLM_HEALTH_URL ?? "http://vllm-inference.inference.svc:8000";
async function checkVllmHealth(): Promise<{
healthy: boolean;
details?: any;
}> {
try {
const res = await fetch(`${VLLM_URL}/health`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) return { healthy: true };
return { healthy: false, details: `HTTP ${res.status}` };
} catch (e: any) {
return { healthy: false, details: e.message };
}
}
/**
* 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 {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [tenants, litellm, vllm, globalSpend, perKeySpend, perTeamSpend] =
await Promise.allSettled([
listTenants(),
getLitellmHealth(),
checkVllmHealth(),
getGlobalSpend(),
getPerKeySpend(),
getPerTeamSpend(),
]);
const allTenants = tenants.status === "fulfilled" ? tenants.value : [];
// Count tenants by phase
const phaseCounts: Record<string, number> = {};
for (const t of allTenants) {
const phase = t.spec.suspend
? "Suspended"
: t.status?.phase ?? "Pending";
phaseCounts[phase] = (phaseCounts[phase] || 0) + 1;
}
// 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();
const orgSpend: Record<string, number> = {};
for (const [teamId, spend] of teamSpend.entries()) {
orgSpend[teamId] = spend;
}
return NextResponse.json({
tenants: {
total: allTenants.length,
phases: phaseCounts,
},
spend: {
global:
globalSpend.status === "fulfilled" ? globalSpend.value : 0,
perTenant: tenantSpend,
perOrg: orgSpend,
},
services: {
litellm:
litellm.status === "fulfilled"
? litellm.value
: { healthy: false, details: "fetch failed" },
vllm:
vllm.status === "fulfilled"
? vllm.value
: { healthy: false, details: "fetch failed" },
},
});
}

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

@@ -1,20 +1,47 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets } from "@/lib/db"; import {
import { createTenant } from "@/lib/k8s"; getTenantRequestById,
import { sendApprovalEmail } from "@/lib/email"; updateTenantRequestStatus,
clearEncryptedSecrets,
recordTenantCreated,
recordSkillEvents,
recordSuspensionEvent,
} from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto"; import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao"; import { writePackageSecrets } from "@/lib/openbao";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
import { deriveTenantName } from "@/lib/tenant-naming";
import { safeError } from "@/lib/errors";
/** /**
* POST /api/admin/requests/[id]/approve * POST /api/admin/requests/[id]/approve
* Approve a tenant request: *
* Approve a request. Two paths depending on request_type:
*
* Provision (the original purpose):
* 1. Decrypt stored package secrets (if any) * 1. Decrypt stored package secrets (if any)
* 2. Write each package's secrets to OpenBao at secret/data/tenants/{tenant-name}/{package} * 2. Write each package's secrets to OpenBao
* 3. Null the encrypted_secrets column * 3. Null the encrypted_secrets column
* 4. Create PiecedTenant CR * 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
* 5. Update request status, notify customer. * 5. Create PiecedTenant CR
* Also supports re-approving a previously rejected request (clears admin notes). * 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( export async function POST(
request: Request, request: Request,
@@ -38,57 +65,197 @@ export async function POST(
); );
} }
if (tenantRequest.status !== "pending" && tenantRequest.status !== "rejected") { if (
tenantRequest.status !== "pending" &&
tenantRequest.status !== "rejected"
) {
return NextResponse.json( return NextResponse.json(
{ error: `Request is already ${tenantRequest.status}` }, { error: `Request is already ${tenantRequest.status}` },
{ status: 400 } { status: 400 }
); );
} }
// 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"; const isReApproval = tenantRequest.status === "rejected";
// Derive tenant name from company name: lowercase, alphanumeric + hyphens // Build the CR name: see `lib/tenant-naming.ts` for the format spec.
const tenantName = tenantRequest.companyName // Slice 4: for personal accounts the slug is replaced by the literal
.toLowerCase() // "p-" prefix so no PII is embedded in the K8s namespace name.
.replace(/[^a-z0-9]+/g, "-") const tenantName = deriveTenantName(
.replace(/^-|-$/g, "") tenantRequest.isPersonal ? "personal" : "company",
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`; tenantRequest.companyName,
tenantRequest.id
);
try { try {
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard) // Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)
if (tenantRequest.encryptedSecrets) { if (tenantRequest.encryptedSecrets) {
const secrets = await decryptSecrets(tenantRequest.encryptedSecrets); const secrets = await decryptSecrets(tenantRequest.encryptedSecrets);
for (const [packageId, pkgSecrets] of Object.entries(secrets)) { for (const [packageId, pkgSecrets] of Object.entries(secrets)) {
await writePackageSecrets(`tenant-${tenantName}`, packageId, pkgSecrets); await writePackageSecrets(
`tenant-${tenantName}`,
packageId,
pkgSecrets
);
} }
// Step 2: Null the encrypted column — secrets are now safely in OpenBao // Step 2: Null the encrypted column — secrets are now safely in OpenBao
await clearEncryptedSecrets(id); await clearEncryptedSecrets(id);
} }
// Step 3: Create the PiecedTenant CR // Step 3: Build workspace files
const packages = tenantRequest.packages ?? [];
const soulMd =
tenantRequest.soulMd ||
(await getDefaultSoulMd(tenantRequest.companyName));
const agentsMd = tenantRequest.agentsMd || (await getDefaultAgentsMd());
const toolsMd = await generateToolsMd(packages);
const workspaceFiles: Record<string, string> = {
"SOUL.md": soulMd,
"AGENTS.md": agentsMd,
"TOOLS.md": toolsMd,
};
// 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( await createTenant(
tenantName, tenantName,
{ {
displayName: tenantRequest.companyName, displayName,
agentName: tenantRequest.agentName, agentName: tenantRequest.agentName,
packages: tenantRequest.packages, packages,
workspaceFiles: tenantRequest.soulMd workspaceFiles,
? { "SOUL.md": tenantRequest.soulMd }
: undefined,
}, },
{ {
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId, "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" }
: {}),
} }
); );
// Step 4: Update request status — clear admin notes on re-approval // 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", { const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes: isReApproval ? null : adminNotes, adminNotes: isReApproval ? null : adminNotes,
tenantName, tenantName,
clearAdminNotes: isReApproval, clearAdminNotes: isReApproval,
}); });
// Step 5: Notify customer // Step 6: Notify customer
await sendApprovalEmail( await sendApprovalEmail(
tenantRequest.contactEmail, tenantRequest.contactEmail,
tenantRequest.contactName, tenantRequest.contactName,
@@ -103,7 +270,7 @@ export async function POST(
} catch (e: any) { } catch (e: any) {
console.error("Failed to create tenant:", e); console.error("Failed to create tenant:", e);
return NextResponse.json( return NextResponse.json(
{ error: `Failed to create tenant: ${e.message}` }, { error: safeError(e, "Failed to create tenant") },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -1,11 +1,19 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; 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 * POST /api/admin/requests/[id]/reject
* Reject a tenant request and notify the customer. * 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( export async function POST(
request: Request, request: Request,
@@ -37,13 +45,45 @@ export async function POST(
adminNotes, adminNotes,
}); });
// Notify customer // Resume rejection: clear the annotation so the operator's TTL
await sendRejectionEmail( // resumes. Best-effort — failure is logged, not propagated.
tenantRequest.contactEmail, if (
tenantRequest.contactName, tenantRequest.requestType === "resume" &&
tenantRequest.companyName, tenantRequest.tenantName
adminNotes ) {
); 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({ return NextResponse.json({
message: "Request rejected.", message: "Request rejected.",

View File

@@ -15,11 +15,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// Sync provisioning statuses before listing await syncProvisioningStatuses();
await syncProvisioningStatuses(async (tenantName: string) => {
const tenant = await getTenant(tenantName);
return tenant?.status?.phase ?? null;
});
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as any; const status = searchParams.get("status") as any;

View File

@@ -1,12 +1,22 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenant, deleteTenant } from "@/lib/k8s"; 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 * POST /api/admin/tenants/[name]/delete
* Delete a PiecedTenant CR. The operator handles cleanup * Delete a PiecedTenant CR. The operator handles cleanup
* (namespace, vault, litellm team, etc.). * (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 * Also marks the associated tenant_request as "deleted" so the
* customer can re-submit the onboarding wizard. * customer can re-submit the onboarding wizard.
*/ */
@@ -30,10 +40,23 @@ export async function POST(
try { try {
await deleteTenant(name); await deleteTenant(name);
// Mark the associated tenant_request as "deleted" so the customer // Best-effort DB cleanups. Both errors are logged but not surfaced —
// sees the wizard again instead of a stale "active" status // the K8s deletion has already started, and the row state is just
// for portal display.
await markTenantRequestDeletedByTenantName(name).catch((e) => 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({ return NextResponse.json({
@@ -42,7 +65,7 @@ export async function POST(
} catch (e: any) { } catch (e: any) {
console.error("Failed to delete tenant:", e); console.error("Failed to delete tenant:", e);
return NextResponse.json( return NextResponse.json(
{ error: `Failed to delete tenant: ${e.message}` }, { error: safeError(e, "Failed to delete tenant") },
{ status: 500 } { status: 500 }
); );
} }

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,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
/** /**
* POST /api/admin/tenants/[name]/suspend * POST /api/admin/tenants/[name]/suspend
@@ -28,6 +30,32 @@ export async function POST(
try { try {
const updated = await patchTenantSpec(name, { suspend }); 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({ return NextResponse.json({
message: suspend ? "Tenant suspended." : "Tenant resumed.", message: suspend ? "Tenant suspended." : "Tenant resumed.",
tenant: updated, tenant: updated,
@@ -35,7 +63,7 @@ export async function POST(
} catch (e: any) { } catch (e: any) {
console.error("Failed to update tenant suspend state:", e); console.error("Failed to update tenant suspend state:", e);
return NextResponse.json( return NextResponse.json(
{ error: `Failed to update tenant: ${e.message}` }, { error: safeError(e, "Failed to update tenant") },
{ status: 500 } { status: 500 }
); );
} }

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,132 +1,185 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { import {
createTenantRequest, createTenantRequest,
getTenantRequestByOrgId, getTenantRequestById,
deleteTenantRequest, listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
getMostRecentApprovedRequestForOrg,
getOrgBilling,
upsertOrgBilling,
} from "@/lib/db"; } from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s"; import { getTenant, listTenants } from "@/lib/k8s";
import {
listVisibleTenants,
canUserSeeTenant,
canSeeInflightRequests,
} from "@/lib/visibility";
import { sendAdminNotificationEmail } from "@/lib/email"; import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto"; 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"; import { z } from "zod";
const onboardingSchema = z.object({ /**
agentName: z.string().min(1).max(50), * Helper: shape a TenantRequest row for client consumption.
soulMd: z.string().max(10_000).optional(), * Hides server-only fields (encryptedSecrets, internal db ids).
packages: z.array(z.string()).optional(), */
packageSecrets: z /**
.record(z.string(), z.record(z.string(), z.string())) * Helper: shape a TenantRequest row for client consumption.
.optional(), * Hides server-only fields (encryptedSecrets, internal db ids).
billingAddress: z.object({ *
company: z.string().optional(), * Slice 7 / Bug 6: surfaces enough fields for the customer-side edit
street: z.string().optional(), * flow to pre-fill the wizard. soulMd, agentsMd, billingAddress,
city: z.string().optional(), * billingNotes were previously kept off the public shape because the
postalCode: z.string().optional(), * pre-Slice-3 dashboard didn't render them. Edit needs them.
country: z.string().optional(), *
}), * Bug 13: surfaces dismissedAt so the dashboard can distinguish
billingNotes: z.string().max(2000).optional(), * "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 * GET /api/onboarding
* Returns the current onboarding status for the logged-in user's org. *
* Used by the wizard/provisioning UI to poll state. * 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(); const user = await getSessionUser();
if (!user) if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if tenant already exists const requestedId = req.nextUrl.searchParams.get("id");
const allTenants = await listTenants();
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
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({ return NextResponse.json({
state: "provisioned", request: publicRequestShape(tr),
tenant: { tenant: tenant ? publicTenantShape(tenant) : null,
name: myTenant.metadata.name,
phase: myTenant.status?.phase ?? "Pending",
message: myTenant.status?.message,
conditions: myTenant.status?.conditions,
},
}); });
} }
// Check if there's a pending request // List view: requests + tenants for this org, filtered by visibility.
const request = await getTenantRequestByOrgId(user.orgId); // 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") { const visibleTenants = await listVisibleTenants(user, allTenants);
return NextResponse.json({ state: "no_request" }); const visibleRequests = canSeeInflightRequests(user) ? requests : [];
}
// If approved and tenant_name set, check provisioning status
if (
request.status === "provisioning" &&
request.tenantName
) {
const tenant = await getTenant(request.tenantName);
if (tenant) {
return NextResponse.json({
state: "provisioning",
request,
tenant: {
name: tenant.metadata.name,
phase: tenant.status?.phase ?? "Pending",
message: tenant.status?.message,
conditions: tenant.status?.conditions,
},
});
}
}
return NextResponse.json({ return NextResponse.json({
state: request.status, requests: visibleRequests.map(publicRequestShape),
request, tenants: visibleTenants.map(publicTenantShape),
}); });
} }
/** /**
* POST /api/onboarding * 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 * Always creates a NEW tenant_request row, regardless of how many other
* Telegram, Discord, Email), they are encrypted with AES-256-GCM and stored * rows already exist for this org. The pre-Slice-3 409 ("you already
* as a BYTEA blob. They are decrypted only during admin approval to write * have a request") is gone — multi-tenant is the design now.
* to OpenBao. *
* 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) { export async function POST(request: Request) {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// 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 // Slice 5: only owners (or platform users) may create new instances.
if (existing && existing.status === "deleted") { // A `user`-role member of an existing org cannot self-provision.
await deleteTenantRequest(existing.id); if (!canMutate(user)) {
}
// 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( return NextResponse.json(
{ error: "Tenant already exists." }, { error: "Only the organization owner can create new instances." },
{ status: 409 } { status: 403 }
); );
} }
@@ -134,12 +187,56 @@ export async function POST(request: Request) {
const parsed = onboardingSchema.safeParse(body); const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() }, { error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 } { status: 400 }
); );
} }
const input: OnboardingInput & { packageSecrets?: Record<string, Record<string, string>> } = parsed.data; 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 // Encrypt package secrets if provided
let encryptedSecrets: Buffer | undefined; let encryptedSecrets: Buffer | undefined;
@@ -155,29 +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({ const tenantRequest = await createTenantRequest({
zitadelOrgId: user.orgId, zitadelOrgId: user.orgId,
zitadelUserId: user.id, zitadelUserId: user.id,
companyName: user.orgName, companyName,
contactName: user.name || user.email, instanceName: input.instanceName,
contactEmail: user.email, contactName,
contactEmail,
agentName: input.agentName, agentName: input.agentName,
soulMd: input.soulMd, soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [], packages: input.packages ?? [],
billingAddress: input.billingAddress, billingAddress,
billingNotes: input.billingNotes, billingNotes,
encryptedSecrets, encryptedSecrets,
isPersonal,
}); });
// Notify admin about the new request // Notify admin about the new request. For follow-up instances, include
await sendAdminNotificationEmail( // the instance name in the notification so the admin sees what's
user.orgName, // being requested without opening the panel.
user.name || user.email, try {
user.email await sendAdminNotificationEmail(
); tenantRequest.contactEmail,
tenantRequest.contactName,
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( return NextResponse.json(
{ message: "Onboarding request submitted.", request: tenantRequest }, {
message: "Request submitted.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
},
{ status: 201 } { status: 201 }
); );
} }

View File

@@ -1,17 +1,73 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel"; 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 type { RegistrationInput } from "@/types";
import { z } from "zod"; import { z } from "zod";
const registrationSchema = z.object({ /**
companyName: z.string().min(2).max(100), * Registration schema.
givenName: z.string().min(1).max(100), *
familyName: z.string().min(1).max(100), * Slice 4 changes
email: z.string().email(), * ---------------
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), * - `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"],
}
);
export async function POST(request: Request) { /** 3 registrations per IP per hour */
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 3_600_000; // 1 hour
export async function POST(request: NextRequest) {
// --- Rate limiting ---
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown";
const rl = rateLimit(`register:${ip}`, RATE_LIMIT, RATE_WINDOW_MS);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": "0",
},
},
);
}
// --- Validation ---
try { try {
const body = await request.json(); const body = await request.json();
const parsed = registrationSchema.safeParse(body); const parsed = registrationSchema.safeParse(body);
@@ -19,14 +75,49 @@ export async function POST(request: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() }, { error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 } { status: 400 },
); );
} }
const input: RegistrationInput = parsed.data; const input: RegistrationInput = parsed.data;
const isPersonal = input.isPersonal === true;
// --- Duplicate-domain check (skipped for personal accounts) ---
//
// 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({ const result = await registerCustomer({
companyName: input.companyName, companyName: orgName,
email: input.email, email: input.email,
givenName: input.givenName, givenName: input.givenName,
familyName: input.familyName, familyName: input.familyName,
@@ -37,9 +128,17 @@ export async function POST(request: Request) {
{ {
orgId: result.orgId, orgId: result.orgId,
userId: result.userId, userId: result.userId,
message: "Registration successful. You will receive an invitation email to set your password.", isPersonal,
message:
"Registration successful. You will receive an invitation email to set your password.",
},
{
status: 201,
headers: {
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": String(rl.remaining),
},
}, },
{ status: 201 }
); );
} catch (e: any) { } catch (e: any) {
console.error("Registration failed:", e); console.error("Registration failed:", e);
@@ -48,14 +147,14 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ error: zitadelMessage || "Registration failed. Please try again." }, { error: zitadelMessage || "Registration failed. Please try again." },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 },
); );
} }
} }
/** /**
* ZITADEL errors come as: * ZITADEL errors come as:
* "ZITADEL POST /path: 400 {"code":3, "message":"..."}" * "ZITADEL POST /path: 400 {"code":3, "message":"..."}"
* Extract the human-readable "message" field. * Extract the human-readable "message" field.
*/ */
function extractZitadelMessage(errorMsg: string): string | null { function extractZitadelMessage(errorMsg: string): string | null {

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,6 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; 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 { 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"];
const MAX_WORKSPACE_FILE_SIZE = 10_000;
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
@@ -17,17 +24,17 @@ export async function GET(
if (!tenant) if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
if ( // Slice 6: visibility now includes assignment-table check for
!user.isPlatform && // user-role members. We return 404 (not 403) to avoid leaking
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId // tenant existence — same as cross-org reads.
) { if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
return NextResponse.json(tenant); return NextResponse.json(tenant);
} catch (e: any) { } catch (e: any) {
return NextResponse.json( return NextResponse.json(
{ error: e.message }, { error: safeError(e, "Failed to fetch tenant") },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 }
); );
} }
@@ -41,7 +48,7 @@ export async function PATCH(
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) { if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
@@ -61,18 +68,174 @@ export async function PATCH(
} }
const specPatch: Record<string, any> = {}; const specPatch: Record<string, any> = {};
if (body.packages !== undefined) specPatch.packages = body.packages;
if (body.workspaceFiles !== undefined) // ── Validate packages against catalog ──
if (body.packages !== undefined) {
if (!Array.isArray(body.packages) || body.packages.length > 10) {
return NextResponse.json(
{ error: "Invalid packages: must be an array of at most 10 items" },
{ status: 400 }
);
}
for (const pkg of body.packages) {
if (typeof pkg !== "string" || !getPackageDef(pkg)) {
return NextResponse.json(
{ error: `Unknown package: ${pkg}` },
{ status: 400 }
);
}
}
specPatch.packages = body.packages;
}
// ── Validate workspaceFiles ──
if (body.workspaceFiles !== undefined) {
if (
typeof body.workspaceFiles !== "object" ||
body.workspaceFiles === null ||
Array.isArray(body.workspaceFiles)
) {
return NextResponse.json(
{ error: "Invalid workspaceFiles: must be an object" },
{ status: 400 }
);
}
for (const [key, value] of Object.entries(body.workspaceFiles)) {
if (!ALLOWED_WORKSPACE_FILES.includes(key)) {
return NextResponse.json(
{
error: `Invalid workspace file: ${key}. Allowed: ${ALLOWED_WORKSPACE_FILES.join(", ")}`,
},
{ status: 400 }
);
}
if (
typeof value !== "string" ||
value.length > MAX_WORKSPACE_FILE_SIZE
) {
return NextResponse.json(
{
error: `Workspace file ${key} must be a string of at most ${MAX_WORKSPACE_FILE_SIZE} characters`,
},
{ status: 400 }
);
}
}
specPatch.workspaceFiles = body.workspaceFiles; specPatch.workspaceFiles = body.workspaceFiles;
if (body.displayName !== undefined) }
// ── Simple string fields ──
if (body.displayName !== undefined) {
if (
typeof body.displayName !== "string" ||
body.displayName.length < 1 ||
body.displayName.length > 100
) {
return NextResponse.json(
{ error: "displayName must be 1-100 characters" },
{ status: 400 }
);
}
specPatch.displayName = body.displayName; specPatch.displayName = body.displayName;
if (body.agentName !== undefined) specPatch.agentName = body.agentName; }
if (body.agentName !== undefined) {
if (
typeof body.agentName !== "string" ||
body.agentName.length < 1 ||
body.agentName.length > 50
) {
return NextResponse.json(
{ error: "agentName must be 1-50 characters" },
{ status: 400 }
);
}
specPatch.agentName = body.agentName;
}
// ── channelUsers (basic shape validation) ──
if (body.channelUsers !== undefined) {
if (
typeof body.channelUsers !== "object" ||
body.channelUsers === null ||
Array.isArray(body.channelUsers)
) {
return NextResponse.json(
{ error: "Invalid channelUsers: must be an object" },
{ status: 400 }
);
}
for (const [channel, users] of Object.entries(body.channelUsers)) {
if (typeof channel !== "string" || channel.length > 50) {
return NextResponse.json(
{ error: `Invalid channel name: ${channel}` },
{ status: 400 }
);
}
if (
!Array.isArray(users) ||
(users as any[]).some(
(u: any) => typeof u !== "string" || u.length > 100
)
) {
return NextResponse.json(
{ error: `Invalid user IDs for channel ${channel}` },
{ status: 400 }
);
}
}
specPatch.channelUsers = body.channelUsers;
}
const updated = await patchTenantSpec(name, specPatch); 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); return NextResponse.json(updated);
} catch (e: any) { } catch (e: any) {
return NextResponse.json( return NextResponse.json(
{ error: e.message }, { error: safeError(e, "Failed to update tenant") },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 }
); );
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { writePackageSecrets } from "@/lib/openbao"; import { writePackageSecrets } from "@/lib/openbao";
import { getPackageDef } from "@/lib/packages"; import { getPackageDef } from "@/lib/packages";
@@ -12,7 +12,7 @@ export async function POST(
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) { if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 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,56 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { listTenants, getTenant, createTenant } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import type { PiecedTenantSpec } from "@/types"; import { listVisibleTenants } from "@/lib/visibility";
export async function GET() { export async function GET() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tenants = await listTenants(); const all = await listTenants();
const visible = await listVisibleTenants(user, all);
if (user.isPlatform) { return NextResponse.json(visible);
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);
}
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = (await request.json()) as {
name: string;
spec: PiecedTenantSpec;
};
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
return NextResponse.json(
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
{ status: 400 }
);
}
const existing = await getTenant(body.name);
if (existing) {
return NextResponse.json(
{ error: "Tenant already exists" },
{ status: 409 }
);
}
const tenant = await createTenant(body.name, body.spec, {
"pieced.ch/zitadel-org-id": user.orgId,
});
return NextResponse.json(tenant, { status: 201 });
} }

View File

@@ -1,20 +1,124 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm"; import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
import {
getTeamInfo,
getTeamSpendLogsV2,
findKeyByAlias,
} from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* GET /api/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) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const teamId = req.nextUrl.searchParams.get("teamId"); const tenantName = req.nextUrl.searchParams.get("tenant");
if (!teamId) let teamId: string | null = null;
return NextResponse.json({ error: "teamId required" }, { status: 400 }); let keyAlias: string | null = null;
// Month param: YYYY-MM, defaults to current month 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);
if (!tenant) {
return NextResponse.json(
{ error: "Tenant not found or not accessible" },
{ status: 404 }
);
}
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.
const now = new Date(); const now = new Date();
const monthParam = req.nextUrl.searchParams.get("month") const monthParam =
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; req.nextUrl.searchParams.get("month") ||
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const [year, month] = monthParam.split("-").map(Number); const [year, month] = monthParam.split("-").map(Number);
const startDate = new Date(year, month - 1, 1); const startDate = new Date(year, month - 1, 1);
@@ -26,22 +130,52 @@ export async function GET(req: NextRequest) {
try { try {
const teamInfo = await getTeamInfo(teamId); 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[] = []; const allRequests: any[] = [];
let page = 1; let page = 1;
while (true) { while (true) {
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100); const result = await getTeamSpendLogsV2(
teamId,
startStr,
endStr,
page,
100,
keyAlias
);
allRequests.push(...(result.data || [])); allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break; if (page >= (result.total_pages || 1)) break;
page++; 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 }> = {}; const byDay: Record<
string,
{ inputTokens: number; outputTokens: number; spend: number }
> = {};
for (const r of allRequests) { for (const r of allRequests) {
const day = (r.startTime || r.endTime || "").slice(0, 10); const day = (r.startTime || r.endTime || "").slice(0, 10);
if (!day) continue; if (!day) continue;
if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 }; if (!byDay[day])
byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
byDay[day].inputTokens += r.prompt_tokens || 0; byDay[day].inputTokens += r.prompt_tokens || 0;
byDay[day].outputTokens += r.completion_tokens || 0; byDay[day].outputTokens += r.completion_tokens || 0;
byDay[day].spend += r.spend || 0; byDay[day].spend += r.spend || 0;
@@ -51,12 +185,19 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d })); .map(([date, d]) => ({ date, ...d }));
const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0); const totalInput = allRequests.reduce(
const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0); (s, r) => s + (r.prompt_tokens || 0),
0
);
const totalOutput = allRequests.reduce(
(s, r) => s + (r.completion_tokens || 0),
0
);
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0); const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
return NextResponse.json({ return NextResponse.json({
teamId, teamId,
keyAlias, // null when admin queries team-wide (no specific tenant)
month: monthParam, month: monthParam,
currentPeriod: { currentPeriod: {
inputTokens: totalInput, inputTokens: totalInput,
@@ -64,13 +205,38 @@ export async function GET(req: NextRequest) {
totalSpend, totalSpend,
requestCount: allRequests.length, requestCount: allRequests.length,
}, },
budget: { // Budget reporting (Feature 7).
maxBudget: teamInfo?.team_info?.max_budget ?? null, //
spend: teamInfo?.team_info?.spend ?? 0, // When the caller scopes to a specific tenant (keyAlias set),
remaining: teamInfo?.team_info?.max_budget // we report THAT tenant's per-key budget — that's what the
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0) // tenant detail page renders, and what the customer expects
: null, // 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: { rateLimits: {
rpm: teamInfo?.team_info?.rpm_limit ?? null, rpm: teamInfo?.team_info?.rpm_limit ?? null,
tpm: teamInfo?.team_info?.tpm_limit ?? null, tpm: teamInfo?.team_info?.tpm_limit ?? null,
@@ -79,6 +245,9 @@ export async function GET(req: NextRequest) {
}); });
} catch (e: any) { } catch (e: any) {
console.error("Usage fetch error:", e.message); console.error("Usage fetch error:", e.message);
return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 }); return NextResponse.json(
{ error: safeError(e, "Failed to fetch usage") },
{ status: 500 }
);
} }
} }

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getPackageDef } from "@/lib/packages";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
/**
* GET /api/workspace-defaults?packages=telegram,web-search
* Returns default content for SOUL.md, AGENTS.md, and TOOLS.md.
* Used by the onboarding wizard to pre-fill textareas.
*
* orgName is always resolved from the authenticated session — never
* accepted as a query parameter.
*/
export async function GET(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Always use the session org name — not a client-supplied parameter
const orgName = user.orgName || "Your Company";
const packagesParam = req.nextUrl.searchParams.get("packages") || "";
const packages = packagesParam
? packagesParam.split(",").filter((id) => id && getPackageDef(id))
: [];
const [soulMd, agentsMd, toolsMd] = await Promise.all([
getDefaultSoulMd(orgName),
getDefaultAgentsMd(),
generateToolsMd(packages),
]);
return NextResponse.json({ soulMd, agentsMd, toolsMd });
}

View File

@@ -1,20 +1,31 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types"; import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
type Tab = "requests" | "tenants"; type Tab = "requests" | "tenants" | "health";
type RequestFilter = "all" | "pending" | "provisioning" | "approved" | "rejected"; type RequestFilter = "all" | "pending" | "provisioning" | "approved" | "rejected";
interface HealthData {
tenants: { total: number; phases: Record<string, number> };
spend: { global: number; perTenant: Record<string, number> };
services: {
litellm: { healthy: boolean; details?: any };
vllm: { healthy: boolean; details?: any };
};
}
interface AdminPanelProps { interface AdminPanelProps {
initialTenants: PiecedTenant[]; initialTenants: PiecedTenant[];
} }
export function AdminPanel({ initialTenants }: AdminPanelProps) { export function AdminPanel({ initialTenants }: AdminPanelProps) {
const t = useTranslations("admin"); const t = useTranslations("admin");
const f = useFormatter();
const [tab, setTab] = useState<Tab>("requests"); const [tab, setTab] = useState<Tab>("requests");
// Requests state // Requests state
@@ -30,6 +41,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const [loadingTenants, setLoadingTenants] = useState(false); const [loadingTenants, setLoadingTenants] = useState(false);
const [deleteModal, setDeleteModal] = useState<string | null>(null); const [deleteModal, setDeleteModal] = useState<string | null>(null);
// Health state
const [health, setHealth] = useState<HealthData | null>(null);
const [loadingHealth, setLoadingHealth] = useState(false);
// Shared // Shared
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -79,6 +94,34 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
} }
}, [tab, fetchTenants]); }, [tab, fetchTenants]);
// ─── Health fetching ───
const fetchHealth = useCallback(async () => {
setLoadingHealth(true);
try {
const res = await fetch("/api/admin/health");
if (!res.ok) throw new Error("Failed to fetch health");
const data = await res.json();
setHealth(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoadingHealth(false);
}
}, []);
useEffect(() => {
if (tab === "health") {
fetchHealth();
}
}, [tab, fetchHealth]);
// Also fetch health for spend data when on tenants tab
useEffect(() => {
if (tab === "tenants" && !health) {
fetchHealth();
}
}, [tab, health, fetchHealth]);
// ─── Request actions ─── // ─── Request actions ───
const handleApprove = async (id: string) => { const handleApprove = async (id: string) => {
setActionLoading(id); setActionLoading(id);
@@ -156,7 +199,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
throw new Error(data.error || "Delete failed"); throw new Error(data.error || "Delete failed");
} }
setDeleteModal(null); 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) { } catch (e: any) {
setError(e.message); setError(e.message);
} finally { } finally {
@@ -212,6 +270,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" /> <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
)} )}
</button> </button>
<button
onClick={() => setTab("health")}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
tab === "health"
? "text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t("health")}
{tab === "health" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
)}
</button>
</div> </div>
{/* Error banner */} {/* Error banner */}
@@ -291,9 +362,40 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors" className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
> >
<td className="px-4 py-3"> <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} {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> </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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="text-text-primary text-sm"> <div className="text-text-primary text-sm">
@@ -315,7 +417,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<RequestStatusBadge status={req.status} /> <RequestStatusBadge status={req.status} />
</td> </td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell"> <td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
{new Date(req.createdAt).toLocaleDateString()} <div
title={`${t("submitted")}: ${formatDateTime(req.createdAt, f)}${
req.updatedAt && req.updatedAt !== req.createdAt
? `\n${t("updated")}: ${formatDateTime(req.updatedAt, f)}`
: ""
}`}
className="leading-tight"
>
<div>{formatDateTime(req.createdAt, f)}</div>
<div className="text-[10px] text-text-muted/70">
{formatRelative(req.createdAt, f)}
</div>
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -435,6 +549,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("packages")} {t("packages")}
</th> </th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("spendChf")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("created")} {t("created")}
</th> </th>
@@ -444,76 +561,100 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tenants.map((tenant) => ( {tenants.map((tenant) => {
<tr const tenantSpend =
key={tenant.metadata.name} health?.spend?.perTenant?.[tenant.metadata.name];
className={`border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors ${ return (
tenant.spec.suspend ? "opacity-60" : "" <tr
}`} key={tenant.metadata.name}
> className={`border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors ${
<td className="px-4 py-3 font-mono text-xs text-accent"> tenant.spec.suspend ? "opacity-60" : ""
{tenant.metadata.name} }`}
</td> >
<td className="px-4 py-3 text-text-primary"> <td className="px-4 py-3 font-mono text-xs text-accent">
<span>{tenant.spec.displayName}</span> {tenant.metadata.name}
{tenant.spec.suspend && ( </td>
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/15 text-amber-400 rounded"> <td className="px-4 py-3 text-text-primary">
{t("suspendedBadge")} <span>{tenant.spec.displayName}</span>
</span> {tenant.spec.suspend && (
)} <span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/15 text-amber-400 rounded">
</td> {t("suspendedBadge")}
<td className="px-4 py-3"> </span>
<StatusBadge )}
phase={tenant.status?.phase ?? "Pending"} </td>
/> <td className="px-4 py-3">
</td> <StatusBadge
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden md:table-cell"> phase={tenant.status?.phase ?? "Pending"}
{tenant.spec.packages?.join(", ") || "—"} />
</td> </td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell"> <td className="px-4 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
{tenant.metadata.creationTimestamp {tenant.spec.packages?.join(", ") || "—"}
? new Date( </td>
tenant.metadata.creationTimestamp <td className="px-4 py-3 text-xs font-mono tabular-nums text-text-secondary hidden md:table-cell">
).toLocaleDateString() {tenantSpend !== undefined
: "—"} ? `CHF ${tenantSpend.toFixed(2)}`
</td> : "—"}
<td className="px-4 py-3"> </td>
<div className="flex gap-1.5 flex-wrap"> <td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
<Link <div
href={`/tenants/${tenant.metadata.name}`} title={formatDateTime(
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors" tenant.metadata.creationTimestamp,
f
)}
className="leading-tight"
> >
{t("manage")} <div>
</Link> {formatDateTime(
<button tenant.metadata.creationTimestamp,
onClick={() => f
handleSuspend( )}
tenant.metadata.name, </div>
!tenant.spec.suspend <div className="text-[10px] text-text-muted/70">
) {formatRelative(
} tenant.metadata.creationTimestamp,
disabled={actionLoading === tenant.metadata.name} f
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50" )}
> </div>
{actionLoading === tenant.metadata.name </div>
? "…" </td>
: tenant.spec.suspend <td className="px-4 py-3">
? t("resume") <div className="flex gap-1.5 flex-wrap">
: t("suspend")} <Link
</button> href={`/tenants/${tenant.metadata.name}`}
<button className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
onClick={() => >
setDeleteModal(tenant.metadata.name) {t("manage")}
} </Link>
disabled={actionLoading === tenant.metadata.name} <button
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50" onClick={() =>
> handleSuspend(
{t("deleteTenant")} tenant.metadata.name,
</button> !tenant.spec.suspend
</div> )
</td> }
</tr> disabled={actionLoading === tenant.metadata.name}
))} className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
>
{actionLoading === tenant.metadata.name
? "…"
: tenant.spec.suspend
? t("resume")
: t("suspend")}
</button>
<button
onClick={() =>
setDeleteModal(tenant.metadata.name)
}
disabled={actionLoading === tenant.metadata.name}
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
>
{t("deleteTenant")}
</button>
</div>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -522,6 +663,115 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</> </>
)} )}
{/* ───── HEALTH TAB ───── */}
{tab === "health" && (
<>
{loadingHealth ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-text-muted text-xs">{t("loadingHealth")}</p>
</div>
) : health ? (
<div className="space-y-6">
{/* Service health indicators */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("serviceHealth")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<ServiceCard
name="vLLM"
subtitle={t("vllmDescription")}
healthy={health.services.vllm.healthy}
t={t}
/>
<ServiceCard
name="LiteLLM"
subtitle={t("litellmDescription")}
healthy={health.services.litellm.healthy}
t={t}
/>
</div>
</div>
{/* Tenant overview */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("tenantOverview")}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard
label={t("totalTenants")}
value={health.tenants.total}
/>
<SummaryCard
label={t("running")}
value={
(health.tenants.phases["Running"] ?? 0) +
(health.tenants.phases["Ready"] ?? 0)
}
color="text-emerald-400"
/>
<SummaryCard
label={t("suspended")}
value={health.tenants.phases["Suspended"] ?? 0}
color="text-amber-400"
/>
<SummaryCard
label={t("errors")}
value={health.tenants.phases["Error"] ?? 0}
color="text-red-400"
/>
</div>
</div>
{/* Spend overview */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("spendOverview")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-surface-1 border border-border rounded-xl p-4">
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
{t("globalSpend")}
</p>
<p className="font-mono text-2xl font-semibold tabular-nums text-text-primary">
CHF {health.spend.global.toFixed(2)}
</p>
</div>
<div className="bg-surface-1 border border-border rounded-xl p-4">
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
{t("activeTenants")}
</p>
<p className="font-mono text-2xl font-semibold tabular-nums text-text-primary">
{Object.keys(health.spend.perTenant).length}
</p>
<p className="text-xs text-text-muted mt-1">
{t("tenantsWithSpend")}
</p>
</div>
</div>
</div>
{/* Refresh button */}
<div className="flex justify-end">
<button
onClick={fetchHealth}
disabled={loadingHealth}
className="px-4 py-2 text-xs font-medium bg-surface-2 border border-border rounded-lg text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
>
{t("refresh")}
</button>
</div>
</div>
) : (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("healthUnavailable")}</p>
</div>
)}
</>
)}
{/* ───── REJECT MODAL ───── */} {/* ───── REJECT MODAL ───── */}
{rejectModal && ( {rejectModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
@@ -596,6 +846,49 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
); );
} }
function ServiceCard({
name,
subtitle,
healthy,
t,
}: {
name: string;
subtitle: string;
healthy: boolean;
t: any;
}) {
return (
<div className="bg-surface-1 border border-border rounded-xl p-4 flex items-center gap-4">
<div
className={`shrink-0 h-10 w-10 rounded-lg flex items-center justify-center ${
healthy ? "bg-emerald-400/15" : "bg-red-400/15"
}`}
>
<div
className={`h-3 w-3 rounded-full ${
healthy ? "bg-emerald-400" : "bg-red-400 animate-pulse"
}`}
/>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary">{name}</p>
<p className="text-xs text-text-muted truncate">{subtitle}</p>
</div>
<div className="ml-auto shrink-0">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
healthy
? "bg-emerald-400/15 text-emerald-400"
: "bg-red-400/15 text-red-400"
}`}
>
{healthy ? t("statusHealthy") : t("statusDown")}
</span>
</div>
</div>
);
}
function RequestStatusBadge({ status }: { status: string }) { function RequestStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
pending: "bg-blue-400/15 text-blue-400", pending: "bg-blue-400/15 text-blue-400",

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,391 @@
"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 [addingSkill, setAddingSkill] = useState(false);
const [skillError, setSkillError] = useState("");
// Core upsert — used by both the "add new skill" form and the inline
// editor on existing rows. Kept event-free so callers can invoke it
// without synthesizing a fake form event.
const upsertSkillPrice = async (skillId: string, dailyPriceChf: 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 }),
});
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));
};
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("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">
<InlinePriceEditor
skillId={sp.skillId}
initialPrice={sp.dailyPriceChf}
onSave={(price) => upsertSkillPrice(sp.skillId, price)}
/>
</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-32">
<span className="text-xs text-text-muted">
{t("dailyPriceLabel")} (CHF)
</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>
<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 skill's daily price. Mounts in
* "view" mode showing the current value as a clickable badge;
* clicking turns it into an input + save/cancel buttons.
*/
function InlinePriceEditor({
skillId,
initialPrice,
onSave,
}: {
skillId: string;
initialPrice: 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);
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="text-sm font-mono hover:underline"
title={t("clickToEdit")}
>
CHF {initialPrice.toFixed(2)}
</button>
);
}
return (
<span className="inline-flex items-center gap-1">
<input
type="number"
step="0.01"
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

@@ -0,0 +1,347 @@
"use client";
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",
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();
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
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[]>) => {
setSaving(true);
setError("");
try {
const res = await fetch(`/api/tenants/${tenantName}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channelUsers: updated }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Update failed");
}
setChannelUsers(updated);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[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();
if (!userId) return;
const current = channelUsers[channel] || [];
if (current.includes(userId)) {
setError(t("alreadyAdded"));
return;
}
setInputValues((prev) => ({ ...prev, [channel]: "" }));
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void addToRelayChannel(channel, userId);
} else {
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
updateChannelUsers(updated);
}
},
[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,
[channel]: current.filter((id) => id !== userId),
};
updateChannelUsers(updated);
},
[channelUsers, updateChannelUsers, removeFromRelayChannel]
);
if (enabledChannels.length === 0) return null;
return (
<div className="space-y-4">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
{t("title")}
</h3>
<p className="text-xs text-text-muted mb-4">{t("description")}</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}
<button
onClick={() => setError("")}
className="ml-2 text-red-300 hover:text-red-200"
>
</button>
</div>
)}
{enabledChannels.map((channel) => {
const users = channelUsers[channel] || [];
const helpKey = CHANNEL_ID_HELP[channel];
return (
<div
key={channel}
className="bg-surface-2 border border-border rounded-lg p-4"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-text-primary capitalize">
{channel}
</h4>
<span className="text-xs text-text-muted tabular-nums">
{users.length} {t("users")}
</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)}
</p>
)}
{/* Current users */}
{users.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{users.map((userId) => (
<span
key={userId}
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}
{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 — 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 { useTranslations } from "next-intl";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
interface DailyUsage { interface DailyUsage {
date: string; date: string;
@@ -18,7 +19,17 @@ interface UsageData {
totalSpend: number; totalSpend: number;
requestCount: 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 }; rateLimits: { rpm: number | null; tpm: number | null };
dailyUsage: DailyUsage[]; dailyUsage: DailyUsage[];
} }
@@ -29,8 +40,31 @@ function fmt(n: number): string {
return n.toString(); 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 { function getCurrentMonth(): string {
@@ -69,7 +103,7 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
const x = i * (barW + 2); const x = i * (barW + 2);
return ( return (
<g key={d.date}> <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 - 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} /> <rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{i % 7 === 0 && ( {i % 7 === 0 && (
@@ -91,7 +125,41 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
); );
} }
export function UsageDisplay({ teamId }: { teamId: string | null }) { /**
* Usage display widget.
*
* 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({
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 t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth); const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null); const [data, setData] = useState<UsageData | null>(null);
@@ -101,20 +169,28 @@ export function UsageDisplay({ teamId }: { teamId: string | null }) {
const isCurrentMonth = month === getCurrentMonth(); const isCurrentMonth = month === getCurrentMonth();
const fetchUsage = useCallback(() => { const fetchUsage = useCallback(() => {
if (!teamId) { setLoading(false); return; }
setLoading(true); setLoading(true);
setError(null); setError(null);
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}&month=${month}`)
const params = new URLSearchParams({ month });
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}`)
.then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); }) .then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); })
.then(setData) .then(setData)
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [teamId, month]); }, [tenant, teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]); useEffect(() => { fetchUsage(); }, [fetchUsage]);
if (!teamId) return null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Month selector */} {/* Month selector */}
@@ -151,11 +227,25 @@ export function UsageDisplay({ teamId }: { teamId: string | null }) {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} /> <StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} /> <StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent /> <StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
<StatCard {canEditBudget && tenant ? (
label={t("budget")} <BudgetEditableCard
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")} 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>
<div className="bg-surface-1 border border-border rounded-xl p-5"> <div className="bg-surface-1 border border-border rounded-xl p-5">
@@ -182,4 +272,4 @@ function StatCard({ label, value, accent }: { label: string; value: string; acce
<div className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div> <div className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div>
</div> </div>
); );
} }

View File

@@ -13,8 +13,13 @@ function NavBar() {
const pathname = usePathname(); const pathname = usePathname();
const user = (session as any)?.platformUser; const user = (session as any)?.platformUser;
const isLogin = pathname === "/login"; // Hide the nav entirely on auth-only routes. These pages have no
if (isLogin) return null; // 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 ( return (
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md"> <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"}> <NavLink href="/dashboard" active={pathname === "/dashboard"}>
{t("dashboard")} {t("dashboard")}
</NavLink> </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 && ( {user?.isPlatform && (
<NavLink href="/admin" active={pathname === "/admin"}> <NavLink href="/admin" active={pathname === "/admin"}>
{t("admin")} {t("admin")}
@@ -51,8 +96,17 @@ function NavBar() {
{/* Right side */} {/* Right side */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{user && ( {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"> <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> </span>
)} )}
<LanguageSwitcher /> <LanguageSwitcher />

View File

@@ -1,31 +1,68 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard"; import { OnboardingWizard } from "./wizard";
import { ProvisioningStatus } from "./provisioning-status";
interface OnboardingFlowProps { interface OnboardingFlowProps {
orgName: string; 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: * Wraps the onboarding wizard. On successful submission, refreshes the
* - no_request → show wizard * router so the parent server component re-renders with the new pending
* - pending/approved/provisioning/rejected → show status * request visible in the dashboard list.
* - After wizard submission → switch to status polling *
* 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) { export function OnboardingFlow({
const [showWizard, setShowWizard] = useState(initialState === "no_request"); orgName,
userName,
userEmail,
hasOrgBilling,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
if (showWizard) { return (
return ( <OnboardingWizard
<OnboardingWizard orgName={orgName}
orgName={orgName} userName={userName}
onComplete={() => setShowWizard(false)} userEmail={userEmail}
/> hasOrgBilling={hasOrgBilling}
); editingRequest={editingRequest}
} onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
return <ProvisioningStatus />; // parent server component will see the new `pending` row and
// render its `<ProvisioningStatus>` card automatically.
router.push("/dashboard");
router.refresh();
}}
/>
);
} }

View File

@@ -1,68 +1,160 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface OnboardingState { interface RequestSummary {
state: string; id: string;
request?: { instanceName?: string | null;
id: string; agentName: string;
status: string; packages: string[];
companyName: string; status: string;
agentName: string; adminNotes?: string;
adminNotes?: string; tenantName?: string;
}; dismissedAt?: string | null;
tenant?: { createdAt?: string;
name: string; updatedAt?: string;
phase: string;
message?: string;
conditions?: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: 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 t = useTranslations("onboarding");
const [data, setData] = useState<OnboardingState | null>(null); const tCommon = useTranslations("common");
const f = useFormatter();
const router = useRouter();
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [actionPending, setActionPending] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const poll = useCallback(async () => { const poll = useCallback(async () => {
try { 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"); if (!res.ok) throw new Error("Failed to fetch status");
const json = await res.json(); const json = await res.json();
setData(json); setData(json);
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} }
}, []); }, [requestId]);
useEffect(() => { useEffect(() => {
poll(); 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 if (terminal) return;
const interval = setInterval(() => {
if (
data?.state === "provisioned" ||
data?.state === "rejected" ||
data?.state === "active"
) {
return;
}
poll();
}, 5000);
const interval = setInterval(poll, 5000);
return () => clearInterval(interval); 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 ( return (
<Card> <Card>
<div className="text-xs text-red-400">{error}</div> <div className="text-xs text-red-400">{error}</div>
@@ -81,8 +173,14 @@ export function ProvisioningStatus() {
); );
} }
// Pending admin approval const status = data.request.status;
if (data.state === "pending") { const label =
data.request.instanceName ||
data.request.tenantName ||
data.request.agentName;
// ─── Pending: awaiting admin approval ───────────────────────────────
if (status === "pending") {
return ( return (
<Card className="animate-in"> <Card className="animate-in">
<div className="text-center py-6"> <div className="text-center py-6">
@@ -104,16 +202,94 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2"> <h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")} {t("pendingTitle")}
</h2> </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"> <p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")} {t("pendingDescription")}
</p> </p>
{data.request.createdAt && (
<p
className="text-xs text-text-muted mt-4"
title={formatDateTime(data.request.createdAt, f)}
>
{t("submittedAt")}{" "}
<span className="text-text-secondary">
{formatRelative(data.request.createdAt, f)}
</span>{" "}
<span className="text-text-muted/60">
({formatDateTime(data.request.createdAt, f)})
</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> </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> </Card>
); );
} }
// Rejected // ─── Rejected: admin declined ───────────────────────────────────────
if (data.state === "rejected") { if (status === "rejected") {
return ( return (
<Card className="animate-in"> <Card className="animate-in">
<div className="text-center py-6"> <div className="text-center py-6">
@@ -135,23 +311,99 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2"> <h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")} {t("rejectedTitle")}
</h2> </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"> <p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")} {t("rejectedDescription")}
</p> </p>
{data.request?.adminNotes && ( {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"> <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">
{data.request.adminNotes} <div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
</p> {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> </div>
</Card> </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 ( if (
data.state === "approved" || status === "approved" ||
data.state === "provisioning" status === "provisioning" ||
(status === "active" && data.tenant && data.tenant.phase !== "Ready")
) { ) {
const phase = data.tenant?.phase ?? "Pending"; const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? []; const conditions = data.tenant?.conditions ?? [];
@@ -165,6 +417,11 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2"> <h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")} {t("provisioningTitle")}
</h2> </h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{t("provisioningDescription")} {t("provisioningDescription")}
</p> </p>
@@ -199,8 +456,8 @@ export function ProvisioningStatus() {
); );
} }
// Provisioned / Running // ─── Active / Ready ─────────────────────────────────────────────────
if (data.state === "provisioned") { if (status === "active") {
return ( return (
<Card className="animate-in"> <Card className="animate-in">
<div className="text-center py-6"> <div className="text-center py-6">
@@ -222,6 +479,11 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2"> <h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")} {t("readyTitle")}
</h2> </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"> <p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")} {t("readyDescription")}
</p> </p>

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