Compare commits

..

30 Commits

Author SHA1 Message Date
542a607b53 Fix zitadel role issues
All checks were successful
Build and Push / build (push) Successful in 1m20s
2026-04-29 09:36:36 +02:00
a31d05b7c2 Team UI
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-04-26 23:07:47 +02:00
22fd5fb2cc TenantAssignment and readside filtering
All checks were successful
Build and Push / build (push) Successful in 1m23s
2026-04-26 22:58:30 +02:00
7c4e20099d Role split and owner gating
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-26 22:45:38 +02:00
3521a0ff4f Personal accounts
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-04-26 22:26:33 +02:00
2c85bf8597 Multitenantperorg enabling
All checks were successful
Build and Push / build (push) Successful in 1m21s
2026-04-26 22:09:26 +02:00
7b22bc4087 OneLiteLLM team per company+virt keys
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-26 21:21:02 +02:00
1f48712e42 Tenant naming logic adjustments
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-04-26 18:47:50 +02:00
0bf4c6cf4c Debug pipeline 2026-04-25 22:48:05 +02:00
4296a70d51 Debug pipeline 2026-04-25 22:34:48 +02:00
c41145bae7 Debug pipeline 2026-04-25 22:29:42 +02:00
d6a6150a7f Debug pipeline 2026-04-25 22:22:39 +02:00
f69a2d4fa2 Debug pipeline 2026-04-25 22:17:10 +02:00
c2ac8b4401 Debug pipeline 2026-04-25 22:10:46 +02:00
3ce3ba0649 Debug pipeline 2026-04-25 22:08:46 +02:00
5b27f54eb3 Debug pipeline 2026-04-25 20:15:30 +02:00
cc5806f031 Debug pipeline 2026-04-25 20:12:22 +02:00
dab18bb9e6 Debug pipeline 2026-04-25 20:10:36 +02:00
de4ff5ebaf Debug pipeline 2026-04-25 20:09:17 +02:00
f3a1ae0267 Debug pipeline 2026-04-25 20:04:55 +02:00
e7d3fa3873 Debug pipeline 2026-04-25 19:30:48 +02:00
baa0e2b597 Add docker env 2026-04-25 19:21:53 +02:00
935dfb8abc Add docker env 2026-04-25 19:20:54 +02:00
65d8a2e2ff Add docker env 2026-04-25 19:07:16 +02:00
d62684dec7 Add docker env 2026-04-25 19:04:42 +02:00
709588302c ci: add Gitea Actions workflows 2026-04-25 18:20:14 +02:00
b9654d7a7c Timestamp and registration checking 2026-04-25 18:09:02 +02:00
f550b3400f Frontend adjustments 2026-04-14 20:45:58 +02:00
f0eca1959b fix(portal): security hardening for pilot readiness
- C1: Rewrite /api/usage to resolve teamId server-side from tenant CR;
  customers can no longer pass arbitrary teamId (IDOR fix)
- C2: Remove POST /api/tenants — tenants are only created via admin
  approval flow
- H1: Validate packages against catalog, workspaceFiles against allowlist,
  and field lengths in PATCH /api/tenants/[name]
- H2: Remove full ZITADEL profile claims logging from JWT callback
- H3: Add safeError() utility; sanitize all error responses to clients,
  toggle raw errors via PORTAL_DEBUG_ERRORS=true
- H4/H5: Escape HTML entities in all email templates (contactName,
  companyName, adminNotes)
