Compare commits

..

28 Commits

Author SHA1 Message Date
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
54 changed files with 3788 additions and 495 deletions

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"

View File

@@ -1,5 +1,25 @@
npm install
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.4 .
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.4
kubectl rollout restart deployment pieced-portal -n pieced-system
#!/usr/bin/env bash
set -euo pipefail
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"

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

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

View File

@@ -0,0 +1,55 @@
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 Link from "next/link";
/**
* /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.
*/
export default async function NewInstancePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
const t = await getTranslations("dashboard");
return (
<div>
<div className="mb-8 animate-in">
<Link
href="/dashboard"
className="inline-flex items-center gap-1.5 mb-4 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
>
<span></span> {t("title")}
</Link>
<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} />
</div>
</div>
);
}

View File

@@ -1,12 +1,18 @@
import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
import { getTenantRequestByOrgId } from "@/lib/db";
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
import {
listVisibleTenants,
canSeeInflightRequests,
isUserScoped,
} from "@/lib/visibility";
import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
import { formatDateTime } from "@/lib/format";
import Link from "next/link";
export default async function DashboardPage() {
@@ -15,10 +21,11 @@ export default async function DashboardPage() {
const t = await getTranslations("dashboard");
const tAdmin = await getTranslations("admin");
const f = await getFormatter();
const allTenants = await listTenants();
// Platform users see overview of all tenants
// Platform users see overview of all tenants — unchanged from pre-Slice-3.
if (user.isPlatform) {
const phaseCount = allTenants.reduce<Record<string, number>>((acc, t) => {
const phase = t.status?.phase ?? "Pending";
@@ -110,9 +117,7 @@ export default async function DashboardPage() {
{tenant.spec.packages?.join(", ") || "—"}
</td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums">
{tenant.metadata.creationTimestamp
? new Date(tenant.metadata.creationTimestamp).toLocaleDateString()
: "—"}
{formatDateTime(tenant.metadata.creationTimestamp, f)}
</td>
<td className="px-5 py-3 text-right">
<Link
@@ -133,19 +138,117 @@ 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.
const orgRequests = canSeeInflightRequests(user)
? await listActiveTenantRequestsByOrgId(user.orgId)
: [];
// Pending requests that don't yet have a tenant CR. Once the CR
// exists, the tenant card carries the live phase, so a separate
// "request" card would just duplicate it. We compare against
// *all* org tenants here (not just visible ones) — otherwise a
// request whose tenant is invisible to the caller would erroneously
// show as in-flight.
const orgScopedTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
const inflightRequests = orgRequests.filter(
(r) => !r.tenantName || !orgScopedTenants.some((t) => t.metadata.name === r.tenantName)
);
// No tenant → check for existing request, show onboarding flow
if (!myTenant) {
const existingRequest = await getTenantRequestByOrgId(user.orgId);
// Treat "deleted" as no request — customer can re-onboard
const initialState =
!existingRequest || existingRequest.status === "deleted"
? "no_request"
: existingRequest.status;
// Slice 5: only owners (and platform users, who'd typically be using
// the admin panel anyway) see the "Create new instance" link. A
// `user`-role member sees the dashboard but not the create flow —
// they need to ask an owner.
const canCreate = canMutate(user);
// First-time / no-visibility branch.
//
// Three sub-cases:
// 1. owner / platform with 0 tenants and 0 requests → show wizard.
// 2. owner / platform with 0 visibility but the org HAS tenants →
// shouldn't happen (owners see all org tenants). Defensive
// fall-through to the wizard.
// 3. user-role with 0 visible tenants → show "ask your owner"
// message, with copy distinguishing whether the org has any
// tenants at all.
if (orgTenants.length === 0 && inflightRequests.length === 0) {
if (userScoped) {
// Slice 6 empty state for `user` role. The org might or might
// not have tenants — either way this user has none assigned.
// The two messages are subtly different: "no instances exist"
// means owner needs to create one; "you're not assigned" means
// owner needs to grant access.
const orgHasTenants = orgScopedTenants.length > 0;
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
<Card className="animate-in animate-in-delay-1">
<div className="text-center py-6">
<h2 className="font-display text-base font-semibold text-text-primary mb-2">
{orgHasTenants
? t("noAssignmentsTitle")
: t("noInstancesYetTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{orgHasTenants
? t("noAssignmentsDescription")
: t("noInstancesYetDescription")}
</p>
</div>
</Card>
</div>
);
}
if (!canCreate) {
// Belt-and-braces: any role that's neither owner-with-create nor
// user-scope ends up here (e.g. weird cases like a session with
// no roles at all). Same generic message as before.
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
<Card className="animate-in animate-in-delay-1">
<p className="text-sm text-text-secondary text-center py-6">
{t("noAccessNoInstances")}
</p>
</Card>
</div>
);
}
return (
<div>
@@ -159,71 +262,109 @@ export default async function DashboardPage() {
</div>
<div className="animate-in animate-in-delay-1">
<OnboardingFlow
orgName={user.orgName}
initialState={initialState as any}
/>
<OnboardingFlow orgName={user.orgName} />
</div>
</div>
);
}
const tenantName = myTenant.metadata.name;
const teamId = myTenant.status?.litellmTeamId || tenantName;
// Returning customer: list of tenants + in-flight requests, plus
// a button to add another instance (owners only).
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
<div className="mb-8 animate-in flex items-start justify-between gap-4">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
{canCreate && (
<Link
href="/dashboard/new"
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
<span>+</span> {t("createInstance")}
</Link>
)}
</div>
{/* Instance status card */}
<div className="mb-6 animate-in animate-in-delay-1">
<Card>
<CardHeader>{t("instanceStatus")}</CardHeader>
<div className="flex items-center gap-4">
<StatusBadge phase={myTenant.status?.phase ?? "Pending"} />
{myTenant.spec.agentName && (
<span className="text-sm text-text-secondary">
{myTenant.spec.agentName}
</span>
)}
{/* In-flight (pending/approved/provisioning/rejected) requests */}
{inflightRequests.length > 0 && (
<div className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("inflightRequests")}
</h2>
<div className="space-y-3">
{inflightRequests.map((r) => (
<ProvisioningStatus key={r.id} requestId={r.id} />
))}
</div>
{myTenant.spec.packages && myTenant.spec.packages.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{myTenant.spec.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2.5 py-0.5"
>
{pkg}
</span>
))}
</div>
)}
</Card>
</div>
</div>
)}
{/* Usage */}
<div className="mb-6 animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay teamId={myTenant.status?.litellmTeamId || teamId} />
</div>
{/* Active tenants */}
{orgTenants.length > 0 && (
<div className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("instances")}
</h2>
<div className="grid gap-4 md:grid-cols-2">
{orgTenants.map((tenant) => (
<Link
key={tenant.metadata.name}
href={`/tenants/${tenant.metadata.name}`}
className="block group"
>
<Card className="h-full hover:border-accent/40 transition-colors">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-text-primary truncate">
{tenant.spec.displayName || tenant.metadata.name}
</div>
<div className="font-mono text-xs text-text-muted truncate mt-0.5">
{tenant.metadata.name}
</div>
</div>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
</div>
{/* Link to tenant detail */}
<Link
href={`/tenants/${tenantName}`}
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-dim transition-colors animate-in animate-in-delay-3"
>
<span></span> {t("manage")}
</Link>
{tenant.spec.agentName && (
<div className="text-xs text-text-secondary mb-2">
{tenant.spec.agentName}
</div>
)}
{tenant.spec.packages && tenant.spec.packages.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{tenant.spec.packages.slice(0, 4).map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
>
{pkg}
</span>
))}
{tenant.spec.packages.length > 4 && (
<span className="text-xs text-text-muted">
+{tenant.spec.packages.length - 4}
</span>
)}
</div>
)}
<div className="text-xs font-medium text-accent group-hover:text-accent-dim transition-colors">
{t("manage")}
</div>
</Card>
</Link>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -7,6 +7,13 @@ import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error";
/**
* Slice 4: a "Register as individual" toggle distinguishes personal
* accounts from company registrations. When the toggle is on:
* - the company name field is hidden (and not sent)
* - the server skips the duplicate-domain check
* - the ZITADEL org is named "{givenName} {familyName} (Personal)"
*/
export default function RegisterPage() {
const t = useTranslations("register");
const tCommon = useTranslations("common");
@@ -18,6 +25,7 @@ export default function RegisterPage() {
familyName: "",
email: "",
});
const [isPersonal, setIsPersonal] = useState(false);
const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState("");
@@ -31,19 +39,33 @@ export default function RegisterPage() {
setState("submitting");
try {
// Build the request body explicitly. For personals we omit
// companyName so the server knows to derive the org name from
// the user's full name. The Zod schema accepts the omission.
const body: Record<string, unknown> = {
givenName: form.givenName,
familyName: form.familyName,
email: form.email,
isPersonal,
};
if (!isPersonal) {
body.companyName = form.companyName;
}
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: form.companyName,
givenName: form.givenName,
familyName: form.familyName,
email: form.email,
}),
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
// Localize known structured codes; fall back to server-supplied
// English message for everything else (validation, ZITADEL errors,
// generic 500s).
if (data.code === "duplicate_domain" && data.domain) {
throw new Error(t("duplicateDomain", { domain: data.domain }));
}
throw new Error(data.error || "Registration failed");
}
@@ -98,21 +120,41 @@ export default function RegisterPage() {
<Card className="animate-in animate-in-delay-1">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Company name */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("companyName")}
</label>
{/* Personal-account toggle */}
<label className="flex items-start gap-3 cursor-pointer select-none p-3 rounded-lg border border-border bg-surface-2 hover:border-accent/40 transition-colors">
<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"
type="checkbox"
checked={isPersonal}
onChange={(e) => setIsPersonal(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border bg-surface-1 text-accent focus:ring-1 focus:ring-accent focus:ring-offset-0"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">
{t("individualToggle")}
</div>
<div className="text-xs text-text-muted mt-0.5">
{t("individualHint")}
</div>
</div>
</label>
{/* Company name — hidden for personal */}
{!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">
@@ -155,7 +197,7 @@ export default function RegisterPage() {
required
value={form.email}
onChange={handleChange}
placeholder="you@company.ch"
placeholder={isPersonal ? "you@example.ch" : "you@company.ch"}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>

View File

@@ -1,12 +1,14 @@
import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { StatusBadge } from "@/components/ui/status-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users";
import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -20,18 +22,23 @@ export default async function TenantDetailPage({
const { name } = await params;
const t = await getTranslations("tenantDetail");
const f = await getFormatter();
const tenant = await getTenant(name);
if (!tenant) notFound();
// Scope check
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
// Slice 6: visibility check encompasses org membership AND, for
// user-role members, the tenant_user_assignments check. notFound()
// (404) rather than redirect/403 to avoid leaking tenant existence.
if (!(await canUserSeeTenant(user, tenant))) {
notFound();
}
// Slice 5: editable surface gated on owner role. Platform users always
// can edit; customer-side, only `owner` may. `user`-role members see
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) =>
@@ -39,6 +46,19 @@ export default async function TenantDetailPage({
);
const channelUsers = tenant.spec.channelUsers || {};
// Admins inspecting another tenant's usage: pass teamId AND keyAlias so
// the backend filters spend logs by this specific tenant's virtual key.
// Without keyAlias the response would include sibling tenants in the
// same org, since teams are now shared (Slice 2).
// Customers viewing their own: pass nothing — backend resolves both
// from the session-bound tenant.
const usageTeamId = user.isPlatform
? tenant.status?.litellmTeamId || undefined
: undefined;
const usageKeyAlias = user.isPlatform
? tenant.status?.litellmKeyAlias || undefined
: undefined;
return (
<div>
{/* Header */}
@@ -54,6 +74,18 @@ export default async function TenantDetailPage({
{t("agent")}: {tenant.spec.agentName}
</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>
{/* Usage */}
@@ -61,7 +93,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay teamId={tenant.status?.litellmTeamId || name} />
<UsageDisplay teamId={usageTeamId} keyAlias={usageKeyAlias} />
</section>
{/* Packages */}
@@ -73,6 +105,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
/>
</section>
@@ -83,6 +116,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledChannels={enabledChannels}
initialChannelUsers={channelUsers}
canEdit={canEdit}
/>
</section>
)}
@@ -92,8 +126,8 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("workspaceFiles")}
</h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} />
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
</section>
</div>
);
}
}

View File

@@ -4,6 +4,7 @@ import { listTenants } from "@/lib/k8s";
import {
getLitellmHealth,
getGlobalSpend,
getPerKeySpend,
getPerTeamSpend,
} from "@/lib/litellm";
@@ -28,6 +29,17 @@ async function checkVllmHealth(): Promise<{
/**
* GET /api/admin/health
* Returns system health overview for the admin panel.
*
* Slice 2 spend layout
* --------------------
* - `spend.global` — total across all teams (LiteLLM-reported)
* - `spend.perTenant[name]` — per-tenant CHF, derived from the per-key
* spend map keyed by `litellmKeyAlias`. Only
* populated for tenants whose status carries
* an alias (post-Slice-2 reconciled CRs).
* - `spend.perOrg[teamId]` — company-level total (= LiteLLM team total).
* Useful for the admin overview to see
* spend-per-customer at a glance.
*/
export async function GET() {
try {
@@ -36,17 +48,17 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [tenants, litellm, vllm, globalSpend, perTeamSpend] =
const [tenants, litellm, vllm, globalSpend, perKeySpend, perTeamSpend] =
await Promise.allSettled([
listTenants(),
getLitellmHealth(),
checkVllmHealth(),
getGlobalSpend(),
getPerKeySpend(),
getPerTeamSpend(),
]);
const allTenants =
tenants.status === "fulfilled" ? tenants.value : [];
const allTenants = tenants.status === "fulfilled" ? tenants.value : [];
// Count tenants by phase
const phaseCounts: Record<string, number> = {};
@@ -57,15 +69,27 @@ export async function GET() {
phaseCounts[phase] = (phaseCounts[phase] || 0) + 1;
}
// Build per-tenant spend map (tenantName → spend)
const spendMap: Record<string, number> = {};
// Build per-tenant spend map (tenantName → spend) from the per-key map.
// Tenants without a `litellmKeyAlias` in status are skipped — they
// simply won't appear in this map until they've been reconciled by
// the Slice-2 operator.
const keySpend =
perKeySpend.status === "fulfilled" ? perKeySpend.value : new Map();
const tenantSpend: Record<string, number> = {};
for (const t of allTenants) {
const alias = t.status?.litellmKeyAlias;
if (alias && keySpend.has(alias)) {
tenantSpend[t.metadata.name] = keySpend.get(alias)!;
}
}
// Build per-org spend map (teamId → spend). Multiple tenants of the
// same org share a teamId, so the same number appears for each.
const teamSpend =
perTeamSpend.status === "fulfilled" ? perTeamSpend.value : new Map();
for (const t of allTenants) {
const teamId = t.status?.litellmTeamId;
if (teamId && teamSpend.has(teamId)) {
spendMap[t.metadata.name] = teamSpend.get(teamId)!;
}
const orgSpend: Record<string, number> = {};
for (const [teamId, spend] of teamSpend.entries()) {
orgSpend[teamId] = spend;
}
return NextResponse.json({
@@ -76,7 +100,8 @@ export async function GET() {
spend: {
global:
globalSpend.status === "fulfilled" ? globalSpend.value : 0,
perTenant: spendMap,
perTenant: tenantSpend,
perOrg: orgSpend,
},
services: {
litellm:

View File

@@ -14,6 +14,8 @@ import {
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
import { deriveTenantName } from "@/lib/tenant-naming";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/requests/[id]/approve
@@ -60,13 +62,14 @@ export async function POST(
const isReApproval = tenantRequest.status === "rejected";
// Derive tenant name from company name: lowercase, alphanumeric + hyphens
const tenantName =
tenantRequest.companyName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`;
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
// Slice 4: for personal accounts the slug is replaced by the literal
// "p-" prefix so no PII is embedded in the K8s namespace name.
const tenantName = deriveTenantName(
tenantRequest.isPersonal ? "personal" : "company",
tenantRequest.companyName,
tenantRequest.id
);
try {
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)
@@ -97,11 +100,23 @@ export async function POST(
"TOOLS.md": toolsMd,
};
// Step 4: Create the PiecedTenant CR
// Step 4: Create the PiecedTenant CR.
// displayName precedence:
// 1. customer-chosen instance name (Slice 3 multi-tenant)
// 2. for personal accounts, the contact name (avoids exposing the
// synthetic "{name} (Personal)" company name in the OpenClaw UI)
// 3. company name otherwise
const displayName =
tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0
? tenantRequest.instanceName.trim()
: tenantRequest.isPersonal
? tenantRequest.contactName || "Assistant"
: tenantRequest.companyName;
await createTenant(
tenantName,
{
displayName: tenantRequest.companyName,
displayName,
agentName: tenantRequest.agentName,
packages,
workspaceFiles,
@@ -133,7 +148,7 @@ export async function POST(
} catch (e: any) {
console.error("Failed to create tenant:", e);
return NextResponse.json(
{ error: `Failed to create tenant: ${e.message}` },
{ error: safeError(e, "Failed to create tenant") },
{ status: 500 }
);
}

View File

@@ -1,12 +1,21 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenant, deleteTenant } from "@/lib/k8s";
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
import {
markTenantRequestDeletedByTenantName,
removeAllAssignmentsForTenant,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/tenants/[name]/delete
* Delete a PiecedTenant CR. The operator handles cleanup
* (namespace, vault, litellm team, etc.).
*
* Slice 6: also cascades the tenant_user_assignments rows so a
* future tenant with the same name (won't happen given UUID-suffix
* naming, but defense in depth) doesn't inherit stale assignments.
*
* Also marks the associated tenant_request as "deleted" so the
* customer can re-submit the onboarding wizard.
*/
@@ -30,10 +39,14 @@ export async function POST(
try {
await deleteTenant(name);
// Mark the associated tenant_request as "deleted" so the customer
// sees the wizard again instead of a stale "active" status
// Best-effort DB cleanups. Both errors are logged but not surfaced —
// the K8s deletion has already started, and the row state is just
// for portal display.
await markTenantRequestDeletedByTenantName(name).catch((e) =>
console.error("Failed to update tenant request after delete:", e)
console.error("Failed to mark tenant request deleted:", e)
);
await removeAllAssignmentsForTenant(name).catch((e) =>
console.error("Failed to clean up tenant assignments:", e)
);
return NextResponse.json({
@@ -42,7 +55,7 @@ export async function POST(
} catch (e: any) {
console.error("Failed to delete tenant:", e);
return NextResponse.json(
{ error: `Failed to delete tenant: ${e.message}` },
{ error: safeError(e, "Failed to delete tenant") },
{ status: 500 }
);
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/tenants/[name]/suspend
@@ -35,7 +36,7 @@ export async function POST(
} catch (e: any) {
console.error("Failed to update tenant suspend state:", e);
return NextResponse.json(
{ error: `Failed to update tenant: ${e.message}` },
{ error: safeError(e, "Failed to update tenant") },
{ status: 500 }
);
}

View File

@@ -1,17 +1,32 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
createTenantRequest,
getTenantRequestByOrgId,
deleteTenantRequest,
getTenantRequestById,
listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
getMostRecentApprovedRequestForOrg,
} from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s";
import {
listVisibleTenants,
canUserSeeTenant,
canSeeInflightRequests,
} from "@/lib/visibility";
import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto";
import type { OnboardingInput } from "@/types";
import { isPersonalOrgName } from "@/lib/personal-org";
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
import { z } from "zod";
const onboardingSchema = z.object({
instanceName: z
.string()
.trim()
.max(80)
.optional()
// Empty string from a form input → drop to undefined so the DB stores NULL
.transform((v) => (v && v.length > 0 ? v : undefined)),
agentName: z.string().min(1).max(50),
soulMd: z.string().max(10_000).optional(),
agentsMd: z.string().max(10_000).optional(),
@@ -30,59 +45,132 @@ const onboardingSchema = z.object({
});
/**
* GET /api/onboarding
* Check the current onboarding state for the logged-in user's org.
* Helper: shape a TenantRequest row for client consumption.
* Hides server-only fields (encryptedSecrets, internal db ids).
*/
export async function GET() {
function publicRequestShape(r: TenantRequest) {
return {
id: r.id,
instanceName: r.instanceName,
agentName: r.agentName,
packages: r.packages,
status: r.status,
adminNotes: r.adminNotes,
tenantName: r.tenantName,
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
*
* 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(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if there's already a running tenant for this org
const allTenants = await listTenants();
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
const requestedId = req.nextUrl.searchParams.get("id");
if (myTenant) {
if (requestedId) {
const tr = await getTenantRequestById(requestedId);
if (!tr) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Customers may only read their own org's requests; platform
// admins/operators may read any.
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Slice 6: a `user`-role customer doesn't see in-flight requests
// even within their own org — they can't act on them and showing
// the row would be a permanent "pending" state with no exit. Owner
// and platform skip this gate.
if (!canSeeInflightRequests(user)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
let tenant: PiecedTenant | null = null;
if (tr.tenantName) {
tenant = (await getTenant(tr.tenantName)) ?? null;
// If a request is already linked to a tenant CR and the caller
// can't see that tenant (assignment scope), don't expose it via
// the request endpoint either. canSeeInflightRequests above
// already shortcuts this for `user`-role, but defense in depth.
if (tenant && !(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
return NextResponse.json({
state: "active",
tenantName: myTenant.metadata.name,
phase: myTenant.status?.phase ?? "Unknown",
request: publicRequestShape(tr),
tenant: tenant ? publicTenantShape(tenant) : null,
});
}
// Check if there's a pending request
const request = await getTenantRequestByOrgId(user.orgId);
// List view: requests + tenants for this org, filtered by visibility.
// For owner/platform, this returns the same data as pre-Slice-6.
// For user-role, requests is forced to [] and tenants is narrowed to
// assignments.
const [requests, allTenants] = await Promise.all([
listActiveTenantRequestsByOrgId(user.orgId),
listTenants(),
]);
if (!request || request.status === "deleted") {
return NextResponse.json({ state: "no_request" });
}
const visibleTenants = await listVisibleTenants(user, allTenants);
const visibleRequests = canSeeInflightRequests(user) ? requests : [];
return NextResponse.json({
state: request.status,
request: {
id: request.id,
agentName: request.agentName,
packages: request.packages,
status: request.status,
adminNotes: request.adminNotes,
tenantName: request.tenantName,
createdAt: request.createdAt,
},
requests: visibleRequests.map(publicRequestShape),
tenants: visibleTenants.map(publicTenantShape),
});
}
/**
* POST /api/onboarding
* Submit the onboarding wizard. Creates a tenant_request with status "pending".
* The actual PiecedTenant CR is NOT created yet — admin approval required.
*
* If packageSecrets are provided (for packages requiring credentials like
* Telegram, Discord, Email), they are encrypted with AES-256-GCM and stored
* as a BYTEA blob. They are decrypted only during admin approval to write
* to OpenBao.
* Always creates a NEW tenant_request row, regardless of how many other
* rows already exist for this org. The pre-Slice-3 409 ("you already
* have a request") is gone — multi-tenant is the design now.
*
* For additional instances in an existing company, the customer's prior
* approved row is used to seed billing/contact info, so the wizard
* doesn't need to re-collect data already on file. The wizard *does*
* still send a billingAddress payload (the field is required by the
* schema), but in practice the client can pre-fill it from
* `getMostRecentApprovedRequestForOrg`.
*
* Encrypted package secrets, if provided, are AES-256-GCM-sealed and
* stored as a BYTEA blob. They are decrypted only during admin approval
* to write to OpenBao.
*/
export async function POST(request: Request) {
const user = await getSessionUser();
@@ -90,6 +178,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Slice 5: only owners (or platform users) may create new instances.
// A `user`-role member of an existing org cannot self-provision.
if (!canMutate(user)) {
return NextResponse.json(
{ error: "Only the organization owner can create new instances." },
{ status: 403 }
);
}
const body = await request.json();
const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) {
@@ -99,40 +196,27 @@ export async function POST(request: Request) {
);
}
// Check for existing request
const existing = await getTenantRequestByOrgId(user.orgId);
if (existing && existing.status !== "deleted") {
return NextResponse.json(
{ error: "Onboarding request already submitted.", request: existing },
{ status: 409 }
);
}
// If previous request was deleted, remove it so a fresh one can be created
if (existing && existing.status === "deleted") {
await deleteTenantRequest(existing.id);
}
// Check for existing tenant
const allTenants = await listTenants();
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (myTenant) {
return NextResponse.json(
{
error: "You already have a tenant provisioned.",
tenantName: myTenant.metadata.name,
},
{ status: 409 }
);
}
const input: OnboardingInput & {
packageSecrets?: Record<string, Record<string, string>>;
} = parsed.data;
// Look up an existing approved request for this org to inherit
// company-level billing data. For brand-new orgs (first registration),
// there is no prior row and we use the form-supplied billingAddress
// verbatim. For follow-up requests, we ignore the form-supplied
// company line in favour of the recorded company name.
const prior = await getMostRecentApprovedRequestForOrg(user.orgId);
// Slice 4: detect personal-account orgs by the canonical " (Personal)"
// suffix on the ZITADEL org name. Set at registration, stable for the
// lifetime of the org. Persisted on the row so admin views and the
// approve handler don't have to re-derive it.
//
// If any prior row has is_personal set, prefer that — it's the same
// org and the value can't change. (The prior-row check is defensive;
// the org-name check should agree.)
const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName);
// Encrypt package secrets if provided
let encryptedSecrets: Buffer | undefined;
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {
@@ -147,34 +231,56 @@ 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;
const billingAddress = prior?.billingAddress ?? input.billingAddress;
const billingNotes = input.billingNotes ?? prior?.billingNotes;
const tenantRequest = await createTenantRequest({
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
companyName: user.orgName,
contactName: user.name,
contactEmail: user.email,
companyName,
instanceName: input.instanceName,
contactName,
contactEmail,
agentName: input.agentName,
soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [],
billingAddress: input.billingAddress,
billingNotes: input.billingNotes,
billingAddress,
billingNotes,
encryptedSecrets,
isPersonal,
});
// Notify admin about the new request
// Notify admin about the new request. For follow-up instances, include
// the instance name in the notification so the admin sees what's
// being requested without opening the panel.
try {
await sendAdminNotificationEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName
tenantRequest.instanceName
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
: tenantRequest.companyName
);
} catch (e) {
console.error("Failed to send admin notification:", e);
}
// For diagnostics: how many other in-flight requests does this org
// already have? Useful for the admin queue.
const allRequests = await listTenantRequestsByOrgId(user.orgId);
return NextResponse.json(
{ message: "Request submitted.", request: tenantRequest },
{
message: "Request submitted.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
},
{ status: 201 }
);
}

View File

@@ -1,17 +1,82 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit";
import { checkDuplicateDomain } from "@/lib/db";
import type { RegistrationInput } from "@/types";
import { z } from "zod";
const registrationSchema = z.object({
companyName: z.string().min(2).max(100),
givenName: z.string().min(1).max(100),
familyName: z.string().min(1).max(100),
email: z.string().email(),
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
});
/**
* Registration schema.
*
* Slice 4 changes
* ---------------
* - `companyName` is no longer always required. It's required when
* `isPersonal` is false/absent, ignored when `isPersonal` is true.
* - `isPersonal` flag distinguishes personal accounts. The server
* derives the ZITADEL org name from `${givenName} ${familyName}
* (Personal)` for personals — the suffix is the canonical marker
* that downstream code (onboarding POST, admin views) uses to
* distinguish personal orgs from companies. Customers cannot rename
* their own org, so the suffix 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
/**
* Suffix appended to personal-account ZITADEL org names. Used here to
* build the org name and elsewhere (session.orgName check) to detect
* whether the current user is on a personal org.
*
* Keep this in sync with `isPersonalOrgName()` in `lib/personal-org.ts`.
*/
const PERSONAL_ORG_SUFFIX = " (Personal)";
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 {
const body = await request.json();
const parsed = registrationSchema.safeParse(body);
@@ -19,14 +84,50 @@ export async function POST(request: Request) {
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
{ status: 400 },
);
}
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: synthesise from full name + " (Personal)" suffix.
// The suffix is the canonical marker for personal orgs.
//
// ZITADEL does NOT enforce org-name uniqueness, so two "Hans Müller
// (Personal)" orgs can coexist; the org id is what matters for our
// labelling and lookups, the name is human-readable only.
const orgName = isPersonal
? `${input.givenName.trim()} ${input.familyName.trim()}${PERSONAL_ORG_SUFFIX}`
: input.companyName!.trim();
const result = await registerCustomer({
companyName: input.companyName,
companyName: orgName,
email: input.email,
givenName: input.givenName,
familyName: input.familyName,
@@ -37,9 +138,17 @@ export async function POST(request: Request) {
{
orgId: result.orgId,
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) {
console.error("Registration failed:", e);
@@ -48,14 +157,14 @@ export async function POST(request: Request) {
return NextResponse.json(
{ error: zitadelMessage || "Registration failed. Please try again." },
{ status: e.statusCode || 500 }
{ status: e.statusCode || 500 },
);
}
}
/**
* ZITADEL errors come as:
* "ZITADEL POST /path: 400 {"code":3, "message":"..."}"
* "ZITADEL POST /path: 400 {"code":3, "message":"..."}"
* Extract the human-readable "message" field.
*/
function extractZitadelMessage(errorMsg: string): string | null {

View File

@@ -1,6 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { 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(
_req: NextRequest,
@@ -17,17 +23,17 @@ export async function GET(
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Slice 6: visibility now includes assignment-table check for
// user-role members. We return 404 (not 403) to avoid leaking
// tenant existence — same as cross-org reads.
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(tenant);
} catch (e: any) {
return NextResponse.json(
{ error: e.message },
{ error: safeError(e, "Failed to fetch tenant") },
{ status: e.statusCode || 500 }
);
}
@@ -41,7 +47,7 @@ export async function PATCH(
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -61,20 +67,130 @@ export async function PATCH(
}
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;
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;
if (body.agentName !== undefined) specPatch.agentName = body.agentName;
if (body.channelUsers !== undefined)
}
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);
return NextResponse.json(updated);
} catch (e: any) {
return NextResponse.json(
{ error: e.message },
{ error: safeError(e, "Failed to update tenant") },
{ status: e.statusCode || 500 }
);
}

View File

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

View File

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

View File

@@ -1,20 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* GET /api/usage
*
* Customers: tenant resolved server-side from the user's orgId. The
* response is filtered by the tenant's `litellmKeyAlias` so
* sibling tenants in the same org don't bleed into the total.
* Platform admins: may pass ?teamId=... to inspect any team. They may
* also pass ?keyAlias=... to scope to a single tenant.
*
* Slice 2 note
* ------------
* LiteLLM teams are now shared across all tenants of an org. The team's
* `/team/info` budget is the *company* budget; the per-tenant numbers
* come from filtering spend logs by `key_alias`. If a tenant has no
* `litellmKeyAlias` in status (transitional state right after upgrade,
* before the operator has reconciled), we fall back to team-level
* filtering — the numbers will be slightly inflated for that one
* reconcile cycle.
*/
export async function GET(req: NextRequest) {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const teamId = req.nextUrl.searchParams.get("teamId");
if (!teamId)
return NextResponse.json({ error: "teamId required" }, { status: 400 });
let teamId: string | null = null;
let keyAlias: string | null = null;
if (user.isPlatform) {
teamId = req.nextUrl.searchParams.get("teamId") ?? null;
keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null;
}
// For customers (or admins without explicit params): resolve from
// the user's *visible* tenants. With Slice 6, a `user`-role member
// can only see usage for tenants they're assigned to — a non-assigned
// user defaults to "no active tenant" (404).
//
// Owner and platform get the full org-scoped list and pick the first
// tenant, matching the dashboard's "current instance" semantics.
if (!teamId) {
const allTenants = await listTenants();
const visible = await listVisibleTenants(user, allTenants);
const orgTenant = visible.find((t) => !!t.status?.litellmTeamId);
if (!orgTenant?.status?.litellmTeamId) {
return NextResponse.json(
{ error: "No active tenant found for your organization" },
{ status: 404 }
);
}
teamId = orgTenant.status.litellmTeamId;
// If the operator has populated the per-tenant key alias, filter by it.
// Falling back to team-level (no alias) will return the org total, which
// is acceptable transitionally but means siblings' usage shows up here.
if (orgTenant.status.litellmKeyAlias) {
keyAlias = orgTenant.status.litellmKeyAlias;
}
}
// Month param: YYYY-MM, defaults to current month
const now = new Date();
const monthParam = req.nextUrl.searchParams.get("month")
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const monthParam =
req.nextUrl.searchParams.get("month") ||
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const [year, month] = monthParam.split("-").map(Number);
const startDate = new Date(year, month - 1, 1);
@@ -26,22 +81,50 @@ export async function GET(req: NextRequest) {
try {
const teamInfo = await getTeamInfo(teamId);
// Fetch all pages
// Fetch all pages from the team. We always query at the team level —
// LiteLLM's /spend/logs/v2 doesn't filter by key_alias reliably across
// versions, so we paginate and post-filter in code. For pilot scale
// this is cheap; if a single team ever exceeds ~10k entries/month we
// can revisit.
const allRequests: any[] = [];
let page = 1;
while (true) {
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100);
const result = await getTeamSpendLogsV2(
teamId,
startStr,
endStr,
page,
100
);
allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break;
page++;
}
// Apply key_alias post-filter when scoping to a single tenant. Match
// both `key_alias` (newer LiteLLM) and `metadata.user_api_key_alias`
// (older builds nest it inside metadata).
const scoped = keyAlias
? allRequests.filter((r) => {
const alias =
r.key_alias ??
r.metadata?.user_api_key_alias ??
r.api_key_alias ??
null;
return alias === keyAlias;
})
: allRequests;
// Aggregate by day
const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {};
for (const r of allRequests) {
const byDay: Record<
string,
{ inputTokens: number; outputTokens: number; spend: number }
> = {};
for (const r of scoped) {
const day = (r.startTime || r.endTime || "").slice(0, 10);
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].outputTokens += r.completion_tokens || 0;
byDay[day].spend += r.spend || 0;
@@ -51,19 +134,30 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d }));
const totalInput = allRequests.reduce((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 totalInput = scoped.reduce(
(s, r) => s + (r.prompt_tokens || 0),
0
);
const totalOutput = scoped.reduce(
(s, r) => s + (r.completion_tokens || 0),
0
);
const totalSpend = scoped.reduce((s, r) => s + (r.spend || 0), 0);
return NextResponse.json({
teamId,
keyAlias, // null when not filtering — useful for the client to know it sees company-wide data
month: monthParam,
currentPeriod: {
inputTokens: totalInput,
outputTokens: totalOutput,
totalSpend,
requestCount: allRequests.length,
requestCount: scoped.length,
},
// Budget is always team-level (= company budget). Spend reported
// here is the team total, not the per-key total — the customer
// wants to see "how much of our company budget is left", not just
// "how much has this one tenant cost".
budget: {
maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0,
@@ -79,6 +173,9 @@ export async function GET(req: NextRequest) {
});
} catch (e: any) {
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

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getPackageDef } from "@/lib/packages";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
@@ -7,9 +8,12 @@ import {
} from "@/lib/workspace-defaults";
/**
* GET /api/workspace-defaults?orgName=...&packages=telegram,web-search
* 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();
@@ -17,10 +21,13 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orgName =
req.nextUrl.searchParams.get("orgName") || user.orgName || "Your Company";
// 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(Boolean) : [];
const packages = packagesParam
? packagesParam.split(",").filter((id) => id && getPackageDef(id))
: [];
const [soulMd, agentsMd, toolsMd] = await Promise.all([
getDefaultSoulMd(orgName),

View File

@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link";
type Tab = "requests" | "tenants" | "health";
@@ -24,6 +25,7 @@ interface AdminPanelProps {
export function AdminPanel({ initialTenants }: AdminPanelProps) {
const t = useTranslations("admin");
const f = useFormatter();
const [tab, setTab] = useState<Tab>("requests");
// Requests state
@@ -369,7 +371,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<RequestStatusBadge status={req.status} />
</td>
<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 className="px-4 py-3">
<div className="flex gap-1.5">
@@ -536,11 +550,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
: "—"}
</td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
{tenant.metadata.creationTimestamp
? new Date(
tenant.metadata.creationTimestamp
).toLocaleDateString()
: "—"}
<div
title={formatDateTime(
tenant.metadata.creationTimestamp,
f
)}
className="leading-tight"
>
<div>
{formatDateTime(
tenant.metadata.creationTimestamp,
f
)}
</div>
<div className="text-[10px] text-text-muted/70">
{formatRelative(
tenant.metadata.creationTimestamp,
f
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex gap-1.5 flex-wrap">

View File

@@ -17,12 +17,15 @@ interface ChannelUsersProps {
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();
@@ -146,44 +149,48 @@ export function ChannelUsers({
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full"
>
{userId}
<button
onClick={() => handleRemove(channel, userId)}
disabled={saving}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
title={t("remove")}
>
</button>
{canEdit && (
<button
onClick={() => handleRemove(channel, userId)}
disabled={saving}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
title={t("remove")}
>
</button>
)}
</span>
))}
</div>
)}
{/* Add user */}
<div className="flex gap-2">
<input
type="text"
value={inputValues[channel] || ""}
onChange={(e) =>
setInputValues((prev) => ({
...prev,
[channel]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
placeholder={t("placeholder")}
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<button
onClick={() => handleAdd(channel)}
disabled={saving || !inputValues[channel]?.trim()}
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "…" : t("add")}
</button>
</div>
{/* Add user — hidden in read-only mode */}
{canEdit && (
<div className="flex gap-2">
<input
type="text"
value={inputValues[channel] || ""}
onChange={(e) =>
setInputValues((prev) => ({
...prev,
[channel]: e.target.value,
}))
}
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>
);
})}

View File

@@ -91,7 +91,23 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
);
}
export function UsageDisplay({ teamId }: { teamId: string | null }) {
/**
* Usage display widget.
*
* - Customers: don't pass teamId or keyAlias — the backend resolves both
* from the session-bound tenant.
* - Admins inspecting a specific tenant: pass `teamId` (the org-level
* LiteLLM team id) AND `keyAlias` (the tenant's virtual-key alias).
* Without `keyAlias`, the response includes spend from sibling tenants
* in the same org, since teams are shared since Slice 2.
*/
export function UsageDisplay({
teamId,
keyAlias,
}: {
teamId?: string | null;
keyAlias?: string | null;
}) {
const t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null);
@@ -101,20 +117,26 @@ export function UsageDisplay({ teamId }: { teamId: string | null }) {
const isCurrentMonth = month === getCurrentMonth();
const fetchUsage = useCallback(() => {
if (!teamId) { setLoading(false); return; }
setLoading(true);
setError(null);
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}&month=${month}`)
const params = new URLSearchParams({ month });
if (teamId) {
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(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [teamId, month]);
}, [teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]);
if (!teamId) return null;
return (
<div className="space-y-4">
{/* Month selector */}
@@ -182,4 +204,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>
);
}
}

View File

@@ -1,31 +1,36 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard";
import { ProvisioningStatus } from "./provisioning-status";
interface OnboardingFlowProps {
orgName: string;
initialState: "no_request" | "pending" | "approved" | "provisioning" | "rejected";
}
/**
* Orchestrates the onboarding experience:
* - no_request → show wizard
* - pending/approved/provisioning/rejected → show status
* - After wizard submission → switch to status polling
* Wraps the onboarding wizard. On successful submission, refreshes the
* router so the parent server component re-renders with the new pending
* request visible in the dashboard list.
*
* Slice 3: this component used to manage the no_request → pending →
* provisioning → active state machine, with conditional rendering of
* `<ProvisioningStatus>`. That state is now reflected at the dashboard
* level (which renders one `<ProvisioningStatus>` per pending request),
* so this wrapper does just one thing: show the wizard, then navigate.
*/
export function OnboardingFlow({ orgName, initialState }: OnboardingFlowProps) {
const [showWizard, setShowWizard] = useState(initialState === "no_request");
export function OnboardingFlow({ orgName }: OnboardingFlowProps) {
const router = useRouter();
if (showWizard) {
return (
<OnboardingWizard
orgName={orgName}
onComplete={() => setShowWizard(false)}
/>
);
}
return <ProvisioningStatus />;
return (
<OnboardingWizard
orgName={orgName}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and
// render its `<ProvisioningStatus>` card automatically.
router.push("/dashboard");
router.refresh();
}}
/>
);
}

View File

@@ -1,66 +1,86 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface OnboardingState {
state: string;
request?: {
id: string;
status: string;
companyName: string;
agentName: string;
adminNotes?: string;
};
tenant?: {
name: string;
phase: string;
message?: string;
conditions?: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
};
interface RequestSummary {
id: string;
instanceName?: string | null;
agentName: string;
packages: string[];
status: string;
adminNotes?: string;
tenantName?: string;
createdAt?: string;
updatedAt?: string;
}
export function ProvisioningStatus() {
interface TenantSummary {
name: string;
displayName: string;
phase: string;
conditions: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
}
interface SingleRequestState {
request: RequestSummary;
tenant: TenantSummary | null;
}
/**
* 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.
*
* The pre-Slice-3 version polled /api/onboarding with no params and
* assumed one-request-per-org — that endpoint shape is gone now.
*/
export function ProvisioningStatus({ requestId }: { requestId: string }) {
const t = useTranslations("onboarding");
const [data, setData] = useState<OnboardingState | null>(null);
const f = useFormatter();
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState("");
const poll = useCallback(async () => {
try {
const res = await fetch("/api/onboarding");
const res = await fetch(
`/api/onboarding?id=${encodeURIComponent(requestId)}`
);
if (!res.ok) throw new Error("Failed to fetch status");
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err.message);
}
}, []);
}, [requestId]);
useEffect(() => {
poll();
// Poll every 5 seconds while not in a terminal state
const interval = setInterval(() => {
if (
data?.state === "provisioned" ||
data?.state === "rejected" ||
data?.state === "active"
) {
return;
}
poll();
}, 5000);
const status = data?.request?.status;
const phase = data?.tenant?.phase;
const terminal =
status === "rejected" ||
status === "active" ||
phase === "Ready" ||
phase === "Running";
if (terminal) return;
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, [poll, data?.state]);
}, [poll, data?.request?.status, data?.tenant?.phase]);
if (error) {
return (
@@ -81,8 +101,14 @@ export function ProvisioningStatus() {
);
}
const status = data.request.status;
const label =
data.request.instanceName ||
data.request.tenantName ||
data.request.agentName;
// Pending admin approval
if (data.state === "pending") {
if (status === "pending") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -104,16 +130,33 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
</p>
{data.request.createdAt && (
<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>
)}
</div>
</Card>
);
}
// Rejected
if (data.state === "rejected") {
if (status === "rejected") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -135,10 +178,13 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request?.adminNotes && (
{data.request.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
{data.request.adminNotes}
</p>
@@ -148,10 +194,11 @@ export function ProvisioningStatus() {
);
}
// Provisioning in progress
// Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready)
if (
data.state === "approved" ||
data.state === "provisioning"
status === "approved" ||
status === "provisioning" ||
(status === "active" && data.tenant && data.tenant.phase !== "Ready")
) {
const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? [];
@@ -165,6 +212,9 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
</p>
@@ -199,8 +249,8 @@ export function ProvisioningStatus() {
);
}
// Provisioned / Running
if (data.state === "provisioned") {
// Active / Ready
if (status === "active") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -222,6 +272,9 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}
</p>

View File

@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, PERSONAL_ORG_SUFFIX } from "@/lib/personal-org";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -55,6 +56,16 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
// Slice 4: personal accounts have an org name of the form
// "{givenName} {familyName} (Personal)". For SOUL.md and the billing
// company line, strip the suffix so the visible string is the user's
// actual name (no stray "(Personal)" leaking onto invoices or into
// the assistant's prompt).
const isPersonal = isPersonalOrgName(orgName);
const displayOrgName = isPersonal
? orgName.slice(0, -PERSONAL_ORG_SUFFIX.length).trim()
: orgName;
const [step, setStep] = useState<Step>("welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
@@ -62,12 +73,16 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
const [config, setConfig] = useState({
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", orgName),
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
billingAddress: {
company: orgName,
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
// want to.
company: isPersonal ? "" : displayOrgName,
street: "",
city: "",
postalCode: "",
@@ -90,7 +105,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
// Fetch DB-stored defaults on mount
useEffect(() => {
fetch(`/api/workspace-defaults?orgName=${encodeURIComponent(orgName)}`)
fetch("/api/workspace-defaults")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
@@ -106,7 +121,8 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
.catch(() => {
/* use inline fallbacks */
});
}, [orgName]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-fetch TOOLS.md preview when packages change
const packagesKey = config.packages.sort().join(",");
@@ -115,14 +131,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
if (prevPackagesKey.current === packagesKey && defaultsLoaded) return;
prevPackagesKey.current = packagesKey;
fetch(
`/api/workspace-defaults?orgName=${encodeURIComponent(orgName)}&packages=${encodeURIComponent(packagesKey)}`
`/api/workspace-defaults?packages=${encodeURIComponent(packagesKey)}`
)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.toolsMd) setToolsMdPreview(data.toolsMd);
})
.catch(() => {});
}, [packagesKey, orgName, defaultsLoaded]);
}, [packagesKey, defaultsLoaded]);
const stepIndex = STEPS.indexOf(step);
@@ -305,6 +321,24 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("instanceName")}
</label>
<input
type="text"
value={config.instanceName}
onChange={(e) =>
setConfig((prev) => ({ ...prev, instanceName: e.target.value }))
}
placeholder={t("instanceNamePlaceholder")}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<p className="text-xs text-text-muted mt-1">
{t("instanceNameHint")}
</p>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
@@ -733,6 +767,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
<div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3">
{config.instanceName.trim() && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("instanceName")}</span>
<span className="text-text-primary font-mono">
{config.instanceName.trim()}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono">

View File

@@ -10,9 +10,18 @@ interface Props {
status?: "pending" | "active" | "error";
tenantName: string;
onToggled: () => void;
/** Slice 5: when false, the enable/disable button is hidden. */
canEdit?: boolean;
}
export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) {
export function PackageCard({
pkg,
enabled,
status,
tenantName,
onToggled,
canEdit = true,
}: Props) {
const t = useTranslations();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -113,17 +122,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
{canEdit ? (
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
) : (
// Slice 5: read-only viewers see a static badge instead of a
// toggle. The status badge above the divider already conveys
// "active/pending/error"; this just clarifies "you can't change
// it" without duplicating the status colour.
<span className="ml-auto text-[10px] text-text-muted italic">
{enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")}
</span>
)}
</div>
</div>

View File

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

View File

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

View File

@@ -1,14 +1,25 @@
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import type { PlatformRole, SessionUser, ZitadelClaims } from "@/types";
import type { PlatformRole, Role, SessionUser, ZitadelClaims } from "@/types";
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
/**
* Pull the role keys from the ZITADEL `urn:zitadel:iam:org:project:roles`
* claim. The claim is shaped as { roleKey: { orgId: orgName } } — we only
* need the keys.
*
* Slice 5: returns Role[] (the union) rather than PlatformRole[]. The
* keys can be either platform or customer roles depending on what the
* project authorization granted; the SessionUser carries them all and
* downstream helpers (canMutate, isCustomerOwner, requirePlatformRole)
* decide what each subset means.
*/
function extractRoles(
rolesObj?: Record<string, Record<string, string>>
): PlatformRole[] {
): Role[] {
if (!rolesObj) return [];
return Object.keys(rolesObj) as PlatformRole[];
return Object.keys(rolesObj) as Role[];
}
export const authConfig: NextAuthConfig = {
@@ -39,7 +50,6 @@ export const authConfig: NextAuthConfig = {
callbacks: {
async jwt({ token, account, profile }) {
if (account && profile) {
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
@@ -51,7 +61,7 @@ export const authConfig: NextAuthConfig = {
return token;
},
async session({ session, token }) {
const roles = (token.roles as PlatformRole[]) ?? [];
const roles = (token.roles as Role[]) ?? [];
const sessionUser: SessionUser = {
id: token.sub!,
name: session.user?.name ?? "",
@@ -59,7 +69,9 @@ export const authConfig: NextAuthConfig = {
orgId: token.orgId as string,
orgName: token.orgName as string,
roles,
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)),
isPlatform: roles.some((r) =>
PLATFORM_ROLES.includes(r as PlatformRole)
),
};
(session as any).platformUser = sessionUser;
return session;

View File

@@ -22,12 +22,27 @@ function getPool(): Pool {
// Schema migration (auto-run on first query)
// ---------------------------------------------------------------------------
// Notes on the Slice 3 changes
// ----------------------------
// 1. Removed `UNIQUE` from `zitadel_org_id` in the CREATE TABLE for fresh
// installs, AND emit a defensive `DROP CONSTRAINT IF EXISTS` for
// existing installs whose schema was created pre-Slice-3. The
// constraint was Postgres-autonamed; the name is deterministic.
// 2. Added `instance_name TEXT` — the customer's human label per
// instance (e.g. "Production", "Dev"). NULL is fine and means "use
// the company name for display".
// 3. Added a unique index on `tenant_name WHERE NOT NULL`. Multiple
// rows in the table can have NULL tenant_name (pending/rejected
// requests), but every approved row points to a distinct K8s CR.
// 4. Added `(zitadel_org_id, status)` index for the list-by-org queries
// introduced this slice.
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS tenant_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL UNIQUE,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
company_name TEXT NOT NULL,
instance_name TEXT,
contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant',
@@ -40,16 +55,26 @@ const MIGRATION_SQL = `
admin_notes TEXT,
tenant_name TEXT,
encrypted_secrets BYTEA,
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name
ON tenant_requests(tenant_name)
WHERE tenant_name IS NOT NULL;
-- Idempotent column adds for existing databases
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE;
-- Slice 3: drop the legacy 1-org-1-request constraint if it exists
ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key;
-- Workspace templates: admin-editable default content for workspace files
CREATE TABLE IF NOT EXISTS workspace_templates (
@@ -57,6 +82,39 @@ const MIGRATION_SQL = `
content TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Slice 6: per-tenant user assignments
-- ---------------------------------------------------------------------------
--
-- Each row grants ONE user visibility into ONE tenant within their own
-- ZITADEL org. Used to narrow the customer 'user' role from "everything
-- in the org" to "only the tenants I've been assigned to". Owners and
-- platform users bypass this table entirely.
--
-- Composite PK is (tenant_name, zitadel_user_id) — a user is either
-- assigned to a tenant or not, no degree.
--
-- The zitadel_org_id column is denormalised onto every row so cascade
-- cleanups when a user leaves an org can be expressed as a single
-- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without
-- joining tenant_requests. The assigned_by column tracks which user
-- (the owner usually) granted the assignment, for audit.
--
-- Cascade on tenant deletion is enforced in application code (the
-- admin delete handler calls removeAllAssignmentsForTenant) rather
-- than via FK — there's no FK target, since K8s CRs aren't a Postgres
-- table.
CREATE TABLE IF NOT EXISTS tenant_user_assignments (
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by TEXT NOT NULL,
PRIMARY KEY (tenant_name, zitadel_user_id)
);
CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id);
CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id);
`;
let migrated = false;
@@ -131,15 +189,17 @@ export async function createTenantRequest(
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`INSERT INTO tenant_requests
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
contact_email, agent_name, soul_md, agents_md, packages, billing_address,
billing_notes, encrypted_secrets)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
contact_name, contact_email, agent_name, soul_md, agents_md,
packages, billing_address, billing_notes, encrypted_secrets,
is_personal)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.instanceName ?? null,
params.contactName,
params.contactEmail,
params.agentName,
@@ -149,6 +209,7 @@ export async function createTenantRequest(
JSON.stringify(params.billingAddress),
params.billingNotes,
params.encryptedSecrets ?? null,
params.isPersonal ?? false,
]
);
return mapRow(result.rows[0]);
@@ -165,12 +226,67 @@ export async function getTenantRequestById(
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
export async function getTenantRequestByOrgId(
/**
* Slice 3: returns ALL requests for an org, most recent first.
*
* Replaces the pre-Slice-3 `getTenantRequestByOrgId` which returned the
* single most recent row. Callers that previously assumed one-row-per-org
* must now iterate or pick by status. The intent is explicit at every
* call site, which is the point of the rename.
*
* Includes rows in every status (pending, approved, provisioning, active,
* rejected, deleted). For "active or in-flight only" filtering, see
* {@link listActiveTenantRequestsByOrgId}.
*/
export async function listTenantRequestsByOrgId(
orgId: string
): Promise<TenantRequest[]> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC",
[orgId]
);
return result.rows.map(mapRow);
}
/**
* As {@link listTenantRequestsByOrgId} but excludes terminal-failed states
* (rejected, deleted). Useful for the dashboard which wants to show
* pending/approved/provisioning/active tenants and pending requests, not
* historical rejections.
*/
export async function listActiveTenantRequestsByOrgId(
orgId: string
): Promise<TenantRequest[]> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`SELECT * FROM tenant_requests
WHERE zitadel_org_id = $1
AND status NOT IN ('deleted', 'rejected')
ORDER BY created_at DESC`,
[orgId]
);
return result.rows.map(mapRow);
}
/**
* Returns the most recent approved-or-active request for an org. Used to
* seed billing/contact defaults when a customer creates an additional
* instance — saves them re-typing data already on file.
*
* Returns null if the org has never had an approved instance (e.g. first
* registration is still pending).
*/
export async function getMostRecentApprovedRequestForOrg(
orgId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC LIMIT 1",
`SELECT * FROM tenant_requests
WHERE zitadel_org_id = $1
AND status IN ('approved', 'provisioning', 'active')
ORDER BY created_at DESC
LIMIT 1`,
[orgId]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
@@ -239,8 +355,21 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
}
/**
* Mark a tenant request as "deleted" when the associated tenant CR is deleted.
* This allows the customer to re-submit the onboarding wizard.
* Wrapper around domain-check.ts that injects the portal's connection pool.
* Kept here so route handlers don't need direct access to the pool.
*/
export async function checkDuplicateDomain(email: string) {
await ensureSchema();
// Lazy import to keep db.ts free of fetch/AbortSignal at module load time.
const { checkRegistrationDomain } = await import("./domain-check");
return checkRegistrationDomain(getPool(), email);
}
/**
* Mark a single tenant request as "deleted" when the associated tenant CR
* is deleted. With multi-tenant per org this affects exactly one row,
* since tenant_name is unique by index. The customer's other instances
* are untouched.
*/
export async function markTenantRequestDeletedByTenantName(
tenantName: string
@@ -264,6 +393,10 @@ export async function deleteTenantRequest(id: string): Promise<void> {
/**
* Sync provisioning statuses: for all requests with status "provisioning",
* check if the PiecedTenant CR has reached "Ready" and update to "active".
*
* Slice 3 note: with multi-tenant per org, this iterates each row
* individually (keyed by its own tenant_name), so multiple in-flight
* tenants in the same org are handled correctly.
*/
export async function syncProvisioningStatuses(): Promise<void> {
await ensureSchema();
@@ -299,6 +432,7 @@ function mapRow(row: any): TenantRequest {
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
companyName: row.company_name,
instanceName: row.instance_name ?? null,
contactName: row.contact_name,
contactEmail: row.contact_email,
agentName: row.agent_name,
@@ -311,7 +445,155 @@ function mapRow(row: any): TenantRequest {
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
// ---------------------------------------------------------------------------
// Slice 6: tenant ↔ user assignments
// ---------------------------------------------------------------------------
/**
* One assignment grants one user visibility into one tenant. Returned
* shape is the camelCase mirror of the Postgres row.
*/
export interface TenantUserAssignment {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
assignedAt: string;
assignedBy: string;
}
function mapAssignmentRow(row: any): TenantUserAssignment {
return {
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at,
assignedBy: row.assigned_by,
};
}
/**
* Returns the set of tenant CR names assigned to the given user.
*
* Hot path on every read for `user`-role customers, so it's intentionally
* a single indexed lookup. The returned array is small (a handful of
* tenants per user); callers usually wrap it in a Set.
*
* Note: this does NOT cross-check the org id — assignments are per-user,
* and a user's org context comes from their JWT. If a user's
* authorization is revoked at the ZITADEL level, their JWT ceases to
* carry the customer role and they can't reach the dashboard at all;
* the orphan rows are cleaned up the next time their org membership
* is re-evaluated (Slice 7's removeAllAssignmentsForUser).
*/
export async function listTenantAssignmentsForUser(
userId: string
): Promise<string[]> {
await ensureSchema();
const result = await getPool().query<{ tenant_name: string }>(
"SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1",
[userId]
);
return result.rows.map((r) => r.tenant_name);
}
/**
* Returns all assignments for a single tenant. Used by the team UI
* (Slice 7) to render "who has access to this instance". Includes
* `assignedBy` and `assignedAt` for audit display.
*/
export async function listAssignmentsForTenant(
tenantName: string
): Promise<TenantUserAssignment[]> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC",
[tenantName]
);
return result.rows.map(mapAssignmentRow);
}
/**
* Grant a user access to a tenant. Idempotent — a duplicate INSERT
* is silently ignored via ON CONFLICT, and the existing
* `assigned_at`/`assigned_by` are preserved (we don't update them on
* re-assign).
*
* Caller is responsible for verifying:
* - The actor (`assignedBy`) holds owner/platform role in `orgId`.
* - The target user (`userId`) is actually a member of the same
* ZITADEL org. We don't validate this here — the team UI fetches
* the org's user list from ZITADEL and selects from it.
* - The tenant CR exists and is labelled with the same `orgId`.
*/
export async function addTenantAssignment(params: {
tenantName: string;
orgId: string;
userId: string;
assignedBy: string;
}): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO tenant_user_assignments
(tenant_name, zitadel_org_id, zitadel_user_id, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`,
[params.tenantName, params.orgId, params.userId, params.assignedBy]
);
}
/**
* Revoke a user's access to a tenant. No-op if the row doesn't exist.
*/
export async function removeTenantAssignment(
tenantName: string,
userId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2",
[tenantName, userId]
);
}
/**
* Cascade cleanup: drop ALL assignments for a tenant when the tenant
* itself is deleted. Called from the admin delete handler.
*
* Without this, an orphan row would stick around forever — a future
* tenant with the same name (won't happen given Slice 1's UUID-suffix
* naming, but defense in depth) would inherit the old assignments.
*/
export async function removeAllAssignmentsForTenant(
tenantName: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1",
[tenantName]
);
}
/**
* Cascade cleanup: drop ALL assignments for a user within a specific
* org. Used by Slice 7's "remove member" flow when an owner kicks a
* user out of the org. Scoped by `orgId` so a user with assignments in
* org A doesn't lose them when removed from org B (multi-org users
* exist when a person registers personally and is also invited to a
* company).
*/
export async function removeAllAssignmentsForUser(
orgId: string,
userId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2",
[orgId, userId]
);
}

273
src/lib/domain-check.ts Normal file
View File

@@ -0,0 +1,273 @@
/**
* Domain-uniqueness check for company registration.
*
* Goal: prevent two people from the same company creating two separate
* ZITADEL orgs. If alice@acme.ch registers Acme GmbH, then later
* bob@acme.ch tries to register Acme Holding AG, we should block bob and
* tell him to ask alice for an invite.
*
* Strategy:
* 1. Extract the domain from the submitted email address.
* 2. If the domain is in PUBLIC_EMAIL_DOMAINS, skip the check entirely
* (gmail/outlook/etc. are not company identifiers — many independent
* personal/sole-proprietor registrations may share gmail.com).
* 3. Otherwise, look up tenant_requests with status NOT IN
* ('rejected', 'deleted'). A domain is "in use" if any active row's
* contact_email shares that domain.
* 4. As a secondary check, query ZITADEL for orgs whose primary verified
* domain matches. This catches orgs created outside the portal flow
* (manually in ZITADEL console, or by an earlier bootstrap script).
* The primary-domain check is BEST-EFFORT — if ZITADEL is unreachable
* or returns an unexpected shape, we log and skip. The DB check is
* authoritative for portal-created orgs and that's what matters most.
*
* Returns the matching domain (lowercased) if a duplicate is found, else
* null. The caller turns that into a 409 response with a localized error.
*/
import { Pool } from "pg";
// ---------------------------------------------------------------------------
// Public email-provider blocklist
// ---------------------------------------------------------------------------
/**
* Domains where personal accounts dominate. Registrations from these are
* allowed to coexist independently — we don't treat "two gmail.com users"
* as the same company.
*
* Conservative list focused on Switzerland + major international providers.
* Adding to this list reduces false positives; removing increases them.
* Anything not on this list is treated as a corporate domain.
*/
export const PUBLIC_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
// Global
"gmail.com",
"googlemail.com",
"outlook.com",
"outlook.de",
"hotmail.com",
"hotmail.de",
"hotmail.fr",
"hotmail.it",
"live.com",
"msn.com",
"yahoo.com",
"yahoo.de",
"yahoo.fr",
"yahoo.it",
"icloud.com",
"me.com",
"mac.com",
"proton.me",
"protonmail.com",
"pm.me",
"tutanota.com",
"tutanota.de",
"tuta.io",
"fastmail.com",
"zoho.com",
"aol.com",
// Switzerland
"bluewin.ch",
"gmx.ch",
"gmx.com",
"gmx.net",
"gmx.de",
"gmx.at",
"hispeed.ch",
"sunrise.ch",
"swissonline.ch",
"vtxnet.ch",
"vtx.ch",
"tele2.ch",
"freesurf.ch",
"bluemail.ch",
"hotmail.ch",
"yahoo.ch",
"mail.ch",
// Germany / Austria (common in DACH region)
"web.de",
"t-online.de",
"freenet.de",
"1und1.de",
"aon.at",
// France / Italy
"orange.fr",
"free.fr",
"laposte.net",
"wanadoo.fr",
"sfr.fr",
"libero.it",
"tiscali.it",
"alice.it",
"virgilio.it",
]);
// ---------------------------------------------------------------------------
// Domain extraction
// ---------------------------------------------------------------------------
/**
* Extract the lowercased domain from an email address. Returns null if the
* input is not a well-formed email (defense in depth — Zod already validates
* the format upstream).
*/
export function extractEmailDomain(email: string): string | null {
const at = email.lastIndexOf("@");
if (at === -1 || at === email.length - 1) return null;
const domain = email.slice(at + 1).trim().toLowerCase();
if (!domain || !domain.includes(".")) return null;
return domain;
}
/**
* True if the domain belongs to a public email provider where multiple
* independent registrations should be allowed.
*/
export function isPublicEmailDomain(domain: string): boolean {
return PUBLIC_EMAIL_DOMAINS.has(domain.toLowerCase());
}
// ---------------------------------------------------------------------------
// Database check
// ---------------------------------------------------------------------------
/**
* Look up active tenant_requests whose contact_email shares the given domain.
* Active = status NOT IN ('rejected', 'deleted').
*
* Slice 4: personal-account rows (is_personal = TRUE) are excluded. A
* person's personal account doesn't claim the domain on behalf of a
* company — alice@acme.ch registering as a personal account must not
* block the actual Acme GmbH from registering later. The personal flag
* lives on the row itself, set by /api/register at creation time.
*
* Uses LOWER() on both sides to handle any historical case inconsistency in
* stored emails. The pattern '%@<domain>' is anchored so 'acme.ch' does not
* match 'notacme.ch' or 'acme.ch.evil.com'.
*/
async function findDuplicateInDb(
pool: Pool,
domain: string
): Promise<boolean> {
const result = await pool.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM tenant_requests
WHERE LOWER(contact_email) LIKE $1
AND status NOT IN ('rejected', 'deleted')
AND is_personal = FALSE`,
[`%@${domain.toLowerCase()}`]
);
return Number(result.rows[0]?.count ?? 0) > 0;
}
// ---------------------------------------------------------------------------
// ZITADEL check (secondary, best-effort)
// ---------------------------------------------------------------------------
/**
* Search ZITADEL orgs by primary verified domain.
*
* Uses the v2 OrganizationService.ListOrganizations API:
* POST {ZITADEL_URL}/v2/organizations/_search
*
* Filter shape (per ZITADEL v2 API): an `organizationDomain` query that
* matches against verified domain. Method is EQUALS and case-insensitive.
*
* Returns true if at least one org matches. Returns false on any error
* (network, auth, schema mismatch) — we log and let the DB check be
* authoritative. The portal must not block legitimate registrations because
* ZITADEL had a hiccup.
*/
async function findDuplicateInZitadel(domain: string): Promise<boolean> {
const ZITADEL_URL = process.env.ZITADEL_ISSUER;
const ZITADEL_SA_PAT = process.env.ZITADEL_SA_PAT;
if (!ZITADEL_URL || !ZITADEL_SA_PAT) {
console.warn("ZITADEL env not configured, skipping org-domain check");
return false;
}
try {
const res = await fetch(`${ZITADEL_URL}/v2/organizations/_search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${ZITADEL_SA_PAT}`,
},
body: JSON.stringify({
queries: [
{
organizationDomain: {
domain,
method: "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE",
},
},
],
// Limit + sort: we only need to know whether ANY org has this domain
pagination: { limit: 1 },
}),
// Belt: hard timeout so a hung ZITADEL doesn't stall registration
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
console.warn(
`ZITADEL org-domain search returned ${res.status}, skipping check`
);
return false;
}
const data = (await res.json()) as {
result?: Array<{ id?: string; name?: string }>;
};
return Array.isArray(data.result) && data.result.length > 0;
} catch (err) {
console.warn("ZITADEL org-domain search failed, skipping check:", err);
return false;
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export interface DuplicateCheckResult {
/** True if registration must be blocked. */
blocked: boolean;
/** The domain that was matched (lowercased). Set when blocked = true. */
domain?: string;
}
/**
* Run the full duplicate-domain check for a registration request.
*
* Order:
* - Parse domain. Invalid → not blocked (Zod already failed if so;
* this is just defensive).
* - Public domain → not blocked.
* - DB hit → blocked.
* - ZITADEL hit → blocked.
* - Otherwise → not blocked.
*/
export async function checkRegistrationDomain(
pool: Pool,
email: string
): Promise<DuplicateCheckResult> {
const domain = extractEmailDomain(email);
if (!domain) return { blocked: false };
if (isPublicEmailDomain(domain)) return { blocked: false };
if (await findDuplicateInDb(pool, domain)) {
return { blocked: true, domain };
}
if (await findDuplicateInZitadel(domain)) {
return { blocked: true, domain };
}
return { blocked: false };
}

View File

@@ -42,11 +42,26 @@ function getFrom(): string {
);
}
/**
* Escape HTML entities to prevent injection in HTML emails.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function sendApprovalEmail(
to: string,
contactName: string,
companyName: string
): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
try {
await getTransporter().sendMail({
from: getFrom(),
@@ -68,8 +83,8 @@ export async function sendApprovalEmail(
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
<p>Hello ${contactName},</p>
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p>
<p>Hello ${safeName},</p>
<p>Great news! Your onboarding request for <strong>${safeCompany}</strong> has been approved.</p>
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
<p>
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
@@ -95,14 +110,18 @@ export async function sendRejectionEmail(
companyName: string,
adminNotes?: string
): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
try {
const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n`
: "";
const notesHtml = adminNotes
const notesHtml = safeNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${adminNotes}</p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
</div>`
: "";
@@ -123,8 +142,8 @@ export async function sendRejectionEmail(
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${contactName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${companyName}</strong> at this time.</p>
<p>Hello ${safeName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
@@ -145,6 +164,10 @@ export async function sendAdminNotificationEmail(
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
const safeCompany = escapeHtml(companyName);
const safeName = escapeHtml(contactName);
const safeEmail = escapeHtml(contactEmail);
try {
await getTransporter().sendMail({
from: getFrom(),
@@ -158,6 +181,23 @@ export async function sendAdminNotificationEmail(
"",
`Review it at https://app.pieced.ch/admin`,
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">New onboarding request</h2>
<p>A new onboarding request has been submitted.</p>
<table style="color: #ccc; font-size: 14px; margin: 16px 0;">
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Company:</td><td>${safeCompany}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
</table>
<p>
<a href="https://app.pieced.ch/admin" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Review Request
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send admin notification email:", err);

37
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Error sanitization for API responses.
*
* By default, returns a generic message to the client and logs the full
* error server-side. Set PORTAL_DEBUG_ERRORS=true to return the raw
* error message to the client (useful during development/debugging).
*/
const DEBUG_ERRORS = process.env.PORTAL_DEBUG_ERRORS === "true";
/**
* Returns a safe error string for API responses.
*
* - In debug mode (PORTAL_DEBUG_ERRORS=true): returns the raw e.message
* - In production mode: returns the fallback string and logs the real error
*
* Recognises common HTTP status codes from k8s/vault errors and returns
* appropriate short messages even in production mode.
*/
export function safeError(e: unknown, fallback: string): string {
const err = e instanceof Error ? e : new Error(String(e));
const statusCode = (err as any).statusCode as number | undefined;
if (DEBUG_ERRORS) {
return err.message;
}
// Map well-known status codes to safe messages
if (statusCode === 404) return "Not found";
if (statusCode === 403) return "Forbidden";
if (statusCode === 409) return "Conflict";
if (statusCode === 401) return "Unauthorized";
// Log full error server-side, return generic to client
console.error(`${fallback}:`, err.message);
return fallback;
}

118
src/lib/format.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* Locale-aware date/time formatting helpers.
*
* Built on top of next-intl's format API, which wraps Intl.DateTimeFormat /
* Intl.RelativeTimeFormat using the active request locale. These helpers add
* three things on top of raw next-intl:
*
* 1. Tolerant input — accepts string | Date | null | undefined and returns
* a stable em-dash for missing values, so call sites don't need to
* conditionally render.
* 2. Two presets used everywhere in the portal (`dateTime`, `dateOnly`)
* so the four locales render consistently. German/French/Italian use
* 24h DD.MM.YYYY HH:mm; English uses 12h MMM D, YYYY h:mm a.
* 3. A `relative` helper that auto-picks the right unit (minute/hour/day/
* week/month) based on the elapsed delta.
*
* Usage in client components:
*
* import { useFormatter } from "next-intl";
* import { formatDateTime, formatRelative } from "@/lib/format";
*
* const f = useFormatter();
* <span>{formatDateTime(req.createdAt, f)}</span>
* <span title={formatDateTime(req.createdAt, f)}>
* {formatRelative(req.createdAt, f)}
* </span>
*
* Usage in server components:
*
* import { getFormatter } from "next-intl/server";
* const f = await getFormatter();
* ...same calls...
*/
// next-intl's `useFormatter()` (client) and `getFormatter()` (server) return
// the same shape. We derive the type from useFormatter's return so we stay
// in sync with next-intl version bumps without hand-maintaining a mirror.
import type { useFormatter } from "next-intl";
type Formatter = ReturnType<typeof useFormatter>;
const FALLBACK = "—";
function toDate(value: string | Date | null | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Full date+time, locale-formatted. Returns "—" if the value is missing.
*
* de: 25.04.2026, 14:30
* en: Apr 25, 2026, 2:30 PM
* fr: 25 avr. 2026, 14:30
* it: 25 apr 2026, 14:30
*/
export function formatDateTime(
value: string | Date | null | undefined,
formatter: Formatter
): string {
const d = toDate(value);
if (!d) return FALLBACK;
return formatter.dateTime(d, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Date only, locale-formatted. Use in dense table cells.
*/
export function formatDateOnly(
value: string | Date | null | undefined,
formatter: Formatter
): string {
const d = toDate(value);
if (!d) return FALLBACK;
return formatter.dateTime(d, {
year: "numeric",
month: "short",
day: "numeric",
});
}
/**
* Relative time ("2 hours ago", "vor 2 Stunden", etc.).
* Picks the unit automatically based on the magnitude of the delta.
* Returns "—" if the value is missing.
*
* Anchors against `now` (defaults to current time) so SSR and client
* render the same string when called within a single request.
*/
export function formatRelative(
value: string | Date | null | undefined,
formatter: Formatter,
now: Date = new Date()
): string {
const d = toDate(value);
if (!d) return FALLBACK;
const diffMs = d.getTime() - now.getTime();
const absSeconds = Math.abs(diffMs) / 1000;
let unit: Intl.RelativeTimeFormatUnit;
if (absSeconds < 60) unit = "second";
else if (absSeconds < 3_600) unit = "minute";
else if (absSeconds < 86_400) unit = "hour";
else if (absSeconds < 604_800) unit = "day";
else if (absSeconds < 2_592_000) unit = "week";
else if (absSeconds < 31_536_000) unit = "month";
else unit = "year";
return formatter.relativeTime(d, { now, unit });
}

View File

@@ -91,6 +91,10 @@ export async function getGlobalSpend(): Promise<number> {
/**
* Fetch per-team spend as a map: teamId → spend (CHF).
* Uses /team/list which includes current spend per team.
*
* Since Slice 2, a "team" is the company-level budget shared across all
* tenants of the same ZITADEL org. So this map gives company totals, not
* per-tenant spend. For per-tenant attribution, use {@link getPerKeySpend}.
*/
export async function getPerTeamSpend(): Promise<Map<string, number>> {
const teams = await listTeams();
@@ -102,3 +106,54 @@ export async function getPerTeamSpend(): Promise<Map<string, number>> {
}
return map;
}
/**
* Fetch per-virtual-key spend as a map: keyAlias → spend (CHF).
*
* Since Slice 2, each PiecedTenant CR owns one virtual key under its
* org's team, with `key_alias = tenant.metadata.name`. Filtering by the
* key alias is how we get genuinely per-tenant spend.
*
* Implementation
* --------------
* Calls `/key/list?return_full_object=true&include_team_keys=true`,
* which returns objects with `spend` and `key_alias`. Older LiteLLM
* builds may return raw token strings instead — we degrade gracefully
* to an empty map in that case rather than throwing, since the admin
* health page should still render even if per-tenant numbers are
* temporarily unavailable.
*
* @returns Map<keyAlias, spend>. May be empty if the LiteLLM build
* doesn't expose key-alias info; callers must handle that.
*/
export async function getPerKeySpend(): Promise<Map<string, number>> {
const map = new Map<string, number>();
try {
const data = await litellmFetch(
"/key/list?return_full_object=true&include_team_keys=true"
);
// Response shape: { keys: [ { key_alias, spend, token, ... } ] }
// or sometimes { data: [...] }, or raw arrays. Be tolerant.
const keys: any[] = Array.isArray(data?.keys)
? data.keys
: Array.isArray(data?.data)
? data.data
: Array.isArray(data)
? data
: [];
for (const k of keys) {
// Skip raw-string entries from older API shapes — we can't attribute them.
if (typeof k !== "object" || k === null) continue;
const alias = k.key_alias ?? k.keyAlias;
if (typeof alias !== "string" || !alias) continue;
const spend =
typeof k.spend === "number" ? k.spend : Number(k.spend) || 0;
map.set(alias, spend);
}
} catch (e) {
console.warn("getPerKeySpend failed, returning empty map:", e);
}
return map;
}

40
src/lib/personal-org.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Personal-account helpers.
*
* Slice 4 establishes the convention that ZITADEL org names for personal
* accounts end with the literal " (Personal)" suffix. This file
* centralises the suffix and the predicate so both registration (which
* sets the suffix) and onboarding (which reads it from the session) use
* the same canonical form.
*
* Why a name suffix and not ZITADEL org metadata?
* -----------------------------------------------
* 1. The suffix is visible in ZITADEL Console, admin tools, JWT claims,
* etc. — useful debugging signal at zero cost.
* 2. Customers cannot rename their own org (requires IAM_OWNER, which
* only the SA holds), so the suffix is stable for the lifetime of
* the org.
* 3. No extra ZITADEL API calls at onboarding time to fetch metadata.
* 4. No extra portal DB tables.
*
* The trade-off: an admin who manually renames a personal org via
* ZITADEL Console could remove the suffix, after which onboarding
* would treat that org as a company. That's a deliberate destructive
* action and the worst outcome is a misnamed K8s CR; nothing breaks.
*/
export const PERSONAL_ORG_SUFFIX = " (Personal)";
/**
* Returns true when the given ZITADEL org name marks a personal account.
*
* The check is exact-suffix match (after trimming). Whitespace inside
* the suffix is significant — `" (personal)"` lowercase or `"(Personal)"`
* without the leading space are not matches and not personal orgs.
*
* Pass `session.orgName` from the SessionUser at the call site.
*/
export function isPersonalOrgName(orgName: string | null | undefined): boolean {
if (!orgName) return false;
return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX);
}

71
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* In-memory sliding-window rate limiter.
*
* Suitable for single-node deployments (pilot scale).
* For multi-replica, replace with Redis-backed store.
*/
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
// Cleanup stale entries every 10 minutes
if (typeof globalThis !== "undefined") {
// Use globalThis to survive HMR in dev — only one interval
const key = "__rateLimitCleanup";
if (!(globalThis as any)[key]) {
(globalThis as any)[key] = setInterval(() => {
const now = Date.now();
for (const [k, entry] of store) {
entry.timestamps = entry.timestamps.filter((t) => now - t < 3_600_000);
if (entry.timestamps.length === 0) store.delete(k);
}
}, 600_000);
}
}
export interface RateLimitResult {
allowed: boolean;
remaining: number;
/** Milliseconds until the oldest request in the window expires */
resetMs: number;
}
/**
* Check and record a rate-limited action.
*
* @param key - Unique key, e.g. `register:${ip}`
* @param limit - Max allowed actions in the window
* @param windowMs - Window size in milliseconds
*/
export function rateLimit(
key: string,
limit: number,
windowMs: number,
): RateLimitResult {
const now = Date.now();
const entry = store.get(key) ?? { timestamps: [] };
// Prune expired timestamps
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
if (entry.timestamps.length >= limit) {
const oldest = entry.timestamps[0];
return {
allowed: false,
remaining: 0,
resetMs: oldest + windowMs - now,
};
}
entry.timestamps.push(now);
store.set(key, entry);
return {
allowed: true,
remaining: limit - entry.timestamps.length,
resetMs: entry.timestamps[0] + windowMs - now,
};
}

View File

@@ -1,19 +1,87 @@
import { auth } from "@/lib/auth";
import type { SessionUser } from "@/types";
/**
* Read-only session lookup. Returns the SessionUser stashed on the
* NextAuth session by `auth.ts::callbacks.session`, or null if there
* is no authenticated session.
*/
export async function getSessionUser(): Promise<SessionUser | null> {
const session = await auth();
return (session as any)?.platformUser ?? null;
}
/**
* Throws if there is no authenticated session. Otherwise returns the
* SessionUser. Use at the top of any handler that requires a logged-in
* user regardless of role.
*/
export async function requireSession(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) throw new Error("Unauthorized");
return user;
}
/**
* Throws unless the caller has a platform-level role
* (platform_admin or platform_operator). Use to gate /api/admin/*
* routes — these handle ANY customer's org and must not be accessible
* to customer-role users.
*/
export async function requirePlatformRole(): Promise<SessionUser> {
const user = await requireSession();
if (!user.isPlatform) throw new Error("Forbidden: platform role required");
return user;
}
// ---------------------------------------------------------------------------
// Slice 5: role predicates and gates
// ---------------------------------------------------------------------------
//
// Naming convention: `is*` are pure predicates over a SessionUser,
// safe to call inline in JSX/server components. `require*` throw on
// failure and are meant for the top of route handlers.
/**
* True when the user is a platform admin/operator OR holds the
* `owner` customer role on their org.
*
* This is the single check for "can mutate". Platform users always
* win because they administer all orgs cross-cut. Customer-side, only
* `owner` may mutate; `user` (and any future read-only customer role)
* cannot.
*/
export function canMutate(user: SessionUser): boolean {
return user.isPlatform || user.roles.includes("owner");
}
/**
* True when the user holds the customer `owner` role on their org.
* Excludes platform users — use {@link canMutate} when both should
* be allowed.
*
* Useful for permissions that are specifically about "this customer's
* own owner", e.g. "owner can invite users into their own org" — a
* platform user shouldn't be casually inviting users into a customer
* org, that's an admin-console action and goes through different
* tooling.
*/
export function isCustomerOwner(user: SessionUser): boolean {
return !user.isPlatform && user.roles.includes("owner");
}
/**
* Throws unless `canMutate(user) === true`. Use at the top of any
* mutating customer-side handler.
*
* The thrown error message is intentionally generic — handlers
* should catch and translate to a 403 JSON response so the client
* doesn't see a stack trace.
*/
export async function requireOwnerRole(): Promise<SessionUser> {
const user = await requireSession();
if (!canMutate(user)) {
throw new Error("Forbidden: owner role required");
}
return user;
}

132
src/lib/tenant-naming.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Deterministic tenant-name derivation for PiecedTenant CRs.
*
* Background
* ----------
* Every PiecedTenant CR's `metadata.name` becomes part of the tenant
* namespace, which the operator builds as `tenant-{name}` (see
* `pieced-operator/api/v1alpha1/piecedtenant_types.go::NamespaceName`).
* Kubernetes namespace names follow the RFC 1123 DNS *label* spec:
* lowercased alphanumeric + hyphens, must start and end with alnum,
* and **max 63 characters**.
*
* That gives us 63 - len("tenant-") = 56 chars to play with for the CR
* name itself. Anything longer is rejected at apply time, so we cap
* here.
*
* Format
* ------
* kind=company → {slug}-{requestIdHex8} e.g. "acme-gmbh-abc12345"
* kind=personal → p-{requestIdHex8} e.g. "p-abc12345"
*
* The 8-hex-char suffix is taken from `tenant_requests.id` (a Postgres
* `gen_random_uuid()` value, set at row insert). Two motivations:
*
* 1. Uniqueness — multiple requests for the same company name no longer
* collide (this is what unblocks Slice 3, multi-tenant per org).
* 2. Stability — the suffix is known at approval time and never changes,
* so the operator and portal agree without coordination. We use the
* request UUID rather than the eventual LiteLLM virtual-key UUID
* because the latter doesn't exist until the operator runs.
*
* 8 hex chars = 32 bits of entropy. Collision probability with 100 active
* tenants per company prefix is ~1e-6; for our pilot scale that's fine.
*
* Limits
* ------
* Suffix is always 8 + 1 (hyphen) = 9 chars. Slug therefore caps at
* 56 - 9 = 47 chars, then we strip any trailing hyphens left by the cut.
*
* Examples
* --------
* deriveTenantName("company", "Acme GmbH", "abc12345-...") = "acme-gmbh-abc12345"
* deriveTenantName("company", "Müller AG", "abc12345-...") = "m-ller-ag-abc12345" (umlaut → "-")
* deriveTenantName("company", "!!!", "abc12345-...") = "t-abc12345" (slug empty → "t-")
* deriveTenantName("personal", "", "abc12345-...") = "p-abc12345"
*/
export type TenantKind = "company" | "personal";
const MAX_NAMESPACE_LEN = 63;
const NAMESPACE_PREFIX = "tenant-";
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length; // 56
const SUFFIX_HEX_LEN = 8;
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1; // including the joining "-"
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN; // 47
export class InvalidRequestIdError extends Error {
constructor(requestId: string) {
super(
`Cannot derive tenant name: requestId "${requestId}" does not contain ${SUFFIX_HEX_LEN} hex characters`
);
this.name = "InvalidRequestIdError";
}
}
/**
* Reduce an arbitrary string to a DNS-label-safe slug. Non-alnum runs
* collapse to a single "-"; leading/trailing hyphens are stripped.
*
* Note this does not transliterate Unicode — "Müller" becomes "m-ller",
* not "mueller". That's deliberate: transliteration introduces locale
* dependencies (de-DE vs de-CH vs sv-SE all disagree on ä→a/ä→ae) and
* we'd rather have a stable, ugly slug than a pretty one that changes
* if we touch the locale config later. Customers see the human-readable
* `displayName`, not the slug.
*/
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Extract the first 8 hex chars of a UUID string. Strips hyphens and
* lowercases first so callers can pass either "abc12345-..." or
* "ABC12345..." form. Postgres `gen_random_uuid()` already emits the
* canonical lowercase-hyphenated form, so this is just defense in depth
* against any hand-inserted IDs.
*/
function requestIdSuffix(requestId: string): string {
const hex = requestId.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{8}/.test(hex)) {
throw new InvalidRequestIdError(requestId);
}
return hex.slice(0, SUFFIX_HEX_LEN);
}
/**
* Build the PiecedTenant CR `metadata.name` for an approved tenant request.
*
* @param kind "company" for normal customer accounts; "personal"
* for individual accounts (Slice 4 — `is_personal=true`).
* @param companyName Raw display name from the registration. Ignored when
* kind="personal".
* @param requestId `tenant_requests.id` (Postgres UUID).
* @returns A K8s-safe CR name, ≤ 56 chars, with an 8-hex suffix.
*/
export function deriveTenantName(
kind: TenantKind,
companyName: string,
requestId: string
): string {
const suffix = requestIdSuffix(requestId);
if (kind === "personal") {
return `p-${suffix}`;
}
// Company branch: slug-{suffix}, with empty-slug fallback.
const rawSlug = slugify(companyName);
// Cap then re-trim — slicing might leave a dangling hyphen if a non-alnum
// run sat right at the boundary (e.g. "acme-foo-bar-..." cut to "acme-foo-").
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
if (!slug) {
return `t-${suffix}`;
}
return `${slug}-${suffix}`;
}

127
src/lib/visibility.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Tenant visibility scoping for the customer-facing portal.
*
* Centralised here so every endpoint that lists or fetches tenants
* agrees on the same rules. A bug in any one of those — say, a stale
* inline filter that returned org-wide results to a `user`-role member
* — would leak siblings' workspace files and channel-user lists.
* One source of truth makes the audit easy.
*
* Visibility model
* ----------------
* platform_admin / platform_operator → all tenants in the cluster.
* owner (customer) → all tenants in their own org.
* user (customer, no owner role) → only tenants they've been
* assigned to via the
* tenant_user_assignments table.
*
* The narrowing for `user` is what turns the customer role into a
* meaningful access boundary. Without it, every member of an org
* would see every tenant — fine for a one-team SaaS, broken for a
* company with separate Production / Staging / Sales instances where
* the Sales team shouldn't see the Production workspace files.
*
* Owners do NOT get filtered against the assignment table even if
* they happen to have rows in it. The owner role beats user-level
* scoping — that's the point of being an owner.
*/
import type { SessionUser, PiecedTenant } from "@/types";
import { listTenantAssignmentsForUser } from "./db";
/** Internal classifier — "what's this caller's visibility scope?". */
type Scope = "all" | "org" | "assigned";
function scopeFor(user: SessionUser): Scope {
if (user.isPlatform) return "all";
if (user.roles.includes("owner")) return "org";
return "assigned";
}
/**
* Filter a list of tenants down to what `user` is allowed to see.
*
* Performs at most one DB query (only when scope is "assigned") and
* runs the K8s-side filter in memory. The K8s list is already small
* (≤100 tenants at pilot scale) so this is fine; if it grew we'd
* push the filter down to the K8s label selector instead.
*/
export async function listVisibleTenants(
user: SessionUser,
all: PiecedTenant[]
): Promise<PiecedTenant[]> {
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;
// scope === "assigned" — narrow to the user's assignment list
const assigned = await listTenantAssignmentsForUser(user.id);
if (assigned.length === 0) return [];
const allowed = new Set(assigned);
return orgScoped.filter((t) => allowed.has(t.metadata.name));
}
/**
* Single-tenant predicate. Returns true when `user` may see (and read
* from) `tenant`. Mutating endpoints additionally need
* `canMutate(user)` from `lib/session.ts` — visibility ≠ permission to
* change.
*
* Returns false (rather than throwing) so handlers can map to the
* status code that fits their semantics — usually 404 for read paths
* (don't leak existence) and 403 for mutation paths (caller already
* knew the tenant existed).
*/
export async function canUserSeeTenant(
user: SessionUser,
tenant: PiecedTenant
): Promise<boolean> {
const scope = scopeFor(user);
if (scope === "all") return true;
// org scope and assigned scope both require the tenant to belong
// to the user's org — different orgs are never visible.
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
return false;
}
if (scope === "org") return true;
// scope === "assigned"
const assigned = await listTenantAssignmentsForUser(user.id);
return assigned.includes(tenant.metadata.name);
}
/**
* "Should `user` see in-flight tenant requests on the dashboard?"
*
* Owners and platform users yes (they own the lifecycle); user-role
* members no (they can't act on requests, and a request that isn't
* yet a tenant has no assignment yet, so showing it would be a
* permanent "pending" with no action they can take).
*/
export function canSeeInflightRequests(user: SessionUser): boolean {
return scopeFor(user) !== "assigned";
}
/**
* Convenience predicate used by client-side empty states. For
* `user`-role members, the dashboard wants to distinguish between
* "your org has no instances" (very rare; ask owner to set one up)
* and "your org has instances but you're not assigned to any" (more
* common; ask owner to grant access).
*
* Callers compute this off the difference between visible and
* org-wide tenant lists; this helper just reifies the test.
*/
export function isUserScoped(user: SessionUser): boolean {
return scopeFor(user) === "assigned";
}

View File

@@ -34,7 +34,10 @@
"footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.",
"successTitle": "Registrierung eingegangen",
"successDescription": "Sie erhalten eine Einladungs-E-Mail mit einem Link, um Ihr Passwort festzulegen und Ihre E-Mail-Adresse zu bestätigen. Danach können Sie sich anmelden und Ihren KI-Assistenten einrichten.",
"goToLogin": "Zur Anmeldung"
"goToLogin": "Zur Anmeldung",
"duplicateDomain": "Für die E-Mail-Domain {domain} ist bereits ein Konto registriert. Bitte wenden Sie sich an Ihren Firmenadministrator, um eingeladen zu werden, oder kontaktieren Sie den PieCed-IT-Support, falls dies ein Fehler ist.",
"individualToggle": "Als Privatperson registrieren",
"individualHint": "Aktivieren Sie diese Option, wenn Sie sich nicht im Namen eines Unternehmens registrieren. Ihr Konto wird als persönlicher Arbeitsbereich eingerichtet."
},
"onboarding": {
"loading": "Status wird geladen…",
@@ -81,7 +84,11 @@
"phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.",
"goToDashboard": "Zum Dashboard"
"goToDashboard": "Zum Dashboard",
"submittedAt": "Eingereicht",
"instanceName": "Instanzname",
"instanceNamePlaceholder": "z.B. Produktion, Dev, Vertrieb",
"instanceNameHint": "Optionaler lesbarer Name, um diese Instanz von anderen in Ihrem Dashboard zu unterscheiden. Leer lassen, um den Firmennamen zu verwenden."
},
"dashboard": {
"title": "Dashboard",
@@ -92,14 +99,24 @@
"noInstance": "Noch keine Instanz bereitgestellt.",
"comingSoon": "Detailansicht folgt in Session 6.2",
"noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.",
"manage": "Instanz & Pakete verwalten"
"manage": "Instanz & Pakete verwalten",
"instances": "Ihre Instanzen",
"inflightRequests": "Laufende Anfragen",
"createInstance": "Neue Instanz erstellen",
"createInstanceDescription": "Eine weitere KI-Assistent-Instanz für Ihre Organisation bereitstellen. Die Anfrage wird von einem Administrator geprüft, bevor die Instanz erstellt wird.",
"noAccessNoInstances": "Ihre Organisation hat noch keine Instanzen. Bitte bitten Sie den Eigentümer der Organisation, eine einzurichten.",
"noAssignmentsTitle": "Keine Instanzen zugewiesen",
"noAssignmentsDescription": "Ihre Organisation verfügt über Instanzen, aber Sie haben keinen Zugriff darauf erhalten. Bitten Sie den Eigentümer Ihrer Organisation, Sie einer Instanz zuzuweisen.",
"noInstancesYetTitle": "Noch keine Instanzen",
"noInstancesYetDescription": "Ihre Organisation verfügt noch über keine Instanzen. Bitten Sie den Eigentümer Ihrer Organisation, eine einzurichten."
},
"tenantDetail": {
"agent": "Agent",
"packages": "Pakete",
"workspaceFiles": "Workspace-Dateien",
"notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten"
"usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt"
},
"usage": {
"inputTokens": "Input-Tokens",
@@ -167,7 +184,9 @@
},
"documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
}
},
"statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert"
},
"admin": {
"title": "Plattform-Admin",
@@ -191,6 +210,7 @@
"agentName": "Agent",
"status": "Status",
"submitted": "Eingereicht",
"updated": "Aktualisiert",
"actions": "Aktionen",
"noRequests": "Keine Anfragen gefunden.",
"loadingRequests": "Anfragen werden geladen…",

View File

@@ -34,7 +34,10 @@
"footer": "Your data is hosted exclusively on-premises in Switzerland.",
"successTitle": "Registration received",
"successDescription": "You will receive an invitation email with a link to set your password and verify your email address. Once completed, you can sign in to set up your AI assistant.",
"goToLogin": "Go to Sign In"
"goToLogin": "Go to Sign In",
"duplicateDomain": "An account for the email domain {domain} is already registered. Please contact your company administrator to be invited, or reach out to PieCed IT support if you believe this is in error.",
"individualToggle": "Register as an individual",
"individualHint": "Tick this if you're not registering on behalf of a company. Your account will be set up as a personal workspace."
},
"onboarding": {
"loading": "Loading status…",
@@ -81,7 +84,11 @@
"phase": "Phase",
"readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.",
"goToDashboard": "Go to Dashboard"
"goToDashboard": "Go to Dashboard",
"submittedAt": "Submitted",
"instanceName": "Instance name",
"instanceNamePlaceholder": "e.g. Production, Dev, Sales",
"instanceNameHint": "Optional human-readable name to distinguish this instance from others on your dashboard. Leave blank to use your company name."
},
"dashboard": {
"title": "Dashboard",
@@ -92,14 +99,24 @@
"noInstance": "No instance provisioned yet.",
"comingSoon": "Detailed view coming in Session 6.2",
"noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.",
"manage": "Manage instance & packages"
"manage": "Manage instance & packages",
"instances": "Your instances",
"inflightRequests": "In-flight requests",
"createInstance": "Create new instance",
"createInstanceDescription": "Provision an additional AI assistant instance for your organization. The request will be reviewed by an administrator before the instance is created.",
"noAccessNoInstances": "Your organization doesn't have any instances yet. Please ask the organization owner to set one up.",
"noAssignmentsTitle": "No instances assigned",
"noAssignmentsDescription": "Your organization has instances, but you haven't been granted access to any of them. Please ask your organization owner to assign you to an instance.",
"noInstancesYetTitle": "No instances yet",
"noInstancesYetDescription": "Your organization doesn't have any instances yet. Please ask your organization owner to set one up."
},
"tenantDetail": {
"agent": "Agent",
"packages": "Packages",
"workspaceFiles": "Workspace Files",
"notFound": "Tenant not found.",
"usage": "Usage & Spend"
"usage": "Usage & Spend",
"provisioned": "Provisioned"
},
"usage": {
"inputTokens": "Input Tokens",
@@ -167,7 +184,9 @@
},
"documentProcessing": {
"description": "Enable document parsing, summarization, and extraction."
}
},
"statusEnabled": "enabled",
"statusDisabled": "disabled"
},
"admin": {
"title": "Platform Admin",
@@ -191,6 +210,7 @@
"agentName": "Agent",
"status": "Status",
"submitted": "Submitted",
"updated": "Updated",
"actions": "Actions",
"noRequests": "No requests found.",
"loadingRequests": "Loading requests…",

View File

@@ -34,7 +34,10 @@
"footer": "Vos données sont hébergées exclusivement on-premises en Suisse.",
"successTitle": "Inscription reçue",
"successDescription": "Vous recevrez un e-mail d'invitation avec un lien pour définir votre mot de passe et vérifier votre adresse e-mail. Ensuite, vous pourrez vous connecter et configurer votre assistant IA.",
"goToLogin": "Aller à la connexion"
"goToLogin": "Aller à la connexion",
"duplicateDomain": "Un compte pour le domaine de courriel {domain} est déjà enregistré. Veuillez contacter l'administrateur de votre entreprise pour être invité, ou contactez le support PieCed IT si vous pensez qu'il s'agit d'une erreur.",
"individualToggle": "S'inscrire en tant que particulier",
"individualHint": "Cochez cette case si vous ne vous inscrivez pas au nom d'une entreprise. Votre compte sera configuré comme espace de travail personnel."
},
"onboarding": {
"loading": "Chargement du statut…",
@@ -81,7 +84,11 @@
"phase": "Phase",
"readyTitle": "Votre assistant est prêt !",
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.",
"goToDashboard": "Aller au tableau de bord"
"goToDashboard": "Aller au tableau de bord",
"submittedAt": "Soumis",
"instanceName": "Nom de l'instance",
"instanceNamePlaceholder": "ex. Production, Dev, Ventes",
"instanceNameHint": "Nom lisible facultatif pour distinguer cette instance des autres sur votre tableau de bord. Laisser vide pour utiliser le nom de votre entreprise."
},
"dashboard": {
"title": "Tableau de bord",
@@ -92,14 +99,24 @@
"noInstance": "Aucune instance provisionnée.",
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
"noInstanceDescription": "Configurez votre instance d'assistant IA pour commencer avec PieCed IT.",
"manage": "Gérer l'instance et les paquets"
"manage": "Gérer l'instance et les paquets",
"instances": "Vos instances",
"inflightRequests": "Demandes en cours",
"createInstance": "Créer une nouvelle instance",
"createInstanceDescription": "Provisionner une instance supplémentaire d'assistant IA pour votre organisation. La demande sera examinée par un administrateur avant la création de l'instance.",
"noAccessNoInstances": "Votre organisation n'a pas encore d'instances. Demandez au propriétaire de l'organisation d'en configurer une.",
"noAssignmentsTitle": "Aucune instance attribuée",
"noAssignmentsDescription": "Votre organisation possède des instances, mais aucun accès ne vous a été accordé. Demandez au propriétaire de votre organisation de vous attribuer une instance.",
"noInstancesYetTitle": "Pas encore d'instances",
"noInstancesYetDescription": "Votre organisation ne possède pas encore d'instances. Demandez au propriétaire de votre organisation d'en configurer une."
},
"tenantDetail": {
"agent": "Agent",
"packages": "Paquets",
"workspaceFiles": "Fichiers workspace",
"notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts"
"usage": "Utilisation et coûts",
"provisioned": "Provisionné"
},
"usage": {
"inputTokens": "Tokens d'entrée",
@@ -167,7 +184,9 @@
},
"documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents."
}
},
"statusEnabled": "activé",
"statusDisabled": "désactivé"
},
"admin": {
"title": "Admin plateforme",
@@ -191,6 +210,7 @@
"agentName": "Agent",
"status": "Statut",
"submitted": "Soumis",
"updated": "Mis à jour",
"actions": "Actions",
"noRequests": "Aucune demande trouvée.",
"loadingRequests": "Chargement des demandes…",

View File

@@ -34,7 +34,10 @@
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
"successTitle": "Registrazione ricevuta",
"successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.",
"goToLogin": "Vai all'accesso"
"goToLogin": "Vai all'accesso",
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatta l'amministratore della tua azienda per essere invitato, oppure contatta il supporto PieCed IT se ritieni che si tratti di un errore.",
"individualToggle": "Registrati come privato",
"individualHint": "Seleziona questa opzione se non ti stai registrando per conto di un'azienda. Il tuo account sarà configurato come area di lavoro personale."
},
"onboarding": {
"loading": "Caricamento stato…",
@@ -81,7 +84,11 @@
"phase": "Fase",
"readyTitle": "Il tuo assistente è pronto!",
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.",
"goToDashboard": "Vai alla dashboard"
"goToDashboard": "Vai alla dashboard",
"submittedAt": "Inviato",
"instanceName": "Nome istanza",
"instanceNamePlaceholder": "es. Produzione, Dev, Vendite",
"instanceNameHint": "Nome leggibile facoltativo per distinguere questa istanza dalle altre nella dashboard. Lasciare vuoto per usare il nome dell'azienda."
},
"dashboard": {
"title": "Dashboard",
@@ -92,14 +99,24 @@
"noInstance": "Nessuna istanza attivata.",
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
"manage": "Gestisci istanza e pacchetti"
"manage": "Gestisci istanza e pacchetti",
"instances": "Le tue istanze",
"inflightRequests": "Richieste in corso",
"createInstance": "Crea nuova istanza",
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una.",
"noAssignmentsTitle": "Nessuna istanza assegnata",
"noAssignmentsDescription": "La tua organizzazione ha delle istanze, ma non ti è stato concesso l'accesso a nessuna di esse. Chiedi al proprietario della tua organizzazione di assegnarti a un'istanza.",
"noInstancesYetTitle": "Nessuna istanza ancora",
"noInstancesYetDescription": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario della tua organizzazione di configurarne una."
},
"tenantDetail": {
"agent": "Agente",
"packages": "Pacchetti",
"workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi"
"usage": "Utilizzo e costi",
"provisioned": "Attivato"
},
"usage": {
"inputTokens": "Token di input",
@@ -167,7 +184,9 @@
},
"documentProcessing": {
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
}
},
"statusEnabled": "abilitato",
"statusDisabled": "disabilitato"
},
"admin": {
"title": "Admin piattaforma",
@@ -191,6 +210,7 @@
"agentName": "Agente",
"status": "Stato",
"submitted": "Inviato",
"updated": "Aggiornato",
"actions": "Azioni",
"noRequests": "Nessuna richiesta trovata.",
"loadingRequests": "Caricamento richieste…",

View File

@@ -5,12 +5,39 @@ export interface ZitadelClaims {
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
}
export type PlatformRole =
| "platform_admin"
| "platform_operator"
| "owner"
| "user"
| "viewer";
/**
* Platform-level roles, granted to PieCed staff only. Hold the IAM-level
* authority to administer the entire installation regardless of which
* customer org a request lands on.
*/
export type PlatformRole = "platform_admin" | "platform_operator";
/**
* Customer-level roles, granted by ZITADEL project authorizations on
* each customer org's "OpenClaw Platform" project grant.
*
* Slice 5 dropped the previously-defined `viewer` role. With the portal
* acting purely as a control plane (the assistant itself runs at
* separate URLs with their own auth), `user` and `viewer` collapsed
* to the same surface — read-only access to instance state and usage.
*
* - `owner` can mutate (packages, workspace files, channel users,
* instance creation, member invites in Slice 7).
* - `user` is read-only in the portal. From Slice 6 onwards `user`
* visibility is also narrowed to assigned tenants only.
*/
export type CustomerRole = "owner" | "user";
/** Union of all roles a JWT can carry. */
export type Role = PlatformRole | CustomerRole;
/**
* @deprecated Use {@link Role} for the union, or {@link PlatformRole}
* / {@link CustomerRole} when you mean a specific subset.
* Kept as a re-export only so existing imports don't
* explode in mid-migration commits.
*/
export type LegacyPlatformRole = Role;
export interface SessionUser {
id: string;
@@ -18,7 +45,7 @@ export interface SessionUser {
email: string;
orgId: string;
orgName: string;
roles: PlatformRole[];
roles: Role[];
isPlatform: boolean;
}
@@ -37,7 +64,18 @@ export interface PiecedTenantStatus {
phase: "Pending" | "Provisioning" | "Running" | "Ready" | "Error" | "Deleting";
message?: string;
observedGeneration?: number;
/**
* Org-level LiteLLM team id (since Slice 2 — shared across all tenants
* of the same ZITADEL org). For per-tenant spend attribution use
* `litellmKeyAlias`, not this field.
*/
litellmTeamId?: string;
/**
* Per-tenant LiteLLM virtual-key alias (set to the CR name). Used by
* the portal to filter spend logs to a single tenant within a shared
* org-level team.
*/
litellmKeyAlias?: string;
tenantNamespace?: string;
enabledPackages?: string[];
conditions?: Array<{
@@ -72,11 +110,23 @@ export interface UsageSummary {
// Registration
export interface RegistrationInput {
companyName: string;
/**
* Required for company registrations. Ignored when `isPersonal` is true —
* the server then derives the ZITADEL org name from the user's full name
* with a "(Personal)" suffix.
*/
companyName?: string;
givenName: string;
familyName: string;
email: string;
preferredLanguage?: string;
/**
* Slice 4: when true, registration creates a personal account (one
* person, no company). Domain-uniqueness check is skipped, ZITADEL org
* is named "{givenName} {familyName} (Personal)", subsequent tenants
* are named with the `p-{requestId[:8]}` convention.
*/
isPersonal?: boolean;
}
// Billing address
@@ -101,6 +151,13 @@ export interface TenantRequest {
zitadelOrgId: string;
zitadelUserId: string;
companyName: string;
/**
* Customer-chosen human label per instance (e.g. "Production", "Dev").
* Optional. When set, used as the K8s `displayName` so the customer's
* dashboard distinguishes their instances. When null, the company
* name is used.
*/
instanceName?: string | null;
contactName: string;
contactEmail: string;
agentName: string;
@@ -113,12 +170,27 @@ export interface TenantRequest {
adminNotes?: string;
tenantName?: string;
encryptedSecrets?: Buffer | null;
/**
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
* vs `{slug}-{suffix}` in `lib/tenant-naming.ts`), display-name
* fallback (contact name vs company name), and exclusion from the
* domain-uniqueness check on subsequent registrations.
*/
isPersonal?: boolean;
createdAt: string;
updatedAt: string;
}
// Onboarding wizard input
export interface OnboardingInput {
/**
* Customer's human label for this instance. Optional; when blank, the
* company name is used as the display name. Required when an org
* already has at least one approved instance, to avoid two
* indistinguishable rows on the dashboard — that constraint is
* enforced server-side, not by the type.
*/
instanceName?: string;
agentName: string;
soulMd?: string;
agentsMd?: string;