2026-04-14 20:20:04 +02:00
6f9f46b2d0 Ratelimit 2026-04-12 18:13:26 +02:00
66 changed files with 5310 additions and 502 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 #!/usr/bin/env bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.4 . set -euo pipefail
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.4
kubectl rollout restart deployment pieced-portal -n pieced-system
VERSION="${1:-}"
REGISTRY="registry.c5ai.ch/pieced/pieced-portal"
if [[ -z "$VERSION" ]]; then
echo "Usage: ./buildanddeploy.sh <version>"
echo "Example: ./buildanddeploy.sh 0.2.0"
exit 1
fi
echo "Building pieced-portal:${VERSION}..."
npm install
docker build -t "${REGISTRY}:${VERSION}" .
docker push "${REGISTRY}:${VERSION}"
echo ""
echo "✓ Pushed ${REGISTRY}:${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Update image tag in pieced-gitops/apps/portal/deployment.yaml:"
echo " image: ${REGISTRY}:${VERSION}"
echo " 2. git commit + push to pieced-gitops"
echo " 3. ArgoCD syncs automatically"

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,64 @@
// Smoke-test for the FindKeyByAlias parsing logic — runs the JSON
// permutations LiteLLM has been seen to emit through the unmarshal
// paths and confirms each ends up at the expected outcome.
//
// Since the operator can't run inside this sandbox, this is a
// JS port of the parsing flow. It exercises decisions the Go code
// makes line-for-line.
const cases = [
{
name: "newer object shape, alias matches",
body: { keys: [{ token: "tk-1", key_alias: "acme-abc12345" }, { token: "tk-2", key_alias: "beta-def67890" }] },
expected: "tk-1",
},
{
name: "newer object shape, alias does not match",
body: { keys: [{ token: "tk-2", key_alias: "beta-def67890" }] },
expected: "",
},
{
name: "newer object shape, empty keys array",
body: { keys: [] },
expected: "",
},
{
name: "older string shape — cannot filter, return empty",
body: { keys: ["sk-abc", "sk-def"] },
expected: "",
},
{
name: "matching alias but missing token field",
body: { keys: [{ key_alias: "acme-abc12345" }] },
expected: "",
},
];
function findKeyByAlias(body, keyAlias) {
// Mirror the Go logic exactly.
let asObjects;
try {
asObjects = body;
if (!asObjects || !Array.isArray(asObjects.keys)) return "";
for (const k of asObjects.keys) {
// Skip non-objects (= older string shape)
if (typeof k !== "object" || k === null) continue;
if (k.key_alias === keyAlias && k.token) {
return k.token;
}
}
} catch {
return "";
}
return "";
}
let pass = 0, fail = 0;
for (const c of cases) {
const got = findKeyByAlias(c.body, "acme-abc12345");
const ok = got === c.expected;
console.log(`${ok ? "PASS" : "FAIL"} got="${got}" want="${c.expected}" [${c.name}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,32 @@
// Standalone JS port of `lib/personal-org.ts::isPersonalOrgName`
// for offline verification.
const PERSONAL_ORG_SUFFIX = " (Personal)";
function isPersonalOrgName(orgName) {
if (!orgName) return false;
return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX);
}
const cases = [
["Bob Müller (Personal)", true, "personal account"],
["Acme GmbH", false, "company"],
["Acme (Personal) Ltd", false, "suffix in middle does not count"],
["Bob (Personal) ", true, "trailing whitespace tolerated"],
["Bob (personal)", false, "case-sensitive — lowercase doesn't match"],
["", false, "empty"],
[null, false, "null"],
[undefined, false, "undefined"],
["Bob (Personal)x", false, "non-trailing suffix"],
[" (Personal)", true, "minimal — empty user name (degenerate but matches)"],
];
let pass = 0, fail = 0;
for (const [name, expected, note] of cases) {
const got = isPersonalOrgName(name);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}] input=${JSON.stringify(name)}`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,38 @@
// Standalone JS port of `lib/session.ts::canMutate` and `isCustomerOwner`
// for offline verification.
//
// SessionUser shape mirrors the TypeScript interface:
// { roles: Role[], isPlatform: boolean, ... }
function canMutate(user) {
return user.isPlatform || user.roles.includes("owner");
}
function isCustomerOwner(user) {
return !user.isPlatform && user.roles.includes("owner");
}
const cases = [
// [user, fn, expected, note]
[{ isPlatform: true, roles: ["platform_admin"] }, canMutate, true, "platform admin can mutate"],
[{ isPlatform: true, roles: ["platform_operator"] }, canMutate, true, "platform operator can mutate"],
[{ isPlatform: false, roles: ["owner"] }, canMutate, true, "customer owner can mutate"],
[{ isPlatform: false, roles: ["user"] }, canMutate, false, "customer user cannot mutate"],
[{ isPlatform: false, roles: [] }, canMutate, false, "no roles cannot mutate"],
[{ isPlatform: false, roles: ["owner", "user"] }, canMutate, true, "owner+user (owner wins)"],
[{ isPlatform: true, roles: ["platform_admin", "owner"] }, isCustomerOwner, false, "platform user with owner role is NOT customerOwner"],
[{ isPlatform: false, roles: ["owner"] }, isCustomerOwner, true, "pure customer owner"],
[{ isPlatform: false, roles: ["user"] }, isCustomerOwner, false, "customer user is not customerOwner"],
[{ isPlatform: false, roles: [] }, isCustomerOwner, false, "empty roles is not customerOwner"],
];
let pass = 0, fail = 0;
for (const [user, fn, expected, note] of cases) {
const got = fn(user);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

98
scripts/verify-team.mjs Normal file
View File

@@ -0,0 +1,98 @@
// Standalone JS port of `lib/team.ts::isValidInviteRole` and the
// org-membership decision used by POST /api/tenants/[name]/assignments.
function isValidInviteRole(role) {
return role === "owner" || role === "user";
}
// Mirrors the assignment-time check: target user must exist in the
// org's member list. Returns true if assign should proceed.
function canAssign(targetUserId, orgMembers) {
return orgMembers.some((m) => m.userId === targetUserId);
}
// Mirrors the dropdown candidate-filter on the AssignedUsersPanel:
// only `user`-role members who aren't already assigned, excluding
// owners (who have implicit access).
function pickCandidates(orgMembers, alreadyAssigned) {
const assigned = new Set(alreadyAssigned);
return orgMembers.filter(
(m) =>
!assigned.has(m.userId) &&
m.roles.includes("user") &&
!m.roles.includes("owner")
);
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const orgMembers = [
{ userId: "u-1", roles: ["owner"] },
{ userId: "u-2", roles: ["user"] },
{ userId: "u-3", roles: ["user"] },
{ userId: "u-4", roles: [] }, // member with no role yet
{ userId: "u-5", roles: ["owner", "user"] }, // dual-role
];
let pass = 0, fail = 0;
console.log("--- isValidInviteRole ---");
const inviteCases = [
["owner", true, "owner is valid"],
["user", true, "user is valid"],
["viewer", false, "viewer rejected (dropped in Slice 5)"],
["platform_admin", false, "platform_admin not invitable"],
["platform_operator", false, "platform_operator not invitable"],
["", false, "empty rejected"],
["OWNER", false, "case-sensitive"],
];
for (const [role, expected, note] of inviteCases) {
const got = isValidInviteRole(role);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
console.log("\n--- canAssign (membership check) ---");
const assignCases = [
["u-1", true, "owner can be assigned (idempotent for owners)"],
["u-2", true, "user-role member can be assigned"],
["u-99", false, "non-member rejected"],
["", false, "empty userId rejected"],
];
for (const [targetId, expected, note] of assignCases) {
const got = canAssign(targetId, orgMembers);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
console.log("\n--- pickCandidates (assign dropdown) ---");
const candidateCases = [
{
assigned: [],
expected: ["u-2", "u-3"],
note: "user-role members minus owners (u-5 is owner+user, excluded)",
},
{
assigned: ["u-2"],
expected: ["u-3"],
note: "u-2 already assigned, only u-3 remains",
},
{
assigned: ["u-2", "u-3"],
expected: [],
note: "everyone assigned",
},
];
for (const c of candidateCases) {
const got = pickCandidates(orgMembers, c.assigned).map((m) => m.userId);
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,97 @@
// Standalone JS port of deriveTenantName for offline verification.
// Mirror lib/tenant-naming.ts byte-for-byte logic.
const MAX_NAMESPACE_LEN = 63;
const NAMESPACE_PREFIX = "tenant-";
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length;
const SUFFIX_HEX_LEN = 8;
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1;
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN;
function slugify(input) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function requestIdSuffix(requestId) {
const hex = requestId.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{8}/.test(hex)) {
throw new Error(`Invalid request id: ${requestId}`);
}
return hex.slice(0, SUFFIX_HEX_LEN);
}
function deriveTenantName(kind, companyName, requestId) {
const suffix = requestIdSuffix(requestId);
if (kind === "personal") return `p-${suffix}`;
const rawSlug = slugify(companyName);
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
if (!slug) return `t-${suffix}`;
return `${slug}-${suffix}`;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const cases = [
// [kind, companyName, requestId, expected, note]
["company", "Acme GmbH", "abc12345-1234-1234-1234-123456789abc", "acme-gmbh-abc12345", "basic company"],
["company", "Müller AG", "abc12345-aaaa", "m-ller-ag-abc12345", "umlaut → '-'"],
["company", "!!!", "abc12345-aaaa", "t-abc12345", "no alnum → 't-' fallback"],
["personal", "irrelevant", "abc12345-aaaa", "p-abc12345", "personal ignores companyName"],
["personal", "", "abc12345-aaaa", "p-abc12345", "personal with empty companyName"],
["company", " Trim Me ", "abc12345-aaaa", "trim-me-abc12345", "leading/trailing whitespace"],
["company", "Foo---Bar", "abc12345-aaaa", "foo-bar-abc12345", "consecutive hyphens collapse"],
["company", "A very long company name that absolutely will exceed the slug limit easily", "abc12345-aaaa", null, "must be <= 56 chars"],
["company", "----", "abc12345-aaaa", "t-abc12345", "all-hyphen → fallback"],
["company", "ACME", "ABCDEF12-...", "acme-abcdef12", "uppercase UUID is lowercased"],
];
let pass = 0, fail = 0;
for (const [kind, name, id, expected, note] of cases) {
let got;
let err = null;
try {
got = deriveTenantName(kind, name, id);
} catch (e) {
err = e.message;
}
// Special length-only cases
if (expected === null) {
const ok = got && got.length <= 56;
console.log(`${ok ? "PASS" : "FAIL"} len(${got}) = ${got?.length} [${note}]`);
if (ok) pass++; else fail++;
continue;
}
if (err) {
console.log(`THROW ${err} [${note}]`);
if (expected === "throw") pass++; else fail++;
continue;
}
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
// Should-throw cases
console.log("\nThrow cases:");
const throwCases = [
["company", "Acme", "", "empty requestId"],
["company", "Acme", "xyz", "non-hex requestId"],
["company", "Acme", "1234567", "too short (7 chars)"],
];
for (const [kind, name, id, note] of throwCases) {
let threw = false;
try { deriveTenantName(kind, name, id); } catch { threw = true; }
console.log(`${threw ? "PASS" : "FAIL"} threw=${threw} [${note}]`);
if (threw) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,120 @@
// Standalone JS port of `lib/visibility.ts` for offline verification.
// Mirrors the synchronous decision logic — DB call (assignments) is
// faked as an array param.
function scopeFor(user) {
if (user.isPlatform) return "all";
if (user.roles.includes("owner")) return "org";
return "assigned";
}
function listVisibleTenants(user, all, assignments = []) {
const scope = scopeFor(user);
if (scope === "all") return all;
const orgScoped = all.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (scope === "org") return orgScoped;
const allowed = new Set(assignments);
return orgScoped.filter((t) => allowed.has(t.metadata.name));
}
function canUserSeeTenant(user, tenant, assignments = []) {
const scope = scopeFor(user);
if (scope === "all") return true;
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
return false;
}
if (scope === "org") return true;
return assignments.includes(tenant.metadata.name);
}
function canSeeInflightRequests(user) {
return scopeFor(user) !== "assigned";
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const platformAdmin = { isPlatform: true, roles: ["platform_admin"], orgId: "platform-org", id: "u-admin" };
const owner = { isPlatform: false, roles: ["owner"], orgId: "org-acme", id: "u-owner" };
const userOnly = { isPlatform: false, roles: ["user"], orgId: "org-acme", id: "u-alice" };
const noRoles = { isPlatform: false, roles: [], orgId: "org-acme", id: "u-bob" };
const tenantA = { metadata: { name: "acme-prod-12345678", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
const tenantB = { metadata: { name: "acme-dev-87654321", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
const tenantC = { metadata: { name: "other-corp-aaaa", labels: { "pieced.ch/zitadel-org-id": "org-other" } } };
const allTenants = [tenantA, tenantB, tenantC];
// ---------------------------------------------------------------------------
// listVisibleTenants
// ---------------------------------------------------------------------------
const listCases = [
{ user: platformAdmin, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321", "other-corp-aaaa"], note: "platform sees all" },
{ user: owner, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner sees all org tenants" },
{ user: owner, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner ignores assignment table even if rows exist" },
{ user: userOnly, assignments: [], expected: [], note: "user with no assignments sees nothing" },
{ user: userOnly, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678"], note: "user sees only assigned tenants" },
{ user: userOnly, assignments: ["acme-prod-12345678", "acme-dev-87654321"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "user sees multiple assigned tenants" },
{ user: userOnly, assignments: ["other-corp-aaaa"], expected: [], note: "stale assignment to other-org tenant doesn't leak" },
{ user: noRoles, assignments: [], expected: [], note: "no roles is treated as user-scope (empty)" },
];
let pass = 0, fail = 0;
console.log("--- listVisibleTenants ---");
for (const c of listCases) {
const got = listVisibleTenants(c.user, allTenants, c.assignments).map((t) => t.metadata.name);
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
if (ok) pass++; else fail++;
}
// ---------------------------------------------------------------------------
// canUserSeeTenant
// ---------------------------------------------------------------------------
console.log("\n--- canUserSeeTenant ---");
const seeCases = [
{ user: platformAdmin, tenant: tenantA, assignments: [], expected: true, note: "platform sees same-cluster tenant" },
{ user: platformAdmin, tenant: tenantC, assignments: [], expected: true, note: "platform sees other-org tenant" },
{ user: owner, tenant: tenantA, assignments: [], expected: true, note: "owner sees own-org tenant" },
{ user: owner, tenant: tenantC, assignments: [], expected: false, note: "owner does NOT see other-org tenant" },
{ user: userOnly, tenant: tenantA, assignments: ["acme-prod-12345678"], expected: true, note: "user sees assigned tenant" },
{ user: userOnly, tenant: tenantA, assignments: [], expected: false, note: "user does NOT see un-assigned own-org tenant" },
{ user: userOnly, tenant: tenantC, assignments: ["other-corp-aaaa"], expected: false, note: "user does NOT see other-org tenant even with stale assignment" },
];
for (const c of seeCases) {
const got = canUserSeeTenant(c.user, c.tenant, c.assignments);
const ok = got === c.expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
if (ok) pass++; else fail++;
}
// ---------------------------------------------------------------------------
// canSeeInflightRequests
// ---------------------------------------------------------------------------
console.log("\n--- canSeeInflightRequests ---");
const requestCases = [
{ user: platformAdmin, expected: true, note: "platform sees in-flight" },
{ user: owner, expected: true, note: "owner sees in-flight" },
{ user: userOnly, expected: false, note: "user-role does NOT see in-flight" },
{ user: noRoles, expected: false, note: "no-roles does NOT see in-flight" },
];
for (const c of requestCases) {
const got = canSeeInflightRequests(c.user);
const ok = got === c.expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

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

View File

@@ -7,6 +7,13 @@ import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error"; 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() { export default function RegisterPage() {
const t = useTranslations("register"); const t = useTranslations("register");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@@ -18,6 +25,7 @@ export default function RegisterPage() {
familyName: "", familyName: "",
email: "", email: "",
}); });
const [isPersonal, setIsPersonal] = useState(false);
const [state, setState] = useState<FormState>("idle"); const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -31,19 +39,33 @@ export default function RegisterPage() {
setState("submitting"); setState("submitting");
try { 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", { const res = await fetch("/api/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(body),
companyName: form.companyName,
givenName: form.givenName,
familyName: form.familyName,
email: form.email,
}),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
// 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"); throw new Error(data.error || "Registration failed");
} }
@@ -98,21 +120,41 @@ export default function RegisterPage() {
<Card className="animate-in animate-in-delay-1"> <Card className="animate-in animate-in-delay-1">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Company name */} {/* Personal-account toggle */}
<div> <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">
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("companyName")}
</label>
<input <input
name="companyName" type="checkbox"
type="text" checked={isPersonal}
required onChange={(e) => setIsPersonal(e.target.checked)}
value={form.companyName} 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"
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> <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 */} {/* Name row */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -155,7 +197,7 @@ export default function RegisterPage() {
required required
value={form.email} value={form.email}
onChange={handleChange} 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" className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/> />
</div> </div>

View File

@@ -0,0 +1,65 @@
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getOrgMembers } from "@/lib/team";
import { Card } from "@/components/ui/card";
import { TeamList } from "@/components/team/team-list";
import { InviteForm } from "@/components/team/invite-form";
import Link from "next/link";
/**
* /team — manage org members.
*
* Visible to owners and platform users only (`canMutate`). User-role
* members are redirected away — they shouldn't browse the roster.
*
* The page loads members server-side for the initial render. The
* `<TeamList>` and `<InviteForm>` client components handle live
* updates after invites and refreshes.
*/
export default async function TeamPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) redirect("/dashboard");
const t = await getTranslations("team");
const tDashboard = await getTranslations("dashboard");
const members = await getOrgMembers(user.orgId);
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> {tDashboard("title")}
</Link>
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">{t("description")}</p>
</div>
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("inviteSectionTitle")}
</h2>
<Card>
<InviteForm />
</Card>
</section>
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("membersSectionTitle")}{" "}
<span className="text-text-muted/60 tabular-nums">
({members.length})
</span>
</h2>
<TeamList initialMembers={members} currentUserId={user.id} />
</section>
</div>
);
}

View File

@@ -1,12 +1,15 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list"; import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users"; import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"]; const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -20,18 +23,23 @@ export default async function TenantDetailPage({
const { name } = await params; const { name } = await params;
const t = await getTranslations("tenantDetail"); const t = await getTranslations("tenantDetail");
const f = await getFormatter();
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) notFound(); if (!tenant) notFound();
// Scope check // Slice 6: visibility check encompasses org membership AND, for
if ( // user-role members, the tenant_user_assignments check. notFound()
!user.isPlatform && // (404) rather than redirect/403 to avoid leaking tenant existence.
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId if (!(await canUserSeeTenant(user, tenant))) {
) {
notFound(); notFound();
} }
// Slice 5: editable surface gated on owner role. Platform users always
// can edit; customer-side, only `owner` may. `user`-role members see
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
const enabledPackages = tenant.spec.packages || []; const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {}; const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) => const enabledChannels = enabledPackages.filter((pkg) =>
@@ -39,6 +47,19 @@ export default async function TenantDetailPage({
); );
const channelUsers = tenant.spec.channelUsers || {}; 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 ( return (
<div> <div>
{/* Header */} {/* Header */}
@@ -54,6 +75,18 @@ export default async function TenantDetailPage({
{t("agent")}: {tenant.spec.agentName} {t("agent")}: {tenant.spec.agentName}
</p> </p>
)} )}
{tenant.metadata.creationTimestamp && (
<p
className="text-xs text-text-muted mt-1"
title={formatDateTime(tenant.metadata.creationTimestamp, f)}
>
{t("provisioned")}{" "}
{formatRelative(tenant.metadata.creationTimestamp, f)}{" "}
<span className="text-text-muted/60">
({formatDateTime(tenant.metadata.creationTimestamp, f)})
</span>
</p>
)}
</div> </div>
{/* Usage */} {/* Usage */}
@@ -61,7 +94,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")} {t("usage")}
</h2> </h2>
<UsageDisplay teamId={tenant.status?.litellmTeamId || name} /> <UsageDisplay teamId={usageTeamId} keyAlias={usageKeyAlias} />
</section> </section>
{/* Packages */} {/* Packages */}
@@ -73,6 +106,7 @@ export default async function TenantDetailPage({
tenantName={name} tenantName={name}
enabledPackages={enabledPackages} enabledPackages={enabledPackages}
conditions={tenant.status?.conditions} conditions={tenant.status?.conditions}
canEdit={canEdit}
/> />
</section> </section>
@@ -83,6 +117,7 @@ export default async function TenantDetailPage({
tenantName={name} tenantName={name}
enabledChannels={enabledChannels} enabledChannels={enabledChannels}
initialChannelUsers={channelUsers} initialChannelUsers={channelUsers}
canEdit={canEdit}
/> />
</section> </section>
)} )}
@@ -92,8 +127,18 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("workspaceFiles")} {t("workspaceFiles")}
</h2> </h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} /> <WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
</section>
{/* Slice 7: Assigned users — visible to anyone who can see the
tenant, editable only by owners/platform users. The component
fetches its own data so the page doesn't need to await. */}
<section className="mt-8 animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("assignedUsers")}
</h2>
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
</section> </section>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { listTenants } from "@/lib/k8s";
import { import {
getLitellmHealth, getLitellmHealth,
getGlobalSpend, getGlobalSpend,
getPerKeySpend,
getPerTeamSpend, getPerTeamSpend,
} from "@/lib/litellm"; } from "@/lib/litellm";
@@ -28,6 +29,17 @@ async function checkVllmHealth(): Promise<{
/** /**
* GET /api/admin/health * GET /api/admin/health
* Returns system health overview for the admin panel. * 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() { export async function GET() {
try { try {
@@ -36,17 +48,17 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const [tenants, litellm, vllm, globalSpend, perTeamSpend] = const [tenants, litellm, vllm, globalSpend, perKeySpend, perTeamSpend] =
await Promise.allSettled([ await Promise.allSettled([
listTenants(), listTenants(),
getLitellmHealth(), getLitellmHealth(),
checkVllmHealth(), checkVllmHealth(),
getGlobalSpend(), getGlobalSpend(),
getPerKeySpend(),
getPerTeamSpend(), getPerTeamSpend(),
]); ]);
const allTenants = const allTenants = tenants.status === "fulfilled" ? tenants.value : [];
tenants.status === "fulfilled" ? tenants.value : [];
// Count tenants by phase // Count tenants by phase
const phaseCounts: Record<string, number> = {}; const phaseCounts: Record<string, number> = {};
@@ -57,15 +69,27 @@ export async function GET() {
phaseCounts[phase] = (phaseCounts[phase] || 0) + 1; phaseCounts[phase] = (phaseCounts[phase] || 0) + 1;
} }
// Build per-tenant spend map (tenantName → spend) // Build per-tenant spend map (tenantName → spend) from the per-key map.
const spendMap: Record<string, number> = {}; // 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 = const teamSpend =
perTeamSpend.status === "fulfilled" ? perTeamSpend.value : new Map(); perTeamSpend.status === "fulfilled" ? perTeamSpend.value : new Map();
for (const t of allTenants) { const orgSpend: Record<string, number> = {};
const teamId = t.status?.litellmTeamId; for (const [teamId, spend] of teamSpend.entries()) {
if (teamId && teamSpend.has(teamId)) { orgSpend[teamId] = spend;
spendMap[t.metadata.name] = teamSpend.get(teamId)!;
}
} }
return NextResponse.json({ return NextResponse.json({
@@ -76,7 +100,8 @@ export async function GET() {
spend: { spend: {
global: global:
globalSpend.status === "fulfilled" ? globalSpend.value : 0, globalSpend.status === "fulfilled" ? globalSpend.value : 0,
perTenant: spendMap, perTenant: tenantSpend,
perOrg: orgSpend,
}, },
services: { services: {
litellm: litellm:

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,32 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { import {
createTenantRequest, createTenantRequest,
getTenantRequestByOrgId, getTenantRequestById,
deleteTenantRequest, listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
getMostRecentApprovedRequestForOrg,
} from "@/lib/db"; } from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s"; import { getTenant, listTenants } from "@/lib/k8s";
import {
listVisibleTenants,
canUserSeeTenant,
canSeeInflightRequests,
} from "@/lib/visibility";
import { sendAdminNotificationEmail } from "@/lib/email"; import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto"; import { encryptSecrets } from "@/lib/crypto";
import type { OnboardingInput } from "@/types"; import { isPersonalOrgName } from "@/lib/personal-org";
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
import { z } from "zod"; import { z } from "zod";
const onboardingSchema = z.object({ 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), agentName: z.string().min(1).max(50),
soulMd: z.string().max(10_000).optional(), soulMd: z.string().max(10_000).optional(),
agentsMd: z.string().max(10_000).optional(), agentsMd: z.string().max(10_000).optional(),
@@ -30,59 +45,132 @@ const onboardingSchema = z.object({
}); });
/** /**
* GET /api/onboarding * Helper: shape a TenantRequest row for client consumption.
* Check the current onboarding state for the logged-in user's org. * 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(); const user = await getSessionUser();
if (!user) { if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
// Check if there's already a running tenant for this org const requestedId = req.nextUrl.searchParams.get("id");
const allTenants = await listTenants();
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (myTenant) { if (requestedId) {
const tr = await getTenantRequestById(requestedId);
if (!tr) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Customers may only read their own org's requests; platform
// admins/operators may read any.
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Slice 6: a `user`-role customer doesn't see in-flight requests
// even within their own org — they can't act on them and showing
// the row would be a permanent "pending" state with no exit. Owner
// and platform skip this gate.
if (!canSeeInflightRequests(user)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
let tenant: PiecedTenant | null = null;
if (tr.tenantName) {
tenant = (await getTenant(tr.tenantName)) ?? null;
// If a request is already linked to a tenant CR and the caller
// can't see that tenant (assignment scope), don't expose it via
// the request endpoint either. canSeeInflightRequests above
// already shortcuts this for `user`-role, but defense in depth.
if (tenant && !(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
return NextResponse.json({ return NextResponse.json({
state: "active", request: publicRequestShape(tr),
tenantName: myTenant.metadata.name, tenant: tenant ? publicTenantShape(tenant) : null,
phase: myTenant.status?.phase ?? "Unknown",
}); });
} }
// Check if there's a pending request // List view: requests + tenants for this org, filtered by visibility.
const request = await getTenantRequestByOrgId(user.orgId); // For owner/platform, this returns the same data as pre-Slice-6.
// For user-role, requests is forced to [] and tenants is narrowed to
// assignments.
const [requests, allTenants] = await Promise.all([
listActiveTenantRequestsByOrgId(user.orgId),
listTenants(),
]);
if (!request || request.status === "deleted") { const visibleTenants = await listVisibleTenants(user, allTenants);
return NextResponse.json({ state: "no_request" }); const visibleRequests = canSeeInflightRequests(user) ? requests : [];
}
return NextResponse.json({ return NextResponse.json({
state: request.status, requests: visibleRequests.map(publicRequestShape),
request: { tenants: visibleTenants.map(publicTenantShape),
id: request.id,
agentName: request.agentName,
packages: request.packages,
status: request.status,
adminNotes: request.adminNotes,
tenantName: request.tenantName,
createdAt: request.createdAt,
},
}); });
} }
/** /**
* POST /api/onboarding * POST /api/onboarding
* Submit the onboarding wizard. Creates a tenant_request with status "pending".
* The actual PiecedTenant CR is NOT created yet — admin approval required.
* *
* If packageSecrets are provided (for packages requiring credentials like * Always creates a NEW tenant_request row, regardless of how many other
* Telegram, Discord, Email), they are encrypted with AES-256-GCM and stored * rows already exist for this org. The pre-Slice-3 409 ("you already
* as a BYTEA blob. They are decrypted only during admin approval to write * have a request") is gone — multi-tenant is the design now.
* to OpenBao. *
* For additional instances in an existing company, the customer's prior
* approved row is used to seed billing/contact info, so the wizard
* doesn't need to re-collect data already on file. The wizard *does*
* still send a billingAddress payload (the field is required by the
* schema), but in practice the client can pre-fill it from
* `getMostRecentApprovedRequestForOrg`.
*
* Encrypted package secrets, if provided, are AES-256-GCM-sealed and
* stored as a BYTEA blob. They are decrypted only during admin approval
* to write to OpenBao.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -90,6 +178,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 body = await request.json();
const parsed = onboardingSchema.safeParse(body); const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) { 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 & { const input: OnboardingInput & {
packageSecrets?: Record<string, Record<string, string>>; packageSecrets?: Record<string, Record<string, string>>;
} = parsed.data; } = 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 // Encrypt package secrets if provided
let encryptedSecrets: Buffer | undefined; let encryptedSecrets: Buffer | undefined;
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { 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({ const tenantRequest = await createTenantRequest({
zitadelOrgId: user.orgId, zitadelOrgId: user.orgId,
zitadelUserId: user.id, zitadelUserId: user.id,
companyName: user.orgName, companyName,
contactName: user.name, instanceName: input.instanceName,
contactEmail: user.email, contactName,
contactEmail,
agentName: input.agentName, agentName: input.agentName,
soulMd: input.soulMd, soulMd: input.soulMd,
agentsMd: input.agentsMd, agentsMd: input.agentsMd,
packages: input.packages ?? [], packages: input.packages ?? [],
billingAddress: input.billingAddress, billingAddress,
billingNotes: input.billingNotes, billingNotes,
encryptedSecrets, encryptedSecrets,
isPersonal,
}); });
// Notify admin about the new request // Notify admin about the new request. For follow-up instances, include
// the instance name in the notification so the admin sees what's
// being requested without opening the panel.
try { try {
await sendAdminNotificationEmail( await sendAdminNotificationEmail(
tenantRequest.contactEmail, tenantRequest.contactEmail,
tenantRequest.contactName, tenantRequest.contactName,
tenantRequest.companyName tenantRequest.instanceName
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
: tenantRequest.companyName
); );
} catch (e) { } catch (e) {
console.error("Failed to send admin notification:", e); console.error("Failed to send admin notification:", e);
} }
// For diagnostics: how many other in-flight requests does this org
// already have? Useful for the admin queue.
const allRequests = await listTenantRequestsByOrgId(user.orgId);
return NextResponse.json( return NextResponse.json(
{ message: "Request submitted.", request: tenantRequest }, {
message: "Request submitted.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
},
{ status: 201 } { 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 { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit";
import { checkDuplicateDomain } from "@/lib/db";
import type { RegistrationInput } from "@/types"; import type { RegistrationInput } from "@/types";
import { z } from "zod"; import { z } from "zod";
const registrationSchema = z.object({ /**
companyName: z.string().min(2).max(100), * Registration schema.
givenName: z.string().min(1).max(100), *
familyName: z.string().min(1).max(100), * Slice 4 changes
email: z.string().email(), * ---------------
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), * - `companyName` is no longer always required. It's required when
}); * `isPersonal` is false/absent, ignored when `isPersonal` is true.
* - `isPersonal` flag distinguishes personal accounts. The server
* derives the ZITADEL org name from `${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 { try {
const body = await request.json(); const body = await request.json();
const parsed = registrationSchema.safeParse(body); const parsed = registrationSchema.safeParse(body);
@@ -19,14 +84,50 @@ export async function POST(request: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() }, { error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 } { status: 400 },
); );
} }
const input: RegistrationInput = parsed.data; const input: RegistrationInput = parsed.data;
const isPersonal = input.isPersonal === true;
// --- Duplicate-domain check (skipped for personal accounts) ---
//
// Personal accounts are explicitly allowed to use any email domain
// (including corporate). Their tenant_request rows are excluded
// from this check by lib/domain-check.ts, so a personal account
// doesn't block a later real-company registration on the same
// domain.
if (!isPersonal) {
const dup = await checkDuplicateDomain(input.email);
if (dup.blocked && dup.domain) {
return NextResponse.json(
{
error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`,
code: "duplicate_domain",
domain: dup.domain,
},
{ status: 409 },
);
}
}
// --- Determine the ZITADEL org name ---
//
// For company: use the customer-supplied companyName (already
// validated to be present + ≥2 chars by the schema refinement).
// For personal: 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({ const result = await registerCustomer({
companyName: input.companyName, companyName: orgName,
email: input.email, email: input.email,
givenName: input.givenName, givenName: input.givenName,
familyName: input.familyName, familyName: input.familyName,
@@ -37,9 +138,17 @@ export async function POST(request: Request) {
{ {
orgId: result.orgId, orgId: result.orgId,
userId: result.userId, userId: result.userId,
message: "Registration successful. You will receive an invitation email to set your password.", isPersonal,
message:
"Registration successful. You will receive an invitation email to set your password.",
},
{
status: 201,
headers: {
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": String(rl.remaining),
},
}, },
{ status: 201 }
); );
} catch (e: any) { } catch (e: any) {
console.error("Registration failed:", e); console.error("Registration failed:", e);
@@ -48,14 +157,14 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ error: zitadelMessage || "Registration failed. Please try again." }, { error: zitadelMessage || "Registration failed. Please try again." },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 },
); );
} }
} }
/** /**
* ZITADEL errors come as: * ZITADEL errors come as:
* "ZITADEL POST /path: 400 {"code":3, "message":"..."}" * "ZITADEL POST /path: 400 {"code":3, "message":"..."}"
* Extract the human-readable "message" field. * Extract the human-readable "message" field.
*/ */
function extractZitadelMessage(errorMsg: string): string | null { function extractZitadelMessage(errorMsg: string): string | null {

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,75 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm"; 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) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const teamId = req.nextUrl.searchParams.get("teamId"); let teamId: string | null = null;
if (!teamId) let keyAlias: string | null = null;
return NextResponse.json({ error: "teamId required" }, { status: 400 });
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 // Month param: YYYY-MM, defaults to current month
const now = new Date(); const now = new Date();
const monthParam = req.nextUrl.searchParams.get("month") const monthParam =
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; req.nextUrl.searchParams.get("month") ||
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const [year, month] = monthParam.split("-").map(Number); const [year, month] = monthParam.split("-").map(Number);
const startDate = new Date(year, month - 1, 1); const startDate = new Date(year, month - 1, 1);
@@ -26,22 +81,50 @@ export async function GET(req: NextRequest) {
try { try {
const teamInfo = await getTeamInfo(teamId); 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[] = []; const allRequests: any[] = [];
let page = 1; let page = 1;
while (true) { while (true) {
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100); const result = await getTeamSpendLogsV2(
teamId,
startStr,
endStr,
page,
100
);
allRequests.push(...(result.data || [])); allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break; if (page >= (result.total_pages || 1)) break;
page++; 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 // Aggregate by day
const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {}; const byDay: Record<
for (const r of allRequests) { string,
{ inputTokens: number; outputTokens: number; spend: number }
> = {};
for (const r of scoped) {
const day = (r.startTime || r.endTime || "").slice(0, 10); const day = (r.startTime || r.endTime || "").slice(0, 10);
if (!day) continue; if (!day) continue;
if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 }; if (!byDay[day])
byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
byDay[day].inputTokens += r.prompt_tokens || 0; byDay[day].inputTokens += r.prompt_tokens || 0;
byDay[day].outputTokens += r.completion_tokens || 0; byDay[day].outputTokens += r.completion_tokens || 0;
byDay[day].spend += r.spend || 0; byDay[day].spend += r.spend || 0;
@@ -51,19 +134,30 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d })); .map(([date, d]) => ({ date, ...d }));
const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0); const totalInput = scoped.reduce(
const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0); (s, r) => s + (r.prompt_tokens || 0),
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 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({ return NextResponse.json({
teamId, teamId,
keyAlias, // null when not filtering — useful for the client to know it sees company-wide data
month: monthParam, month: monthParam,
currentPeriod: { currentPeriod: {
inputTokens: totalInput, inputTokens: totalInput,
outputTokens: totalOutput, outputTokens: totalOutput,
totalSpend, 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: { budget: {
maxBudget: teamInfo?.team_info?.max_budget ?? null, maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0, spend: teamInfo?.team_info?.spend ?? 0,
@@ -79,6 +173,9 @@ export async function GET(req: NextRequest) {
}); });
} catch (e: any) { } catch (e: any) {
console.error("Usage fetch error:", e.message); console.error("Usage fetch error:", e.message);
return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 }); return NextResponse.json(
{ error: safeError(e, "Failed to fetch usage") },
{ status: 500 }
);
} }
} }

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getPackageDef } from "@/lib/packages";
import { import {
getDefaultSoulMd, getDefaultSoulMd,
getDefaultAgentsMd, getDefaultAgentsMd,
@@ -7,9 +8,12 @@ import {
} from "@/lib/workspace-defaults"; } 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. * Returns default content for SOUL.md, AGENTS.md, and TOOLS.md.
* Used by the onboarding wizard to pre-fill textareas. * 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) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -17,10 +21,13 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const orgName = // Always use the session org name — not a client-supplied parameter
req.nextUrl.searchParams.get("orgName") || user.orgName || "Your Company"; const orgName = user.orgName || "Your Company";
const packagesParam = req.nextUrl.searchParams.get("packages") || ""; 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([ const [soulMd, agentsMd, toolsMd] = await Promise.all([
getDefaultSoulMd(orgName), getDefaultSoulMd(orgName),

View File

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

View File

@@ -17,12 +17,15 @@ interface ChannelUsersProps {
enabledChannels: string[]; enabledChannels: string[];
/** Current channelUsers from the PiecedTenant spec */ /** Current channelUsers from the PiecedTenant spec */
initialChannelUsers: Record<string, string[]>; initialChannelUsers: Record<string, string[]>;
/** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */
canEdit?: boolean;
} }
export function ChannelUsers({ export function ChannelUsers({
tenantName, tenantName,
enabledChannels, enabledChannels,
initialChannelUsers, initialChannelUsers,
canEdit = true,
}: ChannelUsersProps) { }: ChannelUsersProps) {
const t = useTranslations("channelUsers"); const t = useTranslations("channelUsers");
const router = useRouter(); 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" 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} {userId}
<button {canEdit && (
onClick={() => handleRemove(channel, userId)} <button
disabled={saving} onClick={() => handleRemove(channel, userId)}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50" disabled={saving}
title={t("remove")} className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
> title={t("remove")}
>
</button>
</button>
)}
</span> </span>
))} ))}
</div> </div>
)} )}
{/* Add user */} {/* Add user — hidden in read-only mode */}
<div className="flex gap-2"> {canEdit && (
<input <div className="flex gap-2">
type="text" <input
value={inputValues[channel] || ""} type="text"
onChange={(e) => value={inputValues[channel] || ""}
setInputValues((prev) => ({ onChange={(e) =>
...prev, setInputValues((prev) => ({
[channel]: e.target.value, ...prev,
})) [channel]: e.target.value,
} }))
onKeyDown={(e) => { }
if (e.key === "Enter") handleAdd(channel); 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" 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)} <button
disabled={saving || !inputValues[channel]?.trim()} onClick={() => handleAdd(channel)}
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" 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> {saving ? "…" : t("add")}
</div> </button>
</div>
)}
</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 t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth); const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null); const [data, setData] = useState<UsageData | null>(null);
@@ -101,20 +117,26 @@ export function UsageDisplay({ teamId }: { teamId: string | null }) {
const isCurrentMonth = month === getCurrentMonth(); const isCurrentMonth = month === getCurrentMonth();
const fetchUsage = useCallback(() => { const fetchUsage = useCallback(() => {
if (!teamId) { setLoading(false); return; }
setLoading(true); setLoading(true);
setError(null); setError(null);
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}&month=${month}`)
const params = new URLSearchParams({ month });
if (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((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); })
.then(setData) .then(setData)
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [teamId, month]); }, [teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]); useEffect(() => { fetchUsage(); }, [fetchUsage]);
if (!teamId) return null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Month selector */} {/* Month selector */}
@@ -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 className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div>
</div> </div>
); );
} }

View File

@@ -40,6 +40,17 @@ function NavBar() {
<NavLink href="/dashboard" active={pathname === "/dashboard"}> <NavLink href="/dashboard" active={pathname === "/dashboard"}>
{t("dashboard")} {t("dashboard")}
</NavLink> </NavLink>
{/* Slice 7: /team is owner+platform only. Match server-side
gate (canMutate). The roles array carries either "owner"
or "user" for customer sessions; isPlatform covers the
platform side. */}
{user &&
(user.isPlatform ||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
<NavLink href="/team" active={pathname === "/team"}>
{t("team")}
</NavLink>
)}
{user?.isPlatform && ( {user?.isPlatform && (
<NavLink href="/admin" active={pathname === "/admin"}> <NavLink href="/admin" active={pathname === "/admin"}>
{t("admin")} {t("admin")}

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages"; import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, PERSONAL_ORG_SUFFIX } from "@/lib/personal-org";
type Step = "welcome" | "configure" | "billing" | "confirm"; type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -55,6 +56,16 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const tPkg = useTranslations("packages"); const tPkg = useTranslations("packages");
const tCommon = useTranslations("common"); 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 [step, setStep] = useState<Step>("welcome");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -62,12 +73,16 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const [defaultsLoaded, setDefaultsLoaded] = useState(false); const [defaultsLoaded, setDefaultsLoaded] = useState(false);
const [config, setConfig] = useState({ const [config, setConfig] = useState({
instanceName: "",
agentName: "Assistant", agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", orgName), soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS, agentsMd: FALLBACK_AGENTS,
packages: [] as string[], packages: [] as string[],
billingAddress: { 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: "", street: "",
city: "", city: "",
postalCode: "", postalCode: "",
@@ -90,7 +105,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
// Fetch DB-stored defaults on mount // Fetch DB-stored defaults on mount
useEffect(() => { useEffect(() => {
fetch(`/api/workspace-defaults?orgName=${encodeURIComponent(orgName)}`) fetch("/api/workspace-defaults")
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((data) => { .then((data) => {
if (data) { if (data) {
@@ -106,7 +121,8 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
.catch(() => { .catch(() => {
/* use inline fallbacks */ /* use inline fallbacks */
}); });
}, [orgName]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-fetch TOOLS.md preview when packages change // Re-fetch TOOLS.md preview when packages change
const packagesKey = config.packages.sort().join(","); const packagesKey = config.packages.sort().join(",");
@@ -115,14 +131,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
if (prevPackagesKey.current === packagesKey && defaultsLoaded) return; if (prevPackagesKey.current === packagesKey && defaultsLoaded) return;
prevPackagesKey.current = packagesKey; prevPackagesKey.current = packagesKey;
fetch( fetch(
`/api/workspace-defaults?orgName=${encodeURIComponent(orgName)}&packages=${encodeURIComponent(packagesKey)}` `/api/workspace-defaults?packages=${encodeURIComponent(packagesKey)}`
) )
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((data) => { .then((data) => {
if (data?.toolsMd) setToolsMdPreview(data.toolsMd); if (data?.toolsMd) setToolsMdPreview(data.toolsMd);
}) })
.catch(() => {}); .catch(() => {});
}, [packagesKey, orgName, defaultsLoaded]); }, [packagesKey, defaultsLoaded]);
const stepIndex = STEPS.indexOf(step); const stepIndex = STEPS.indexOf(step);
@@ -305,6 +321,24 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</p> </p>
<div className="space-y-4"> <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> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")} {t("agentName")}
@@ -733,6 +767,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3"> <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"> <div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span> <span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono"> <span className="text-text-primary font-mono">

View File

@@ -10,9 +10,18 @@ interface Props {
status?: "pending" | "active" | "error"; status?: "pending" | "active" | "error";
tenantName: string; tenantName: string;
onToggled: () => void; 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 t = useTranslations();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({}); const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -113,17 +122,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
{pkg.requiresSecrets && ( {pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span> <span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)} )}
<button {canEdit ? (
onClick={enabled ? () => togglePackage(false) : handleEnable} <button
disabled={saving} onClick={enabled ? () => togglePackage(false) : handleEnable}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${ disabled={saving}
enabled className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2" enabled
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20" ? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
} disabled:opacity-50`} : "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> {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>
</div> </div>

View File

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

View File

@@ -8,9 +8,11 @@ const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
interface Props { interface Props {
tenantName: string; tenantName: string;
files: Record<string, 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 t = useTranslations("workspace");
const [activeTab, setActiveTab] = useState<string>("SOUL.md"); const [activeTab, setActiveTab] = useState<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files); 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); const [error, setError] = useState<string | null>(null);
function handleChange(content: string) { function handleChange(content: string) {
if (!canEdit) return;
setLocalFiles((prev) => ({ ...prev, [activeTab]: content })); setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
setDirty(true); setDirty(true);
} }
@@ -62,20 +65,25 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
</button> </button>
))} ))}
</div> </div>
<button {canEdit && (
onClick={handleSave} <button
disabled={!dirty || saving} onClick={handleSave}
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" 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> {saving ? "…" : t("save")}
</button>
)}
</div> </div>
<textarea <textarea
value={localFiles[activeTab] || ""} value={localFiles[activeTab] || ""}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
readOnly={!canEdit}
spellCheck={false} 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 })} placeholder={t("placeholder", { file: activeTab })}
/> />

View File

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

View File

@@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
interface OrgMember {
userId: string;
email: string;
displayName: string;
givenName: string;
familyName: string;
roles: string[];
}
interface Props {
initialMembers: OrgMember[];
currentUserId: string;
}
/**
* TeamList — renders the org's members. Refreshes after invites by
* polling the API; the InviteForm broadcasts a `team:refresh` window
* event after a successful invite so the list updates immediately
* rather than waiting for the next reload.
*/
export function TeamList({ initialMembers, currentUserId }: Props) {
const t = useTranslations("team");
const [members, setMembers] = useState<OrgMember[]>(initialMembers);
useEffect(() => {
function refresh() {
fetch("/api/team")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.members) setMembers(data.members);
})
.catch(() => {});
}
window.addEventListener("team:refresh", refresh);
return () => window.removeEventListener("team:refresh", refresh);
}, []);
if (members.length === 0) {
return (
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
{t("noMembers")}
</div>
);
}
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<ul className="divide-y divide-border">
{members.map((m) => (
<li
key={m.userId}
className="px-5 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{m.displayName || m.email}
</span>
{m.userId === currentUserId && (
<span className="text-[10px] uppercase tracking-wider text-accent">
{t("you")}
</span>
)}
</div>
<div className="text-xs text-text-muted truncate font-mono">
{m.email}
</div>
</div>
<div className="flex flex-wrap gap-1.5 justify-end">
{m.roles.length === 0 && (
<span className="text-[10px] uppercase tracking-wider text-text-muted bg-surface-3 px-2 py-0.5 rounded-full">
{t("noRole")}
</span>
)}
{m.roles.map((r) => (
<span
key={r}
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full ${
r === "owner"
? "bg-accent/15 text-accent border border-accent/20"
: "bg-surface-3 text-text-secondary border border-border"
}`}
>
{r}
</span>
))}
</div>
</li>
))}
</ul>
</div>
);
}

View File

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

View File

@@ -1,14 +1,25 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import type { NextAuthConfig } 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"]; 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( function extractRoles(
rolesObj?: Record<string, Record<string, string>> rolesObj?: Record<string, Record<string, string>>
): PlatformRole[] { ): Role[] {
if (!rolesObj) return []; if (!rolesObj) return [];
return Object.keys(rolesObj) as PlatformRole[]; return Object.keys(rolesObj) as Role[];
} }
export const authConfig: NextAuthConfig = { export const authConfig: NextAuthConfig = {
@@ -39,7 +50,6 @@ export const authConfig: NextAuthConfig = {
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
if (account && profile) { if (account && profile) {
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
const claims = profile as unknown as ZitadelClaims; const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"]; token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
@@ -51,7 +61,7 @@ export const authConfig: NextAuthConfig = {
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
const roles = (token.roles as PlatformRole[]) ?? []; const roles = (token.roles as Role[]) ?? [];
const sessionUser: SessionUser = { const sessionUser: SessionUser = {
id: token.sub!, id: token.sub!,
name: session.user?.name ?? "", name: session.user?.name ?? "",
@@ -59,7 +69,9 @@ export const authConfig: NextAuthConfig = {
orgId: token.orgId as string, orgId: token.orgId as string,
orgName: token.orgName as string, orgName: token.orgName as string,
roles, roles,
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)), isPlatform: roles.some((r) =>
PLATFORM_ROLES.includes(r as PlatformRole)
),
}; };
(session as any).platformUser = sessionUser; (session as any).platformUser = sessionUser;
return session; return session;

View File

@@ -22,12 +22,27 @@ function getPool(): Pool {
// Schema migration (auto-run on first query) // 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 = ` const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS tenant_requests ( CREATE TABLE IF NOT EXISTS tenant_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, zitadel_user_id TEXT NOT NULL,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
instance_name TEXT,
contact_name TEXT NOT NULL, contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL, contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant', agent_name TEXT NOT NULL DEFAULT 'Assistant',
@@ -40,16 +55,26 @@ const MIGRATION_SQL = `
admin_notes TEXT, admin_notes TEXT,
tenant_name TEXT, tenant_name TEXT,
encrypted_secrets BYTEA, encrypted_secrets BYTEA,
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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_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_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 -- 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 encrypted_secrets BYTEA;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT; 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 -- Workspace templates: admin-editable default content for workspace files
CREATE TABLE IF NOT EXISTS workspace_templates ( CREATE TABLE IF NOT EXISTS workspace_templates (
@@ -57,6 +82,39 @@ const MIGRATION_SQL = `
content TEXT NOT NULL, content TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 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; let migrated = false;
@@ -131,15 +189,17 @@ export async function createTenantRequest(
await ensureSchema(); await ensureSchema();
const result = await getPool().query<TenantRequest>( const result = await getPool().query<TenantRequest>(
`INSERT INTO tenant_requests `INSERT INTO tenant_requests
(zitadel_org_id, zitadel_user_id, company_name, contact_name, (zitadel_org_id, zitadel_user_id, company_name, instance_name,
contact_email, agent_name, soul_md, agents_md, packages, billing_address, contact_name, contact_email, agent_name, soul_md, agents_md,
billing_notes, encrypted_secrets) packages, billing_address, billing_notes, encrypted_secrets,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) is_personal)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *`, RETURNING *`,
[ [
params.zitadelOrgId, params.zitadelOrgId,
params.zitadelUserId, params.zitadelUserId,
params.companyName, params.companyName,
params.instanceName ?? null,
params.contactName, params.contactName,
params.contactEmail, params.contactEmail,
params.agentName, params.agentName,
@@ -149,6 +209,7 @@ export async function createTenantRequest(
JSON.stringify(params.billingAddress), JSON.stringify(params.billingAddress),
params.billingNotes, params.billingNotes,
params.encryptedSecrets ?? null, params.encryptedSecrets ?? null,
params.isPersonal ?? false,
] ]
); );
return mapRow(result.rows[0]); return mapRow(result.rows[0]);
@@ -165,12 +226,67 @@ export async function getTenantRequestById(
return result.rows[0] ? mapRow(result.rows[0]) : null; 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 orgId: string
): Promise<TenantRequest | null> { ): Promise<TenantRequest | null> {
await ensureSchema(); await ensureSchema();
const result = await getPool().query<TenantRequest>( 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] [orgId]
); );
return result.rows[0] ? mapRow(result.rows[0]) : null; 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. * Wrapper around domain-check.ts that injects the portal's connection pool.
* This allows the customer to re-submit the onboarding wizard. * 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( export async function markTenantRequestDeletedByTenantName(
tenantName: string tenantName: string
@@ -264,6 +393,10 @@ export async function deleteTenantRequest(id: string): Promise<void> {
/** /**
* Sync provisioning statuses: for all requests with status "provisioning", * Sync provisioning statuses: for all requests with status "provisioning",
* check if the PiecedTenant CR has reached "Ready" and update to "active". * 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> { export async function syncProvisioningStatuses(): Promise<void> {
await ensureSchema(); await ensureSchema();
@@ -299,6 +432,7 @@ function mapRow(row: any): TenantRequest {
zitadelOrgId: row.zitadel_org_id, zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id, zitadelUserId: row.zitadel_user_id,
companyName: row.company_name, companyName: row.company_name,
instanceName: row.instance_name ?? null,
contactName: row.contact_name, contactName: row.contact_name,
contactEmail: row.contact_email, contactEmail: row.contact_email,
agentName: row.agent_name, agentName: row.agent_name,
@@ -311,7 +445,155 @@ function mapRow(row: any): TenantRequest {
adminNotes: row.admin_notes, adminNotes: row.admin_notes,
tenantName: row.tenant_name, tenantName: row.tenant_name,
encryptedSecrets: row.encrypted_secrets ?? null, encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
createdAt: row.created_at?.toISOString?.() ?? row.created_at, createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_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( export async function sendApprovalEmail(
to: string, to: string,
contactName: string, contactName: string,
companyName: string companyName: string
): Promise<void> { ): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
try { try {
await getTransporter().sendMail({ await getTransporter().sendMail({
from: getFrom(), from: getFrom(),
@@ -68,8 +83,8 @@ export async function sendApprovalEmail(
html: ` 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;"> <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> <h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
<p>Hello ${contactName},</p> <p>Hello ${safeName},</p>
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</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>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
<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;"> <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, companyName: string,
adminNotes?: string adminNotes?: string
): Promise<void> { ): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
try { try {
const notesBlock = adminNotes const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n` ? `\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;"> ? `<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: #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>` </div>`
: ""; : "";
@@ -123,8 +142,8 @@ export async function sendRejectionEmail(
html: ` 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;"> <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> <h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${contactName},</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>${companyName}</strong> at this time.</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} ${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p> <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;" /> <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; const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return; if (!adminEmail) return;
const safeCompany = escapeHtml(companyName);
const safeName = escapeHtml(contactName);
const safeEmail = escapeHtml(contactEmail);
try { try {
await getTransporter().sendMail({ await getTransporter().sendMail({
from: getFrom(), from: getFrom(),
@@ -158,6 +181,23 @@ export async function sendAdminNotificationEmail(
"", "",
`Review it at https://app.pieced.ch/admin`, `Review it at https://app.pieced.ch/admin`,
].join("\n"), ].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) { } catch (err) {
console.error("Failed to send admin notification email:", 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). * Fetch per-team spend as a map: teamId → spend (CHF).
* Uses /team/list which includes current spend per team. * 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>> { export async function getPerTeamSpend(): Promise<Map<string, number>> {
const teams = await listTeams(); const teams = await listTeams();
@@ -102,3 +106,54 @@ export async function getPerTeamSpend(): Promise<Map<string, number>> {
} }
return map; 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 { auth } from "@/lib/auth";
import type { SessionUser } from "@/types"; 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> { export async function getSessionUser(): Promise<SessionUser | null> {
const session = await auth(); const session = await auth();
return (session as any)?.platformUser ?? null; 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> { export async function requireSession(): Promise<SessionUser> {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) throw new Error("Unauthorized"); if (!user) throw new Error("Unauthorized");
return user; 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> { export async function requirePlatformRole(): Promise<SessionUser> {
const user = await requireSession(); const user = await requireSession();
if (!user.isPlatform) throw new Error("Forbidden: platform role required"); if (!user.isPlatform) throw new Error("Forbidden: platform role required");
return user; 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;
}

168
src/lib/team.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* Team management — high-level operations on top of `lib/zitadel.ts`.
*
* Two responsibilities:
* 1. Fetching the joined "members + roles" view for an org, used by
* the /team page and the assigned-users panel.
* 2. Inviting a new member end-to-end (create user + send invite +
* assign role) with rollback on partial failure, mirroring
* `registerCustomer` for new orgs.
*
* Allowed customer roles
* ----------------------
* Slice 7 reduced scope: invitations may only set the customer roles
* `owner` or `user`. Platform roles cannot be granted via the portal —
* those are managed in ZITADEL Console with stricter access. The
* `viewer` role is gone since Slice 5.
*/
import {
listOrgUsers,
listOrgAuthorizations,
createHumanUser,
createInviteCode,
createAuthorization,
type OrgUser,
} from "./zitadel";
import type { CustomerRole } from "@/types";
const ALLOWED_INVITE_ROLES: CustomerRole[] = ["owner", "user"];
export function isValidInviteRole(role: string): role is CustomerRole {
return (ALLOWED_INVITE_ROLES as string[]).includes(role);
}
export interface OrgMember {
userId: string;
email: string;
displayName: string;
givenName: string;
familyName: string;
/**
* Roles held by this member on the org's project grant. Usually a
* single-element array (one of "owner" / "user"). Could be empty
* if the user exists in the org but has no project authorization
* yet — appears as "no role" in the UI.
*/
roles: string[];
}
/**
* Fetch the joined members-with-roles view for an org. Two ZITADEL
* calls run in parallel (users + authorizations) then joined in memory.
*
* If either call fails, returns whatever the other one produced —
* users without roles render as "no role" badges; missing users are
* just absent. Better degraded than empty.
*/
export async function getOrgMembers(orgId: string): Promise<OrgMember[]> {
const [users, auths] = await Promise.all([
listOrgUsers(orgId),
listOrgAuthorizations(orgId),
]);
// Group authorizations by userId — one user could in principle hold
// multiple authorization rows (one per role assigned at different
// times). Flatten roleKeys.
const rolesByUser = new Map<string, Set<string>>();
for (const a of auths) {
const set = rolesByUser.get(a.userId) ?? new Set<string>();
for (const r of a.roleKeys) set.add(r);
rolesByUser.set(a.userId, set);
}
return users.map((u) => ({
userId: u.userId,
email: u.email,
displayName: u.displayName,
givenName: u.givenName,
familyName: u.familyName,
roles: Array.from(rolesByUser.get(u.userId) ?? []),
}));
}
/**
* Look up a single org member by userId. Convenience wrapper used to
* resolve a userId in an assignment row to a display name. Returns
* null if the user no longer exists in the org (stale assignment row).
*/
export async function getOrgMember(
orgId: string,
userId: string
): Promise<OrgMember | null> {
const all = await getOrgMembers(orgId);
return all.find((m) => m.userId === userId) ?? null;
}
export interface InviteResult {
userId: string;
emailAlreadyExists: boolean;
}
/**
* Invite a new member into an existing customer org.
*
* Three steps:
* 1. createHumanUser — create the ZITADEL human, no password.
* 2. createInviteCode — send the invite email (set password + verify).
* 3. createAuthorization — assign the chosen customer role.
*
* If any step after (1) fails, the user is NOT rolled back. Reasoning:
* unlike registration where a half-created org is useless, a
* half-invited user can be cleaned up manually in ZITADEL Console and
* re-invited. The mid-failure cost of partial state is low; the cost of
* a wrong rollback is double-creation on retry. So we surface the
* error and let the operator decide.
*
* The invite-email step is best-effort — if SMTP is misconfigured the
* user is created and authorized but no email goes out. Owner can
* resend manually from ZITADEL Console.
*
* Note: ZITADEL rejects creating a user with an email that already
* exists in the same instance. The error is surfaced as-is from the
* `extractZitadelMessage`-aware caller.
*/
export async function inviteOrgMember(params: {
orgId: string;
email: string;
givenName: string;
familyName: string;
role: CustomerRole;
preferredLanguage?: string;
}): Promise<InviteResult> {
// Step 1: create the user
const user = await createHumanUser({
orgId: params.orgId,
email: params.email,
givenName: params.givenName,
familyName: params.familyName,
preferredLanguage: params.preferredLanguage,
});
// Step 2: send invite — best-effort
try {
await createInviteCode(user.id);
} catch (err) {
console.warn(
`Invite email could not be sent for user ${user.id} (SMTP may not be configured):`,
err
);
}
// Step 3: assign role
await createAuthorization({
userId: user.id,
organizationId: params.orgId,
roleKeys: [params.role],
});
return {
userId: user.id,
emailAlreadyExists: false,
};
}
/**
* Re-export for convenience.
*/
export type { OrgUser };

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

@@ -156,6 +156,18 @@ export interface ProjectGrantResult {
/** /**
* Grant the "OpenClaw Platform" project to a customer organization. * Grant the "OpenClaw Platform" project to a customer organization.
*
* The grant's `roleKeys` whitelist what authorizations the customer org
* may self-manage: a grant containing only "owner" prevents the customer
* from inviting members in the `user` role, because ZITADEL rejects
* `CreateAuthorization` for any role outside the grant with
* `Errors.Project.Role.NotFound`.
*
* Default is therefore `["owner", "user"]` — the full set of customer
* roles defined in `types/index.ts::CustomerRole`. Platform roles are
* intentionally NOT granted; those are administered separately and
* should never be assignable from inside a customer org.
*
* Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant * Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant
*/ */
export async function createProjectGrant( export async function createProjectGrant(
@@ -168,11 +180,44 @@ export async function createProjectGrant(
{ {
projectId: ZITADEL_PROJECT_ID, projectId: ZITADEL_PROJECT_ID,
grantedOrganizationId: grantedOrgId, grantedOrganizationId: grantedOrgId,
roleKeys: roleKeys || ["owner"], roleKeys: roleKeys || ["owner", "user"],
} }
); );
} }
/**
* List the role keys defined on the OpenClaw Platform project.
*
* Used by the instrumentation self-check on startup to warn loudly if
* the canonical role keys (owner / user / platform_admin / platform_operator)
* are missing — a misconfiguration that silently breaks team management
* and customer registration. See `scripts/zitadel-roles.mjs` for repair.
*
* Returns [] on any error (network, auth, shape drift) so callers can
* decide what to do without inheriting a thrown exception during boot.
*
* Connect RPC: zitadel.project.v2.ProjectService/ListProjectRoles
*/
export async function listProjectRoles(): Promise<string[]> {
try {
const data = await connectRpc<{ projectRoles?: any[] }>(
"zitadel.project.v2.ProjectService",
"ListProjectRoles",
{ projectId: ZITADEL_PROJECT_ID }
);
if (!data?.projectRoles || !Array.isArray(data.projectRoles)) return [];
return data.projectRoles
.map((r: any) => (typeof r?.key === "string" ? r.key : ""))
.filter(Boolean);
} catch (err) {
console.warn(
`Failed to list project roles for ${ZITADEL_PROJECT_ID} (returning empty):`,
err
);
return [];
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// v2 Authorization API — Connect RPC // v2 Authorization API — Connect RPC
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -213,6 +258,158 @@ export async function deleteOrganization(orgId: string): Promise<void> {
await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE"); await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE");
} }
// ---------------------------------------------------------------------------
// Slice 7: search/list APIs for team management
// ---------------------------------------------------------------------------
//
// Two endpoints used by the Team UI:
// - listOrgUsers → POST /v2/users (search with organizationIdQuery)
// - listOrgAuthorizations → Connect RPC to AuthorizationService.ListAuthorizations
//
// Caveats
// -------
// ZITADEL's v2 API surface evolves; the request/response shapes below were
// written against the v2 schema as documented at the time of authoring
// (organizationIdQuery filter on UserService.SearchUsers; ListAuthorizations
// with a ListQuery + filter pair). If your installed ZITADEL version uses
// slightly different field names, parsing here is intentionally tolerant —
// the helpers return [] rather than throwing on shape drift, log a warning,
// and the caller's UI shows an empty team list (which is recoverable).
//
// If you find a discrepancy, fix the request shape here and re-deploy; the
// rest of the team UI doesn't care about the on-the-wire format.
export interface OrgUser {
userId: string;
email: string;
givenName: string;
familyName: string;
displayName: string;
}
/**
* List all users belonging to a given ZITADEL organization. Paginated;
* we cap at 200 per call which is generous for the pilot scale.
*/
export async function listOrgUsers(orgId: string): Promise<OrgUser[]> {
try {
const data = await zitadelFetch<{ result?: any[] }>(
"/v2/users",
"POST",
{
queries: [{ organizationIdQuery: { organizationId: orgId } }],
// Sort by username so the team list is deterministic across reloads
sortingColumn: "USER_FIELD_NAME_USERNAME",
query: { limit: 200, asc: true },
}
);
if (!data?.result || !Array.isArray(data.result)) return [];
return data.result.flatMap((row: any) => {
// ZITADEL distinguishes human and machine users; we only want humans.
const human = row?.human;
if (!human) return [];
const profile = human.profile ?? {};
const email = human.email?.email ?? "";
const userId = row.userId ?? row.id ?? "";
if (!userId) return [];
return [
{
userId,
email,
givenName: profile.givenName ?? "",
familyName: profile.familyName ?? "",
displayName:
profile.displayName ??
`${profile.givenName ?? ""} ${profile.familyName ?? ""}`.trim() ??
email,
} as OrgUser,
];
});
} catch (err) {
console.warn(
`Failed to list users for org ${orgId} (returning empty):`,
err
);
return [];
}
}
export interface OrgAuthorization {
authorizationId: string;
userId: string;
organizationId: string;
projectId: string;
roleKeys: string[];
}
/**
* List authorizations for the OpenClaw Platform project, filtered to a
* single organization. Used by the team UI to render each member's
* effective role.
*
* Connect RPC: zitadel.authorization.v2.AuthorizationService/ListAuthorizations
*
* Implementation note (filter shape & response parsing)
* -----------------------------------------------------
* The v2 AuthorizationService accepts a `filters` array of oneof variants
* (project_id, organization_id, role_key, …) but the JSON-over-Connect
* wrapper naming differs between ZITADEL versions and isn't well-documented
* for ID filters. Rather than chase a moving target, we fetch all
* authorizations the SA can see and narrow client-side by project+org.
* At pilot scale this is a single sub-100-row query — well within budget.
*
* Response shape (v2 stable, confirmed against ZITADEL v4.12):
* authorizations: [{
* id, state,
* project: { id, name, organizationId },
* organization: { id, name },
* user: { id, displayName, preferredLoginName, … },
* roles: [{ key, displayName, group }],
* }]
*
* Returns [] on any error so the team page can render a degraded view
* (members visible, roles blank) rather than blowing up entirely.
*/
export async function listOrgAuthorizations(
orgId: string
): Promise<OrgAuthorization[]> {
try {
const data = await connectRpc<{ authorizations?: any[] }>(
"zitadel.authorization.v2.AuthorizationService",
"ListAuthorizations",
{ pagination: { limit: 1000 } }
);
if (!data?.authorizations || !Array.isArray(data.authorizations)) {
return [];
}
return data.authorizations
.filter(
(row: any) =>
row?.project?.id === ZITADEL_PROJECT_ID &&
row?.organization?.id === orgId
)
.map((row: any) => ({
authorizationId: row.id ?? "",
userId: row.user?.id ?? "",
organizationId: row.organization?.id ?? orgId,
projectId: row.project?.id ?? ZITADEL_PROJECT_ID,
roleKeys: Array.isArray(row.roles)
? row.roles
.map((r: any) => (typeof r?.key === "string" ? r.key : ""))
.filter(Boolean)
: [],
}));
} catch (err) {
console.warn(
`Failed to list authorizations for org ${orgId} (returning empty):`,
err
);
return [];
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Full registration flow // Full registration flow
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -265,8 +462,12 @@ export async function registerCustomer(params: {
); );
} }
// 4. Grant project to org // 4. Grant project to org with both customer roles so the org's
const grant = await createProjectGrant(org.organizationId, ["owner"]); // owner can invite users in either `owner` or `user` role afterwards.
const grant = await createProjectGrant(org.organizationId, [
"owner",
"user",
]);
// 5. Assign "owner" role to user // 5. Assign "owner" role to user
await createAuthorization({ await createAuthorization({

View File

@@ -11,7 +11,8 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"save": "Speichern", "save": "Speichern",
"error": "Ein Fehler ist aufgetreten", "error": "Ein Fehler ist aufgetreten",
"register": "Registrieren" "register": "Registrieren",
"team": "Team"
}, },
"login": { "login": {
"title": "PieCed Portal", "title": "PieCed Portal",
@@ -34,7 +35,10 @@
"footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.", "footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.",
"successTitle": "Registrierung eingegangen", "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.", "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": { "onboarding": {
"loading": "Status wird geladen…", "loading": "Status wird geladen…",
@@ -81,7 +85,11 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!", "readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -92,14 +100,25 @@
"noInstance": "Noch keine Instanz bereitgestellt.", "noInstance": "Noch keine Instanz bereitgestellt.",
"comingSoon": "Detailansicht folgt in Session 6.2", "comingSoon": "Detailansicht folgt in Session 6.2",
"noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.", "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": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
"packages": "Pakete", "packages": "Pakete",
"workspaceFiles": "Workspace-Dateien", "workspaceFiles": "Workspace-Dateien",
"notFound": "Tenant nicht gefunden.", "notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten" "usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt",
"assignedUsers": "Zugewiesene Benutzer"
}, },
"usage": { "usage": {
"inputTokens": "Input-Tokens", "inputTokens": "Input-Tokens",
@@ -167,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion." "description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
} },
"statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert"
}, },
"admin": { "admin": {
"title": "Plattform-Admin", "title": "Plattform-Admin",
@@ -191,6 +212,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Status", "status": "Status",
"submitted": "Eingereicht", "submitted": "Eingereicht",
"updated": "Aktualisiert",
"actions": "Aktionen", "actions": "Aktionen",
"noRequests": "Keine Anfragen gefunden.", "noRequests": "Keine Anfragen gefunden.",
"loadingRequests": "Anfragen werden geladen…", "loadingRequests": "Anfragen werden geladen…",
@@ -249,5 +271,32 @@
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein", "telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein", "discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
"emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll." "emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll."
},
"team": {
"title": "Team",
"description": "Verwalten Sie die Mitglieder Ihrer Organisation. Laden Sie Kollegen ein und weisen Sie sie Instanzen zu.",
"inviteSectionTitle": "Mitglied einladen",
"membersSectionTitle": "Mitglieder",
"noMembers": "Noch keine Mitglieder.",
"you": "Sie",
"noRole": "keine Rolle",
"givenName": "Vorname",
"familyName": "Nachname",
"email": "E-Mail",
"role": "Rolle",
"roleUser": "Benutzer (nur Lesezugriff, muss Instanzen zugewiesen werden)",
"roleOwner": "Eigentümer (Vollzugriff auf alle Instanzen)",
"roleHint": "Eigentümer können Instanzen, Abrechnung und Teammitglieder verwalten. Benutzer können nur die ihnen zugewiesenen Instanzen anzeigen.",
"inviteButton": "Einladung senden",
"inviteSent": "Einladung gesendet. Der Benutzer erhält eine E-Mail mit einem Link zum Festlegen des Passworts.",
"inviteUserExists": "Ein Benutzer mit dieser E-Mail-Adresse ist bereits registriert."
},
"assignments": {
"loading": "Zuweisungen werden geladen…",
"noneAssigned": "Dieser Instanz sind noch keine Benutzer zugewiesen.",
"noCandidates": "Keine Teammitglieder verfügbar zum Zuweisen. Laden Sie zuerst Benutzer auf der Team-Seite ein.",
"pickUser": "Benutzer auswählen…",
"assign": "Zuweisen",
"revoke": "Entfernen"
} }
} }

View File

@@ -11,7 +11,8 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"error": "An error occurred", "error": "An error occurred",
"register": "Register" "register": "Register",
"team": "Team"
}, },
"login": { "login": {
"title": "PieCed Portal", "title": "PieCed Portal",
@@ -34,7 +35,10 @@
"footer": "Your data is hosted exclusively on-premises in Switzerland.", "footer": "Your data is hosted exclusively on-premises in Switzerland.",
"successTitle": "Registration received", "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.", "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": { "onboarding": {
"loading": "Loading status…", "loading": "Loading status…",
@@ -81,7 +85,11 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Your assistant is ready!", "readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -92,14 +100,25 @@
"noInstance": "No instance provisioned yet.", "noInstance": "No instance provisioned yet.",
"comingSoon": "Detailed view coming in Session 6.2", "comingSoon": "Detailed view coming in Session 6.2",
"noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.", "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": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
"packages": "Packages", "packages": "Packages",
"workspaceFiles": "Workspace Files", "workspaceFiles": "Workspace Files",
"notFound": "Tenant not found.", "notFound": "Tenant not found.",
"usage": "Usage & Spend" "usage": "Usage & Spend",
"provisioned": "Provisioned",
"assignedUsers": "Assigned users"
}, },
"usage": { "usage": {
"inputTokens": "Input Tokens", "inputTokens": "Input Tokens",
@@ -167,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Enable document parsing, summarization, and extraction." "description": "Enable document parsing, summarization, and extraction."
} },
"statusEnabled": "enabled",
"statusDisabled": "disabled"
}, },
"admin": { "admin": {
"title": "Platform Admin", "title": "Platform Admin",
@@ -191,6 +212,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Status", "status": "Status",
"submitted": "Submitted", "submitted": "Submitted",
"updated": "Updated",
"actions": "Actions", "actions": "Actions",
"noRequests": "No requests found.", "noRequests": "No requests found.",
"loadingRequests": "Loading requests…", "loadingRequests": "Loading requests…",
@@ -249,5 +271,32 @@
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here", "telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here", "discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
"emailIdHelp": "Enter the email address that should be authorized to interact with the assistant." "emailIdHelp": "Enter the email address that should be authorized to interact with the assistant."
},
"team": {
"title": "Team",
"description": "Manage members of your organization. Invite colleagues and assign them to instances.",
"inviteSectionTitle": "Invite a member",
"membersSectionTitle": "Members",
"noMembers": "No members yet.",
"you": "You",
"noRole": "no role",
"givenName": "First name",
"familyName": "Last name",
"email": "Email",
"role": "Role",
"roleUser": "User (read-only, must be assigned to instances)",
"roleOwner": "Owner (full access to all instances)",
"roleHint": "Owners can manage instances, billing, and team members. Users can only view instances they've been assigned to.",
"inviteButton": "Send invitation",
"inviteSent": "Invitation sent. The user will receive an email with a link to set their password.",
"inviteUserExists": "A user with this email is already registered."
},
"assignments": {
"loading": "Loading assignments…",
"noneAssigned": "No users are assigned to this instance yet.",
"noCandidates": "No team members available to assign. Invite users from the Team page first.",
"pickUser": "Select a user…",
"assign": "Assign",
"revoke": "Remove"
} }
} }

View File

@@ -11,7 +11,8 @@
"cancel": "Annuler", "cancel": "Annuler",
"save": "Enregistrer", "save": "Enregistrer",
"error": "Une erreur est survenue", "error": "Une erreur est survenue",
"register": "S'inscrire" "register": "S'inscrire",
"team": "Équipe"
}, },
"login": { "login": {
"title": "Portail PieCed", "title": "Portail PieCed",
@@ -34,7 +35,10 @@
"footer": "Vos données sont hébergées exclusivement on-premises en Suisse.", "footer": "Vos données sont hébergées exclusivement on-premises en Suisse.",
"successTitle": "Inscription reçue", "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.", "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": { "onboarding": {
"loading": "Chargement du statut…", "loading": "Chargement du statut…",
@@ -81,7 +85,11 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Votre assistant est prêt !", "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.", "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": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -92,14 +100,25 @@
"noInstance": "Aucune instance provisionnée.", "noInstance": "Aucune instance provisionnée.",
"comingSoon": "Vue détaillée à venir dans la Session 6.2", "comingSoon": "Vue détaillée à venir dans la Session 6.2",
"noInstanceDescription": "Configurez votre instance d'assistant IA pour commencer avec PieCed IT.", "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": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
"packages": "Paquets", "packages": "Paquets",
"workspaceFiles": "Fichiers workspace", "workspaceFiles": "Fichiers workspace",
"notFound": "Locataire non trouvé.", "notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts" "usage": "Utilisation et coûts",
"provisioned": "Provisionné",
"assignedUsers": "Utilisateurs attribués"
}, },
"usage": { "usage": {
"inputTokens": "Tokens d'entrée", "inputTokens": "Tokens d'entrée",
@@ -167,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents." "description": "Activez l'analyse, le résumé et l'extraction de documents."
} },
"statusEnabled": "activé",
"statusDisabled": "désactivé"
}, },
"admin": { "admin": {
"title": "Admin plateforme", "title": "Admin plateforme",
@@ -191,6 +212,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Statut", "status": "Statut",
"submitted": "Soumis", "submitted": "Soumis",
"updated": "Mis à jour",
"actions": "Actions", "actions": "Actions",
"noRequests": "Aucune demande trouvée.", "noRequests": "Aucune demande trouvée.",
"loadingRequests": "Chargement des demandes…", "loadingRequests": "Chargement des demandes…",
@@ -249,5 +271,32 @@
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici", "telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici", "discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
"emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant." "emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant."
},
"team": {
"title": "Équipe",
"description": "Gérez les membres de votre organisation. Invitez des collègues et attribuez-leur des instances.",
"inviteSectionTitle": "Inviter un membre",
"membersSectionTitle": "Membres",
"noMembers": "Aucun membre pour l'instant.",
"you": "Vous",
"noRole": "aucun rôle",
"givenName": "Prénom",
"familyName": "Nom de famille",
"email": "E-mail",
"role": "Rôle",
"roleUser": "Utilisateur (lecture seule, doit être affecté à des instances)",
"roleOwner": "Propriétaire (accès complet à toutes les instances)",
"roleHint": "Les propriétaires peuvent gérer les instances, la facturation et les membres de l'équipe. Les utilisateurs ne peuvent voir que les instances qui leur sont attribuées.",
"inviteButton": "Envoyer l'invitation",
"inviteSent": "Invitation envoyée. L'utilisateur recevra un e-mail avec un lien pour définir son mot de passe.",
"inviteUserExists": "Un utilisateur avec cette adresse e-mail est déjà enregistré."
},
"assignments": {
"loading": "Chargement des attributions…",
"noneAssigned": "Aucun utilisateur n'est encore attribué à cette instance.",
"noCandidates": "Aucun membre de l'équipe disponible pour l'attribution. Invitez d'abord des utilisateurs depuis la page Équipe.",
"pickUser": "Sélectionner un utilisateur…",
"assign": "Attribuer",
"revoke": "Retirer"
} }
} }

View File

@@ -11,7 +11,8 @@
"cancel": "Annulla", "cancel": "Annulla",
"save": "Salva", "save": "Salva",
"error": "Si è verificato un errore", "error": "Si è verificato un errore",
"register": "Registrati" "register": "Registrati",
"team": "Team"
}, },
"login": { "login": {
"title": "Portale PieCed", "title": "Portale PieCed",
@@ -34,7 +35,10 @@
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.", "footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
"successTitle": "Registrazione ricevuta", "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.", "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": { "onboarding": {
"loading": "Caricamento stato…", "loading": "Caricamento stato…",
@@ -81,7 +85,11 @@
"phase": "Fase", "phase": "Fase",
"readyTitle": "Il tuo assistente è pronto!", "readyTitle": "Il tuo assistente è pronto!",
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -92,14 +100,25 @@
"noInstance": "Nessuna istanza attivata.", "noInstance": "Nessuna istanza attivata.",
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2", "comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.", "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": { "tenantDetail": {
"agent": "Agente", "agent": "Agente",
"packages": "Pacchetti", "packages": "Pacchetti",
"workspaceFiles": "File workspace", "workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.", "notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi" "usage": "Utilizzo e costi",
"provisioned": "Attivato",
"assignedUsers": "Utenti assegnati"
}, },
"usage": { "usage": {
"inputTokens": "Token di input", "inputTokens": "Token di input",
@@ -167,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti." "description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
} },
"statusEnabled": "abilitato",
"statusDisabled": "disabilitato"
}, },
"admin": { "admin": {
"title": "Admin piattaforma", "title": "Admin piattaforma",
@@ -191,6 +212,7 @@
"agentName": "Agente", "agentName": "Agente",
"status": "Stato", "status": "Stato",
"submitted": "Inviato", "submitted": "Inviato",
"updated": "Aggiornato",
"actions": "Azioni", "actions": "Azioni",
"noRequests": "Nessuna richiesta trovata.", "noRequests": "Nessuna richiesta trovata.",
"loadingRequests": "Caricamento richieste…", "loadingRequests": "Caricamento richieste…",
@@ -249,5 +271,32 @@
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui", "telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui", "discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
"emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente." "emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente."
},
"team": {
"title": "Team",
"description": "Gestisci i membri della tua organizzazione. Invita colleghi e assegnali alle istanze.",
"inviteSectionTitle": "Invita un membro",
"membersSectionTitle": "Membri",
"noMembers": "Nessun membro ancora.",
"you": "Tu",
"noRole": "nessun ruolo",
"givenName": "Nome",
"familyName": "Cognome",
"email": "E-mail",
"role": "Ruolo",
"roleUser": "Utente (sola lettura, deve essere assegnato a istanze)",
"roleOwner": "Proprietario (accesso completo a tutte le istanze)",
"roleHint": "I proprietari possono gestire istanze, fatturazione e membri del team. Gli utenti possono solo visualizzare le istanze a loro assegnate.",
"inviteButton": "Invia invito",
"inviteSent": "Invito inviato. L'utente riceverà un'e-mail con un link per impostare la password.",
"inviteUserExists": "Un utente con questa e-mail è già registrato."
},
"assignments": {
"loading": "Caricamento assegnazioni…",
"noneAssigned": "Nessun utente è ancora assegnato a questa istanza.",
"noCandidates": "Nessun membro del team disponibile per l'assegnazione. Invita prima gli utenti dalla pagina Team.",
"pickUser": "Seleziona un utente…",
"assign": "Assegna",
"revoke": "Rimuovi"
} }
} }

View File

@@ -5,12 +5,39 @@ export interface ZitadelClaims {
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>; "urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
} }
export type PlatformRole = /**
| "platform_admin" * Platform-level roles, granted to PieCed staff only. Hold the IAM-level
| "platform_operator" * authority to administer the entire installation regardless of which
| "owner" * customer org a request lands on.
| "user" */
| "viewer"; 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 { export interface SessionUser {
id: string; id: string;
@@ -18,7 +45,7 @@ export interface SessionUser {
email: string; email: string;
orgId: string; orgId: string;
orgName: string; orgName: string;
roles: PlatformRole[]; roles: Role[];
isPlatform: boolean; isPlatform: boolean;
} }
@@ -37,7 +64,18 @@ export interface PiecedTenantStatus {
phase: "Pending" | "Provisioning" | "Running" | "Ready" | "Error" | "Deleting"; phase: "Pending" | "Provisioning" | "Running" | "Ready" | "Error" | "Deleting";
message?: string; message?: string;
observedGeneration?: number; 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; 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; tenantNamespace?: string;
enabledPackages?: string[]; enabledPackages?: string[];
conditions?: Array<{ conditions?: Array<{
@@ -72,11 +110,23 @@ export interface UsageSummary {
// Registration // Registration
export interface RegistrationInput { 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; givenName: string;
familyName: string; familyName: string;
email: string; email: string;
preferredLanguage?: 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 // Billing address
@@ -101,6 +151,13 @@ export interface TenantRequest {
zitadelOrgId: string; zitadelOrgId: string;
zitadelUserId: string; zitadelUserId: string;
companyName: 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; contactName: string;
contactEmail: string; contactEmail: string;
agentName: string; agentName: string;
@@ -113,12 +170,27 @@ export interface TenantRequest {
adminNotes?: string; adminNotes?: string;
tenantName?: string; tenantName?: string;
encryptedSecrets?: Buffer | null; 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; createdAt: string;
updatedAt: string; updatedAt: string;
} }
// Onboarding wizard input // Onboarding wizard input
export interface OnboardingInput { 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; agentName: string;
soulMd?: string; soulMd?: string;
agentsMd?: string; agentsMd?: string;