Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ff22394c | |||
| 395d2f43cc | |||
| 6f42b56ad5 | |||
| 85c4302f7a | |||
| 726151d90b | |||
| a13af83655 | |||
| b58bdadad4 | |||
| d375a099f0 | |||
| 666dd64580 | |||
| 188bef2ece | |||
| 57258bca92 | |||
| c7ab4c6b4e | |||
| b77dd04b15 | |||
| 11157b872c | |||
| 8273d08f15 | |||
| b023c068eb | |||
| 2c1e7af797 | |||
| 08460f93d4 | |||
| 392b0991a5 | |||
| 46369fda01 | |||
| 647afcfbe7 | |||
| b12bca8818 | |||
| a79d0defa4 | |||
| de1bb9bd02 | |||
| a5812dca9a | |||
| 7d58c78cb9 |
@@ -17,3 +17,7 @@ LITELLM_MASTER_KEY=
|
||||
|
||||
# Portal Database (CloudNativePG)
|
||||
DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal
|
||||
|
||||
# Threema relay (central pieced-threema-gateway)
|
||||
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
|
||||
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
|
||||
|
||||
134
README.md
134
README.md
@@ -1,100 +1,66 @@
|
||||
# PieCed Portal
|
||||
# Threema UX v2 — QR available on demand
|
||||
|
||||
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform.
|
||||
Replaces the previous attempt that had the QR rendering inline above
|
||||
the channel help text. That placement didn't work for you in practice
|
||||
because it wasn't visible when you actually wanted it.
|
||||
|
||||
## Stack
|
||||
## What this does instead
|
||||
|
||||
| Layer | Choice |
|
||||
|-------|--------|
|
||||
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
|
||||
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
|
||||
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
|
||||
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
|
||||
| i18n | next-intl 4.x (en/de) |
|
||||
| Styling | Tailwind CSS 4 |
|
||||
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
|
||||
1. **"Show QR" link** next to the channel title in the threema card —
|
||||
visible at all times, clickable any time you want the QR.
|
||||
|
||||
## Setup
|
||||
2. **Auto-opens the modal** the first time you focus the add-ID input
|
||||
on the page (so a new user adding their first ID sees it without
|
||||
needing to click "Show QR" themselves). Doesn't re-pop after
|
||||
dismissal — the link covers re-opens.
|
||||
|
||||
### 1. ZITADEL Application
|
||||
3. **Modal** with QR + 3-step instructions + "AIAGENT" label. Plain
|
||||
`<img>` (no next/image), closes on ESC / overlay click / × button.
|
||||
|
||||
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform":
|
||||
The earlier `threema-setup.tsx` component file is removed — replaced by
|
||||
`threema-qr-modal.tsx`.
|
||||
|
||||
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE**
|
||||
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
|
||||
3. Post-logout URI: `https://app.pieced.ch/login`
|
||||
4. Note Client ID and Client Secret
|
||||
## Files
|
||||
|
||||
### 2. OpenBao Secrets
|
||||
```
|
||||
src/lib/threema-gateway-config.ts # unchanged from before — central gateway constants
|
||||
src/components/channel-users/threema-qr-modal.tsx # NEW — the modal
|
||||
src/components/channel-users/channel-users.tsx # MODIFIED — Show QR button + focus auto-open + modal mount
|
||||
deploy/patch-i18n-threema.mjs # adds threemaSetup.showQr label in 4 langs
|
||||
public/threema/qr_code_AIAGENT.png # unchanged
|
||||
```
|
||||
|
||||
## Apply
|
||||
|
||||
```bash
|
||||
bao kv put pieced/portal/oidc \
|
||||
client_id="<from step 1>" \
|
||||
client_secret="<from step 1>" \
|
||||
nextauth_secret="$(openssl rand -base64 32)"
|
||||
cd /path/to/pieced-portal
|
||||
|
||||
# Remove the old inline component if you applied the previous archive
|
||||
rm -f src/components/channel-users/threema-setup.tsx
|
||||
|
||||
# Drop new + modified files
|
||||
unzip -o /path/to/threema-ux-v2.zip
|
||||
|
||||
# Update messages
|
||||
node deploy/patch-i18n-threema.mjs
|
||||
|
||||
# TS check
|
||||
npx tsc --noEmit
|
||||
|
||||
git add -A
|
||||
git commit -m "Threema QR: on-demand modal + auto-open on first add"
|
||||
git push
|
||||
```
|
||||
|
||||
### 3. Build & Push
|
||||
|
||||
```bash
|
||||
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
|
||||
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
|
||||
```
|
||||
|
||||
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
|
||||
|
||||
### 4. DNS
|
||||
|
||||
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Fill in values — K8s client uses ~/.kube/config locally
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
After redeploy, the threema card under Authorized Users shows:
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── auth/[...nextauth]/route.ts # NextAuth handler
|
||||
│ │ ├── tenants/route.ts # Tenant CRUD (K8s API)
|
||||
│ │ └── usage/route.ts # Usage stub
|
||||
│ ├── [locale]/
|
||||
│ │ ├── layout.tsx # Locale layout + NavShell
|
||||
│ │ ├── page.tsx # Redirect → /dashboard
|
||||
│ │ ├── login/page.tsx # ZITADEL sign-in
|
||||
│ │ ├── dashboard/page.tsx # Customer dashboard
|
||||
│ │ └── admin/page.tsx # Platform admin tenant list
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── globals.css # Tailwind 4 theme
|
||||
├── components/
|
||||
│ ├── layout/nav-shell.tsx # Header + navigation
|
||||
│ └── ui/ # Reusable UI components
|
||||
├── i18n/
|
||||
│ ├── routing.ts # next-intl 4.x routing config
|
||||
│ ├── navigation.ts # Localized Link, redirect, etc.
|
||||
│ └── request.ts # Server-side i18n config
|
||||
├── lib/
|
||||
│ ├── auth.ts # NextAuth v5 + ZITADEL config
|
||||
│ ├── k8s.ts # K8s client for PiecedTenant CRs
|
||||
│ ├── litellm.ts # LiteLLM API client
|
||||
│ └── session.ts # Session helpers
|
||||
├── messages/
|
||||
│ ├── en.json
|
||||
│ └── de.json
|
||||
└── types/index.ts # Shared TypeScript types
|
||||
threema [Show QR] 0 users
|
||||
────────────────────
|
||||
<help text: 'Enter your own Threema ID...'>
|
||||
────────────────────
|
||||
[ input: A8K2P3X7 ] [ Add ]
|
||||
^^^ focusing this opens the QR modal the first time
|
||||
```
|
||||
|
||||
## Session Roadmap
|
||||
|
||||
- **6.1** ← This session: scaffold, auth, basic pages
|
||||
- **6.2**: Instance management, package config, usage display
|
||||
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
|
||||
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
- **6.5**: Admin panel (tenant lifecycle, billing overview)
|
||||
Clicking "Show QR" or focusing the input → modal with QR + steps.
|
||||
|
||||
70
deploy/README-threema.md
Normal file
70
deploy/README-threema.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Wiring Threema relay into the portal
|
||||
|
||||
Drop-in files in this archive:
|
||||
|
||||
```
|
||||
src/lib/packages.ts # add 'threema' to catalog + customProvisioning flag
|
||||
src/lib/threema-relay.ts # new — admin API client
|
||||
src/app/api/tenants/[name]/threema/route.ts # new — POST provision / DELETE deprovision
|
||||
src/app/api/tenants/[name]/threema/routes/route.ts # new — atomic add/remove of a single Threema ID
|
||||
src/components/channel-users/channel-users.tsx # branch threema through relay-managed endpoint
|
||||
src/components/packages/package-card.tsx # handle customProvisioning enable/disable
|
||||
deploy/patch-i18n-threema.mjs # idempotent i18n key injection
|
||||
```
|
||||
|
||||
## Manual steps after dropping in
|
||||
|
||||
1. `.env` (and `.env.example`) — add:
|
||||
```
|
||||
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
|
||||
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
|
||||
```
|
||||
The portal pod's OpenBao client should also read `secret/data/threema-gateway/admin` and surface `token` as this env var (existing ESO pattern in the portal's Helm chart).
|
||||
|
||||
2. Patch the message files (one-time):
|
||||
```bash
|
||||
node deploy/patch-i18n-threema.mjs
|
||||
```
|
||||
|
||||
3. Re-export `CHANNEL_PACKAGE_IDS` is unchanged in source; verify
|
||||
`tenants/[name]/page.tsx` still derives the enabled-channels list
|
||||
from it — it should now include `threema` automatically once a
|
||||
tenant has it in `spec.packages`.
|
||||
|
||||
4. Type-check:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Flow summary
|
||||
|
||||
### Enabling Threema for a tenant
|
||||
1. Customer toggles the threema package card.
|
||||
2. PackageCard sees `customProvisioning: true` → POSTs `/api/tenants/<name>/threema`.
|
||||
3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`.
|
||||
4. Handler writes them to OpenBao at `secret/data/tenants/tenant-<name>/threema-relay`.
|
||||
5. PackageCard then PATCHes `tenant.spec.packages` to include `threema`.
|
||||
6. Operator reconciles: ExternalSecret syncs OpenBao → Secret; OpenClaw pod restarts with `THREEMA_RELAY_*` env vars; plugin registers `threema` channel.
|
||||
|
||||
### Customer adds a Threema ID
|
||||
1. UI calls `POST /api/tenants/<name>/threema/routes` with `{threemaId}`.
|
||||
2. Handler calls relay `POST /admin/routes` (uniqueness enforced at PK).
|
||||
3. On 201 or 409-from-same-tenant: handler patches K8s `spec.channelUsers.threema`.
|
||||
4. On 409-from-other-tenant: 409 to client with explanation.
|
||||
5. On K8s patch failure after relay success: handler compensates by `DELETE /admin/routes/...` at the relay.
|
||||
|
||||
### Customer removes a Threema ID
|
||||
1. UI calls `DELETE /api/tenants/<name>/threema/routes?threemaId=...`.
|
||||
2. Handler patches K8s `spec.channelUsers.threema` to drop the ID.
|
||||
3. Handler calls relay `DELETE /admin/routes/...` (404 = idempotent OK).
|
||||
4. If relay drop fails: K8s already updated, surface warning but treat as success — relay deletes are idempotent on retry.
|
||||
|
||||
### Disabling Threema for a tenant
|
||||
1. Customer disables the threema card.
|
||||
2. PackageCard DELETEs `/api/tenants/<name>/threema`.
|
||||
3. Handler calls relay `DELETE /admin/tokens/<name>` (cascades to all routes for this tenant).
|
||||
4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-<name>/threema-relay`.
|
||||
5. PackageCard then PATCHes `tenant.spec.packages` to drop `threema`.
|
||||
6. Operator reconciles: ExternalSecret targets a missing OpenBao path → Secret deleted → OpenClaw pod restarts without `threema` channel.
|
||||
|
||||
There's a small window (between step 4 and the operator's reconcile) where the pod still thinks it has a relay token but the relay has revoked it. Outbound during that window returns 401 from the relay; inbound is blackholed at the relay because routes are gone. Both are graceful failures.
|
||||
122
deploy/patch-i18n-threema.mjs
Normal file
122
deploy/patch-i18n-threema.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node deploy/patch-i18n-threema.mjs
|
||||
*
|
||||
* Idempotently injects (or overwrites) customer-facing Threema texts:
|
||||
* - packages.threema.{description, instructions, disclaimer}
|
||||
* - channelUsers.threemaIdHelp
|
||||
* - channelUsers.threemaSetup.{title, step1, step2, step3, qrAlt}
|
||||
*
|
||||
* Replaces the earlier version of this script entirely. The new texts:
|
||||
* - Drop "Gateway account" jargon (customers don't know it)
|
||||
* - Drop asterisk-prefix references (customers don't see / type it)
|
||||
* - Tell the customer to add their OWN Threema ID, not someone else's
|
||||
* - Disclose that Threema charges per message via the gateway
|
||||
* - Walk through the QR-scan + add-your-ID flow explicitly
|
||||
*
|
||||
* Re-running is safe — keys are set, not merged, so this is the
|
||||
* source of truth for the values it touches.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
pkg: {
|
||||
description:
|
||||
"Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
|
||||
instructions:
|
||||
"1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
|
||||
disclaimer:
|
||||
"Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates.",
|
||||
},
|
||||
channelHelp:
|
||||
"Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
|
||||
setup: {
|
||||
title: "Add the assistant to your Threema",
|
||||
step1: "Open Threema on your phone.",
|
||||
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||
step3: "Then add your own Threema ID below.",
|
||||
qrAlt: "QR code to add {gateway} as a Threema contact",
|
||||
showQr: "Show QR",
|
||||
},
|
||||
},
|
||||
de: {
|
||||
pkg: {
|
||||
description:
|
||||
"Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
|
||||
instructions:
|
||||
"1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
disclaimer:
|
||||
"Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan.",
|
||||
},
|
||||
channelHelp:
|
||||
"Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
|
||||
setup: {
|
||||
title: "Assistenten zu Threema hinzufügen",
|
||||
step1: "Öffnen Sie Threema auf Ihrem Telefon.",
|
||||
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||
showQr: "QR anzeigen",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
pkg: {
|
||||
description:
|
||||
"Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
|
||||
instructions:
|
||||
"1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
|
||||
disclaimer:
|
||||
"Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur.",
|
||||
},
|
||||
channelHelp:
|
||||
"Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
|
||||
setup: {
|
||||
title: "Ajouter l'assistant à Threema",
|
||||
step1: "Ouvrez Threema sur votre téléphone.",
|
||||
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
|
||||
showQr: "Afficher le QR",
|
||||
},
|
||||
},
|
||||
it: {
|
||||
pkg: {
|
||||
description:
|
||||
"Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
instructions:
|
||||
"1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
|
||||
disclaimer:
|
||||
"I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali.",
|
||||
},
|
||||
channelHelp:
|
||||
"Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
|
||||
setup: {
|
||||
title: "Aggiungi l'assistente a Threema",
|
||||
step1: "Apri Threema sul tuo telefono.",
|
||||
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
|
||||
showQr: "Mostra QR",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const [lang, entries] of Object.entries(i18n)) {
|
||||
const path = `src/messages/${lang}.json`;
|
||||
const json = JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
json.packages = json.packages ?? {};
|
||||
json.packages.threema = {
|
||||
description: entries.pkg.description,
|
||||
instructions: entries.pkg.instructions,
|
||||
disclaimer: entries.pkg.disclaimer,
|
||||
};
|
||||
|
||||
json.channelUsers = json.channelUsers ?? {};
|
||||
json.channelUsers.threemaIdHelp = entries.channelHelp;
|
||||
json.channelUsers.threemaSetup = entries.setup;
|
||||
|
||||
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
|
||||
console.log(`Patched ${path}`);
|
||||
}
|
||||
BIN
public/threema/qr_code_AIAGENT.png
Normal file
BIN
public/threema/qr_code_AIAGENT.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
71
src/app/[locale]/admin/openclaw/page.tsx
Normal file
71
src/app/[locale]/admin/openclaw/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants, getOpenClawDefaults } from "@/lib/k8s";
|
||||
import { OpenClawAdminPanel } from "@/components/admin/openclaw-admin-panel";
|
||||
|
||||
/**
|
||||
* /admin/openclaw — platform-default OpenClaw image + per-tenant
|
||||
* overrides table.
|
||||
*
|
||||
* Two sections:
|
||||
* 1. Default — readable from `pieced-openclaw-config` ConfigMap.
|
||||
* Editable via the same form. Empty fields show as "(unset)"
|
||||
* and the operator falls back to its built-in default in that
|
||||
* case (intentionally invisible to the portal — the binary's
|
||||
* baked version moves with releases and we don't want the UI
|
||||
* to claim a misleading "current default").
|
||||
* 2. Tenant table — every tenant in the cluster with its current
|
||||
* override (or "follows default"). Clicking a row opens a small
|
||||
* inline editor.
|
||||
*
|
||||
* Authorization is gated server-side: `user.isPlatform` only. Any
|
||||
* other user gets redirected to /dashboard.
|
||||
*/
|
||||
export default async function OpenClawAdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("openclawAdmin");
|
||||
|
||||
// Parallel fetch — defaults and tenants are independent.
|
||||
const [defaults, tenants] = await Promise.all([
|
||||
getOpenClawDefaults(),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
// Sort tenants: overridden first (more interesting to review),
|
||||
// then alphabetically by display name. Helps the admin spot which
|
||||
// tenants are off the platform default at a glance.
|
||||
const sorted = [...tenants].sort((a, b) => {
|
||||
const aOverride = a.spec.openClawImage ? 1 : 0;
|
||||
const bOverride = b.spec.openClawImage ? 1 : 0;
|
||||
if (aOverride !== bOverride) return bOverride - aOverride;
|
||||
return (a.spec.displayName || a.metadata.name).localeCompare(
|
||||
b.spec.displayName || b.metadata.name
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<OpenClawAdminPanel
|
||||
initialDefaults={defaults}
|
||||
tenants={sorted.map((tn) => ({
|
||||
name: tn.metadata.name,
|
||||
displayName: tn.spec.displayName || tn.metadata.name,
|
||||
phase: tn.status?.phase ?? "Unknown",
|
||||
override: tn.spec.openClawImage?.tag
|
||||
? { tag: tn.spec.openClawImage.tag }
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,22 @@ export default async function AdminPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
||||
<div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
||||
</div>
|
||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
|
||||
/**
|
||||
@@ -55,6 +55,8 @@ export default async function NewInstancePage() {
|
||||
}
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -73,6 +75,7 @@ export default async function NewInstancePage() {
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
|
||||
import {
|
||||
listActiveTenantRequestsByOrgId,
|
||||
syncProvisioningStatuses,
|
||||
getOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canSeeInflightRequests,
|
||||
@@ -160,10 +164,35 @@ export default async function DashboardPage() {
|
||||
|
||||
// Pending/in-flight requests are only shown to roles that can act on
|
||||
// them. `user`-role customers see no request cards.
|
||||
//
|
||||
// syncProvisioningStatuses runs on every dashboard load: it walks
|
||||
// active and provisioning rows and reconciles them against the
|
||||
// current cluster state. Without this, the operator-initiated
|
||||
// 60-day TTL deletion (Bug 37b) leaves the portal showing "Your
|
||||
// assistant is ready!" cards for tenants that no longer exist —
|
||||
// the operator deletes the CR, but the DB row stays at active=true
|
||||
// until something updates it. Running the sync at every dashboard
|
||||
// load keeps the portal eventually consistent with the cluster
|
||||
// without needing a separate cron/job.
|
||||
//
|
||||
// Cost: one K8s GET per row in (active, provisioning) status. At
|
||||
// pilot scale this is small; if it grows we'd cache or move to a
|
||||
// periodic background job.
|
||||
if (canSeeInflightRequests(user)) {
|
||||
await syncProvisioningStatuses();
|
||||
}
|
||||
const orgRequests = canSeeInflightRequests(user)
|
||||
? await listActiveTenantRequestsByOrgId(user.orgId)
|
||||
: [];
|
||||
|
||||
// Bug 35: orgs that already have a billing record skip the wizard's
|
||||
// billing step. Fetched here so the dashboard's empty-state mount of
|
||||
// OnboardingFlow knows what to do; for the additional-tenant flow at
|
||||
// /dashboard/new we fetch the same flag in that route's own server
|
||||
// component.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it. We compare against
|
||||
@@ -174,7 +203,16 @@ export default async function DashboardPage() {
|
||||
(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)
|
||||
(r) =>
|
||||
// Only show provision (initial creation) requests on the
|
||||
// dashboard. Resume requests (Bug 37a) belong with their
|
||||
// specific tenant — the SubscriptionToggle on the tenant
|
||||
// detail page renders the pending state there. Showing them
|
||||
// on the dashboard too would duplicate the surface and
|
||||
// confuse customers about which tenant they refer to.
|
||||
r.requestType !== "resume" &&
|
||||
(!r.tenantName ||
|
||||
!orgScopedTenants.some((t) => t.metadata.name === r.tenantName))
|
||||
);
|
||||
|
||||
// Slice 5: only owners (and platform users, who'd typically be using
|
||||
@@ -278,6 +316,7 @@ export default async function DashboardPage() {
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
47
src/app/[locale]/settings/billing/page.tsx
Normal file
47
src/app/[locale]/settings/billing/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
||||
|
||||
/**
|
||||
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
|
||||
*
|
||||
* Server-side fetches the existing record (if any) and passes it to
|
||||
* the client form. The form posts to PUT /api/billing on submit.
|
||||
*
|
||||
* Access: same gate as the API — owners and platform admins. `user`
|
||||
* role redirects to /settings (which also wouldn't list billing for
|
||||
* them). 403 here would be friendlier than redirect, but the most
|
||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
||||
* with their owner — silent redirect is gentle.
|
||||
*/
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!canMutate(user)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
const t = await getTranslations("settingsBilling");
|
||||
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<BillingSettingsForm
|
||||
initial={billing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
81
src/app/[locale]/settings/page.tsx
Normal file
81
src/app/[locale]/settings/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /settings — landing page for user/org-level configuration (Bug 35
|
||||
* intentionally landed billing here rather than at /billing because we
|
||||
* expect more settings categories: notifications, API keys, default
|
||||
* workspace templates, etc.). Currently lists a single category card;
|
||||
* the layout scales to a sidebar nav once there are 3+.
|
||||
*
|
||||
* Access: any authenticated user (the cards themselves gate further;
|
||||
* non-owner users would not see "Billing" as actionable, etc.).
|
||||
*/
|
||||
export default async function SettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
// Build the list of settings cards. Each entry has a stable key, a
|
||||
// route, and a visibility predicate. Currently only billing; this
|
||||
// shape leaves headroom for adding more without restructuring.
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visible: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: "billing",
|
||||
href: "/settings/billing",
|
||||
title: t("billingTitle"),
|
||||
// Personal customers (B2C) don't have a VAT number; the
|
||||
// description shouldn't mention one. Same pattern used in the
|
||||
// form itself (label/field gating).
|
||||
description: user.isPersonal
|
||||
? t("billingDescriptionPersonal")
|
||||
: t("billingDescription"),
|
||||
// Owners and platform admins can edit billing. `user` role
|
||||
// can't even view it — billing details aren't useful to them.
|
||||
visible: canMutate(user),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleSections = sections.filter((s) => s.visible);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{visibleSections.length === 0 && (
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary">{t("nothingForYou")}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 animate-in animate-in-delay-1">
|
||||
{visibleSections.map((s) => (
|
||||
<Link
|
||||
key={s.key}
|
||||
href={s.href}
|
||||
className="block rounded-xl border border-border bg-surface-1 p-4 hover:border-text-secondary transition-colors"
|
||||
>
|
||||
<div className="font-medium text-text-primary">{s.title}</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{s.description}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
103
src/app/[locale]/support/[id]/page.tsx
Normal file
103
src/app/[locale]/support/[id]/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSupportTicketById,
|
||||
listCommentsForTicket,
|
||||
} from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { TicketStatusBadge } from "@/components/support/ticket-status-badge";
|
||||
import { TicketCategoryLabel } from "@/components/support/ticket-category-label";
|
||||
import { TicketThread } from "@/components/support/ticket-thread";
|
||||
import { TicketAdminControls } from "@/components/support/ticket-admin-controls";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* /support/[id] — single ticket detail.
|
||||
*
|
||||
* Same UI for customer and admin; admin gets an extra
|
||||
* `<TicketAdminControls>` block for changing status/category. The
|
||||
* customer side gets a "Close ticket" link if they want to mark it
|
||||
* resolved themselves.
|
||||
*
|
||||
* Authorization mirrors the API: customer sees their own; platform
|
||||
* admin sees any. 404 (not 403) when a customer accesses someone
|
||||
* else's ticket — don't leak existence.
|
||||
*/
|
||||
export default async function TicketDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const { id } = await params;
|
||||
const ticket = await getSupportTicketById(id);
|
||||
if (!ticket) notFound();
|
||||
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
|
||||
notFound();
|
||||
}
|
||||
const comments = await listCommentsForTicket(id);
|
||||
const t = await getTranslations("support");
|
||||
const f = await getFormatter();
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-6 animate-in">
|
||||
<BackLink href="/support" label={t("title")} />
|
||||
<div className="flex items-start justify-between gap-3 mt-2">
|
||||
<h1 className="font-display text-2xl font-semibold">
|
||||
{ticket.title}
|
||||
</h1>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-2 flex items-center gap-2 flex-wrap">
|
||||
<TicketCategoryLabel category={ticket.category} />
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t("openedBy", {
|
||||
name: ticket.contactName,
|
||||
when: formatDateTime(ticket.createdAt, f),
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">#{ticket.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original ticket description, rendered as the first message
|
||||
in the thread. Visually distinct via the customer-author
|
||||
styling (handled inside <TicketThread>). */}
|
||||
<div className="space-y-4 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between text-xs text-text-muted mb-2">
|
||||
<span className="font-medium text-text-primary">
|
||||
{ticket.contactName}
|
||||
</span>
|
||||
<span>{formatDateTime(ticket.createdAt, f)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-primary whitespace-pre-wrap">
|
||||
{ticket.description}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<TicketThread
|
||||
ticketId={ticket.id}
|
||||
ticketStatus={ticket.status}
|
||||
comments={comments}
|
||||
isPlatform={user.isPlatform}
|
||||
isOwnTicket={ticket.zitadelUserId === user.id}
|
||||
/>
|
||||
|
||||
{user.isPlatform && (
|
||||
<TicketAdminControls
|
||||
ticketId={ticket.id}
|
||||
currentStatus={ticket.status}
|
||||
currentCategory={ticket.category}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
37
src/app/[locale]/support/new/page.tsx
Normal file
37
src/app/[locale]/support/new/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { TicketCreateForm } from "@/components/support/ticket-create-form";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
|
||||
/**
|
||||
* /support/new — create ticket form.
|
||||
*
|
||||
* Platform admins shouldn't open tickets via this UI (they'd be
|
||||
* opening one as if from a customer, which is confusing). Redirect
|
||||
* them back to the queue. Non-admins of any role can create.
|
||||
*/
|
||||
export default async function NewTicketPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.isPlatform) redirect("/support");
|
||||
const t = await getTranslations("support");
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<BackLink href="/support" label={t("title")} />
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("newTicketTitle")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("newTicketSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<TicketCreateForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
97
src/app/[locale]/support/page.tsx
Normal file
97
src/app/[locale]/support/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
listSupportTicketsForUser,
|
||||
listAllSupportTickets,
|
||||
} from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { TicketStatusBadge } from "@/components/support/ticket-status-badge";
|
||||
import { TicketCategoryLabel } from "@/components/support/ticket-category-label";
|
||||
|
||||
/**
|
||||
* /support — ticket list.
|
||||
*
|
||||
* Customers see their own tickets only (per Feature 5: per-user
|
||||
* scope, NOT per-org). Platform admins see the global queue. Same
|
||||
* UI shell, different list source — the rendering logic is
|
||||
* identical because the per-row data is the same shape.
|
||||
*
|
||||
* Sorting: newest activity first (the DB query already orders by
|
||||
* updated_at DESC). Open tickets bubble to the top by virtue of
|
||||
* having recent activity, but we don't sort by status; that's a
|
||||
* filter the admin can add later if the queue grows.
|
||||
*/
|
||||
export default async function SupportListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("support");
|
||||
const f = await getFormatter();
|
||||
|
||||
const tickets = user.isPlatform
|
||||
? await listAllSupportTickets()
|
||||
: await listSupportTicketsForUser(user.id);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{user.isPlatform ? t("titleAdmin") : t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPlatform ? t("subtitleAdmin") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{!user.isPlatform && (
|
||||
<Link
|
||||
href="/support/new"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{t("newTicket")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary text-center py-6">
|
||||
{user.isPlatform ? t("emptyAdmin") : t("empty")}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2 animate-in animate-in-delay-1">
|
||||
{tickets.map((tk) => (
|
||||
<Link
|
||||
key={tk.id}
|
||||
href={`/support/${tk.id}`}
|
||||
className="block rounded-xl border border-border bg-surface-1 p-4 hover:border-text-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-text-primary truncate">
|
||||
{tk.title}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 flex items-center gap-2">
|
||||
<TicketCategoryLabel category={tk.category} />
|
||||
<span>·</span>
|
||||
<span>{formatRelative(tk.updatedAt, f)}</span>
|
||||
{user.isPlatform && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{tk.contactEmail}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TicketStatusBadge status={tk.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getPendingResumeRequestForTenant } from "@/lib/db";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { WarningBadge } from "@/components/ui/warning-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
@@ -12,8 +13,15 @@ import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
||||
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
||||
|
||||
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
|
||||
// CHANNEL_PACKAGES used to be a hardcoded literal here
|
||||
// (`["telegram", "discord", "email"]`). It now derives from the
|
||||
// portal-side catalog so adding a new channel anywhere only requires
|
||||
// editing src/lib/packages.ts. The `email` channel was dropped as
|
||||
// part of the Phase A package-model rework — IMAP/SMTP is now the
|
||||
// `mail` skill instead.
|
||||
const CHANNEL_PACKAGES = CHANNEL_PACKAGE_IDS;
|
||||
|
||||
export default async function TenantDetailPage({
|
||||
params,
|
||||
@@ -47,6 +55,13 @@ export default async function TenantDetailPage({
|
||||
// The current state comes from spec.suspend on the CR.
|
||||
const isSuspended = Boolean(tenant.spec.suspend);
|
||||
|
||||
// Bug 37a: when the tenant is suspended, an owner can request
|
||||
// reactivation (admin-gated). Look up whether one is in flight so
|
||||
// the SubscriptionToggle can render the right state.
|
||||
const pendingResumeRequest = isSuspended
|
||||
? await getPendingResumeRequestForTenant(name)
|
||||
: null;
|
||||
|
||||
// Bug 7: assigned-users panel is meaningless for personal tenants
|
||||
// (sole-owner by definition; the only "assignee" is the owner
|
||||
// themselves). We hide the panel when EITHER the CR carries the
|
||||
@@ -134,6 +149,53 @@ export default async function TenantDetailPage({
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{t("suspendedDescription")}
|
||||
</div>
|
||||
{/* Retention countdown. suspendedAt is stamped by the
|
||||
operator on first transition to suspended; missing
|
||||
values fall through silently rather than rendering
|
||||
garbage (operator hasn't reconciled yet, edge case).
|
||||
The 60-day window is the operator's
|
||||
retentionAfterSuspend constant; if you change one,
|
||||
change both. We don't expose the constant via API —
|
||||
the value rarely changes and duplicating it here
|
||||
beats fetching a single int over the network. */}
|
||||
{tenant.status?.suspendedAt && (() => {
|
||||
const suspendedAt = new Date(tenant.status.suspendedAt);
|
||||
const deletionAt = new Date(suspendedAt);
|
||||
deletionAt.setDate(deletionAt.getDate() + 60);
|
||||
const now = new Date();
|
||||
const msRemaining = deletionAt.getTime() - now.getTime();
|
||||
const daysRemaining = Math.max(
|
||||
0,
|
||||
Math.ceil(msRemaining / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
// < 7 days: red/critical to draw attention. Otherwise
|
||||
// amber, matching the banner.
|
||||
const urgent = daysRemaining < 7;
|
||||
return (
|
||||
<div
|
||||
className={`text-xs mt-2 ${
|
||||
urgent ? "text-red-400" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{t("suspendedSince", {
|
||||
date: formatDateTime(
|
||||
tenant.status.suspendedAt,
|
||||
f
|
||||
),
|
||||
})}
|
||||
{" · "}
|
||||
{daysRemaining > 0
|
||||
? t("suspendedDeletionIn", {
|
||||
days: daysRemaining,
|
||||
date: formatDateTime(
|
||||
deletionAt.toISOString(),
|
||||
f
|
||||
),
|
||||
})
|
||||
: t("suspendedDeletionImminent")}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +206,7 @@ export default async function TenantDetailPage({
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("usage")}
|
||||
</h2>
|
||||
<UsageDisplay tenant={name} />
|
||||
<UsageDisplay tenant={name} canEditBudget={canEdit} />
|
||||
</section>
|
||||
|
||||
{/* Packages */}
|
||||
@@ -208,7 +270,21 @@ export default async function TenantDetailPage({
|
||||
? t("subscriptionDescriptionSuspended")
|
||||
: t("subscriptionDescriptionActive")}
|
||||
</p>
|
||||
<SubscriptionToggle tenantName={name} suspended={isSuspended} />
|
||||
<SubscriptionToggle
|
||||
tenantName={name}
|
||||
suspended={isSuspended}
|
||||
isPlatform={user.isPlatform}
|
||||
pendingResumeRequest={
|
||||
pendingResumeRequest
|
||||
? {
|
||||
id: pendingResumeRequest.id,
|
||||
createdAt: pendingResumeRequest.createdAt,
|
||||
customerNotes:
|
||||
pendingResumeRequest.customerNotes ?? null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
75
src/app/api/admin/openclaw/route.ts
Normal file
75
src/app/api/admin/openclaw/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOpenClawDefaults, setOpenClawDefaults } from "@/lib/k8s";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Platform-wide default OpenClaw image tag (admin-only).
|
||||
*
|
||||
* GET — read the current default tag from the
|
||||
* `pieced-openclaw-config` ConfigMap. Can be empty string if no
|
||||
* default is configured; the operator uses its built-in fallback
|
||||
* in that case.
|
||||
*
|
||||
* PATCH — update the tag. Send "" to clear. The operator watches
|
||||
* this ConfigMap and re-enqueues all tenants without a per-tenant
|
||||
* override on change, so existing tenants roll forward to the new
|
||||
* default automatically. Tenants WITH an override are unaffected.
|
||||
*
|
||||
* Tag-only by design — see operator notes.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
defaultTag: z.string().trim().max(256),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await getOpenClawDefaults());
|
||||
} catch (e: any) {
|
||||
console.error("Failed to read openclaw defaults:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to read defaults") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await setOpenClawDefaults({
|
||||
defaultTag: parsed.data.defaultTag,
|
||||
});
|
||||
return NextResponse.json(next);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to update openclaw defaults:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update defaults") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
updateTenantRequestStatus,
|
||||
clearEncryptedSecrets,
|
||||
} from "@/lib/db";
|
||||
import { createTenant } from "@/lib/k8s";
|
||||
import { sendApprovalEmail } from "@/lib/email";
|
||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||
import { decryptSecrets } from "@/lib/crypto";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import {
|
||||
@@ -19,14 +19,26 @@ import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/approve
|
||||
* Approve a tenant request:
|
||||
* 1. Decrypt stored package secrets (if any)
|
||||
* 2. Write each package's secrets to OpenBao at secret/data/tenants/{tenant-name}/{package}
|
||||
* 3. Null the encrypted_secrets column
|
||||
* 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
* 5. Create PiecedTenant CR
|
||||
* 6. Update request status, notify customer.
|
||||
* Also supports re-approving a previously rejected request (clears admin notes).
|
||||
*
|
||||
* Approve a request. Two paths depending on request_type:
|
||||
*
|
||||
* Provision (the original purpose):
|
||||
* 1. Decrypt stored package secrets (if any)
|
||||
* 2. Write each package's secrets to OpenBao
|
||||
* 3. Null the encrypted_secrets column
|
||||
* 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
* 5. Create PiecedTenant CR
|
||||
* 6. Update request status, notify customer.
|
||||
* Supports re-approving a previously rejected request (clears admin notes).
|
||||
*
|
||||
* Resume (Bug 37a):
|
||||
* 1. PATCH spec.suspend=false on the existing PiecedTenant CR.
|
||||
* 2. Clear the `pieced.ch/resume-request-pending` annotation so the
|
||||
* operator knows the request is settled (and doesn't pause its
|
||||
* 60-day TTL forever — though now that the tenant isn't suspended,
|
||||
* the timer is moot).
|
||||
* 3. Mark request approved, notify customer.
|
||||
* No CR creation, no secret materialisation, no workspace files.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -60,6 +72,58 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Resume request: short path. Just patch the existing tenant, clear
|
||||
// the annotation, mark approved.
|
||||
if (tenantRequest.requestType === "resume") {
|
||||
if (!tenantRequest.tenantName) {
|
||||
// Shouldn't happen — resume requests are created with tenant_name
|
||||
// set. Defensive 500 if it does.
|
||||
return NextResponse.json(
|
||||
{ error: "Resume request has no tenant_name" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||
// Best-effort — annotation cleanup is also done by the operator
|
||||
// when it sees suspend=false on the next reconcile (it clears
|
||||
// status.suspendedAt), but explicitly clearing here keeps the
|
||||
// CR clean.
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
tenantRequest.tenantName,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"post-approve annotation clear failed; not blocking",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
await updateTenantRequestStatus(id, "approved", { adminNotes });
|
||||
|
||||
await sendResumeApprovalEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName
|
||||
).catch((e) => console.error("resume approval email failed:", e));
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Resume approved. Tenant is reactivating.",
|
||||
tenantName: tenantRequest.tenantName,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Resume approval failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to approve resume") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isReApproval = tenantRequest.status === "rejected";
|
||||
|
||||
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||
import { sendRejectionEmail } from "@/lib/email";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/reject
|
||||
* Reject a tenant request and notify the customer.
|
||||
*
|
||||
* For resume requests (Bug 37a): also clears the
|
||||
* `pieced.ch/resume-request-pending` annotation on the tenant CR.
|
||||
* The operator's 60-day TTL then resumes counting from the original
|
||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||
* a fresh resume request later if circumstances change, but that
|
||||
* starts a new pending row and re-stamps the annotation.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -37,13 +45,45 @@ export async function POST(
|
||||
adminNotes,
|
||||
});
|
||||
|
||||
// Notify customer
|
||||
await sendRejectionEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName,
|
||||
adminNotes
|
||||
);
|
||||
// Resume rejection: clear the annotation so the operator's TTL
|
||||
// resumes. Best-effort — failure is logged, not propagated.
|
||||
if (
|
||||
tenantRequest.requestType === "resume" &&
|
||||
tenantRequest.tenantName
|
||||
) {
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
tenantRequest.tenantName,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"post-reject annotation clear failed; operator's TTL will pause until annotation removed by admin",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify customer. Resume requests get a different email — the
|
||||
// tenant already exists; copy needs to mention "stays suspended" and
|
||||
// the 60-day retention deadline. Provision rejections use the
|
||||
// original onboarding-rejection wording.
|
||||
if (tenantRequest.requestType === "resume") {
|
||||
await sendResumeRejectionEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName,
|
||||
adminNotes
|
||||
);
|
||||
} else {
|
||||
await sendRejectionEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName,
|
||||
adminNotes
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Request rejected.",
|
||||
|
||||
78
src/app/api/admin/tenants/[name]/openclaw-image/route.ts
Normal file
78
src/app/api/admin/tenants/[name]/openclaw-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Per-tenant OpenClaw image override (admin-only).
|
||||
*
|
||||
* Why admin-only: customers cannot pick OpenClaw versions. This
|
||||
* exists so the platform team can A/B-test new releases on specific
|
||||
* tenants without rolling them out fleet-wide. The endpoint enforces
|
||||
* `user.isPlatform`; even owners of the tenant's org cannot use it.
|
||||
*
|
||||
* PATCH body shapes:
|
||||
* - { tag: "2026.4.22" } → use this tag
|
||||
* - { tag: "" } or empty body → clear override (revert to platform
|
||||
* default)
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
tag: z.string().trim().max(256).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tag = parsed.data.tag ?? "";
|
||||
const isClearing = tag === "";
|
||||
|
||||
// Merge-patch semantics: openClawImage: null removes the field
|
||||
// from the spec; openClawImage: { tag } sets it.
|
||||
const spec: any = isClearing
|
||||
? { openClawImage: null }
|
||||
: { openClawImage: { tag } };
|
||||
|
||||
try {
|
||||
const updated = await patchTenantSpec(name, spec);
|
||||
return NextResponse.json({
|
||||
message: isClearing
|
||||
? "Override cleared; tenant follows platform default."
|
||||
: "Override set.",
|
||||
openClawImage: updated.spec.openClawImage ?? null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Failed to set tenant openclaw image:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update tenant image") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
128
src/app/api/billing/route.ts
Normal file
128
src/app/api/billing/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Org-scoped billing API (Bug 35).
|
||||
*
|
||||
* GET — return the current billing record for the caller's org, or
|
||||
* 404 if none has been captured yet. The /settings/billing page
|
||||
* renders an empty form on 404 (first-time edit) and a pre-filled
|
||||
* form on 200.
|
||||
*
|
||||
* PUT — upsert the billing record. Required for any subsequent tenant
|
||||
* provisioning unless the caller is on a personal org. Validation:
|
||||
* - All address fields required.
|
||||
* - VAT number required for company orgs (where `user.isPersonal`
|
||||
* is false). Optional for personal orgs.
|
||||
* - billing_email validated as RFC-5322-ish.
|
||||
*
|
||||
* Authorization:
|
||||
* - GET: any authenticated user in the org. We expose only their
|
||||
* own org's billing — orgId is scoped from the session.
|
||||
* - PUT: owners and platform admins (canMutate check). Customers
|
||||
* in `user` role cannot edit billing.
|
||||
*/
|
||||
|
||||
const billingSchema = z.object({
|
||||
companyName: z.string().min(1).max(200),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
postalCode: z.string().min(1).max(20),
|
||||
city: z.string().min(1).max(100),
|
||||
country: z.string().min(2).max(3), // ISO 3166-1 alpha-2 or alpha-3
|
||||
vatNumber: z
|
||||
.string()
|
||||
.max(50)
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v && v.trim() !== "" ? v.trim() : null)),
|
||||
billingEmail: z.string().email().max(200),
|
||||
notes: z
|
||||
.string()
|
||||
.max(2000)
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v && v.trim() !== "" ? v.trim() : null)),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
if (!billing) {
|
||||
// 404 carries semantic meaning here — "no record yet". Callers
|
||||
// (settings page, wizard) treat this as the empty-form state.
|
||||
return NextResponse.json(
|
||||
{ error: "No billing record for this org" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = billingSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Company orgs (B2B) require companyName AND VAT. Personal orgs
|
||||
// (B2C — private individuals) need neither; their /settings/billing
|
||||
// form hides both fields and we don't ask the API to enforce them.
|
||||
if (!user.isPersonal) {
|
||||
const missing: Record<string, string[]> = {};
|
||||
if (!parsed.data.companyName || parsed.data.companyName.trim().length === 0) {
|
||||
missing.companyName = ["Required for companies"];
|
||||
}
|
||||
if (!parsed.data.vatNumber) {
|
||||
missing.vatNumber = ["Required for companies"];
|
||||
}
|
||||
if (Object.keys(missing).length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Company name and VAT number are required for company accounts.",
|
||||
details: { fieldErrors: missing },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: parsed.data.companyName,
|
||||
streetAddress: parsed.data.streetAddress,
|
||||
postalCode: parsed.data.postalCode,
|
||||
city: parsed.data.city,
|
||||
country: parsed.data.country,
|
||||
vatNumber: parsed.data.vatNumber,
|
||||
billingEmail: parsed.data.billingEmail,
|
||||
notes: parsed.data.notes,
|
||||
});
|
||||
return NextResponse.json({ billing });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to upsert org billing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to save billing") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateTenantRequestEditableFields,
|
||||
} from "@/lib/db";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
@@ -91,6 +92,25 @@ export async function DELETE(
|
||||
|
||||
try {
|
||||
await updateTenantRequestStatus(id, "cancelled");
|
||||
|
||||
// Customer cancels their own pending resume request: clear the
|
||||
// operator-side annotation so the 60-day TTL resumes counting.
|
||||
// Best-effort — the operator handles missing annotation gracefully.
|
||||
if (tr.requestType === "resume" && tr.tenantName) {
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
tr.tenantName,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"post-cancel annotation clear failed; not blocking",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Request cancelled.", id });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to cancel request:", e);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -255,8 +257,137 @@ export async function POST(request: Request) {
|
||||
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;
|
||||
|
||||
// Bug 35: org-scoped billing.
|
||||
//
|
||||
// Resolution rules:
|
||||
// 1. If org_billing exists, use it (synthesise a BillingAddress
|
||||
// shape for the audit copy on tenant_requests). Wizard's
|
||||
// submitted billingAddress is ignored — the org has billing
|
||||
// on file, the wizard skipped that step.
|
||||
// 2. If no org_billing AND wizard supplied billingAddress, use
|
||||
// the wizard's data and save to org_billing for next time.
|
||||
// VAT is enforced by billingAddressSchema (required for
|
||||
// everyone).
|
||||
// 3. If no org_billing AND no wizard billingAddress: reject.
|
||||
// Billing is required for all customers regardless of
|
||||
// personal/company org structure — we're a commercial
|
||||
// product. Personal accounts (sole proprietors, individuals)
|
||||
// are still subject to billing capture.
|
||||
//
|
||||
// The synthetic BillingAddress for case 1 collapses fields that
|
||||
// org_billing has more granularly; good enough for audit, since
|
||||
// /settings/billing is the authoritative editor going forward.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
let billingAddress: TenantRequest["billingAddress"];
|
||||
let billingNotes = input.billingNotes ?? prior?.billingNotes;
|
||||
|
||||
if (orgBilling) {
|
||||
billingAddress = {
|
||||
company: orgBilling.companyName,
|
||||
street: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
vatNumber: orgBilling.vatNumber ?? undefined,
|
||||
};
|
||||
} else if (input.billingAddress) {
|
||||
// Wizard supplied billing — re-validate the strict shape (the
|
||||
// outer onboardingSchema marks it optional now, so we can't rely
|
||||
// on its enforcement of the inner required fields).
|
||||
const billingCheck = billingAddressSchema.safeParse(input.billingAddress);
|
||||
if (!billingCheck.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid billing address",
|
||||
details: billingCheck.error.flatten(),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Company orgs (B2B) require companyName AND vatNumber.
|
||||
// Personal orgs (B2C — private individuals) require neither;
|
||||
// the wizard hides both fields for them and the API doesn't
|
||||
// enforce.
|
||||
if (!isPersonal) {
|
||||
const missing: Record<string, string[]> = {};
|
||||
if (
|
||||
!billingCheck.data.company ||
|
||||
billingCheck.data.company.trim().length === 0
|
||||
) {
|
||||
missing["billingAddress.company"] = ["Required for companies"];
|
||||
}
|
||||
if (
|
||||
!billingCheck.data.vatNumber ||
|
||||
billingCheck.data.vatNumber.length === 0
|
||||
) {
|
||||
missing["billingAddress.vatNumber"] = ["Required for companies"];
|
||||
}
|
||||
if (Object.keys(missing).length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Company name and VAT number are required for company accounts.",
|
||||
details: { fieldErrors: missing },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
billingAddress = billingCheck.data;
|
||||
|
||||
// Persist to org_billing. For personal customers (B2C, no
|
||||
// company line), fall back to their display name from the
|
||||
// session — invoices addressed to their actual name rather than
|
||||
// an opaque org id like "personal-3f2a8b1c". For companies the
|
||||
// wizard's company field is filled.
|
||||
const personalDisplayName = (user.name || user.email || "").trim();
|
||||
try {
|
||||
await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName:
|
||||
(billingCheck.data.company || "").trim() ||
|
||||
(isPersonal ? personalDisplayName : user.orgName) ||
|
||||
user.orgName,
|
||||
streetAddress: billingCheck.data.street,
|
||||
postalCode: billingCheck.data.postalCode,
|
||||
city: billingCheck.data.city,
|
||||
country: billingCheck.data.country,
|
||||
// Personal: undefined (no VAT). Company: enforced non-empty
|
||||
// by the check above.
|
||||
vatNumber: isPersonal ? null : billingCheck.data.vatNumber!,
|
||||
billingEmail: contactEmail,
|
||||
notes: billingNotes ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-fatal — the tenant_request still gets created with the
|
||||
// billingAddress audit copy. The customer can re-save via
|
||||
// /settings/billing if this failed.
|
||||
console.warn(
|
||||
"failed to save org_billing on first capture; tenant_request still created with audit copy",
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No billing supplied AND no org_billing record. Required for
|
||||
// everyone — commercial product, no personal-orgs-skip
|
||||
// shortcut. Customer must complete the wizard's billing step
|
||||
// or set up /settings/billing first.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Billing information is required. Please complete the billing step or set it up at /settings/billing.",
|
||||
details: {
|
||||
fieldErrors: {
|
||||
billingAddress: ["Required"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
|
||||
149
src/app/api/support/tickets/[id]/comments/route.ts
Normal file
149
src/app/api/support/tickets/[id]/comments/route.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSupportTicketById,
|
||||
createSupportTicketComment,
|
||||
updateSupportTicket,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
sendSupportTicketReplyEmail,
|
||||
sendSupportAdminNotificationEmail,
|
||||
} from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import type { SupportTicketStatus } from "@/types";
|
||||
|
||||
/**
|
||||
* Comments on a support ticket (Feature 5). Threaded chronologically;
|
||||
* no nested replies.
|
||||
*
|
||||
* Auto status transitions on comment:
|
||||
* - Customer reply on a `waiting_for_customer` → `in_progress`
|
||||
* (the ball is back in admin's court).
|
||||
* - Customer reply on a `resolved` ticket → `reopened`
|
||||
* (customer disagreed with the resolution).
|
||||
* - Admin reply on `open` or `reopened` → `in_progress`
|
||||
* (signals admin has engaged).
|
||||
* - Admin reply on `in_progress` → `waiting_for_customer`
|
||||
* (admin's response, ball is in customer's court).
|
||||
* - Otherwise no change.
|
||||
*
|
||||
* The auto-bump is opportunistic — caller may pass an explicit
|
||||
* status override via the PATCH endpoint instead. We only auto-bump
|
||||
* here when no comment-side override is provided (the comment POST
|
||||
* doesn't accept a status field).
|
||||
*
|
||||
* Email rules:
|
||||
* - Customer replies → admin queue gets an "admin notification" email.
|
||||
* - Admin replies → customer gets a reply email (with the body
|
||||
* inline so they can read on mobile without clicking).
|
||||
* - No "you just commented" confirmation back to the author.
|
||||
*
|
||||
* The customer reply path skips the separate status-change email
|
||||
* even when the status auto-bumps, on the principle that one email
|
||||
* per action is enough; the admin will see the reply notification
|
||||
* and the new status in the queue.
|
||||
*/
|
||||
|
||||
const createSchema = z.object({
|
||||
body: z.string().trim().min(1, "required").max(10_000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Compute the auto-bumped status (if any) for a comment from a given
|
||||
* author kind. Returns the new status if it should change, or null
|
||||
* if it should stay the same.
|
||||
*/
|
||||
function autoBumpStatus(
|
||||
current: SupportTicketStatus,
|
||||
authorKind: "customer" | "admin"
|
||||
): SupportTicketStatus | null {
|
||||
if (authorKind === "customer") {
|
||||
if (current === "waiting_for_customer") return "in_progress";
|
||||
if (current === "resolved") return "reopened";
|
||||
return null;
|
||||
}
|
||||
// admin
|
||||
if (current === "open" || current === "reopened") return "in_progress";
|
||||
if (current === "in_progress") return "waiting_for_customer";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const ticket = await getSupportTicketById(id);
|
||||
if (!ticket) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Same authorization as the GET on the parent resource.
|
||||
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authorKind: "customer" | "admin" = user.isPlatform
|
||||
? "admin"
|
||||
: "customer";
|
||||
|
||||
try {
|
||||
const comment = await createSupportTicketComment({
|
||||
ticketId: id,
|
||||
authorUserId: user.id,
|
||||
authorName: user.name,
|
||||
authorKind,
|
||||
body: parsed.data.body,
|
||||
});
|
||||
|
||||
// Auto-bump status if the comment changes the ball's court.
|
||||
const nextStatus = autoBumpStatus(ticket.status, authorKind);
|
||||
if (nextStatus) {
|
||||
await updateSupportTicket(id, { status: nextStatus });
|
||||
}
|
||||
|
||||
// Email the other side. Customer's reply → admin queue;
|
||||
// admin's reply → customer.
|
||||
if (authorKind === "customer") {
|
||||
sendSupportAdminNotificationEmail({
|
||||
reason: "replied",
|
||||
ticketId: ticket.id,
|
||||
title: ticket.title,
|
||||
contactName: ticket.contactName,
|
||||
contactEmail: ticket.contactEmail,
|
||||
body: parsed.data.body,
|
||||
category: ticket.category,
|
||||
}).catch((e) => console.error("admin notification:", e));
|
||||
} else {
|
||||
sendSupportTicketReplyEmail({
|
||||
to: ticket.contactEmail,
|
||||
contactName: ticket.contactName,
|
||||
ticketId: ticket.id,
|
||||
title: ticket.title,
|
||||
authorName: user.name,
|
||||
body: parsed.data.body,
|
||||
}).catch((e) => console.error("reply email:", e));
|
||||
}
|
||||
|
||||
return NextResponse.json({ comment }, { status: 201 });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create support ticket comment:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to add comment") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
132
src/app/api/support/tickets/[id]/route.ts
Normal file
132
src/app/api/support/tickets/[id]/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSupportTicketById,
|
||||
listCommentsForTicket,
|
||||
updateSupportTicket,
|
||||
} from "@/lib/db";
|
||||
import { sendSupportTicketStatusEmail } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Single support ticket detail (Feature 5).
|
||||
*
|
||||
* GET — returns the ticket plus all comments in chronological order.
|
||||
* Authorization: customer sees their own; platform admin sees any.
|
||||
*
|
||||
* PATCH — change status and/or category. Admin only. Sends a status
|
||||
* change email to the customer if status changed, UNLESS the same
|
||||
* call also creates a comment (in that case the comment endpoint
|
||||
* handles the email so the customer doesn't get two messages).
|
||||
*
|
||||
* No DELETE — tickets are durable history. Resolved tickets stay in
|
||||
* the DB for the audit trail.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
status: z
|
||||
.enum(["open", "in_progress", "waiting_for_customer", "resolved", "reopened"])
|
||||
.optional(),
|
||||
category: z
|
||||
.enum(["bug", "feature_request", "question", "billing", "other"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const ticket = await getSupportTicketById(id);
|
||||
if (!ticket) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Authorization: customer can see their own; platform admin can
|
||||
// see any. Owners cannot see their org's tickets — confirmed by
|
||||
// Feature 5 visibility design (per-user, not per-org).
|
||||
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
|
||||
// Don't leak existence — same 404 as if the ticket didn't exist.
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const comments = await listCommentsForTicket(id);
|
||||
return NextResponse.json({ ticket, comments });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const ticket = await getSupportTicketById(id);
|
||||
if (!ticket) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authorization: status/category changes are admin-only EXCEPT
|
||||
// the customer can close their own ticket via status='resolved'
|
||||
// (Feature 5 design — gives them an "I figured it out, never mind"
|
||||
// escape hatch). Customer cannot reopen via this endpoint — that
|
||||
// happens automatically when they comment on a resolved ticket
|
||||
// (handled in the comments POST).
|
||||
const isCustomerSelfClose =
|
||||
!user.isPlatform &&
|
||||
ticket.zitadelUserId === user.id &&
|
||||
parsed.data.status === "resolved" &&
|
||||
parsed.data.category === undefined;
|
||||
|
||||
if (!user.isPlatform && !isCustomerSelfClose) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const previousStatus = ticket.status;
|
||||
const updated = await updateSupportTicket(id, parsed.data);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Email customer when admin (not the customer themselves)
|
||||
// changes status. Skip on customer-self-close — they know what
|
||||
// they did. Skip when status didn't actually change (admin
|
||||
// edited only category).
|
||||
if (
|
||||
user.isPlatform &&
|
||||
parsed.data.status !== undefined &&
|
||||
parsed.data.status !== previousStatus
|
||||
) {
|
||||
sendSupportTicketStatusEmail({
|
||||
to: ticket.contactEmail,
|
||||
contactName: ticket.contactName,
|
||||
ticketId: ticket.id,
|
||||
title: ticket.title,
|
||||
newStatus: parsed.data.status,
|
||||
}).catch((e) => console.error("status email:", e));
|
||||
}
|
||||
|
||||
return NextResponse.json({ ticket: updated });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to update support ticket:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update ticket") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
103
src/app/api/support/tickets/route.ts
Normal file
103
src/app/api/support/tickets/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
createSupportTicket,
|
||||
listSupportTicketsForUser,
|
||||
listAllSupportTickets,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
sendSupportTicketCreatedEmail,
|
||||
sendSupportAdminNotificationEmail,
|
||||
} from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Support tickets API (Feature 5).
|
||||
*
|
||||
* Visibility: tickets are scoped strictly per-user (zitadel_user_id).
|
||||
* Coworkers in the same org cannot see each other's tickets — this
|
||||
* is the team's design choice for privacy. Platform admins see
|
||||
* everything (the admin queue lives at the same UI but pulls from
|
||||
* a different list).
|
||||
*
|
||||
* GET — for platform users, returns all tickets across all users.
|
||||
* For everyone else, returns only the caller's own tickets. The
|
||||
* client decides the rendering based on user role; we just return
|
||||
* the right list.
|
||||
*
|
||||
* POST — creates a ticket, sends a confirmation email to the
|
||||
* customer and a notification email to the admin distribution list.
|
||||
*/
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().trim().min(3, "required").max(200),
|
||||
description: z.string().trim().min(10, "required").max(10_000),
|
||||
category: z.enum(["bug", "feature_request", "question", "billing", "other"]),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Platform admins get the global queue; everyone else sees their
|
||||
// own tickets only. Visibility-by-default-deny: even an org owner
|
||||
// doesn't see their coworkers' tickets, by Feature 5 design.
|
||||
const tickets = user.isPlatform
|
||||
? await listAllSupportTickets()
|
||||
: await listSupportTicketsForUser(user.id);
|
||||
return NextResponse.json({ tickets });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const ticket = await createSupportTicket({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
category: parsed.data.category,
|
||||
contactName: user.name,
|
||||
contactEmail: user.email,
|
||||
});
|
||||
|
||||
// Fire-and-log email notifications. Both are best-effort;
|
||||
// failure to send doesn't roll back the ticket creation.
|
||||
sendSupportTicketCreatedEmail({
|
||||
to: user.email,
|
||||
contactName: user.name,
|
||||
ticketId: ticket.id,
|
||||
title: ticket.title,
|
||||
}).catch((e) => console.error("ticket created email:", e));
|
||||
sendSupportAdminNotificationEmail({
|
||||
reason: "created",
|
||||
ticketId: ticket.id,
|
||||
title: ticket.title,
|
||||
contactName: user.name,
|
||||
contactEmail: user.email,
|
||||
body: ticket.description,
|
||||
category: ticket.category,
|
||||
}).catch((e) => console.error("admin notification:", e));
|
||||
|
||||
return NextResponse.json({ ticket }, { status: 201 });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create support ticket:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to create ticket") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/app/api/tenants/[name]/budget/route.ts
Normal file
126
src/app/api/tenants/[name]/budget/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { findKeyByAlias, updateKeyBudget } from "@/lib/litellm";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Update the per-tenant budget — operates on the LiteLLM virtual
|
||||
* key, NOT on the team.
|
||||
*
|
||||
* Why per-key
|
||||
* -----------
|
||||
* Each tenant in an org has its own virtual key
|
||||
* (`key_alias = tenant.metadata.name`); the team that owns those
|
||||
* keys is org-scoped and shared across all the org's tenants. A
|
||||
* budget on the team would cap the whole org; a budget on the key
|
||||
* caps just this one tenant. Customers landing on the tenant detail
|
||||
* page reasonably expect "edit budget" to mean "the budget of THIS
|
||||
* tenant" — so we put it on the key.
|
||||
*
|
||||
* The team-level (org-wide) budget is a separate control that lives
|
||||
* in /settings (not yet implemented) — the two coexist: LiteLLM
|
||||
* applies whichever cap is hit first.
|
||||
*
|
||||
* Schema:
|
||||
* - maxBudget: number > 0 (set a cap), or null (remove the cap).
|
||||
* - budgetDuration: one of "30d", "1mo", "1y", or null (lifetime).
|
||||
*
|
||||
* Authorization: owners and platform admins.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
// > 0 because LiteLLM rejects 0 and a zero cap would lock the key
|
||||
// out instantly. Upper bound 1M as a typo guard.
|
||||
maxBudget: z.number().positive().max(1_000_000).nullable(),
|
||||
budgetDuration: z.enum(["30d", "1mo", "1y"]).nullable(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
// Don't leak existence — same 404 a non-visible tenant gets.
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const teamId = tenant.status?.litellmTeamId;
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Tenant has no LiteLLM team yet. Please wait until provisioning completes.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive: removing the cap should null out the duration too —
|
||||
// a reset cadence on an unlimited budget is meaningless and would
|
||||
// confuse LiteLLM's bookkeeping.
|
||||
const maxBudget = parsed.data.maxBudget;
|
||||
const budgetDuration =
|
||||
maxBudget === null ? null : parsed.data.budgetDuration;
|
||||
|
||||
// Look up the key by alias (= tenant name). The token returned is
|
||||
// what /key/update wants in the `key` field.
|
||||
let keyInfo;
|
||||
try {
|
||||
keyInfo = await findKeyByAlias(teamId, name);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to look up tenant key:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to look up tenant key") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
if (!keyInfo) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Tenant has no virtual key yet. Please wait until provisioning completes.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateKeyBudget(keyInfo.token, { maxBudget, budgetDuration });
|
||||
return NextResponse.json({
|
||||
message: maxBudget === null ? "Budget removed." : "Budget updated.",
|
||||
maxBudget,
|
||||
budgetDuration,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Failed to update key budget:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update budget") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
199
src/app/api/tenants/[name]/resume-request/route.ts
Normal file
199
src/app/api/tenants/[name]/resume-request/route.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import {
|
||||
createResumeRequest,
|
||||
getPendingResumeRequestForTenant,
|
||||
getTenantRequestByTenantName,
|
||||
} from "@/lib/db";
|
||||
import { sendResumeRequestAdminNotificationEmail } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Body schema. Both fields optional; the customer can submit a
|
||||
* resume request with no body at all (the JS client sends `{}`),
|
||||
* or with a note explaining their reactivation rationale.
|
||||
*
|
||||
* Length cap mirrors `billing_notes` (2000 chars) — same lower
|
||||
* bound for "free-form text we don't want abused".
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
customerNotes: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.optional()
|
||||
.transform((v) => (v && v.length > 0 ? v : undefined)),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tenants/[name]/resume-request
|
||||
*
|
||||
* Owner-initiated request to reactivate a suspended tenant (Bug 37a).
|
||||
* Creates a pending tenant_request of type 'resume' for admin review,
|
||||
* and stamps the PiecedTenant CR with an annotation that pauses the
|
||||
* operator's 60-day deletion timer.
|
||||
*
|
||||
* Why a request flow at all
|
||||
* -------------------------
|
||||
* Customers can self-serve cancel; resume requires admin oversight.
|
||||
* Reactivation may involve re-validating billing, confirming the
|
||||
* customer still wants to be active, or other manual steps. The
|
||||
* request flow gives admins a queue to review, with the same approve/
|
||||
* reject UX as initial provision requests.
|
||||
*
|
||||
* Authorization
|
||||
* -------------
|
||||
* Owners and platform admins. Platform admins shouldn't normally use
|
||||
* this endpoint — they have direct PATCH suspend access — but it's
|
||||
* permissive in case admin tooling pivots.
|
||||
*
|
||||
* Validation
|
||||
* ----------
|
||||
* - Tenant must exist and be visible to the caller.
|
||||
* - Tenant must be currently suspended. Resuming an active tenant
|
||||
* is meaningless.
|
||||
* - At most one pending resume request per tenant. Enforced by the
|
||||
* DB's partial unique index, but we also check explicitly here to
|
||||
* return a friendly 409 instead of a 500.
|
||||
*
|
||||
* Side effects on success
|
||||
* -----------------------
|
||||
* - INSERT into tenant_requests (request_type='resume', status='pending')
|
||||
* - PATCH annotation `pieced.ch/resume-request-pending=<request-id>` on
|
||||
* the CR. This is the operator's signal to pause its 60-day deletion
|
||||
* timer until the request transitions to terminal.
|
||||
*
|
||||
* The annotation set is best-effort: if the K8s PATCH fails after the
|
||||
* DB insert, the row exists without the annotation. The customer
|
||||
* sees the request as pending; admin can still approve. The only
|
||||
* functional consequence is the 60-day timer doesn't pause until the
|
||||
* next request transition, which is fine in practice (admin response
|
||||
* times are dramatically shorter than 60 days).
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!tenant.spec.suspend) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant is not suspended; nothing to resume." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Body is optional — the customer can submit a resume request
|
||||
// with no payload at all, or attach a free-form note.
|
||||
const rawBody = await req.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(rawBody ?? {});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const customerNotes = parsed.data.customerNotes;
|
||||
|
||||
// Already a pending request? Don't duplicate.
|
||||
const existing = await getPendingResumeRequestForTenant(name);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "A resume request for this tenant is already pending.",
|
||||
request: { id: existing.id, createdAt: existing.createdAt },
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Pull traceability fields (companyName, agentName) from the original
|
||||
// provision request. The schema marks these NOT NULL, so we have to
|
||||
// populate them; copying from the provision row keeps the resume
|
||||
// row navigable in the admin UI without making up values.
|
||||
const provision = await getTenantRequestByTenantName(name);
|
||||
|
||||
try {
|
||||
const resumeRequest = await createResumeRequest({
|
||||
tenantName: name,
|
||||
zitadelOrgId:
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
contactName: user.name,
|
||||
contactEmail: user.email,
|
||||
companyName: provision?.companyName ?? tenant.spec.displayName ?? name,
|
||||
agentName: provision?.agentName ?? "Assistant",
|
||||
customerNotes,
|
||||
});
|
||||
|
||||
// Stamp the annotation so the operator pauses its TTL. If this
|
||||
// fails the request still exists; surface the error so admin
|
||||
// tooling can re-stamp if needed, but don't roll back.
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
name,
|
||||
"pieced.ch/resume-request-pending",
|
||||
resumeRequest.id
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"resume request created but annotation could not be set; operator's 60-day timer will not pause until next reconcile triggered by request transition",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Notify admin distribution. Fire-and-log: failure to email
|
||||
// doesn't roll back the request creation. The customer's note
|
||||
// (if any) is included so admin can triage from the email
|
||||
// without opening the queue.
|
||||
sendResumeRequestAdminNotificationEmail({
|
||||
tenantName: name,
|
||||
companyName: resumeRequest.companyName,
|
||||
contactName: resumeRequest.contactName,
|
||||
contactEmail: resumeRequest.contactEmail,
|
||||
customerNotes,
|
||||
}).catch((e) =>
|
||||
console.error("resume admin notification email failed:", e)
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Resume request submitted. An admin will review shortly.",
|
||||
request: { id: resumeRequest.id, status: resumeRequest.status },
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (e: any) {
|
||||
// Unique violation (a pending row already exists for this tenant)
|
||||
// is friendly-handled above; this catches everything else.
|
||||
if (e.code === "23505") {
|
||||
return NextResponse.json(
|
||||
{ error: "A resume request for this tenant is already pending." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
console.error("Resume request creation failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to submit resume request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
@@ -12,37 +12,38 @@ const patchSchema = z.object({
|
||||
/**
|
||||
* PATCH /api/tenants/[name]/suspend
|
||||
*
|
||||
* Customer-side "Cancel subscription" / "Resume" toggle (Bug 31).
|
||||
* Direct suspend control on the PiecedTenant CR. Sets `spec.suspend`
|
||||
* to true (cancel) or false (resume).
|
||||
*
|
||||
* Sets `spec.suspend` on the PiecedTenant CR. The operator interprets
|
||||
* this flag as "stop reconciling this tenant" — workloads, packages,
|
||||
* and channel-user changes are no longer applied. Existing data is
|
||||
* preserved (namespace, ConfigMaps, OpenBao secrets, CNPG database,
|
||||
* billing records). Resuming sets the flag back to false and the
|
||||
* operator picks up reconciliation on the next loop.
|
||||
* Authorization (Bug 37a)
|
||||
* -----------------------
|
||||
* - suspend=true → owners and platform admins may call.
|
||||
* - suspend=false → platform admins ONLY. Owners must go through the
|
||||
* resume-request flow (POST /api/tenants/[name]/resume-request),
|
||||
* which creates a pending request for admin approval. This
|
||||
* asymmetry is by design: cancellation is self-service (low risk;
|
||||
* reversible by request); reactivation requires admin oversight
|
||||
* (e.g. to re-validate billing, confirm intent).
|
||||
*
|
||||
* Authorization
|
||||
* -------------
|
||||
* - Customer-side: only an `owner` of the tenant's org may call this.
|
||||
* `canMutate` is the right gate (mirrors the rest of the customer
|
||||
* API surface). User-role members cannot cancel a subscription.
|
||||
* - Platform staff: allowed via `canMutate`'s isPlatform branch, but
|
||||
* in practice they should use admin tooling for this — the action
|
||||
* is exposed here for the customer's benefit.
|
||||
* Customer flow:
|
||||
* - Cancel: PATCH suspend=true here
|
||||
* - Resume: POST /resume-request — creates a 'resume' tenant_request,
|
||||
* admin approves via /api/admin/requests/[id]/approve which
|
||||
* then PATCHes suspend=false here as a platform user.
|
||||
*
|
||||
* Visibility check is via `canUserSeeTenant` — same notFound() trick
|
||||
* as the detail page, so we don't leak existence of tenants the
|
||||
* caller can't see.
|
||||
* Workload behaviour
|
||||
* ------------------
|
||||
* On suspend=true the operator deletes the OpenClawInstance, stopping
|
||||
* the pod within seconds. Tenant data — namespace, ConfigMaps,
|
||||
* OpenBao secrets, CNPG database, LiteLLM team — is retained.
|
||||
*
|
||||
* Note on workload teardown
|
||||
* -------------------------
|
||||
* As of this writing, the operator's `suspend` handling is "skip
|
||||
* reconciliation and set status.phase to Suspended". The underlying
|
||||
* StatefulSet keeps running until next reconciliation, which won't
|
||||
* happen while suspended. Group D will add scale-to-zero so cancelled
|
||||
* subscriptions actually stop incurring compute. Until then, an
|
||||
* operator following up with a `kubectl scale` is the workaround.
|
||||
* Customer data is preserved either way.
|
||||
* Suspended tenants enter a 60-day retention window (operator
|
||||
* constant `retentionAfterSuspend`); after that, the tenant is fully
|
||||
* deleted unless a pending resume request exists. The operator
|
||||
* checks the `pieced.ch/resume-request-pending` annotation to know
|
||||
* about pending requests; we set it here when admin approves the
|
||||
* resume (transitively, via the admin-approve endpoint), and clear
|
||||
* it when the request reaches a terminal state.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
@@ -76,6 +77,18 @@ export async function PATCH(
|
||||
}
|
||||
const { suspend } = parsed.data;
|
||||
|
||||
// Bug 37a: resume (suspend=false) is platform-admin only via this
|
||||
// endpoint. Owners must go through the resume-request flow.
|
||||
if (!suspend && !user.isPlatform) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Resume requires platform-admin approval. Submit a resume request via /api/tenants/[name]/resume-request.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// No-op early exit. Avoids a needless K8s patch + status churn when
|
||||
// the user double-clicks the button or the UI is briefly out of sync.
|
||||
if (Boolean(tenant.spec.suspend) === suspend) {
|
||||
@@ -87,10 +100,32 @@ export async function PATCH(
|
||||
|
||||
try {
|
||||
await patchTenantSpec(name, { suspend });
|
||||
|
||||
// On admin-side resume, also clear the pending-resume-request
|
||||
// annotation if it exists. Belt-and-suspenders: the admin-approve
|
||||
// endpoint already clears it on its happy path, but a platform
|
||||
// user resuming directly via this endpoint shouldn't leave the
|
||||
// annotation behind. Best-effort: failure to clear the annotation
|
||||
// is logged but doesn't fail the resume.
|
||||
if (!suspend) {
|
||||
try {
|
||||
await setTenantAnnotation(
|
||||
name,
|
||||
"pieced.ch/resume-request-pending",
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"failed to clear resume-request-pending annotation; operator will see it stale until next request transition",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: suspend
|
||||
? "Subscription cancelled. Your data is preserved."
|
||||
? "Subscription cancelled. Your data is preserved for 60 days."
|
||||
: "Subscription resumed.",
|
||||
suspend,
|
||||
},
|
||||
|
||||
168
src/app/api/tenants/[name]/threema/route.ts
Normal file
168
src/app/api/tenants/[name]/threema/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import {
|
||||
writePackageSecrets,
|
||||
deletePackageSecrets,
|
||||
} from "@/lib/openbao";
|
||||
import { mintToken, revokeToken } from "@/lib/threema-relay";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Threema package provisioning — special-cased because the credentials
|
||||
* are platform-issued (relay mints them), not customer-supplied.
|
||||
*
|
||||
* POST /api/tenants/:name/threema
|
||||
* - Mints a per-tenant bearer + HMAC secret from the central relay.
|
||||
* - Writes both to OpenBao under
|
||||
* secret/data/tenants/<tenant-{name}>/threema-relay so the
|
||||
* operator's ExternalSecret can sync them into the tenant
|
||||
* namespace alongside other channel secrets.
|
||||
* - Returns 200 on success. The caller (PackageCard) then PATCHes
|
||||
* tenant.spec.packages to add "threema".
|
||||
*
|
||||
* DELETE /api/tenants/:name/threema
|
||||
* - Revokes the per-tenant token at the relay (cascades to all
|
||||
* routes — the relay's tokens.deleteToken also deletes routes).
|
||||
* - Deletes the OpenBao secret so the ExternalSecret/operator can
|
||||
* converge cleanly.
|
||||
* - Returns 200 on success even if no token existed (idempotent).
|
||||
*
|
||||
* Failure semantics
|
||||
* -----------------
|
||||
* On POST: if minting succeeds but the OpenBao write fails, we attempt
|
||||
* to revoke the just-minted token before returning the error. That way
|
||||
* the relay doesn't keep an orphan token row that nothing can use.
|
||||
* Best-effort cleanup; if the revoke also fails, the relay admin can
|
||||
* use DELETE /admin/tokens/<name> manually.
|
||||
*
|
||||
* On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we
|
||||
* return the error and stop — leaving OpenBao alone means the pod's
|
||||
* still-mounted secret keeps working in the brief window between
|
||||
* "customer hits disable" and "operator reconciles spec without threema",
|
||||
* which is more graceful than yanking the secret out from under a
|
||||
* running pod.
|
||||
*/
|
||||
|
||||
const VAULT_SUFFIX = "threema-relay";
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!canMutate(user))
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { name } = await params;
|
||||
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const minted = await mintToken(name);
|
||||
if (!minted.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Relay mint failed: ${minted.message}` },
|
||||
{ status: minted.kind === "http" ? 502 : 503 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, {
|
||||
token: minted.token,
|
||||
"hmac-secret": minted.hmacSecret,
|
||||
});
|
||||
} catch (e) {
|
||||
// Compensate: revoke the just-minted token so the relay doesn't
|
||||
// hold an orphan. Best-effort — log and continue surfacing the
|
||||
// original error.
|
||||
const revoke = await revokeToken(name);
|
||||
if (!revoke.ok) {
|
||||
console.error(
|
||||
`[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`,
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("[threema/provision]", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Provisioning failed") },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!canMutate(user))
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { name } = await params;
|
||||
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const revoke = await revokeToken(name);
|
||||
// 404 from relay = nothing to revoke = idempotent success.
|
||||
if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Relay revoke failed: ${revoke.message}` },
|
||||
{ status: revoke.kind === "http" ? 502 : 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the OpenBao secret. Idempotent — deletePackageSecrets
|
||||
// tolerates 404.
|
||||
try {
|
||||
await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX);
|
||||
} catch (e) {
|
||||
// Already revoked at the relay — surface the openbao failure
|
||||
// but keep the partial-success state visible.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`,
|
||||
partial: true,
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[threema/deprovision]", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Deprovisioning failed") },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
217
src/app/api/tenants/[name]/threema/routes/route.ts
Normal file
217
src/app/api/tenants/[name]/threema/routes/route.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import {
|
||||
createRoute,
|
||||
deleteRoute,
|
||||
isRouteConflictForOtherTenant,
|
||||
isRouteConflictForSameTenant,
|
||||
listRoutes,
|
||||
} from "@/lib/threema-relay";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Threema route management — keeps three places in sync:
|
||||
*
|
||||
* 1. Relay DB (`routes` table) — source of truth for uniqueness
|
||||
* 2. K8s spec.channelUsers.threema — what the operator sees
|
||||
* 3. Customer UI — derived from (2)
|
||||
*
|
||||
* Add (POST) order: relay first (to claim uniqueness atomically), then
|
||||
* K8s. On K8s failure we compensate by deleting the relay route.
|
||||
*
|
||||
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
|
||||
* is the customer-facing semantic that matters), then relay. On relay
|
||||
* failure we DO NOT rollback K8s — the customer wanted it gone, and
|
||||
* the relay's stale route will be cleaned up on the next retry (deletes
|
||||
* are idempotent at the relay).
|
||||
*
|
||||
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
|
||||
* spec.channelUsers.threema, which REPLACES the entire array. We GET
|
||||
* the latest array, mutate it, then PATCH. Two concurrent adds from the
|
||||
* same customer's tabs can lose one of them. Acceptable at pilot scale
|
||||
* (single-digit customers, low concurrency); revisit with SSA + field
|
||||
* managers if it ever bites.
|
||||
*/
|
||||
|
||||
const ROUTE_BODY = z.object({
|
||||
threemaId: z
|
||||
.string()
|
||||
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
|
||||
});
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
|
||||
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||
if (!canMutate(user))
|
||||
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant)
|
||||
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||
}
|
||||
return { tenant };
|
||||
}
|
||||
|
||||
function currentThreemaIds(tenantSpec: any): string[] {
|
||||
const cu = tenantSpec?.channelUsers ?? {};
|
||||
const ids = cu.threema;
|
||||
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
// ---- GET ------------------------------------------------------------------
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
const { name } = await params;
|
||||
const loaded = await loadTenantOrError(name, user);
|
||||
if (loaded.error) return loaded.error;
|
||||
|
||||
const res = await listRoutes(name);
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Relay list failed: ${res.message}` },
|
||||
{ status: res.kind === "http" ? 502 : 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ routes: res.routes });
|
||||
}
|
||||
|
||||
// ---- POST -----------------------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
const { name } = await params;
|
||||
const loaded = await loadTenantOrError(name, user);
|
||||
if (loaded.error) return loaded.error;
|
||||
const tenant = loaded.tenant;
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = ROUTE_BODY.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid threemaId", details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const threemaId = parsed.data.threemaId;
|
||||
|
||||
// Step 1: claim at the relay. Uniqueness is enforced here.
|
||||
const claim = await createRoute(name, threemaId);
|
||||
if (!claim.ok) {
|
||||
if (isRouteConflictForOtherTenant(claim, name)) {
|
||||
return NextResponse.json(
|
||||
{ error: "This Threema ID is already registered to another tenant" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!isRouteConflictForSameTenant(claim, name)) {
|
||||
// Genuine non-409 failure
|
||||
return NextResponse.json(
|
||||
{ error: `Relay create failed: ${claim.message}` },
|
||||
{ status: claim.kind === "http" ? claim.status : 503 },
|
||||
);
|
||||
}
|
||||
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
|
||||
}
|
||||
|
||||
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
|
||||
const existing = currentThreemaIds(tenant!.spec);
|
||||
if (!existing.includes(threemaId)) {
|
||||
const next = [...existing, threemaId];
|
||||
try {
|
||||
await patchTenantSpec(name, {
|
||||
channelUsers: {
|
||||
...(tenant!.spec?.channelUsers ?? {}),
|
||||
threema: next,
|
||||
} as Record<string, string[]>,
|
||||
});
|
||||
} catch (e) {
|
||||
// Compensate: drop the relay route so we don't leave an orphan.
|
||||
const compensate = await deleteRoute(name, threemaId);
|
||||
if (!compensate.ok) {
|
||||
console.error(
|
||||
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, threemaId });
|
||||
}
|
||||
|
||||
// ---- DELETE ---------------------------------------------------------------
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
const { name } = await params;
|
||||
const loaded = await loadTenantOrError(name, user);
|
||||
if (loaded.error) return loaded.error;
|
||||
const tenant = loaded.tenant;
|
||||
|
||||
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
|
||||
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
|
||||
const parsed = ROUTE_BODY.safeParse({ threemaId });
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid threemaId", details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: drop from K8s (idempotent).
|
||||
const existing = currentThreemaIds(tenant!.spec);
|
||||
if (existing.includes(parsed.data.threemaId)) {
|
||||
const next = existing.filter((id) => id !== parsed.data.threemaId);
|
||||
try {
|
||||
await patchTenantSpec(name, {
|
||||
channelUsers: {
|
||||
...(tenant!.spec?.channelUsers ?? {}),
|
||||
threema: next,
|
||||
} as Record<string, string[]>,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: drop at relay (also idempotent — 404 is fine).
|
||||
const dropped = await deleteRoute(name, parsed.data.threemaId);
|
||||
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
|
||||
// K8s is already updated; surface but don't rollback. Next time the
|
||||
// user toggles, both will converge.
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
threemaId: parsed.data.threemaId,
|
||||
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listVisibleTenants } from "@/lib/visibility";
|
||||
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
||||
import {
|
||||
getTeamInfo,
|
||||
getTeamSpendLogsV2,
|
||||
findKeyByAlias,
|
||||
} from "@/lib/litellm";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
@@ -126,6 +130,16 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const teamInfo = await getTeamInfo(teamId);
|
||||
|
||||
// Per-tenant budget lives on the virtual key, not the team
|
||||
// (Feature 7 fix). When the request is scoped to a specific
|
||||
// tenant (keyAlias provided), look up the key so we can return
|
||||
// the per-tenant cap. Tolerate failure — older LiteLLM builds
|
||||
// or short-lived race conditions during provisioning shouldn't
|
||||
// 500 the whole usage page; we degrade to "no key info".
|
||||
const keyInfo = keyAlias
|
||||
? await findKeyByAlias(teamId, keyAlias).catch(() => null)
|
||||
: null;
|
||||
|
||||
// Page through results — server-side filtered by key_alias when
|
||||
// provided. Pagination still needed because LiteLLM caps
|
||||
// page_size at 100, and a busy tenant can easily exceed that in
|
||||
@@ -191,17 +205,38 @@ export async function GET(req: NextRequest) {
|
||||
totalSpend,
|
||||
requestCount: allRequests.length,
|
||||
},
|
||||
// Budget is always team-level (= company budget). Spend reported
|
||||
// here is the team total, not the per-key total — the customer
|
||||
// wants to see "how much of our company budget is left", not
|
||||
// just "how much has this one tenant cost".
|
||||
budget: {
|
||||
maxBudget: teamInfo?.team_info?.max_budget ?? null,
|
||||
spend: teamInfo?.team_info?.spend ?? 0,
|
||||
remaining: teamInfo?.team_info?.max_budget
|
||||
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
|
||||
: null,
|
||||
},
|
||||
// Budget reporting (Feature 7).
|
||||
//
|
||||
// When the caller scopes to a specific tenant (keyAlias set),
|
||||
// we report THAT tenant's per-key budget — that's what the
|
||||
// tenant detail page renders, and what the customer expects
|
||||
// when they see "Budget" on a tenant's page.
|
||||
//
|
||||
// When unscoped (admin / org-wide view), we fall back to the
|
||||
// team budget — that's the org-wide cap, conceptually different
|
||||
// but the only thing meaningful at that scope.
|
||||
//
|
||||
// The two cases display the same way; the editor button gates
|
||||
// on whether we know which tenant we're on (= keyAlias set).
|
||||
budget: keyAlias && keyInfo
|
||||
? {
|
||||
maxBudget: keyInfo.maxBudget,
|
||||
spend: keyInfo.spend,
|
||||
remaining:
|
||||
keyInfo.maxBudget !== null
|
||||
? keyInfo.maxBudget - keyInfo.spend
|
||||
: null,
|
||||
budgetDuration: keyInfo.budgetDuration,
|
||||
}
|
||||
: {
|
||||
maxBudget: teamInfo?.team_info?.max_budget ?? null,
|
||||
spend: teamInfo?.team_info?.spend ?? 0,
|
||||
remaining: teamInfo?.team_info?.max_budget
|
||||
? teamInfo.team_info.max_budget -
|
||||
(teamInfo.team_info.spend ?? 0)
|
||||
: null,
|
||||
budgetDuration: teamInfo?.team_info?.budget_duration ?? null,
|
||||
},
|
||||
rateLimits: {
|
||||
rpm: teamInfo?.team_info?.rpm_limit ?? null,
|
||||
tpm: teamInfo?.team_info?.tpm_limit ?? null,
|
||||
|
||||
@@ -362,9 +362,40 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-text-primary text-sm">
|
||||
<div className="font-medium text-text-primary text-sm flex items-center gap-2">
|
||||
{req.companyName}
|
||||
{/* Bug 37a: distinguish resume requests in the
|
||||
queue. Provision and resume share status
|
||||
semantics but very different action
|
||||
consequences — a resume approval just
|
||||
un-suspends an existing tenant, no
|
||||
provisioning workflow runs. */}
|
||||
{req.requestType === "resume" && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] font-semibold rounded uppercase tracking-wider bg-success/15 text-success"
|
||||
title={t("resumeRequestTooltip")}
|
||||
>
|
||||
{t("resumeRequestBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{req.requestType === "resume" && req.tenantName && (
|
||||
<div className="text-text-muted text-xs font-mono mt-0.5">
|
||||
{req.tenantName}
|
||||
</div>
|
||||
)}
|
||||
{/* Feature 6: customer's reactivation rationale,
|
||||
shown inline so admin can triage without
|
||||
opening a detail view. Truncated for
|
||||
queue density; full content on hover. */}
|
||||
{req.requestType === "resume" && req.customerNotes && (
|
||||
<div
|
||||
className="text-text-secondary text-xs mt-1 max-w-[280px] line-clamp-2 whitespace-pre-wrap"
|
||||
title={req.customerNotes}
|
||||
>
|
||||
{req.customerNotes}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-text-primary text-sm">
|
||||
|
||||
277
src/components/admin/openclaw-admin-panel.tsx
Normal file
277
src/components/admin/openclaw-admin-panel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { OpenClawDefaults } from "@/lib/k8s";
|
||||
|
||||
interface TenantRow {
|
||||
name: string;
|
||||
displayName: string;
|
||||
phase: string;
|
||||
override: { tag: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialDefaults: OpenClawDefaults;
|
||||
tenants: TenantRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-section admin UI:
|
||||
* - Default editor card at the top — single input for the tag.
|
||||
* - Tenant table below — each row has an inline edit/clear control.
|
||||
*
|
||||
* No optimistic updates: every save round-trips to the API and we
|
||||
* router.refresh() to re-render the server-side state. Keeps the UI
|
||||
* honest about what's actually applied (controller-runtime watch
|
||||
* latency can be a couple of seconds).
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale.
|
||||
*/
|
||||
export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
|
||||
const t = useTranslations("openclawAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
const [defaults, setDefaults] = useState(initialDefaults);
|
||||
const [defaultTag, setDefaultTag] = useState(initialDefaults.defaultTag);
|
||||
const [savingDefault, setSavingDefault] = useState(false);
|
||||
const [defaultError, setDefaultError] = useState("");
|
||||
const [defaultSaved, setDefaultSaved] = useState(false);
|
||||
|
||||
const onSaveDefault = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSavingDefault(true);
|
||||
setDefaultError("");
|
||||
setDefaultSaved(false);
|
||||
try {
|
||||
const res = await fetch("/api/admin/openclaw", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultTag: defaultTag.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("saveFailed"));
|
||||
}
|
||||
const next = await res.json();
|
||||
setDefaults(next);
|
||||
setDefaultSaved(true);
|
||||
} catch (e: any) {
|
||||
setDefaultError(e.message);
|
||||
} finally {
|
||||
setSavingDefault(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Default editor */}
|
||||
<section className="animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("defaultSection")}
|
||||
</h2>
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("defaultDescription")}
|
||||
</p>
|
||||
<form onSubmit={onSaveDefault} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldTag")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defaultTag}
|
||||
onChange={(e) => setDefaultTag(e.target.value)}
|
||||
placeholder="2026.4.22"
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">{t("emptyHint")}</p>
|
||||
</div>
|
||||
|
||||
{defaultError && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{defaultError}
|
||||
</div>
|
||||
)}
|
||||
{defaultSaved && !defaultError && (
|
||||
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
|
||||
{t("defaultSaved")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingDefault}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Tenant overrides */}
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("overridesSection")}
|
||||
</h2>
|
||||
<Card>
|
||||
{tenants.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary text-center py-6">
|
||||
{t("noTenants")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tenants.map((tn) => (
|
||||
<TenantOverrideRow
|
||||
key={tn.name}
|
||||
tenant={tn}
|
||||
platformDefault={defaults}
|
||||
onChanged={() => router.refresh()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single row in the tenants table. Collapsed by default; click to
|
||||
* expand the inline editor.
|
||||
*/
|
||||
function TenantOverrideRow({
|
||||
tenant,
|
||||
platformDefault,
|
||||
onChanged,
|
||||
}: {
|
||||
tenant: TenantRow;
|
||||
platformDefault: OpenClawDefaults;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const t = useTranslations("openclawAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tag, setTag] = useState(tenant.override?.tag ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (clear = false) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/tenants/${encodeURIComponent(tenant.name)}/openclaw-image`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(clear ? {} : { tag: tag.trim() }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("saveFailed"));
|
||||
}
|
||||
setExpanded(false);
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const effective = tenant.override?.tag
|
||||
? tenant.override.tag
|
||||
: platformDefault.defaultTag || t("builtinFallback");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-surface-1 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-text-primary truncate">
|
||||
{tenant.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted font-mono truncate mt-0.5">
|
||||
{tenant.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-4 min-w-0">
|
||||
{tenant.override ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-amber-400/15 text-amber-400 border border-amber-400/20">
|
||||
{t("statusOverridden")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-400/15 text-blue-400 border border-blue-400/20">
|
||||
{t("statusFollowsDefault")}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-text-muted font-mono truncate max-w-[260px] mt-1">
|
||||
{effective}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-border bg-surface-1">
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldTag")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder={
|
||||
platformDefault.defaultTag
|
||||
? `${t("defaultPrefix")} ${platformDefault.defaultTag}`
|
||||
: ""
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{tenant.override && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(true)}
|
||||
disabled={saving}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("clearOverride")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(false)}
|
||||
disabled={saving || !tag.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("saveOverride")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,32 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ThreemaQrModal } from "./threema-qr-modal";
|
||||
|
||||
/** Maps channel IDs to the instructions for finding the user ID. */
|
||||
const CHANNEL_ID_HELP: Record<string, string> = {
|
||||
telegram: "telegramIdHelp",
|
||||
discord: "discordIdHelp",
|
||||
email: "emailIdHelp",
|
||||
threema: "threemaIdHelp",
|
||||
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
|
||||
// the `mail` skill (category=skill, not channel), so it never appears
|
||||
// in `enabledChannels`. If a future channel is added to the catalog,
|
||||
// give it an entry here so the help blurb renders.
|
||||
};
|
||||
|
||||
/**
|
||||
* Channels whose user list is managed through a dedicated endpoint
|
||||
* instead of the generic PATCH /api/tenants/:name flow.
|
||||
*
|
||||
* Threema is the only one today — adding/removing a Threema ID has to
|
||||
* synchronise with the central pieced-threema-gateway relay's `routes`
|
||||
* table (uniqueness enforced there, not in K8s). The
|
||||
* /api/tenants/:name/threema/routes endpoint owns that two-step
|
||||
* coordination (relay first, then K8s, with compensation on K8s
|
||||
* failure). Other channels just patch the K8s spec directly.
|
||||
*/
|
||||
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
|
||||
|
||||
interface ChannelUsersProps {
|
||||
tenantName: string;
|
||||
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
|
||||
@@ -34,6 +52,14 @@ export function ChannelUsers({
|
||||
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||
const [channelUsers, setChannelUsers] =
|
||||
useState<Record<string, string[]>>(initialChannelUsers);
|
||||
/** Which channel's QR helper modal is open, if any. */
|
||||
const [showQrFor, setShowQrFor] = useState<string | null>(null);
|
||||
/**
|
||||
* Tracks channels for which we've already auto-opened the helper
|
||||
* modal on this page load. Prevents the modal from re-popping every
|
||||
* time the user refocuses the input after dismissing it.
|
||||
*/
|
||||
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const updateChannelUsers = useCallback(
|
||||
async (updated: Record<string, string[]>) => {
|
||||
@@ -60,6 +86,70 @@ export function ChannelUsers({
|
||||
[tenantName, router]
|
||||
);
|
||||
|
||||
/**
|
||||
* Threema (and any future relay-managed channel) uses a dedicated
|
||||
* endpoint that synchronises the central relay's routes table with
|
||||
* the K8s spec atomically. We call it per-ID rather than sending the
|
||||
* whole array because uniqueness is enforced ID-by-ID at the relay,
|
||||
* and the error UX of "this ID is taken" is per-add.
|
||||
*/
|
||||
const addToRelayChannel = useCallback(
|
||||
async (channel: string, threemaId: string) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tenants/${tenantName}/${channel}/routes`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ threemaId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
|
||||
}
|
||||
setChannelUsers((prev) => {
|
||||
const current = prev[channel] ?? [];
|
||||
if (current.includes(threemaId)) return prev;
|
||||
return { ...prev, [channel]: [...current, threemaId] };
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[tenantName, router]
|
||||
);
|
||||
|
||||
const removeFromRelayChannel = useCallback(
|
||||
async (channel: string, threemaId: string) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
|
||||
}
|
||||
setChannelUsers((prev) => {
|
||||
const current = prev[channel] ?? [];
|
||||
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[tenantName, router]
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(channel: string) => {
|
||||
const userId = inputValues[channel]?.trim();
|
||||
@@ -71,18 +161,27 @@ export function ChannelUsers({
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
[channel]: [...current, userId],
|
||||
};
|
||||
setInputValues((prev) => ({ ...prev, [channel]: "" }));
|
||||
updateChannelUsers(updated);
|
||||
|
||||
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||
void addToRelayChannel(channel, userId);
|
||||
} else {
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
[channel]: [...current, userId],
|
||||
};
|
||||
updateChannelUsers(updated);
|
||||
}
|
||||
},
|
||||
[channelUsers, inputValues, updateChannelUsers, t]
|
||||
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(channel: string, userId: string) => {
|
||||
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||
void removeFromRelayChannel(channel, userId);
|
||||
return;
|
||||
}
|
||||
const current = channelUsers[channel] || [];
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
@@ -90,7 +189,7 @@ export function ChannelUsers({
|
||||
};
|
||||
updateChannelUsers(updated);
|
||||
},
|
||||
[channelUsers, updateChannelUsers]
|
||||
[channelUsers, updateChannelUsers, removeFromRelayChannel]
|
||||
);
|
||||
|
||||
if (enabledChannels.length === 0) return null;
|
||||
@@ -126,9 +225,19 @@ export function ChannelUsers({
|
||||
className="bg-surface-2 border border-border rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-text-primary capitalize">
|
||||
{channel}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-text-primary capitalize">
|
||||
{channel}
|
||||
</h4>
|
||||
{channel === "threema" && (
|
||||
<button
|
||||
onClick={() => setShowQrFor("threema")}
|
||||
className="text-xs font-medium text-accent hover:text-accent-dim cursor-pointer underline underline-offset-2"
|
||||
>
|
||||
{t("threemaSetup.showQr")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted tabular-nums">
|
||||
{users.length} {t("users")}
|
||||
</span>
|
||||
@@ -176,6 +285,17 @@ export function ChannelUsers({
|
||||
[channel]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onFocus={() => {
|
||||
// For threema specifically, open the QR helper the
|
||||
// first time the user clicks into the input on this
|
||||
// page load. We don't repeat after dismiss — the
|
||||
// "Show QR" button next to the channel name covers
|
||||
// re-opens on demand.
|
||||
if (channel === "threema" && !autoOpened.has("threema")) {
|
||||
setShowQrFor("threema");
|
||||
setAutoOpened((prev) => new Set(prev).add("threema"));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd(channel);
|
||||
}}
|
||||
@@ -194,6 +314,11 @@ export function ChannelUsers({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<ThreemaQrModal
|
||||
open={showQrFor === "threema"}
|
||||
onClose={() => setShowQrFor(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/channel-users/threema-qr-modal.tsx
Normal file
82
src/components/channel-users/threema-qr-modal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
|
||||
interface ThreemaQrModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand modal showing the QR for adding the assistant on Threema.
|
||||
* Triggered by the "Show QR" button in the threema channel card and
|
||||
* closes on overlay click, ESC, or the close button.
|
||||
*
|
||||
* Uses a plain <img> not next/image — image optimization adds nothing
|
||||
* for a 57KB static PNG and removes a potential source of rendering
|
||||
* bugs in the Next.js standalone build.
|
||||
*/
|
||||
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
|
||||
const t = useTranslations("channelUsers.threemaSetup");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-base font-semibold text-text-primary">
|
||||
{t("title")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-primary text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-3 rounded-md">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
|
||||
width={220}
|
||||
height={220}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-text-muted font-mono">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
|
||||
<li>{t("step1")}</li>
|
||||
<li>{t("step2")}</li>
|
||||
<li>{t("step3")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/components/dashboard/budget-editable-card.tsx
Normal file
275
src/components/dashboard/budget-editable-card.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
/**
|
||||
* Format remaining budget as CHF. Same adaptive precision rule as the
|
||||
* usage display: 2 decimals for amounts ≥ 1, 4 for smaller values
|
||||
* so per-request residuals don't round to zero. The currency comes
|
||||
* from LiteLLM via our CHF pricing config — see chf() in
|
||||
* usage-display.tsx for the full reasoning.
|
||||
*/
|
||||
function formatRemaining(n: number): string {
|
||||
const decimals = Math.abs(n) >= 1 ? 2 : 4;
|
||||
return `CHF ${n.toFixed(decimals)}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tenantName: string;
|
||||
maxBudget: number | null;
|
||||
remaining: number | null;
|
||||
budgetDuration: string | null;
|
||||
/** Called after a successful save so the parent re-fetches usage. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clickable Budget StatCard with edit modal (Feature 7).
|
||||
*
|
||||
* The display side mirrors the read-only StatCard layout exactly so
|
||||
* the grid stays uniform. The "click to edit" hint is implicit via
|
||||
* hover state — a "Set" / "Edit" link in the corner would be louder
|
||||
* but adds clutter on a tile that's already busy. Customers who
|
||||
* mouse over discover it.
|
||||
*
|
||||
* Important UX note shown in the modal: the budget is org-scoped,
|
||||
* not per-tenant. All tenants in the same ZITADEL org share the
|
||||
* underlying LiteLLM team. Without that callout, a customer with
|
||||
* multiple tenants might think they're capping just one.
|
||||
*/
|
||||
export function BudgetEditableCard({
|
||||
tenantName,
|
||||
maxBudget,
|
||||
remaining,
|
||||
budgetDuration,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
const t = useTranslations("usage");
|
||||
const tCommon = useTranslations("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Form state. Mode = "unlimited" | "capped". When unlimited, the
|
||||
// duration dropdown is hidden because LiteLLM's reset cadence is
|
||||
// meaningless without a cap.
|
||||
const [mode, setMode] = useState<"unlimited" | "capped">(
|
||||
maxBudget !== null ? "capped" : "unlimited"
|
||||
);
|
||||
const [budgetInput, setBudgetInput] = useState<string>(
|
||||
maxBudget !== null ? String(maxBudget) : ""
|
||||
);
|
||||
const [duration, setDuration] = useState<"30d" | "1mo" | "1y">(
|
||||
(budgetDuration === "30d" ||
|
||||
budgetDuration === "1mo" ||
|
||||
budgetDuration === "1y")
|
||||
? budgetDuration
|
||||
: "1mo"
|
||||
);
|
||||
|
||||
// Reset form when modal opens — picks up any change made elsewhere
|
||||
// (e.g. another browser tab) since this card was last re-rendered.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode(maxBudget !== null ? "capped" : "unlimited");
|
||||
setBudgetInput(maxBudget !== null ? String(maxBudget) : "");
|
||||
setDuration(
|
||||
(budgetDuration === "30d" ||
|
||||
budgetDuration === "1mo" ||
|
||||
budgetDuration === "1y")
|
||||
? budgetDuration
|
||||
: "1mo"
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
}, [open, maxBudget, budgetDuration]);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let body: { maxBudget: number | null; budgetDuration: string | null };
|
||||
if (mode === "unlimited") {
|
||||
body = { maxBudget: null, budgetDuration: null };
|
||||
} else {
|
||||
const parsed = parseFloat(budgetInput);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(t("budgetInvalid"));
|
||||
}
|
||||
body = { maxBudget: parsed, budgetDuration: duration };
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/tenants/${encodeURIComponent(tenantName)}/budget`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("budgetSaveFailed"));
|
||||
}
|
||||
setOpen(false);
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-surface-1 border border-accent/40 rounded-xl p-4 text-left hover:border-accent transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 group block w-full"
|
||||
>
|
||||
<div className="text-xs text-text-muted mb-1 flex items-center justify-between">
|
||||
<span>{t("budget")}</span>
|
||||
<span className="text-[10px] text-accent inline-flex items-center gap-1">
|
||||
{/* Pencil icon — unambiguous "this is editable" affordance.
|
||||
Visible at all times (was hover-only before, which on
|
||||
touch devices and at-a-glance scanning gave no
|
||||
indication the card was clickable). */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
{t("budgetEdit")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-text-primary tabular-nums">
|
||||
{remaining !== null ? formatRemaining(remaining) : t("noLimit")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("budgetEditTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("budgetEditDescription")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Mode toggle: unlimited vs capped. Two radios are
|
||||
clearer than a single "max" field where 0 means
|
||||
unlimited (which would conflict with our zod
|
||||
validation requiring positive). */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="budget-mode"
|
||||
checked={mode === "unlimited"}
|
||||
onChange={() => setMode("unlimited")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{t("budgetModeUnlimited")}</span>
|
||||
<span className="block text-xs text-text-muted">
|
||||
{t("budgetModeUnlimitedDescription")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="budget-mode"
|
||||
checked={mode === "capped"}
|
||||
onChange={() => setMode("capped")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{t("budgetModeCapped")}</span>
|
||||
<span className="block text-xs text-text-muted">
|
||||
{t("budgetModeCappedDescription")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === "capped" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("budgetAmount")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-sm text-text-muted font-medium">
|
||||
CHF
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="1000000"
|
||||
step="0.01"
|
||||
required
|
||||
value={budgetInput}
|
||||
onChange={(e) => setBudgetInput(e.target.value)}
|
||||
className="w-full pl-12 pr-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("budgetResetCadence")}
|
||||
</label>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) =>
|
||||
setDuration(e.target.value as "30d" | "1mo" | "1y")
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
>
|
||||
<option value="30d">{t("budgetCadence_30d")}</option>
|
||||
<option value="1mo">{t("budgetCadence_1mo")}</option>
|
||||
<option value="1y">{t("budgetCadence_1y")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={saving}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : tCommon("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||
|
||||
interface DailyUsage {
|
||||
date: string;
|
||||
@@ -18,7 +19,17 @@ interface UsageData {
|
||||
totalSpend: number;
|
||||
requestCount: number;
|
||||
};
|
||||
budget: { maxBudget: number | null; spend: number; remaining: number | null };
|
||||
budget: {
|
||||
maxBudget: number | null;
|
||||
spend: number;
|
||||
remaining: number | null;
|
||||
/**
|
||||
* Feature 7: budget reset cadence as stored on LiteLLM.
|
||||
* Strings: "30d" / "1mo" / "1y" / null (no reset). UI maps these
|
||||
* to user-friendly labels.
|
||||
*/
|
||||
budgetDuration: string | null;
|
||||
};
|
||||
rateLimits: { rpm: number | null; tpm: number | null };
|
||||
dailyUsage: DailyUsage[];
|
||||
}
|
||||
@@ -29,8 +40,31 @@ function fmt(n: number): string {
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function usd(n: number): string {
|
||||
return `$${n.toFixed(4)}`;
|
||||
/**
|
||||
* Format a numeric amount as CHF.
|
||||
*
|
||||
* Note on currency labelling: LiteLLM stores raw cost numbers it
|
||||
* receives from upstream (OpenAI/Anthropic), which originate as USD.
|
||||
* The PieCed pricing config (Slice 5) converts those numbers to
|
||||
* CHF before LiteLLM persists them, so the values flowing through
|
||||
* here are already CHF amounts. We label them as such in the UI;
|
||||
* "USD" or "$" anywhere in the customer-facing experience would
|
||||
* be misleading.
|
||||
*
|
||||
* Precision is adaptive:
|
||||
* - Amounts ≥ 1 CHF: 2 decimals (typical money formatting).
|
||||
* - Smaller amounts: 4 decimals — per-request inference costs are
|
||||
* routinely sub-rappen, and rounding to 2dp
|
||||
* would render CHF 0.0042 as "CHF 0.00",
|
||||
* which obscures real costs from customers
|
||||
* looking at the daily breakdown.
|
||||
*
|
||||
* This is a customer-facing display helper; for storage and
|
||||
* comparisons keep using the raw number.
|
||||
*/
|
||||
function chf(n: number): string {
|
||||
const decimals = Math.abs(n) >= 1 ? 2 : 4;
|
||||
return `CHF ${n.toFixed(decimals)}`;
|
||||
}
|
||||
|
||||
function getCurrentMonth(): string {
|
||||
@@ -69,7 +103,7 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||
const x = i * (barW + 2);
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {usd(d.spend)}</title>
|
||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
||||
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
||||
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
||||
{i % 7 === 0 && (
|
||||
@@ -113,10 +147,18 @@ export function UsageDisplay({
|
||||
tenant,
|
||||
teamId,
|
||||
keyAlias,
|
||||
canEditBudget = false,
|
||||
}: {
|
||||
tenant?: string | null;
|
||||
teamId?: string | null;
|
||||
keyAlias?: string | null;
|
||||
/**
|
||||
* Feature 7: when true, the Budget StatCard becomes clickable and
|
||||
* opens the budget editor. Off by default — owners and platform
|
||||
* admins get it on; `user` role customers see the budget read-only.
|
||||
* Server component decides this via canMutate(user).
|
||||
*/
|
||||
canEditBudget?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("usage");
|
||||
const [month, setMonth] = useState(getCurrentMonth);
|
||||
@@ -185,11 +227,25 @@ export function UsageDisplay({
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
|
||||
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
|
||||
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent />
|
||||
<StatCard
|
||||
label={t("budget")}
|
||||
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")}
|
||||
/>
|
||||
<StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
|
||||
{canEditBudget && tenant ? (
|
||||
<BudgetEditableCard
|
||||
tenantName={tenant}
|
||||
maxBudget={data.budget.maxBudget}
|
||||
remaining={data.budget.remaining}
|
||||
budgetDuration={data.budget.budgetDuration}
|
||||
onSaved={fetchUsage}
|
||||
/>
|
||||
) : (
|
||||
<StatCard
|
||||
label={t("budget")}
|
||||
value={
|
||||
data.budget.remaining !== null
|
||||
? chf(data.budget.remaining)
|
||||
: t("noLimit")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-5">
|
||||
|
||||
@@ -59,6 +59,32 @@ function NavBar() {
|
||||
{t("team")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Bug 35: /settings is shown to anyone who can mutate org-level
|
||||
state — owners and platform admins. Personal accounts also
|
||||
see it; their billing page is optional but the entry point
|
||||
exists for consistency. `user`-role customers don't see it
|
||||
(canMutate is false). */}
|
||||
{user &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink
|
||||
href="/settings"
|
||||
active={pathname.startsWith("/settings")}
|
||||
>
|
||||
{t("settings")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Feature 5: Support is available to every signed-in
|
||||
user. Customers see their own tickets only; platform
|
||||
admins see the queue. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/support"
|
||||
active={pathname.startsWith("/support")}
|
||||
>
|
||||
{t("support")}
|
||||
</NavLink>
|
||||
)}
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
|
||||
@@ -12,6 +12,13 @@ interface OnboardingFlowProps {
|
||||
*/
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
/**
|
||||
* Bug 35: true if the org already has a billing record. The wizard
|
||||
* uses this to skip the billing step on subsequent tenants — capture
|
||||
* once at first onboarding, reuse afterwards. Editable later via
|
||||
* /settings/billing.
|
||||
*/
|
||||
hasOrgBilling?: boolean;
|
||||
/**
|
||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||
* the given pending request. See `OnboardingWizard` for the full
|
||||
@@ -37,6 +44,7 @@ export function OnboardingFlow({
|
||||
orgName,
|
||||
userName,
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
editingRequest,
|
||||
}: OnboardingFlowProps) {
|
||||
const router = useRouter();
|
||||
@@ -46,6 +54,7 @@ export function OnboardingFlow({
|
||||
orgName={orgName}
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
editingRequest={editingRequest}
|
||||
onComplete={() => {
|
||||
// Navigate back to /dashboard and re-fetch on the server. The
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
|
||||
@@ -250,43 +251,38 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
</div>
|
||||
|
||||
{confirmCancel && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setConfirmCancel(false);
|
||||
}}
|
||||
<Modal
|
||||
open={confirmCancel}
|
||||
onClose={() => setConfirmCancel(false)}
|
||||
ariaLabel={t("cancelConfirmRequestTitle")}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmRequestTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("cancelConfirmRequestDescription")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending
|
||||
? tCommon("loading")
|
||||
: t("cancelRequestConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmRequestTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("cancelConfirmRequestDescription")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending
|
||||
? tCommon("loading")
|
||||
: t("cancelRequestConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
|
||||
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
||||
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
||||
import {
|
||||
configureStepSchema,
|
||||
@@ -16,7 +16,26 @@ import {
|
||||
|
||||
type Step = "welcome" | "configure" | "billing" | "confirm";
|
||||
|
||||
const STEPS: Step[] = ["welcome", "configure", "billing", "confirm"];
|
||||
// The step list. Composed once and used to compute "next/prev" arrows
|
||||
// and progress indicator. Bug 35: the billing step is conditional —
|
||||
// orgs that already have billing on file (subsequent tenants, or
|
||||
// pre-filled via /settings/billing) skip it. The wizard's submit
|
||||
// payload omits billingAddress in that case; the API picks up the
|
||||
// existing org_billing row server-side.
|
||||
function makeSteps(opts: {
|
||||
hasOrgBilling: boolean;
|
||||
isEditing: boolean;
|
||||
}): Step[] {
|
||||
const base: Step[] = ["welcome", "configure", "billing", "confirm"];
|
||||
// Edit mode currently still shows the billing step because we want
|
||||
// the customer to be able to fix billing on a still-pending request
|
||||
// BEFORE it reaches admin. Once approved, edits go through
|
||||
// /settings/billing instead. Same step set for editing as new for now.
|
||||
if (opts.hasOrgBilling && !opts.isEditing) {
|
||||
return base.filter((s) => s !== "billing");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Inline fallbacks — only used if the API call to /api/workspace-defaults fails
|
||||
const FALLBACK_SOUL = `# AI Assistant
|
||||
@@ -50,6 +69,7 @@ translation, and general question answering.
|
||||
`;
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "core" as const, labelKey: "categories.core" },
|
||||
{ key: "channel" as const, labelKey: "categories.channels" },
|
||||
{ key: "skill" as const, labelKey: "categories.skills" },
|
||||
] as const;
|
||||
@@ -64,6 +84,18 @@ interface WizardProps {
|
||||
*/
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
/**
|
||||
* Bug 35: when true, the wizard skips the billing step. The org
|
||||
* already has billing on file (captured during a previous tenant's
|
||||
* onboarding, or set directly via /settings/billing), and we don't
|
||||
* re-prompt for it. The submit payload omits billingAddress in that
|
||||
* case; the API picks up the existing record server-side.
|
||||
*
|
||||
* In edit mode this is ignored — the wizard re-renders the step
|
||||
* with the request's original billingAddress so the customer can
|
||||
* fix it before admin approves.
|
||||
*/
|
||||
hasOrgBilling?: boolean;
|
||||
/**
|
||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||
@@ -90,6 +122,7 @@ interface WizardProps {
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
vatNumber?: string;
|
||||
};
|
||||
billingNotes: string;
|
||||
};
|
||||
@@ -100,6 +133,7 @@ export function OnboardingWizard({
|
||||
orgName,
|
||||
userName,
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
editingRequest,
|
||||
onComplete,
|
||||
}: WizardProps) {
|
||||
@@ -122,6 +156,13 @@ export function OnboardingWizard({
|
||||
isPersonal,
|
||||
});
|
||||
const isEditing = Boolean(editingRequest);
|
||||
// STEPS is recomputed from props so toggling hasOrgBilling at the
|
||||
// server level (e.g. between renders if the customer just saved
|
||||
// billing on /settings/billing in another tab) flows through. Cheap.
|
||||
const STEPS = makeSteps({
|
||||
hasOrgBilling: Boolean(hasOrgBilling),
|
||||
isEditing,
|
||||
});
|
||||
|
||||
// Edit mode jumps straight to the configure step — the welcome step
|
||||
// is a first-time onboarding affordance and only adds friction when
|
||||
@@ -148,6 +189,7 @@ export function OnboardingWizard({
|
||||
city: editingRequest.billingAddress.city ?? "",
|
||||
postalCode: editingRequest.billingAddress.postalCode ?? "",
|
||||
country: editingRequest.billingAddress.country ?? "CH",
|
||||
vatNumber: editingRequest.billingAddress.vatNumber ?? "",
|
||||
},
|
||||
billingNotes: editingRequest.billingNotes,
|
||||
};
|
||||
@@ -157,7 +199,13 @@ export function OnboardingWizard({
|
||||
agentName: "Assistant",
|
||||
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
|
||||
agentsMd: FALLBACK_AGENTS,
|
||||
packages: [] as string[],
|
||||
// CORE defaults: heartbeat + cron + active-memory pre-selected so
|
||||
// the assistant can be proactive, run scheduled tasks, and recall
|
||||
// stable context out of the box. Customers can untoggle any of
|
||||
// them before submitting. core-voice is fully wired (Phase B)
|
||||
// but stays unselected — opt-in keeps audio spend predictable
|
||||
// for tenants who don't intend to use voice channels.
|
||||
packages: [...DEFAULT_PACKAGE_IDS] as string[],
|
||||
billingAddress: {
|
||||
// For personal accounts, leave the company field empty — it'll
|
||||
// appear on invoices. The user can still type something if they
|
||||
@@ -167,6 +215,7 @@ export function OnboardingWizard({
|
||||
city: "",
|
||||
postalCode: "",
|
||||
country: "CH",
|
||||
vatNumber: "",
|
||||
},
|
||||
billingNotes: "",
|
||||
};
|
||||
@@ -372,11 +421,25 @@ export function OnboardingWizard({
|
||||
: "/api/onboarding";
|
||||
const method = editingRequest ? "PATCH" : "POST";
|
||||
|
||||
// Bug 35: when the org already has billing on file, the wizard
|
||||
// skipped the billing step and `config.billingAddress` is the
|
||||
// empty default. Strip it from the payload so the API picks up
|
||||
// the existing org_billing record server-side rather than
|
||||
// validating the empty form against billingStepSchema (which
|
||||
// would reject for a company org).
|
||||
const submitConfig = hasOrgBilling
|
||||
? (() => {
|
||||
const { billingAddress: _bill, billingNotes: _notes, ...rest } =
|
||||
config;
|
||||
return rest;
|
||||
})()
|
||||
: config;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...config,
|
||||
...submitConfig,
|
||||
packageSecrets:
|
||||
Object.keys(secretsPayload).length > 0
|
||||
? secretsPayload
|
||||
@@ -635,7 +698,7 @@ export function OnboardingWizard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePackage(pkg.id)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-surface-3/30 transition-colors"
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors cursor-pointer hover:bg-surface-3/30"
|
||||
>
|
||||
<div className="text-left">
|
||||
<span
|
||||
@@ -906,6 +969,39 @@ export function OnboardingWizard({
|
||||
</select>
|
||||
</FieldWithError>
|
||||
|
||||
{/* Bug 35: VAT identifier. Required for company customers
|
||||
(B2B). Hidden entirely for personal customers (B2C —
|
||||
private individuals don't have a VAT number); the API
|
||||
enforces the same rule. Editable later via
|
||||
/settings/billing for company customers if their VAT
|
||||
id changes. */}
|
||||
{!isPersonal && (
|
||||
<FieldWithError error={errors["billingAddress.vatNumber"]}>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("billingVatNumber")} <RequiredMark />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.billingAddress.vatNumber ?? ""}
|
||||
onChange={(e) => {
|
||||
clearError("billingAddress.vatNumber");
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
billingAddress: {
|
||||
...prev.billingAddress,
|
||||
vatNumber: e.target.value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
placeholder="CHE-123.456.789 MWST"
|
||||
className={inputClass(errors["billingAddress.vatNumber"])}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("billingVatHelp")}
|
||||
</p>
|
||||
</FieldWithError>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("billingNotes")}
|
||||
@@ -919,7 +1015,11 @@ export function OnboardingWizard({
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={t("billingNotesPlaceholder")}
|
||||
placeholder={t(
|
||||
isPersonal
|
||||
? "billingNotesPlaceholderPersonal"
|
||||
: "billingNotesPlaceholder"
|
||||
)}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
||||
/>
|
||||
</div>
|
||||
@@ -1024,6 +1124,19 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* Bug 35: VAT review row. Company customers see this so
|
||||
they can verify the VAT id they typed before submitting.
|
||||
Personal customers never see it — they don't have a
|
||||
VAT number, the form didn't ask, the review hides it. */}
|
||||
{!isPersonal &&
|
||||
config.billingAddress.vatNumber &&
|
||||
config.billingAddress.vatNumber.trim().length > 0 && (
|
||||
<ReviewRow
|
||||
label={t("billingVatNumber")}
|
||||
value={config.billingAddress.vatNumber}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
<ReviewRow
|
||||
label={t("reviewContactEmail")}
|
||||
value={userEmail || ""}
|
||||
|
||||
@@ -30,6 +30,26 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleEnable() {
|
||||
if (pkg.customProvisioning) {
|
||||
// Platform-side provisioning, then add to packages list.
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!provRes.ok) {
|
||||
const err = await provRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||
}
|
||||
await togglePackage(true);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pkg.requiresSecrets) {
|
||||
setShowModal(true);
|
||||
setSecrets({});
|
||||
@@ -40,6 +60,34 @@ export function PackageCard({
|
||||
await togglePackage(true);
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (pkg.customProvisioning) {
|
||||
// Revoke platform-side credentials FIRST so the relay drops
|
||||
// routes before the operator removes the channel from the
|
||||
// OpenClaw config. Partial-success (token revoked, OpenBao
|
||||
// delete failed) returns 503 with partial=true and we surface
|
||||
// the error rather than continuing — the secret may still be
|
||||
// valid in OpenBao and rolling back the relay revoke isn't
|
||||
// possible (it cascaded to routes).
|
||||
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!deprovRes.ok) {
|
||||
const err = await deprovRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
|
||||
}
|
||||
}
|
||||
await togglePackage(false);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePackage(enable: boolean) {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -124,7 +172,7 @@ export function PackageCard({
|
||||
)}
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? () => togglePackage(false) : handleEnable}
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "core" as const, labelKey: "categories.core" },
|
||||
{ key: "channel" as const, labelKey: "categories.channels" },
|
||||
{ key: "skill" as const, labelKey: "categories.skills" },
|
||||
] as const;
|
||||
|
||||
279
src/components/settings/billing-settings-form.tsx
Normal file
279
src/components/settings/billing-settings-form.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { OrgBilling } from "@/types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface Props {
|
||||
/** Existing billing record, or null on first edit. */
|
||||
initial: OrgBilling | null;
|
||||
/**
|
||||
* True if the caller is on a personal org. Personal customers
|
||||
* (B2C — private individuals) don't have a company name or VAT
|
||||
* number; the form re-labels the company-name field as "Full name"
|
||||
* and hides VAT.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/** Default company name for company orgs on first edit. */
|
||||
orgName: string;
|
||||
/** Default full-name for personal orgs on first edit. */
|
||||
userName: string;
|
||||
/**
|
||||
* Default billing email — the address the user registered with.
|
||||
* Used on first edit (when `initial` is null). Customers can still
|
||||
* type a different address (e.g. accounting@…) but the registration
|
||||
* email is a sensible starting point.
|
||||
*/
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable billing form. Used by /settings/billing; the wizard's
|
||||
* inline billing step (Bug 35 phase 2) reuses the same shape but is
|
||||
* implemented separately because of its different submit semantics
|
||||
* (one combined wizard submit, vs. this page's standalone PUT).
|
||||
*
|
||||
* The form does NOT do client-side VAT format validation — too many
|
||||
* country variations to get right, and the API will reject empty
|
||||
* VAT for company orgs anyway. The asterisk on the field plus the
|
||||
* server error suffices.
|
||||
*/
|
||||
export function BillingSettingsForm({
|
||||
initial,
|
||||
isPersonal,
|
||||
orgName,
|
||||
userName,
|
||||
userEmail,
|
||||
}: Props) {
|
||||
const t = useTranslations("settingsBilling");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
const [companyName, setCompanyName] = useState(
|
||||
initial?.companyName ?? (isPersonal ? userName : orgName)
|
||||
);
|
||||
const [streetAddress, setStreetAddress] = useState(
|
||||
initial?.streetAddress ?? ""
|
||||
);
|
||||
const [postalCode, setPostalCode] = useState(initial?.postalCode ?? "");
|
||||
const [city, setCity] = useState(initial?.city ?? "");
|
||||
const [country, setCountry] = useState(initial?.country ?? "CH");
|
||||
const [vatNumber, setVatNumber] = useState(initial?.vatNumber ?? "");
|
||||
// Default billing email to the user's registration email when no
|
||||
// record exists yet. They can change it (a separate accounting
|
||||
// address is common); we just want sensible pre-fill on first edit.
|
||||
const [billingEmail, setBillingEmail] = useState(
|
||||
initial?.billingEmail ?? userEmail ?? ""
|
||||
);
|
||||
const [notes, setNotes] = useState(initial?.notes ?? "");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
try {
|
||||
const res = await fetch("/api/billing", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
companyName,
|
||||
streetAddress,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
vatNumber: vatNumber.trim() || null,
|
||||
billingEmail,
|
||||
notes: notes.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("saveFailed"));
|
||||
}
|
||||
setSuccess(true);
|
||||
// Refresh server props so the form re-renders with the saved
|
||||
// record's timestamps. Subtle but useful: the "last updated"
|
||||
// line below ticks forward.
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Bug 35: this field stores `company_name` in the DB but
|
||||
the label changes by customer type:
|
||||
- Company (B2B): "Company name" — the legal entity.
|
||||
- Personal (B2C): "Full name" — the individual's
|
||||
invoice name (may differ from their session display
|
||||
name; e.g. legal name vs friendly name).
|
||||
Required for both. The DB column is NOT NULL either way. */}
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{isPersonal ? t("fullName") : t("companyName")}{" "}
|
||||
<span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("streetAddress")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={streetAddress}
|
||||
onChange={(e) => setStreetAddress(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("postalCode")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("city")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("country")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
>
|
||||
<option value="CH">Switzerland</option>
|
||||
<option value="LI">Liechtenstein</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="AT">Austria</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="IT">Italy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bug 35: VAT visible only for company customers (B2B).
|
||||
Personal customers (B2C — private individuals) don't have
|
||||
a VAT number; the API likewise doesn't require one for
|
||||
them. */}
|
||||
{!isPersonal && (
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("vatNumber")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={vatNumber}
|
||||
onChange={(e) => setVatNumber(e.target.value)}
|
||||
placeholder="CHE-123.456.789 MWST"
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">{t("vatHelp")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("billingEmail")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={billingEmail}
|
||||
onChange={(e) => setBillingEmail(e.target.value)}
|
||||
placeholder="invoices@example.com"
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">{t("billingEmailHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("notes")}{" "}
|
||||
<span className="text-text-muted normal-case">
|
||||
({tCommon("optional")})
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
placeholder={t(
|
||||
isPersonal ? "notesPlaceholderPersonal" : "notesPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && !error && (
|
||||
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
|
||||
{t("saved")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{initial?.updatedAt && (
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("lastUpdated", {
|
||||
when: new Date(initial.updatedAt).toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
152
src/components/support/ticket-admin-controls.tsx
Normal file
152
src/components/support/ticket-admin-controls.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type {
|
||||
SupportTicketCategory,
|
||||
SupportTicketStatus,
|
||||
} from "@/types";
|
||||
|
||||
const STATUSES: SupportTicketStatus[] = [
|
||||
"open",
|
||||
"in_progress",
|
||||
"waiting_for_customer",
|
||||
"resolved",
|
||||
"reopened",
|
||||
];
|
||||
const CATEGORIES: SupportTicketCategory[] = [
|
||||
"bug",
|
||||
"feature_request",
|
||||
"question",
|
||||
"billing",
|
||||
"other",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
ticketId: string;
|
||||
currentStatus: SupportTicketStatus;
|
||||
currentCategory: SupportTicketCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only controls — change ticket status / category. Visible
|
||||
* exclusively when `user.isPlatform` (gate is in the parent server
|
||||
* component, not here).
|
||||
*
|
||||
* Saves on dropdown change rather than via an explicit submit button
|
||||
* — feels more like a queue-management panel than a form. Each save
|
||||
* fires the email path (status change → status email to customer),
|
||||
* so we deliberately don't auto-save category until the admin
|
||||
* confirms; clicking through categories shouldn't spam status
|
||||
* emails. (Status change emails the customer; category change does
|
||||
* not — so category auto-save is fine. Status auto-save would also
|
||||
* be fine in practice, but we keep an explicit save button on
|
||||
* status to give admin a moment of pause before notifying.)
|
||||
*
|
||||
* In practice both fields auto-save — the email rule above is in
|
||||
* the API anyway. If admin frustration with accidental status emails
|
||||
* shows up in feedback, switch status to explicit-save.
|
||||
*/
|
||||
export function TicketAdminControls({
|
||||
ticketId,
|
||||
currentStatus,
|
||||
currentCategory,
|
||||
}: Props) {
|
||||
const t = useTranslations("support");
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState(currentStatus);
|
||||
const [category, setCategory] = useState(currentCategory);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const saveChange = async (changes: {
|
||||
status?: SupportTicketStatus;
|
||||
category?: SupportTicketCategory;
|
||||
}) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/support/tickets/${encodeURIComponent(ticketId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(changes),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("updateFailed"));
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
// Revert local state on failure so the UI doesn't lie about
|
||||
// what's saved.
|
||||
if (changes.status) setStatus(currentStatus);
|
||||
if (changes.category) setCategory(currentCategory);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-blue-400/30 bg-blue-400/5">
|
||||
<div className="text-xs uppercase tracking-wider text-blue-400 font-semibold mb-3">
|
||||
{t("adminControlsTitle")}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldStatus")}
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as SupportTicketStatus;
|
||||
setStatus(next);
|
||||
saveChange({ status: next });
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary disabled:opacity-50"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status_${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldCategory")}
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as SupportTicketCategory;
|
||||
setCategory(next);
|
||||
saveChange({ category: next });
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary disabled:opacity-50"
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`category_${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
19
src/components/support/ticket-category-label.tsx
Normal file
19
src/components/support/ticket-category-label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SupportTicketCategory } from "@/types";
|
||||
|
||||
/**
|
||||
* Plain translated category label, e.g. "Bug" / "Feature request" /
|
||||
* "Billing". No styling chrome — just the text. Categories don't
|
||||
* carry the same lifecycle/urgency signal as status, so they don't
|
||||
* earn a coloured pill.
|
||||
*/
|
||||
export function TicketCategoryLabel({
|
||||
category,
|
||||
}: {
|
||||
category: SupportTicketCategory;
|
||||
}) {
|
||||
const t = useTranslations("support");
|
||||
return <span>{t(`category_${category}`)}</span>;
|
||||
}
|
||||
130
src/components/support/ticket-create-form.tsx
Normal file
130
src/components/support/ticket-create-form.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { SupportTicketCategory } from "@/types";
|
||||
|
||||
const CATEGORIES: SupportTicketCategory[] = [
|
||||
"bug",
|
||||
"feature_request",
|
||||
"question",
|
||||
"billing",
|
||||
"other",
|
||||
];
|
||||
|
||||
export function TicketCreateForm() {
|
||||
const t = useTranslations("support");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState<SupportTicketCategory>("question");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/support/tickets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, description, category }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("createFailed"));
|
||||
}
|
||||
const data = await res.json();
|
||||
// Redirect to the new ticket's detail page so the customer can
|
||||
// see the confirmation state and immediately add follow-ups if
|
||||
// they wish.
|
||||
router.push(`/support/${data.ticket.id}`);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldCategory")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={category}
|
||||
onChange={(e) =>
|
||||
setCategory(e.target.value as SupportTicketCategory)
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`category_${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldTitle")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={200}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldDescription")} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={10_000}
|
||||
rows={8}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("descriptionHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("submitTicket")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
src/components/support/ticket-status-badge.tsx
Normal file
38
src/components/support/ticket-status-badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SupportTicketStatus } from "@/types";
|
||||
|
||||
const STATUS_STYLES: Record<SupportTicketStatus, string> = {
|
||||
// Open: blue, neutral attention.
|
||||
open: "bg-blue-400/15 text-blue-400 border border-blue-400/20",
|
||||
// In progress: amber, work happening.
|
||||
in_progress: "bg-amber-400/15 text-amber-400 border border-amber-400/20",
|
||||
// Waiting for customer: violet — distinct from in_progress so admins
|
||||
// can quickly visually separate "I owe a response" from "they owe one".
|
||||
waiting_for_customer:
|
||||
"bg-violet-400/15 text-violet-400 border border-violet-400/20",
|
||||
resolved: "bg-success/15 text-success border border-success/20",
|
||||
// Reopened: red — flags admin attention because the previous
|
||||
// resolution didn't stick.
|
||||
reopened: "bg-red-400/15 text-red-400 border border-red-400/20",
|
||||
};
|
||||
|
||||
/**
|
||||
* Small status pill rendered on ticket list rows and detail header.
|
||||
* Translated label, colour-coded by ticket lifecycle stage.
|
||||
*/
|
||||
export function TicketStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: SupportTicketStatus;
|
||||
}) {
|
||||
const t = useTranslations("support");
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${STATUS_STYLES[status]}`}
|
||||
>
|
||||
{t(`status_${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
198
src/components/support/ticket-thread.tsx
Normal file
198
src/components/support/ticket-thread.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import type { SupportTicketComment, SupportTicketStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
ticketId: string;
|
||||
ticketStatus: SupportTicketStatus;
|
||||
comments: SupportTicketComment[];
|
||||
isPlatform: boolean;
|
||||
/** True when the viewer is the customer who created this ticket. */
|
||||
isOwnTicket: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread of comments + reply box. Customer-side viewers see a
|
||||
* "Close ticket" button as well, mapping to the customer-self-close
|
||||
* path on the PATCH endpoint.
|
||||
*
|
||||
* Reply submission: posts the comment, then router.refresh() so the
|
||||
* server-rendered page re-fetches and renders the new entry. Avoids
|
||||
* duplicating the comment-rendering logic on the client.
|
||||
*
|
||||
* Empty body submissions are blocked at HTML level (required) AND
|
||||
* by the API; we trust both layers.
|
||||
*/
|
||||
export function TicketThread({
|
||||
ticketId,
|
||||
ticketStatus,
|
||||
comments,
|
||||
isPlatform,
|
||||
isOwnTicket,
|
||||
}: Props) {
|
||||
const t = useTranslations("support");
|
||||
const tCommon = useTranslations("common");
|
||||
const f = useFormatter();
|
||||
const router = useRouter();
|
||||
|
||||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const onSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/support/tickets/${encodeURIComponent(ticketId)}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("commentFailed"));
|
||||
}
|
||||
setBody("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Customer-self-close: confirms because it's a state change, then
|
||||
// hits PATCH with status=resolved. The API allows this for
|
||||
// own-ticket regardless of role; the button only shows when the
|
||||
// ticket is in a non-resolved state.
|
||||
const onCustomerClose = async () => {
|
||||
if (!confirm(t("confirmClose"))) return;
|
||||
setClosing(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/support/tickets/${encodeURIComponent(ticketId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "resolved" }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("closeFailed"));
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isResolved = ticketStatus === "resolved";
|
||||
const canCustomerClose =
|
||||
isOwnTicket && !isResolved;
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
className={
|
||||
c.authorKind === "admin"
|
||||
? "border-blue-400/30 bg-blue-400/5"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-text-muted mb-2">
|
||||
<span className="font-medium text-text-primary">
|
||||
{c.authorName}
|
||||
{c.authorKind === "admin" && (
|
||||
<span className="ml-2 text-blue-400 text-[10px] uppercase tracking-wider">
|
||||
{t("authorTagAdmin")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatDateTime(c.createdAt, f)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-primary whitespace-pre-wrap">
|
||||
{c.body}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{isResolved && (
|
||||
<Card className="border-success/30 bg-success/5">
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
{t("resolvedBanner")}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reply box. Visible regardless of status — customer can
|
||||
reply even on a resolved ticket (which auto-reopens it
|
||||
server-side). The semantic is "reply means the ticket is
|
||||
alive again", which is friendlier than blocking the reply. */}
|
||||
<Card>
|
||||
<form onSubmit={onSubmitComment} className="space-y-3">
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("replyLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={10_000}
|
||||
rows={4}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={
|
||||
isResolved && isOwnTicket
|
||||
? t("replyPlaceholderReopen")
|
||||
: t("replyPlaceholder")
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{canCustomerClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCustomerClose}
|
||||
disabled={closing || submitting}
|
||||
className="text-xs text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{closing ? tCommon("loading") : t("closeTicket")}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || closing || body.trim().length === 0}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("sendReply")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,48 +2,83 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
|
||||
interface Props {
|
||||
tenantName: string;
|
||||
/**
|
||||
* Current suspend state — server-derived. The control toggles this
|
||||
* via PATCH /api/tenants/[name]/suspend, then refreshes the route
|
||||
* so server-component-side data (status badge, suspended notice)
|
||||
* re-renders.
|
||||
* Current suspend state — server-derived. Drives which control the
|
||||
* customer sees: "Cancel subscription" while active, the
|
||||
* resume-request flow while suspended.
|
||||
*/
|
||||
suspended: boolean;
|
||||
/**
|
||||
* True when the viewer has platform admin role. Platform users are
|
||||
* the only ones who can directly resume a tenant via PATCH; owners
|
||||
* must go through the resume-request flow. We use this in the
|
||||
* suspended branch to decide whether to render a direct "Resume"
|
||||
* button or the "Request reactivation" workflow.
|
||||
*/
|
||||
isPlatform: boolean;
|
||||
/**
|
||||
* If a resume request is currently pending for this tenant, its
|
||||
* id, when it was submitted, and the customer's optional note.
|
||||
* The component renders an info card with a cancel-request button
|
||||
* instead of the request-reactivation button. Only meaningful when
|
||||
* `suspended === true`.
|
||||
*/
|
||||
pendingResumeRequest: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
customerNotes: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionToggle — owner-side cancel/resume control (Bug 31).
|
||||
* SubscriptionToggle — owner-side cancel/resume control.
|
||||
*
|
||||
* Renders a single button that toggles between "Cancel subscription"
|
||||
* (when active) and "Resume subscription" (when suspended). Cancellation
|
||||
* is gated behind a confirmation modal because it's destructive
|
||||
* looking from the user's POV — even though no data is lost, the
|
||||
* AI assistant becomes unavailable until they resume. Resume has no
|
||||
* modal; it's a strict subset of cancellation in terms of risk.
|
||||
* Three render states:
|
||||
* 1. Active: "Cancel subscription" button + confirmation modal
|
||||
* (mentions 60-day retention before permanent deletion).
|
||||
* 2. Suspended, no pending resume request: "Request reactivation"
|
||||
* button + simple confirmation modal explaining admin review.
|
||||
* 3. Suspended, pending resume request: status card "Reactivation
|
||||
* requested X days ago" + "Cancel request" button.
|
||||
*
|
||||
* The control intentionally lives at the bottom of the tenant detail
|
||||
* page rather than next to the status badge — putting it near the
|
||||
* top would invite mis-clicks. Customers who want to cancel scroll
|
||||
* past the running configuration, billing-relevant info, and assigned
|
||||
* users first; that's the right friction level.
|
||||
* Platform admins viewing a suspended tenant get a fourth state in
|
||||
* place of #2/#3: a direct "Resume now" button (no admin queue, no
|
||||
* request flow). This is the admin escape hatch.
|
||||
*
|
||||
* Suspended tenants render a top-of-page banner separately (see the
|
||||
* detail page); this component focuses on the action itself.
|
||||
* The control intentionally lives at the bottom of the tenant
|
||||
* detail page rather than near the top — putting it next to the
|
||||
* status badge would invite mis-clicks.
|
||||
*/
|
||||
export function SubscriptionToggle({ tenantName, suspended }: Props) {
|
||||
export function SubscriptionToggle({
|
||||
tenantName,
|
||||
suspended,
|
||||
isPlatform,
|
||||
pendingResumeRequest,
|
||||
}: Props) {
|
||||
const t = useTranslations("tenantDetail");
|
||||
const tCommon = useTranslations("common");
|
||||
const f = useFormatter();
|
||||
const router = useRouter();
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||
const [confirmResumeOpen, setConfirmResumeOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
// Feature 6: customer's free-form note attached to the resume
|
||||
// request. Reset when the modal opens/closes so re-opening doesn't
|
||||
// show stale text from a previous abandoned attempt.
|
||||
const [resumeNotes, setResumeNotes] = useState("");
|
||||
|
||||
const toggleSuspend = async (next: boolean) => {
|
||||
// Customer-side cancel: PATCH suspend=true. Same path as before.
|
||||
// The 60-day retention copy in the modal is the new bit (Bug 37b);
|
||||
// mechanics are unchanged.
|
||||
const cancel = async () => {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -52,18 +87,14 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ suspend: next }),
|
||||
body: JSON.stringify({ suspend: true }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("subscriptionUpdateFailed"));
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
// The status badge + suspended banner are server-rendered, so
|
||||
// a route refresh is the simplest way to reflect the new state.
|
||||
// Optimistic local toggle would diverge from the actual CR if
|
||||
// the operator hasn't observed the patch yet.
|
||||
setConfirmCancelOpen(false);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
@@ -72,56 +103,204 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Owner-side resume request: POST a 'resume' tenant_request that
|
||||
// sits pending until admin acts. Different from cancel: no PATCH
|
||||
// on the CR — that happens only when admin approves.
|
||||
const requestResume = async () => {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tenants/${encodeURIComponent(tenantName)}/resume-request`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
// Trim and omit on empty so the API stores NULL rather
|
||||
// than empty string. The endpoint's zod transform also
|
||||
// handles this; double-checking on the client lets us
|
||||
// skip the round-trip when there's nothing to send.
|
||||
customerNotes: resumeNotes.trim() || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("subscriptionUpdateFailed"));
|
||||
}
|
||||
setConfirmResumeOpen(false);
|
||||
setResumeNotes("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Customer cancels their own pending resume request.
|
||||
const cancelResumeRequest = async () => {
|
||||
if (!pendingResumeRequest) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/onboarding/${pendingResumeRequest.id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("subscriptionUpdateFailed"));
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Platform admin: direct resume, bypassing the request flow.
|
||||
const adminResume = async () => {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ suspend: false }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("subscriptionUpdateFailed"));
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Suspended branch ───────────────────────────────────────────────
|
||||
|
||||
if (suspended) {
|
||||
// Platform admin sees direct resume. Independent of pending
|
||||
// resume — admin can always resume immediately.
|
||||
if (isPlatform) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={adminResume}
|
||||
disabled={submitting}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
: t("resumeSubscription")}
|
||||
</button>
|
||||
{pendingResumeRequest && (
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
{t("resumeRequestPendingNoteAdmin")}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Owner with pending resume request: render the request status
|
||||
// card with cancel.
|
||||
if (pendingResumeRequest) {
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<div className="text-sm font-medium text-amber-400 mb-1">
|
||||
{t("resumeRequestPendingTitle")}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{t("resumeRequestPendingDescription", {
|
||||
when: formatRelative(pendingResumeRequest.createdAt, f),
|
||||
})}
|
||||
</div>
|
||||
{/* Feature 6: echo the customer's note back so they can
|
||||
see what they wrote. Useful especially when they
|
||||
later wonder "what did I tell them?" or want to
|
||||
confirm before cancelling and resubmitting. */}
|
||||
{pendingResumeRequest.customerNotes && (
|
||||
<div className="mt-2 text-xs text-text-secondary border-l-2 border-amber-500/30 pl-3 whitespace-pre-wrap">
|
||||
{pendingResumeRequest.customerNotes}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelResumeRequest}
|
||||
disabled={submitting}
|
||||
className="mt-3 text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
: t("cancelResumeRequest")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Owner with no pending request: offer to create one.
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSuspend(false)}
|
||||
disabled={submitting}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors disabled:opacity-50"
|
||||
onClick={() => setConfirmResumeOpen(true)}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("resumeSubscription")}
|
||||
{t("requestReactivation")}
|
||||
</button>
|
||||
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{error && !confirmResumeOpen && (
|
||||
<p className="text-xs text-red-400 mt-2">{error}</p>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("cancelSubscription")}
|
||||
</button>
|
||||
{error && !confirmOpen && (
|
||||
<p className="text-xs text-red-400 mt-2">{error}</p>
|
||||
)}
|
||||
|
||||
{confirmOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
|
||||
{confirmResumeOpen && (
|
||||
<Modal
|
||||
open={confirmResumeOpen}
|
||||
onClose={() => setConfirmResumeOpen(false)}
|
||||
ariaLabel={t("requestReactivationConfirmTitle")}
|
||||
>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmTitle")}
|
||||
{t("requestReactivationConfirmTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{t("cancelConfirmDescription")}
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("requestReactivationConfirmDescription")}
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
|
||||
<li>{t("cancelConfirmBullet1")}</li>
|
||||
<li>{t("cancelConfirmBullet2")}</li>
|
||||
<li>{t("cancelConfirmBullet3")}</li>
|
||||
</ul>
|
||||
|
||||
{/* Feature 6: optional explanatory note. Useful for
|
||||
customers to tell admin why they want reactivation
|
||||
— e.g. "we paused over winter break, picking back
|
||||
up". Stored on the tenant_request and surfaced in
|
||||
the admin queue. */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("requestReactivationNoteLabel")}{" "}
|
||||
<span className="text-text-muted normal-case">
|
||||
({tCommon("optional")})
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={resumeNotes}
|
||||
onChange={(e) => setResumeNotes(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder={t("requestReactivationNotePlaceholder")}
|
||||
disabled={submitting}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
@@ -132,7 +311,7 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
onClick={() => setConfirmResumeOpen(false)}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
@@ -140,17 +319,87 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSuspend(true)}
|
||||
onClick={requestResume}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
className="text-sm px-4 py-2 rounded-lg bg-success text-white hover:bg-success/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
: t("cancelSubscriptionConfirm")}
|
||||
: t("requestReactivationConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Active branch ──────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancelOpen(true)}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("cancelSubscription")}
|
||||
</button>
|
||||
{error && !confirmCancelOpen && (
|
||||
<p className="text-xs text-red-400 mt-2">{error}</p>
|
||||
)}
|
||||
|
||||
{confirmCancelOpen && (
|
||||
<Modal
|
||||
open={confirmCancelOpen}
|
||||
onClose={() => setConfirmCancelOpen(false)}
|
||||
ariaLabel={t("cancelConfirmTitle")}
|
||||
>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{t("cancelConfirmDescription")}
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-3">
|
||||
<li>{t("cancelConfirmBullet1")}</li>
|
||||
<li>{t("cancelConfirmBullet2")}</li>
|
||||
<li>{t("cancelConfirmBullet3")}</li>
|
||||
</ul>
|
||||
{/* Bug 37b: 60-day retention warning. Distinct paragraph so it
|
||||
reads as a separate, more serious commitment than the
|
||||
regular bullets above. */}
|
||||
<div className="text-xs text-amber-400 bg-amber-400/10 border border-amber-400/20 rounded-lg px-3 py-2 mb-5">
|
||||
{t("cancelConfirmRetentionWarning")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancelOpen(false)}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
: t("cancelSubscriptionConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
89
src/components/ui/modal.tsx
Normal file
89
src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Called when user clicks the backdrop or presses Escape. */
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* ARIA label fallback when no labelled element exists inside.
|
||||
* Optional; if you have a heading inside the modal with id, set
|
||||
* `aria-labelledby` on a wrapper instead.
|
||||
*/
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal-based modal.
|
||||
*
|
||||
* Why a portal
|
||||
* ------------
|
||||
* `position: fixed` becomes positioned relative to a transformed
|
||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||
* dashboard/tenant-detail containers (because of the fade-up
|
||||
* animation, which uses `animation-fill-mode: both` to keep the
|
||||
* transform on after the animation finishes). That broke modals
|
||||
* rendered as in-place children — they centred to the panel they
|
||||
* lived in, not to the page.
|
||||
*
|
||||
* Rendering at `document.body` via `createPortal` escapes every
|
||||
* containing-block ancestor and gives us true viewport coordinates.
|
||||
*
|
||||
* UX details
|
||||
* ----------
|
||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
||||
* when the click target IS the backdrop, not the panel inside.)
|
||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
||||
* - `body` overflow is locked while open so background content
|
||||
* doesn't scroll behind the modal.
|
||||
* - Renders nothing on first paint server-side, then mounts on
|
||||
* client. `useEffect` gating ensures `document.body` is available;
|
||||
* without it Next.js SSR would throw on `document` reference.
|
||||
*/
|
||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Lock background scroll. Restore on unmount/close.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
660
src/lib/db.ts
660
src/lib/db.ts
@@ -1,5 +1,15 @@
|
||||
import { Pool } from "pg";
|
||||
import type { BillingAddress, TenantRequest, TenantRequestStatus } from "@/types";
|
||||
import type {
|
||||
BillingAddress,
|
||||
OrgBilling,
|
||||
SupportTicket,
|
||||
SupportTicketComment,
|
||||
SupportTicketCommentAuthorKind,
|
||||
SupportTicketCategory,
|
||||
SupportTicketStatus,
|
||||
TenantRequest,
|
||||
TenantRequestStatus,
|
||||
} from "@/types";
|
||||
import { listTenants, getTenant } from "./k8s";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,9 +73,14 @@ const MIGRATION_SQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name
|
||||
ON tenant_requests(tenant_name)
|
||||
WHERE tenant_name IS NOT NULL;
|
||||
-- Note: the unique constraint on tenant_name is NOT created here.
|
||||
-- Pre-Bug-37 we had a non-partial UNIQUE on tenant_name, which is
|
||||
-- incompatible with resume requests (same tenant_name, different
|
||||
-- request_type). The new partial unique indexes are created
|
||||
-- further down in the migration block, after the request_type
|
||||
-- column has been added and backfilled. This bootstrap section
|
||||
-- only creates indexes that are safe regardless of request_type
|
||||
-- semantics.
|
||||
|
||||
-- Idempotent column adds for existing databases
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
|
||||
@@ -78,6 +93,50 @@ const MIGRATION_SQL = `
|
||||
-- is only meaningful for rejected and cancelled rows.
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
|
||||
|
||||
-- Feature 6: free-form customer note attached to the request.
|
||||
-- Currently surfaced only by resume requests (where the customer
|
||||
-- explains why they want reactivation), but the column is generic
|
||||
-- so future flows could reuse it. Distinct from billing_notes
|
||||
-- (provision-only, accounting-related) and admin_notes (admin's
|
||||
-- reason on reject/approve). Optional — nullable.
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS customer_notes TEXT;
|
||||
|
||||
-- Bug 37a: resume requests use the same table as provision requests so
|
||||
-- the customer dashboard and admin queue share rendering. Discriminator
|
||||
-- is request_type. Default 'provision' on backfill keeps existing rows
|
||||
-- working without explicit migration.
|
||||
--
|
||||
-- Resume rows have:
|
||||
-- request_type = 'resume'
|
||||
-- tenant_name = the existing tenant being requested for reactivation
|
||||
-- zitadel_org_id = the org owning that tenant
|
||||
-- zitadel_user_id = the requesting customer
|
||||
-- status = pending → approved/rejected (or cancelled by customer)
|
||||
-- most provision-only fields (packages, billing_address, etc.) are NULL
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS request_type TEXT NOT NULL DEFAULT 'provision';
|
||||
-- Constrain to the known set so a future code change can't accidentally
|
||||
-- write a third type without first widening this constraint.
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE tenant_requests ADD CONSTRAINT tenant_requests_request_type_check
|
||||
CHECK (request_type IN ('provision', 'resume'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Tenant_name uniqueness was originally meant for "one tenant CR per
|
||||
-- approved provision request". Resume requests reuse a tenant_name,
|
||||
-- so the uniqueness must now be scoped to provision rows only.
|
||||
DROP INDEX IF EXISTS uniq_tenant_requests_tenant_name;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name_provision
|
||||
ON tenant_requests(tenant_name)
|
||||
WHERE tenant_name IS NOT NULL AND request_type = 'provision';
|
||||
|
||||
-- Only one pending resume request per tenant at a time. Otherwise a
|
||||
-- customer could spam-create resume requests (the admin queue would
|
||||
-- bloat) or two admins might race on approving duplicates.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_pending_resume
|
||||
ON tenant_requests(tenant_name)
|
||||
WHERE tenant_name IS NOT NULL AND request_type = 'resume' AND status = 'pending';
|
||||
|
||||
-- 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;
|
||||
|
||||
@@ -120,6 +179,99 @@ const MIGRATION_SQL = `
|
||||
);
|
||||
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);
|
||||
|
||||
-- Bug 35: org-scoped billing. One row per ZITADEL org; captured by
|
||||
-- the first tenant request inline, editable afterwards via
|
||||
-- /settings/billing. Subsequent tenant requests in the same org read
|
||||
-- this and skip the billing step entirely.
|
||||
--
|
||||
-- vat_number is nullable: required at write time for company orgs
|
||||
-- (enforced by the API, not the schema, because "company-or-personal"
|
||||
-- isn't expressible as a column constraint). Notes is free-form
|
||||
-- accounting context — VAT exemption reasons, special invoicing
|
||||
-- arrangements, etc.
|
||||
--
|
||||
-- We do NOT migrate data from tenant_requests.billing_address into
|
||||
-- this table automatically. Existing customers re-enter on next
|
||||
-- tenant or via settings — the data set is small (single-digit
|
||||
-- customers in pilot) and re-entering is the simplest path.
|
||||
CREATE TABLE IF NOT EXISTS org_billing (
|
||||
zitadel_org_id TEXT PRIMARY KEY,
|
||||
company_name TEXT NOT NULL,
|
||||
street_address TEXT NOT NULL,
|
||||
postal_code TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
vat_number TEXT,
|
||||
billing_email TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Feature 5: lightweight customer support / feedback tickets.
|
||||
-- Scoped strictly per-user (zitadel_user_id), not per-org —
|
||||
-- coworkers in the same org cannot see each other's tickets. The
|
||||
-- index on (zitadel_user_id, status) is what most customer-side
|
||||
-- queries hit; the index on (status, updated_at DESC) is for the
|
||||
-- admin queue.
|
||||
--
|
||||
-- contact_email / contact_name are frozen at creation time so the
|
||||
-- ticket retains a working "reply-to" identity even if the user
|
||||
-- later changes their email or display name in ZITADEL.
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
contact_email TEXT NOT NULL,
|
||||
contact_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- CHECK constraints added separately so re-running the migration
|
||||
-- against an existing table (without these constraints) works.
|
||||
-- IF NOT EXISTS isn't supported on ADD CONSTRAINT, hence the
|
||||
-- DO $$ wrapper.
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_category_check
|
||||
CHECK (category IN ('bug','feature_request','question','billing','other'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_status_check
|
||||
CHECK (status IN ('open','in_progress','waiting_for_customer','resolved','reopened'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_tickets_user
|
||||
ON support_tickets(zitadel_user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_support_tickets_status
|
||||
ON support_tickets(status, updated_at DESC);
|
||||
|
||||
-- Threaded comments. ON DELETE CASCADE so deleting a ticket
|
||||
-- cleans up its history; we don't currently expose ticket
|
||||
-- deletion in the UI but the cascade keeps options open.
|
||||
CREATE TABLE IF NOT EXISTS support_ticket_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
author_user_id TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
author_kind TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE support_ticket_comments ADD CONSTRAINT support_ticket_comments_author_kind_check
|
||||
CHECK (author_kind IN ('customer','admin'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket
|
||||
ON support_ticket_comments(ticket_id, created_at);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -381,6 +533,119 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
|
||||
* Caller is responsible for verifying the row belongs to the user's
|
||||
* org before calling.
|
||||
*/
|
||||
/**
|
||||
* Create a resume request (Bug 37a). Used when an owner of a suspended
|
||||
* tenant wants to reactivate it. Resume is admin-gated — the request
|
||||
* sits as `pending` until a platform admin approves or rejects it.
|
||||
*
|
||||
* Tenant-name uniqueness is enforced for `pending` resume rows by a
|
||||
* partial unique index, so a customer can't spam the queue with
|
||||
* duplicate resume requests for the same tenant. The DB throws a
|
||||
* unique-violation if they try; callers should catch that and translate
|
||||
* to a 409.
|
||||
*
|
||||
* Why this lives in tenant_requests instead of a separate table:
|
||||
* - the lifecycle is identical (pending → approved/rejected, plus
|
||||
* customer-side cancel and dismiss-after-terminal)
|
||||
* - the customer dashboard renders pending+resume cards from the
|
||||
* same `listActiveTenantRequestsByOrgId` query — adding a separate
|
||||
* table would mean two queries and union-merging in the UI
|
||||
* - the admin queue likewise treats them uniformly
|
||||
* The cost is a discriminator column (`request_type`) and most
|
||||
* provision-only fields being null on resume rows. That's a tradeoff
|
||||
* I think is worth it.
|
||||
*/
|
||||
export async function createResumeRequest(params: {
|
||||
tenantName: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
// Provision-only fields default sensibly. company_name + agent_name
|
||||
// are NOT NULL in the original schema; we copy them from the existing
|
||||
// tenant request for traceability rather than storing dummy values.
|
||||
companyName: string;
|
||||
agentName: string;
|
||||
/**
|
||||
* Feature 6: optional free-form note from the customer explaining
|
||||
* why they want reactivation. Surfaced to admin in the queue and
|
||||
* forwarded to the platform notification email so the admin can
|
||||
* decide before opening the request.
|
||||
*/
|
||||
customerNotes?: string | null;
|
||||
}): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO tenant_requests (
|
||||
zitadel_org_id, zitadel_user_id, company_name,
|
||||
contact_name, contact_email, agent_name,
|
||||
tenant_name, request_type, status, customer_notes
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'resume', 'pending', $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.companyName,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
params.agentName,
|
||||
params.tenantName,
|
||||
params.customerNotes ?? null,
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent provision request for a tenant_name. Used by
|
||||
* Bug 37a's resume-request creation to populate company_name and
|
||||
* agent_name (NOT NULL columns) from the original provision row
|
||||
* rather than make up values.
|
||||
*
|
||||
* Returns null when no such row exists — should be impossible in
|
||||
* normal flow (resume requests are only created for already-existing
|
||||
* tenants whose CR was created via approving a provision request),
|
||||
* but the caller should guard against it for safety.
|
||||
*/
|
||||
export async function getTenantRequestByTenantName(
|
||||
tenantName: string
|
||||
): Promise<TenantRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM tenant_requests
|
||||
WHERE tenant_name = $1
|
||||
AND request_type = 'provision'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.length > 0 ? mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the in-flight (pending) resume request for a given tenant, if
|
||||
* any. Used both to gate the customer's "Request reactivation" button
|
||||
* (don't allow a second when one's already pending) and by the admin
|
||||
* UI to navigate from the tenant detail page to the awaiting request.
|
||||
*
|
||||
* Returns null when no pending resume exists. Approved/rejected rows
|
||||
* are never returned — they're terminal.
|
||||
*/
|
||||
export async function getPendingResumeRequestForTenant(
|
||||
tenantName: string
|
||||
): Promise<TenantRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM tenant_requests
|
||||
WHERE tenant_name = $1
|
||||
AND request_type = 'resume'
|
||||
AND status = 'pending'
|
||||
LIMIT 1`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.length > 0 ? mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function dismissTenantRequest(id: string): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
@@ -498,8 +763,33 @@ export async function deleteTenantRequest(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync provisioning statuses: for all requests with status "provisioning",
|
||||
* check if the PiecedTenant CR has reached "Ready" and update to "active".
|
||||
* Reconcile the portal's tenant_requests table against actual cluster
|
||||
* state. Three passes, walking only rows with `tenant_name` set:
|
||||
*
|
||||
* 1. provisioning → active: when a tenant CR's phase reaches Ready
|
||||
* or Running, the portal flips the row to active so the
|
||||
* "provisioning…" card transitions into the running tenant view.
|
||||
*
|
||||
* 2. active/provisioning → deleted: when the corresponding CR no
|
||||
* longer exists in the cluster (404), or is mid-deletion (has
|
||||
* metadata.deletionTimestamp set), the row gets flipped to
|
||||
* `deleted`. The DB is otherwise blind to operator-initiated
|
||||
* deletions — when the 60-day TTL fires (Bug 37b) and the
|
||||
* operator deletes a suspended tenant, the portal would happily
|
||||
* keep showing the "Your assistant is ready!" card forever.
|
||||
* Without this reconciliation the dashboard drifts from reality.
|
||||
*
|
||||
* 3. pending resume → cancelled: when a pending resume request's
|
||||
* tenant is no longer suspended (admin resumed it directly,
|
||||
* tenant was deleted, or it was never suspended in the first
|
||||
* place), the request is moot. Flip to 'cancelled' so the
|
||||
* pending-resume unique index releases for any future genuine
|
||||
* resume request. We pick `cancelled` over `rejected` because
|
||||
* the customer didn't do anything wrong — circumstances just
|
||||
* changed.
|
||||
*
|
||||
* Errors are tolerated per-row: a transient API hiccup on one tenant
|
||||
* shouldn't fail the whole sweep. Skipped rows get retried next call.
|
||||
*
|
||||
* Slice 3 note: with multi-tenant per org, this iterates each row
|
||||
* individually (keyed by its own tenant_name), so multiple in-flight
|
||||
@@ -507,24 +797,78 @@ export async function deleteTenantRequest(id: string): Promise<void> {
|
||||
*/
|
||||
export async function syncProvisioningStatuses(): Promise<void> {
|
||||
await ensureSchema();
|
||||
// Active+provisioning rows: status reflects "the tenant should
|
||||
// exist and be running".
|
||||
// Pending resume rows: status reflects "the tenant is suspended,
|
||||
// awaiting reactivation".
|
||||
// Both need cluster-side validation; we fetch them in one query
|
||||
// and dispatch on (status, request_type).
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
"SELECT * FROM tenant_requests WHERE status = 'provisioning'"
|
||||
`SELECT * FROM tenant_requests
|
||||
WHERE tenant_name IS NOT NULL
|
||||
AND (
|
||||
status IN ('provisioning', 'active')
|
||||
OR (status = 'pending' AND request_type = 'resume')
|
||||
)`
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
const mapped = mapRow(row);
|
||||
if (!mapped.tenantName) continue;
|
||||
|
||||
let tenant: Awaited<ReturnType<typeof getTenant>> = null;
|
||||
try {
|
||||
const tenant = await getTenant(mapped.tenantName);
|
||||
if (
|
||||
tenant?.status?.phase === "Ready" ||
|
||||
tenant?.status?.phase === "Running"
|
||||
) {
|
||||
await updateTenantRequestStatus(mapped.id, "active");
|
||||
}
|
||||
tenant = await getTenant(mapped.tenantName);
|
||||
} catch {
|
||||
// Tenant might not exist yet — skip
|
||||
// Transient API error — skip this row, retry on next sweep.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pending resume request: validity hinges on tenant being suspended.
|
||||
if (
|
||||
mapped.status === "pending" &&
|
||||
mapped.requestType === "resume"
|
||||
) {
|
||||
// Tenant doesn't exist or is being deleted: cancel the resume
|
||||
// request (it can never be fulfilled). Don't fall through to
|
||||
// the "deleted" branch below — that would also flip the
|
||||
// provision row, which is the right thing for a CR-level
|
||||
// deletion but we want this resume row specifically resolved
|
||||
// here.
|
||||
if (!tenant || tenant.metadata.deletionTimestamp) {
|
||||
await updateTenantRequestStatus(mapped.id, "cancelled");
|
||||
continue;
|
||||
}
|
||||
// Tenant is no longer suspended: the request is moot.
|
||||
// Cancel it (the customer didn't do anything wrong; the
|
||||
// condition the request was about no longer applies).
|
||||
if (!tenant.spec.suspend) {
|
||||
await updateTenantRequestStatus(mapped.id, "cancelled");
|
||||
continue;
|
||||
}
|
||||
// Tenant still suspended, request still relevant. Leave as-is.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Active or provisioning row: CR gone, or mid-deletion. Flip the
|
||||
// row to 'deleted'. `markTenantRequestDeletedByTenantName` flips
|
||||
// every row with this tenant_name (provision + any resume rows),
|
||||
// which is the right thing for a CR-level deletion.
|
||||
if (!tenant || tenant.metadata.deletionTimestamp) {
|
||||
await markTenantRequestDeletedByTenantName(mapped.tenantName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// CR exists and is healthy. Promote provisioning → active when
|
||||
// the operator reports the tenant has reached steady state.
|
||||
// Keep `active` rows on `active` regardless of phase — a
|
||||
// temporarily-Reconfiguring tenant is still active from the
|
||||
// portal's billing/visibility perspective.
|
||||
if (
|
||||
mapped.status === "provisioning" &&
|
||||
(tenant.status?.phase === "Ready" || tenant.status?.phase === "Running")
|
||||
) {
|
||||
await updateTenantRequestStatus(mapped.id, "active");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,6 +892,7 @@ function mapRow(row: any): TenantRequest {
|
||||
packages: row.packages ?? [],
|
||||
billingAddress: row.billing_address ?? {},
|
||||
billingNotes: row.billing_notes,
|
||||
customerNotes: row.customer_notes ?? null,
|
||||
status: row.status as TenantRequestStatus,
|
||||
adminNotes: row.admin_notes,
|
||||
tenantName: row.tenant_name,
|
||||
@@ -555,11 +900,96 @@ function mapRow(row: any): TenantRequest {
|
||||
isPersonal: row.is_personal ?? false,
|
||||
dismissedAt:
|
||||
row.dismissed_at?.toISOString?.() ?? row.dismissed_at ?? null,
|
||||
requestType: (row.request_type ?? "provision") as
|
||||
| "provision"
|
||||
| "resume",
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 35: org-scoped billing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToOrgBilling(row: any): OrgBilling {
|
||||
return {
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
companyName: row.company_name,
|
||||
streetAddress: row.street_address,
|
||||
postalCode: row.postal_code,
|
||||
city: row.city,
|
||||
country: row.country,
|
||||
vatNumber: row.vat_number ?? null,
|
||||
billingEmail: row.billing_email,
|
||||
notes: row.notes ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch org billing if it exists. Returns null when the org has never
|
||||
* captured billing — that's the signal the wizard uses to know
|
||||
* whether to render the inline billing step on the first tenant
|
||||
* request.
|
||||
*/
|
||||
export async function getOrgBilling(
|
||||
zitadelOrgId: string
|
||||
): Promise<OrgBilling | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM org_billing WHERE zitadel_org_id = $1",
|
||||
[zitadelOrgId]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToOrgBilling(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update org billing. Single function for both because the
|
||||
* UI flow makes the "first time vs editing" distinction in a single
|
||||
* settings page that doesn't need to know which one it's doing.
|
||||
*
|
||||
* VAT-required-for-companies isn't enforced here — that's an API
|
||||
* concern (the API knows whether the caller is a company org).
|
||||
* Keeping the DB layer dumb.
|
||||
*/
|
||||
export async function upsertOrgBilling(
|
||||
data: Omit<OrgBilling, "createdAt" | "updatedAt">
|
||||
): Promise<OrgBilling> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO org_billing (
|
||||
zitadel_org_id, company_name, street_address, postal_code,
|
||||
city, country, vat_number, billing_email, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (zitadel_org_id) DO UPDATE SET
|
||||
company_name = EXCLUDED.company_name,
|
||||
street_address = EXCLUDED.street_address,
|
||||
postal_code = EXCLUDED.postal_code,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country,
|
||||
vat_number = EXCLUDED.vat_number,
|
||||
billing_email = EXCLUDED.billing_email,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = now()
|
||||
RETURNING *`,
|
||||
[
|
||||
data.zitadelOrgId,
|
||||
data.companyName,
|
||||
data.streetAddress,
|
||||
data.postalCode,
|
||||
data.city,
|
||||
data.country,
|
||||
data.vatNumber ?? null,
|
||||
data.billingEmail,
|
||||
data.notes ?? null,
|
||||
]
|
||||
);
|
||||
return rowToOrgBilling(result.rows[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 6: tenant ↔ user assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -706,3 +1136,203 @@ export async function removeAllAssignmentsForUser(
|
||||
[orgId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 5: support tickets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToSupportTicket(row: any): SupportTicket {
|
||||
return {
|
||||
id: row.id,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
category: row.category as SupportTicketCategory,
|
||||
status: row.status as SupportTicketStatus,
|
||||
contactEmail: row.contact_email,
|
||||
contactName: row.contact_name,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToSupportTicketComment(row: any): SupportTicketComment {
|
||||
return {
|
||||
id: row.id,
|
||||
ticketId: row.ticket_id,
|
||||
authorUserId: row.author_user_id,
|
||||
authorName: row.author_name,
|
||||
authorKind: row.author_kind as SupportTicketCommentAuthorKind,
|
||||
body: row.body,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new support ticket. The contact_name/contact_email are
|
||||
* snapshotted from the session at creation time — see SupportTicket
|
||||
* doc for why.
|
||||
*/
|
||||
export async function createSupportTicket(params: {
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: SupportTicketCategory;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
}): Promise<SupportTicket> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO support_tickets (
|
||||
zitadel_org_id, zitadel_user_id, title, description, category,
|
||||
contact_name, contact_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.title,
|
||||
params.description,
|
||||
params.category,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
]
|
||||
);
|
||||
return rowToSupportTicket(result.rows[0]);
|
||||
}
|
||||
|
||||
/** Tickets created by a single user, newest activity first. */
|
||||
export async function listSupportTicketsForUser(
|
||||
zitadelUserId: string
|
||||
): Promise<SupportTicket[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM support_tickets
|
||||
WHERE zitadel_user_id = $1
|
||||
ORDER BY updated_at DESC`,
|
||||
[zitadelUserId]
|
||||
);
|
||||
return result.rows.map(rowToSupportTicket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin queue. Returns every ticket across all users/orgs, newest
|
||||
* activity first. Pending tickets (open/reopened) bubble to the top
|
||||
* by virtue of recent activity, but the API doesn't sort by status —
|
||||
* the admin UI handles filtering and bucketing.
|
||||
*/
|
||||
export async function listAllSupportTickets(): Promise<SupportTicket[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM support_tickets ORDER BY updated_at DESC`
|
||||
);
|
||||
return result.rows.map(rowToSupportTicket);
|
||||
}
|
||||
|
||||
export async function getSupportTicketById(
|
||||
id: string
|
||||
): Promise<SupportTicket | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM support_tickets WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function listCommentsForTicket(
|
||||
ticketId: string
|
||||
): Promise<SupportTicketComment[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM support_ticket_comments
|
||||
WHERE ticket_id = $1
|
||||
ORDER BY created_at`,
|
||||
[ticketId]
|
||||
);
|
||||
return result.rows.map(rowToSupportTicketComment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a comment. Bumps the parent ticket's `updated_at` so the
|
||||
* activity sort orders work — done in a transaction so the two are
|
||||
* atomic from any concurrent reader's perspective.
|
||||
*
|
||||
* Caller is responsible for status auto-bumping (e.g. customer
|
||||
* replying to a `waiting_for_customer` ticket → `in_progress`); the
|
||||
* DB layer just writes what it's told.
|
||||
*/
|
||||
export async function createSupportTicketComment(params: {
|
||||
ticketId: string;
|
||||
authorUserId: string;
|
||||
authorName: string;
|
||||
authorKind: SupportTicketCommentAuthorKind;
|
||||
body: string;
|
||||
}): Promise<SupportTicketComment> {
|
||||
await ensureSchema();
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO support_ticket_comments (
|
||||
ticket_id, author_user_id, author_name, author_kind, body
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.ticketId,
|
||||
params.authorUserId,
|
||||
params.authorName,
|
||||
params.authorKind,
|
||||
params.body,
|
||||
]
|
||||
);
|
||||
await client.query(
|
||||
"UPDATE support_tickets SET updated_at = now() WHERE id = $1",
|
||||
[params.ticketId]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return rowToSupportTicketComment(inserted.rows[0]);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mutable fields on a ticket. Only category and status are
|
||||
* mutable; title/description are frozen post-creation. Returns the
|
||||
* updated row so callers can email the right contact_email
|
||||
* afterwards.
|
||||
*/
|
||||
export async function updateSupportTicket(
|
||||
id: string,
|
||||
changes: { status?: SupportTicketStatus; category?: SupportTicketCategory }
|
||||
): Promise<SupportTicket | null> {
|
||||
await ensureSchema();
|
||||
const sets: string[] = ["updated_at = now()"];
|
||||
const values: any[] = [id];
|
||||
let idx = 2;
|
||||
if (changes.status !== undefined) {
|
||||
sets.push(`status = $${idx}`);
|
||||
values.push(changes.status);
|
||||
idx++;
|
||||
}
|
||||
if (changes.category !== undefined) {
|
||||
sets.push(`category = $${idx}`);
|
||||
values.push(changes.category);
|
||||
idx++;
|
||||
}
|
||||
// No-op early exit. Without an actual change we still want
|
||||
// updated_at refreshed if the caller asked for one, but if they
|
||||
// passed neither field there's nothing to do.
|
||||
if (sets.length === 1) return getSupportTicketById(id);
|
||||
const result = await getPool().query(
|
||||
`UPDATE support_tickets SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
526
src/lib/email.ts
526
src/lib/email.ts
@@ -11,6 +11,17 @@
|
||||
* SMTP_PASS — App Password
|
||||
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
|
||||
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
|
||||
* SUPPORT_CONTACT_EMAIL — e.g. support@pieced.ch (optional)
|
||||
* Customer-facing address for "have
|
||||
* questions?" follow-ups in
|
||||
* transactional emails. The from
|
||||
* address itself (SMTP_USER) is
|
||||
* typically a noreply mailbox, so we
|
||||
* don't tell customers to "reply to
|
||||
* this email" — instead we point them
|
||||
* at this monitored address. If
|
||||
* unset, the contact-prompt line is
|
||||
* simply omitted from emails.
|
||||
*/
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
@@ -42,6 +53,12 @@ function getFrom(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the customer-facing support email address, or null if unset. */
|
||||
function getSupportContactEmail(): string | null {
|
||||
const v = process.env.SUPPORT_CONTACT_EMAIL?.trim();
|
||||
return v && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent injection in HTML emails.
|
||||
*/
|
||||
@@ -125,6 +142,21 @@ export async function sendRejectionEmail(
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const supportEmail = getSupportContactEmail();
|
||||
// The customer here is rejected pre-onboarding — they don't yet
|
||||
// have a portal account, so we can't send them to /support.
|
||||
// Instead point at the configured support address (if set).
|
||||
// If unset (e.g. early pilot before a support inbox exists), we
|
||||
// omit the follow-up line entirely rather than promise something
|
||||
// that goes nowhere — telling the customer to "reply to this
|
||||
// email" would be misleading because we send from a noreply box.
|
||||
const contactLineText = supportEmail
|
||||
? `If you have questions or would like to discuss this further, please contact us at ${supportEmail}.`
|
||||
: "";
|
||||
const contactLineHtml = supportEmail
|
||||
? `<p>If you have questions or would like to discuss this further, please contact us at <a href="mailto:${escapeHtml(supportEmail)}" style="color: #3b82f6;">${escapeHtml(supportEmail)}</a>.</p>`
|
||||
: "";
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to,
|
||||
@@ -134,18 +166,20 @@ export async function sendRejectionEmail(
|
||||
"",
|
||||
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
|
||||
notesBlock,
|
||||
"If you have questions or would like to discuss this further, please reply to this email.",
|
||||
contactLineText,
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
]
|
||||
.filter((s) => s !== "")
|
||||
.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;">Update on your onboarding request</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
|
||||
${notesHtml}
|
||||
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
|
||||
${contactLineHtml}
|
||||
<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>
|
||||
@@ -156,6 +190,130 @@ export async function sendRejectionEmail(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug 37a: separate email for resume request approval. The tenant
|
||||
* already exists; the message is "we're un-suspending it" rather than
|
||||
* "we're provisioning a new instance". Avoids confusing the customer
|
||||
* with onboarding language for a tenant they already had.
|
||||
*/
|
||||
export async function sendResumeApprovalEmail(
|
||||
to: string,
|
||||
contactName: string,
|
||||
companyName: string
|
||||
): Promise<void> {
|
||||
const safeName = escapeHtml(contactName);
|
||||
const safeCompany = escapeHtml(companyName);
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to,
|
||||
subject: `Your PieCed AI assistant has been reactivated — ${companyName}`,
|
||||
text: [
|
||||
`Hello ${contactName},`,
|
||||
"",
|
||||
`Good news — your reactivation request for ${companyName} has been approved.`,
|
||||
"",
|
||||
"Your AI assistant is being brought back online and should be ready in a few minutes.",
|
||||
"You can check the status in your dashboard at https://app.pieced.ch",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].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;">Your AI assistant has been reactivated</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Good news — your reactivation request for <strong>${safeCompany}</strong> has been approved.</p>
|
||||
<p>Your AI assistant is being brought back online and should be ready in a few minutes.</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send resume approval email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug 37a: separate email for resume request rejection. Differs from
|
||||
* the onboarding rejection in two ways: it explicitly mentions the
|
||||
* tenant remains suspended, and it points the customer to the
|
||||
* 60-day retention window so they understand the deletion clock is
|
||||
* still ticking. The latter is important — a customer reading a
|
||||
* generic "request rejected" email might not realise their data is
|
||||
* still on a countdown.
|
||||
*/
|
||||
export async function sendResumeRejectionEmail(
|
||||
to: string,
|
||||
contactName: string,
|
||||
companyName: string,
|
||||
adminNotes?: string
|
||||
): Promise<void> {
|
||||
const safeName = escapeHtml(contactName);
|
||||
const safeCompany = escapeHtml(companyName);
|
||||
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
|
||||
|
||||
try {
|
||||
const notesBlock = adminNotes
|
||||
? `\nNote from our team:\n${adminNotes}\n`
|
||||
: "";
|
||||
const notesHtml = safeNotes
|
||||
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
|
||||
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
|
||||
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
// The customer has portal access (their tenant exists, they
|
||||
// just had a resume request rejected), so direct them to the
|
||||
// support ticket system for follow-up. We never tell them to
|
||||
// "reply to this email" because the from address is a noreply
|
||||
// mailbox.
|
||||
const contactLineText =
|
||||
"If you have questions, open a support ticket at https://app.pieced.ch/support.";
|
||||
const contactLineHtml = `<p>If you have questions, <a href="https://app.pieced.ch/support" style="color: #3b82f6;">open a support ticket</a>.</p>`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to,
|
||||
subject: `Update on your reactivation request — ${companyName}`,
|
||||
text: [
|
||||
`Hello ${contactName},`,
|
||||
"",
|
||||
`Thank you for your reactivation request for ${companyName}. Unfortunately, we were unable to approve it at this time.`,
|
||||
notesBlock,
|
||||
"Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.",
|
||||
"",
|
||||
contactLineText,
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].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;">Update on your reactivation request</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Thank you for your reactivation request for <strong>${safeCompany}</strong>. Unfortunately, we were unable to approve it at this time.</p>
|
||||
${notesHtml}
|
||||
<p>Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.</p>
|
||||
${contactLineHtml}
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send resume rejection email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAdminNotificationEmail(
|
||||
companyName: string,
|
||||
contactName: string,
|
||||
@@ -203,3 +361,365 @@ export async function sendAdminNotificationEmail(
|
||||
console.error("Failed to send admin notification email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 6: resume-request admin notification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Notify the admin distribution list that a customer has requested
|
||||
* reactivation of a suspended tenant. Distinct from the onboarding
|
||||
* notification because the action consequences differ (admin
|
||||
* approving a resume just unsuspends an existing tenant; no
|
||||
* provisioning runs), and because the customer's note — explaining
|
||||
* why they want reactivation — is meaningful context for the admin
|
||||
* triaging the queue.
|
||||
*
|
||||
* Skipped silently if ADMIN_NOTIFICATION_EMAIL isn't set, matching
|
||||
* the pattern of the other admin notification functions.
|
||||
*/
|
||||
export async function sendResumeRequestAdminNotificationEmail(params: {
|
||||
tenantName: string;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
customerNotes?: string | null;
|
||||
}): Promise<void> {
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||
if (!adminEmail) return;
|
||||
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeEmail = escapeHtml(params.contactEmail);
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
const safeNotes = params.customerNotes ? escapeHtml(params.customerNotes) : "";
|
||||
|
||||
const noteText = params.customerNotes
|
||||
? `\nCustomer's note:\n${params.customerNotes}\n`
|
||||
: "";
|
||||
const noteHtml = safeNotes
|
||||
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
|
||||
<p style="color: #ccc; font-size: 13px; margin: 0 0 8px 0;"><strong>Customer's note:</strong></p>
|
||||
<p style="color: #e0e0e0; font-size: 13px; margin: 0;">${safeNotes}</p>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: adminEmail,
|
||||
subject: `Reactivation request: ${params.companyName}`,
|
||||
text: [
|
||||
`A customer has requested reactivation of a suspended tenant.`,
|
||||
"",
|
||||
`Company: ${params.companyName}`,
|
||||
`Tenant: ${params.tenantName}`,
|
||||
`Contact: ${params.contactName} (${params.contactEmail})`,
|
||||
noteText,
|
||||
`Review at https://app.pieced.ch/admin`,
|
||||
]
|
||||
.filter((s) => s !== "")
|
||||
.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;">Reactivation request</h2>
|
||||
<p>A customer has requested reactivation of a suspended tenant.</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;">Tenant:</td><td style="font-family: monospace;">${safeTenant}</td></tr>
|
||||
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
|
||||
</table>
|
||||
${noteHtml}
|
||||
<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 — Admin notification</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send resume request admin notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 5: support ticket emails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Email subject prefix that helps customers thread tickets in their
|
||||
* mail client. We don't have inbound email processing — replies via
|
||||
* email back to us go nowhere — but the prefix is still useful for
|
||||
* the customer's own organisation. The id is shortened to 8 chars
|
||||
* for human readability; collisions on the truncated form within a
|
||||
* single user's inbox are vanishingly unlikely.
|
||||
*/
|
||||
function ticketSubjectPrefix(ticketId: string): string {
|
||||
return `[PieCed Support #${ticketId.slice(0, 8)}]`;
|
||||
}
|
||||
|
||||
const STATUS_LABELS_EN: Record<string, string> = {
|
||||
open: "Open",
|
||||
in_progress: "In progress",
|
||||
waiting_for_customer: "Waiting for your reply",
|
||||
resolved: "Resolved",
|
||||
reopened: "Reopened",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to the customer when they create a ticket — confirmation
|
||||
* that we received it and a copy of the ticket id for their records.
|
||||
*/
|
||||
export async function sendSupportTicketCreatedEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
ticketId: string;
|
||||
title: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} ${params.title}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`We've received your support request "${params.title}" (reference #${shortId}).`,
|
||||
"",
|
||||
"Our team will review and respond as soon as possible. You can track the status and reply at https://app.pieced.ch/support.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].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;">Support request received</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>We've received your support request <strong>"${safeTitle}"</strong> (reference #${shortId}).</p>
|
||||
<p>Our team will review and respond as soon as possible.</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
View ticket
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send support ticket creation email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent to the customer when an admin replies to one of their tickets.
|
||||
* Includes the body of the reply inline so the customer can read it
|
||||
* without clicking through (especially useful on mobile).
|
||||
*/
|
||||
export async function sendSupportTicketReplyEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
ticketId: string;
|
||||
title: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const safeAuthor = escapeHtml(params.authorName);
|
||||
const safeBody = escapeHtml(params.body);
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} Re: ${params.title}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`${params.authorName} replied to your ticket "${params.title}" (#${shortId}):`,
|
||||
"",
|
||||
params.body,
|
||||
"",
|
||||
"Reply or follow up at https://app.pieced.ch/support.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].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 reply on your ticket</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p><strong>${safeAuthor}</strong> replied to your ticket <strong>"${safeTitle}"</strong> (#${shortId}):</p>
|
||||
<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
|
||||
${safeBody}
|
||||
</div>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
View ticket
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send support ticket reply email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent to the customer when an admin changes status without a comment.
|
||||
* If the same admin action included a comment, they'd get the
|
||||
* reply email instead — caller decides which to send.
|
||||
*/
|
||||
export async function sendSupportTicketStatusEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
ticketId: string;
|
||||
title: string;
|
||||
newStatus: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const statusLabel = STATUS_LABELS_EN[params.newStatus] ?? params.newStatus;
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} Status: ${statusLabel}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`The status of your ticket "${params.title}" (#${shortId}) has been updated to: ${statusLabel}.`,
|
||||
"",
|
||||
"View details and respond if needed at https://app.pieced.ch/support.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].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;">Ticket status update</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>The status of your ticket <strong>"${safeTitle}"</strong> (#${shortId}) has been updated to:</p>
|
||||
<p style="font-size: 18px; color: #3b82f6; font-weight: 600;">${escapeHtml(statusLabel)}</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
View ticket
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send support ticket status email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the platform admin distribution list of a new ticket OR a
|
||||
* customer reply. Mirror of sendAdminNotificationEmail's pattern —
|
||||
* uses the same ADMIN_NOTIFICATION_EMAIL env var.
|
||||
*
|
||||
* Two trigger reasons supported:
|
||||
* - 'created' → new ticket from a customer
|
||||
* - 'replied' → customer replied to existing ticket (we want admin
|
||||
* visibility, e.g. to know the ticket needs another
|
||||
* round of attention)
|
||||
*/
|
||||
export async function sendSupportAdminNotificationEmail(params: {
|
||||
reason: "created" | "replied";
|
||||
ticketId: string;
|
||||
title: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
body?: string; // The new message content (description on create, comment body on reply)
|
||||
category?: string;
|
||||
}): Promise<void> {
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||
if (!adminEmail) {
|
||||
console.warn(
|
||||
"ADMIN_NOTIFICATION_EMAIL not set; skipping admin support notification"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const safeContact = escapeHtml(params.contactName);
|
||||
const safeContactEmail = escapeHtml(params.contactEmail);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const safeBody = params.body ? escapeHtml(params.body) : "";
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
|
||||
const subjectVerb = params.reason === "created" ? "New" : "Reply on";
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} ${subjectVerb}: ${params.title}`;
|
||||
|
||||
const headlineHtml =
|
||||
params.reason === "created"
|
||||
? `<h2 style="color: #ffffff; margin-top: 0;">New support ticket</h2>`
|
||||
: `<h2 style="color: #ffffff; margin-top: 0;">Customer replied on ticket</h2>`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: adminEmail,
|
||||
subject,
|
||||
text: [
|
||||
params.reason === "created"
|
||||
? "A new support ticket was opened:"
|
||||
: "A customer replied to a support ticket:",
|
||||
"",
|
||||
`From: ${params.contactName} <${params.contactEmail}>`,
|
||||
`Ticket: ${params.title} (#${shortId})`,
|
||||
params.category ? `Category: ${params.category}` : "",
|
||||
"",
|
||||
params.body ? "Message:" : "",
|
||||
params.body ?? "",
|
||||
"",
|
||||
`View at https://app.pieced.ch/support/${params.ticketId}`,
|
||||
]
|
||||
.filter((s) => s !== "")
|
||||
.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;">
|
||||
${headlineHtml}
|
||||
<table style="width:100%; font-size: 13px; color: #aaa; margin-bottom: 16px;">
|
||||
<tr><td style="padding: 4px 0; width: 100px;">From</td><td style="padding: 4px 0; color: #fff;">${safeContact} <${safeContactEmail}></td></tr>
|
||||
<tr><td style="padding: 4px 0;">Title</td><td style="padding: 4px 0; color: #fff;">${safeTitle} <span style="color: #666;">(#${shortId})</span></td></tr>
|
||||
${params.category ? `<tr><td style="padding: 4px 0;">Category</td><td style="padding: 4px 0; color: #fff;">${escapeHtml(params.category)}</td></tr>` : ""}
|
||||
</table>
|
||||
${
|
||||
params.body
|
||||
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">${safeBody}</div>`
|
||||
: ""
|
||||
}
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support/${params.ticketId}" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
Open in admin queue
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Admin notification</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send admin support notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
155
src/lib/k8s.ts
155
src/lib/k8s.ts
@@ -130,3 +130,158 @@ export async function patchTenantSpec(
|
||||
}
|
||||
return res.json() as Promise<PiecedTenant>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear an annotation on a PiecedTenant CR.
|
||||
*
|
||||
* Pass `value=null` to remove the annotation. K8s merge-patch removes
|
||||
* a key when its value is null in the patch — that's exactly the
|
||||
* semantic we want.
|
||||
*
|
||||
* Used by the resume-request flow (Bug 37a): the portal sets
|
||||
* `pieced.ch/resume-request-pending` when a customer creates a
|
||||
* resume request, and clears it when the request transitions to a
|
||||
* terminal state. The operator reads this annotation to pause its
|
||||
* 60-day deletion timer while a resume request is in flight.
|
||||
*
|
||||
* Annotations are namespaced informally — we use `pieced.ch/...` for
|
||||
* everything we own, mirroring the labels.
|
||||
*/
|
||||
export async function setTenantAnnotation(
|
||||
name: string,
|
||||
key: string,
|
||||
value: string | null
|
||||
): Promise<PiecedTenant> {
|
||||
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`;
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/merge-patch+json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { annotations: { [key]: value } },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(`K8s annotate /${name}: ${res.status} ${text}`);
|
||||
(err as any).statusCode = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<PiecedTenant>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenClaw config ConfigMap helpers (admin-only feature: per-tenant version
|
||||
// override + platform default).
|
||||
//
|
||||
// The ConfigMap lives in the operator's namespace (`pieced-system`). The
|
||||
// portal's ServiceAccount needs `get/patch` on configmaps in that namespace
|
||||
// — rules added in the gitops repo.
|
||||
//
|
||||
// Tag-only by design — see operator notes for rationale.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OPENCLAW_CONFIGMAP_NAME = "pieced-openclaw-config";
|
||||
|
||||
/**
|
||||
* Operator namespace. Reads the env var so the portal can be deployed in
|
||||
* non-default namespaces without code changes; defaults to "pieced-system"
|
||||
* matching the operator's chart default.
|
||||
*/
|
||||
function getOperatorNamespace(): string {
|
||||
return process.env.OPERATOR_NAMESPACE ?? "pieced-system";
|
||||
}
|
||||
|
||||
export interface OpenClawDefaults {
|
||||
/** Image tag (e.g. "2026.4.22"). Empty string means unset. */
|
||||
defaultTag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the platform-default OpenClaw image tag. Returns empty string
|
||||
* if unset, and `{ defaultTag: "" }` if the ConfigMap doesn't exist yet
|
||||
* — the operator's built-in fallback is invisible to the portal by
|
||||
* design (we don't want the UI to claim "current default: 2026.x" when
|
||||
* it's actually the operator binary's baked-in version; that would be
|
||||
* misleading once the binary updates).
|
||||
*/
|
||||
export async function getOpenClawDefaults(): Promise<OpenClawDefaults> {
|
||||
const ns = getOperatorNamespace();
|
||||
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json", ...getAuthHeaders() },
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return { defaultTag: "" };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(
|
||||
`K8s GET configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
|
||||
);
|
||||
(err as any).statusCode = res.status;
|
||||
throw err;
|
||||
}
|
||||
const cm = (await res.json()) as { data?: Record<string, string> };
|
||||
return { defaultTag: cm.data?.defaultTag ?? "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the platform-default OpenClaw image tag. Empty string clears
|
||||
* the field (operator falls back to its built-in default).
|
||||
*
|
||||
* Creates the ConfigMap if it doesn't exist (PATCH on missing resource
|
||||
* 404s; we retry as POST). Keeps the admin UI usable on a fresh install
|
||||
* where the helm-shipped CM was deleted or never created.
|
||||
*/
|
||||
export async function setOpenClawDefaults(
|
||||
defaults: OpenClawDefaults
|
||||
): Promise<OpenClawDefaults> {
|
||||
const ns = getOperatorNamespace();
|
||||
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
|
||||
const patch = { data: { defaultTag: defaults.defaultTag } };
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/merge-patch+json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
const createUrl = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps`;
|
||||
const createRes = await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: { name: OPENCLAW_CONFIGMAP_NAME, namespace: ns },
|
||||
data: patch.data,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const text = await createRes.text();
|
||||
throw new Error(
|
||||
`K8s POST configmap ${OPENCLAW_CONFIGMAP_NAME}: ${createRes.status} ${text}`
|
||||
);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(
|
||||
`K8s PATCH configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
|
||||
);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,94 @@ export async function listTeams(): Promise<any[]> {
|
||||
return Array.isArray(data) ? data : data?.data ?? data?.teams ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a virtual key on a team by its alias and return its current
|
||||
* state (token, spend, budget cap, reset cadence). Returns null if
|
||||
* the alias doesn't match any key on the team.
|
||||
*
|
||||
* Why we need this
|
||||
* ----------------
|
||||
* Per-tenant budgets live on the virtual key, not the team. The
|
||||
* portal needs to:
|
||||
* 1. Display the current key's `max_budget` / `budget_duration` /
|
||||
* `spend` on the tenant detail page.
|
||||
* 2. Pass the key's `token` to `/key/update` when the customer
|
||||
* changes the budget.
|
||||
*
|
||||
* The token is opaque to the customer; the operator's
|
||||
* `FindKeyByAlias` does the same lookup for stale-key cleanup. We
|
||||
* mirror its API call here.
|
||||
*/
|
||||
export async function findKeyByAlias(
|
||||
teamId: string,
|
||||
keyAlias: string
|
||||
): Promise<{
|
||||
token: string;
|
||||
spend: number;
|
||||
maxBudget: number | null;
|
||||
budgetDuration: string | null;
|
||||
} | null> {
|
||||
const data = await litellmFetch(
|
||||
`/key/list?team_id=${encodeURIComponent(teamId)}&return_full_object=true&include_team_keys=true`
|
||||
);
|
||||
const keys: any[] = Array.isArray(data?.keys)
|
||||
? data.keys
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
for (const k of keys) {
|
||||
if (typeof k !== "object" || k === null) continue;
|
||||
const alias = k.key_alias ?? k.keyAlias;
|
||||
if (alias !== keyAlias) continue;
|
||||
if (typeof k.token !== "string" || !k.token) continue;
|
||||
return {
|
||||
token: k.token,
|
||||
spend: typeof k.spend === "number" ? k.spend : Number(k.spend) || 0,
|
||||
maxBudget:
|
||||
typeof k.max_budget === "number"
|
||||
? k.max_budget
|
||||
: k.max_budget == null
|
||||
? null
|
||||
: Number(k.max_budget) || null,
|
||||
budgetDuration:
|
||||
typeof k.budget_duration === "string" ? k.budget_duration : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a virtual key's budget cap and reset duration.
|
||||
*
|
||||
* Pass `maxBudget: null` to remove the cap. Pass `budgetDuration:
|
||||
* null` to make the budget never reset (lifetime cap).
|
||||
*
|
||||
* Identified by `key` parameter — accepts either the raw `sk-...`
|
||||
* token or its hash (LiteLLM accepts both shapes on /key/update).
|
||||
* The portal flow uses the hash returned by `findKeyByAlias`.
|
||||
*/
|
||||
export async function updateKeyBudget(
|
||||
key: string,
|
||||
changes: {
|
||||
maxBudget?: number | null;
|
||||
budgetDuration?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const body: Record<string, any> = { key };
|
||||
if (changes.maxBudget !== undefined) {
|
||||
body.max_budget = changes.maxBudget;
|
||||
}
|
||||
if (changes.budgetDuration !== undefined) {
|
||||
body.budget_duration = changes.budgetDuration;
|
||||
}
|
||||
await litellmFetch("/key/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiteLLM health status.
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
/**
|
||||
* Portal-side package catalog. Hardcoded mirror of the operator-side
|
||||
* catalog ConfigMap (deploy/helm/pieced-operator/templates/catalog-cm.yaml).
|
||||
*
|
||||
* The two have to stay in sync:
|
||||
* - `id` here must match the catalog key in the ConfigMap.
|
||||
* - `secrets[].key` here must match the catalog's env_vars[].secret_key
|
||||
* so that POST /api/tenants/:name/secrets writes to the same OpenBao
|
||||
* path the operator's ExternalSecret reads from.
|
||||
* - `requiresSecrets` is true when the catalog declares any env_var
|
||||
* that is a secret (vault_path_suffix set, no default value).
|
||||
*
|
||||
* Category model (Phase A rework):
|
||||
* - core — platform-behaviour toggles (heartbeat, cron,
|
||||
* active-memory, voice). Mostly no secrets. core-voice is
|
||||
* fully wired (Phase B): toggling installs the STT / TTS /
|
||||
* Talk surface via the operator's config_patch, routed
|
||||
* through LiteLLM (pieced-stt, pieced-tts-inbound,
|
||||
* pieced-tts-talk).
|
||||
* - channel — messaging integration.
|
||||
* - skill — ClawHub skill install.
|
||||
*
|
||||
* Custom provisioning (Threema):
|
||||
* The `threema` channel sets `requiresSecrets: false` because its
|
||||
* credentials are platform-issued, not customer-entered. Enabling
|
||||
* threema goes through a dedicated endpoint
|
||||
* (/api/tenants/:name/threema) that mints token + HMAC secret from
|
||||
* the central pieced-threema-gateway relay and writes them to OpenBao
|
||||
* at secret/data/tenants/<name>/threema-relay before the package is
|
||||
* added to spec.packages. Disabling reverses both steps. The
|
||||
* `customProvisioning` flag here tells the package-card UI to use
|
||||
* that endpoint instead of the standard /secrets+PATCH dance.
|
||||
*/
|
||||
|
||||
export interface PackageSecretField {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
placeholderKey: string;
|
||||
}
|
||||
|
||||
export type PackageCategory = "core" | "channel" | "skill";
|
||||
|
||||
export interface PackageDef {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -12,10 +48,53 @@ export interface PackageDef {
|
||||
secrets?: PackageSecretField[];
|
||||
instructionsKey?: string;
|
||||
disclaimerKey?: string;
|
||||
category: "channel" | "skill";
|
||||
category: PackageCategory;
|
||||
/**
|
||||
* When true, enabling/disabling this package goes through
|
||||
* /api/tenants/:name/<id> (POST/DELETE) instead of the generic
|
||||
* /secrets+PATCH flow. The handler at that path does platform-side
|
||||
* provisioning (mint credentials, register with sibling services, etc.)
|
||||
* that the customer is not aware of.
|
||||
*/
|
||||
customProvisioning?: boolean;
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
// -------------------------------------------------------------------------
|
||||
// CORE
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: "core-heartbeat",
|
||||
name: "Heartbeat (Proactive Checks)",
|
||||
descriptionKey: "packages.coreHeartbeat.description",
|
||||
requiresSecrets: false,
|
||||
category: "core",
|
||||
},
|
||||
{
|
||||
id: "core-cron",
|
||||
name: "Scheduled Tasks (Cron)",
|
||||
descriptionKey: "packages.coreCron.description",
|
||||
requiresSecrets: false,
|
||||
category: "core",
|
||||
},
|
||||
{
|
||||
id: "core-active-memory",
|
||||
name: "Active Memory",
|
||||
descriptionKey: "packages.coreActiveMemory.description",
|
||||
requiresSecrets: false,
|
||||
category: "core",
|
||||
},
|
||||
{
|
||||
id: "core-voice",
|
||||
name: "Voice Interaction",
|
||||
descriptionKey: "packages.coreVoice.description",
|
||||
requiresSecrets: false,
|
||||
category: "core",
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CHANNELS
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
@@ -43,42 +122,201 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
labelKey: "packages.discord.botTokenLabel",
|
||||
placeholderKey: "packages.discord.botTokenPlaceholder",
|
||||
},
|
||||
// app-id was missing from the portal catalog historically while the
|
||||
// operator catalog declared DISCORD_APP_ID as a required env var.
|
||||
// Tenants who enabled Discord ended up with the env var blank
|
||||
// because the secrets POST never wrote an `app-id` key to OpenBao
|
||||
// and the operator's ExternalSecret couldn't populate it. Added
|
||||
// here as part of the Phase A rework to close the alignment gap;
|
||||
// not strictly secret (the application ID is visible in the bot's
|
||||
// profile URL) but stored alongside the bot token for convenience.
|
||||
{
|
||||
key: "app-id",
|
||||
labelKey: "packages.discord.appIdLabel",
|
||||
placeholderKey: "packages.discord.appIdPlaceholder",
|
||||
},
|
||||
],
|
||||
instructionsKey: "packages.discord.instructions",
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
name: "Email",
|
||||
descriptionKey: "packages.email.description",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{ key: "smtp-host", labelKey: "packages.email.smtpHostLabel", placeholderKey: "packages.email.smtpHostPlaceholder" },
|
||||
{ key: "smtp-user", labelKey: "packages.email.smtpUserLabel", placeholderKey: "packages.email.smtpUserPlaceholder" },
|
||||
{ key: "smtp-password", labelKey: "packages.email.smtpPasswordLabel", placeholderKey: "packages.email.smtpPasswordPlaceholder" },
|
||||
{ key: "imap-host", labelKey: "packages.email.imapHostLabel", placeholderKey: "packages.email.imapHostPlaceholder" },
|
||||
],
|
||||
instructionsKey: "packages.email.instructions",
|
||||
disclaimerKey: "packages.email.disclaimer",
|
||||
id: "threema",
|
||||
name: "Threema",
|
||||
descriptionKey: "packages.threema.description",
|
||||
// No customer-entered secrets. The token + hmac secret are minted
|
||||
// server-side by the relay's /admin/tokens endpoint when the
|
||||
// package is enabled, and stored in OpenBao by the portal. The
|
||||
// `customProvisioning` flag steers the PackageCard UI through the
|
||||
// dedicated /api/tenants/:name/threema endpoint instead.
|
||||
requiresSecrets: false,
|
||||
customProvisioning: true,
|
||||
instructionsKey: "packages.threema.instructions",
|
||||
disclaimerKey: "packages.threema.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SKILLS
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: "web-search",
|
||||
name: "Web Search",
|
||||
descriptionKey: "packages.webSearch.description",
|
||||
id: "git-cli",
|
||||
name: "Git CLI",
|
||||
descriptionKey: "packages.gitCli.description",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "document-processing",
|
||||
name: "Document Processing",
|
||||
descriptionKey: "packages.documentProcessing.description",
|
||||
id: "github",
|
||||
name: "GitHub (gh CLI)",
|
||||
descriptionKey: "packages.github.description",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "token",
|
||||
labelKey: "packages.github.tokenLabel",
|
||||
placeholderKey: "packages.github.tokenPlaceholder",
|
||||
},
|
||||
],
|
||||
instructionsKey: "packages.github.instructions",
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "gitea",
|
||||
name: "Gitea",
|
||||
descriptionKey: "packages.gitea.description",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "token",
|
||||
labelKey: "packages.gitea.tokenLabel",
|
||||
placeholderKey: "packages.gitea.tokenPlaceholder",
|
||||
},
|
||||
],
|
||||
instructionsKey: "packages.gitea.instructions",
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "whisper-self-hosted",
|
||||
name: "Whisper (Self-Hosted Transcription)",
|
||||
descriptionKey: "packages.whisperSelfHosted.description",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "searxng-local-search",
|
||||
name: "Web Search (SearXNG)",
|
||||
descriptionKey: "packages.searxngLocalSearch.description",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "gog",
|
||||
name: "Google Workspace (Gog)",
|
||||
descriptionKey: "packages.gog.description",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "client-id",
|
||||
labelKey: "packages.gog.clientIdLabel",
|
||||
placeholderKey: "packages.gog.clientIdPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "client-secret",
|
||||
labelKey: "packages.gog.clientSecretLabel",
|
||||
placeholderKey: "packages.gog.clientSecretPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "refresh-token",
|
||||
labelKey: "packages.gog.refreshTokenLabel",
|
||||
placeholderKey: "packages.gog.refreshTokenPlaceholder",
|
||||
},
|
||||
],
|
||||
instructionsKey: "packages.gog.instructions",
|
||||
disclaimerKey: "packages.gog.disclaimer",
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "mail",
|
||||
name: "Email (IMAP / SMTP)",
|
||||
descriptionKey: "packages.mail.description",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "imap-host",
|
||||
labelKey: "packages.mail.imapHostLabel",
|
||||
placeholderKey: "packages.mail.imapHostPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "imap-user",
|
||||
labelKey: "packages.mail.imapUserLabel",
|
||||
placeholderKey: "packages.mail.imapUserPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "imap-pass",
|
||||
labelKey: "packages.mail.imapPassLabel",
|
||||
placeholderKey: "packages.mail.imapPassPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "smtp-host",
|
||||
labelKey: "packages.mail.smtpHostLabel",
|
||||
placeholderKey: "packages.mail.smtpHostPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "smtp-user",
|
||||
labelKey: "packages.mail.smtpUserLabel",
|
||||
placeholderKey: "packages.mail.smtpUserPlaceholder",
|
||||
},
|
||||
{
|
||||
key: "smtp-pass",
|
||||
labelKey: "packages.mail.smtpPassLabel",
|
||||
placeholderKey: "packages.mail.smtpPassPlaceholder",
|
||||
},
|
||||
],
|
||||
instructionsKey: "packages.mail.instructions",
|
||||
disclaimerKey: "packages.mail.disclaimer",
|
||||
category: "skill",
|
||||
},
|
||||
];
|
||||
|
||||
export function getPackageDef(id: string): PackageDef | undefined {
|
||||
return PACKAGE_CATALOG.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of channel-category packages. Derived from the catalog so it
|
||||
* cannot drift from the source of truth (previously hardcoded as
|
||||
* `["telegram", "discord", "email"]` in tenants/[name]/page.tsx —
|
||||
* removed as part of the Phase A package-model rework).
|
||||
*
|
||||
* Consumers: tenant detail page (filter spec.packages to channel set
|
||||
* before rendering the channel-users panel).
|
||||
*/
|
||||
export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
|
||||
.filter((p) => p.category === "channel")
|
||||
.map((p) => p.id);
|
||||
|
||||
/**
|
||||
* Default packages selected when the wizard opens a fresh onboarding
|
||||
* request. The three CORE behaviours that make the assistant feel
|
||||
* "smart out of the box":
|
||||
* - heartbeat: proactive checks (otherwise the assistant is purely
|
||||
* reactive).
|
||||
* - cron: scheduled tasks (daily briefings, reminders).
|
||||
* - active-memory: long-term recall of stable preferences and habits.
|
||||
*
|
||||
* Each adds some token cost — active-memory the most (one extra
|
||||
* sub-agent turn per inbound message) — so customers can untoggle any
|
||||
* of them before submitting.
|
||||
*
|
||||
* core-voice is intentionally NOT a default. It is fully wired (Phase B)
|
||||
* and customers can enable it from the wizard, but it incurs separate
|
||||
* audio spend on every inbound voice note (Whisper STT) and every
|
||||
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
|
||||
* cost predictable for tenants who don't intend to use voice channels.
|
||||
*/
|
||||
export const DEFAULT_PACKAGE_IDS: string[] = [
|
||||
"core-heartbeat",
|
||||
"core-cron",
|
||||
"core-active-memory",
|
||||
];
|
||||
|
||||
33
src/lib/threema-gateway-config.ts
Normal file
33
src/lib/threema-gateway-config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Threema central gateway info shown to customers.
|
||||
*
|
||||
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
|
||||
* every tenant talks to that. The constants below are hardcoded for
|
||||
* that account. The values are intentionally kept here (and not split
|
||||
* across i18n / env / runtime config) so when we move to multiple
|
||||
* gateway accounts there's a single file to refactor.
|
||||
*
|
||||
* To go dynamic (future):
|
||||
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
|
||||
* either per-tenant from the relay's admin API, or from an
|
||||
* env var that lists the active account.
|
||||
* 2. Move the QR PNG into a server-rendered route that takes a
|
||||
* gateway ID query param.
|
||||
* 3. Update consumers (today only ThreemaSetup) to accept the
|
||||
* gateway info as a prop and pass it from a server component.
|
||||
*
|
||||
* In display contexts we strip the leading asterisk from the Threema
|
||||
* ID — customers don't understand the `*X` prefix convention used for
|
||||
* Gateway accounts, and the QR code carries the real value anyway. We
|
||||
* keep the asterisk only for places where the technical value matters
|
||||
* (server-side message routing, debug logs).
|
||||
*/
|
||||
|
||||
export const THREEMA_GATEWAY = {
|
||||
/** Technical Threema Gateway ID, with leading asterisk. */
|
||||
id: "*AIAGENT",
|
||||
/** Display name shown to customers (no asterisk). */
|
||||
displayName: "AIAGENT",
|
||||
/** Public path to the QR code PNG served from `public/`. */
|
||||
qrCodePath: "/threema/qr_code_AIAGENT.png",
|
||||
} as const;
|
||||
202
src/lib/threema-relay.ts
Normal file
202
src/lib/threema-relay.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Admin client for the central pieced-threema-gateway relay.
|
||||
*
|
||||
* All calls authenticate with a static admin bearer (THREEMA_RELAY_ADMIN_TOKEN
|
||||
* from env, sourced from OpenBao at secret/data/threema-gateway/admin). The
|
||||
* portal is the only caller; never expose these functions through a public
|
||||
* HTTP surface.
|
||||
*
|
||||
* Resilience: every method returns a strongly-typed result rather than
|
||||
* throwing. Network errors surface as `{ ok: false, kind: 'network' }`,
|
||||
* 4xx/5xx as `{ ok: false, kind: 'http', status, ... }`. Callers that need
|
||||
* to compensate (e.g. delete a route after a K8s patch failure) can
|
||||
* inspect `kind` to decide whether retry is sensible.
|
||||
*/
|
||||
|
||||
const RELAY_URL = (process.env.THREEMA_RELAY_URL ?? "").replace(/\/+$/, "");
|
||||
const ADMIN_TOKEN = process.env.THREEMA_RELAY_ADMIN_TOKEN ?? "";
|
||||
|
||||
function assertConfigured(): void {
|
||||
if (!RELAY_URL) throw new Error("THREEMA_RELAY_URL not set");
|
||||
if (!ADMIN_TOKEN) throw new Error("THREEMA_RELAY_ADMIN_TOKEN not set");
|
||||
}
|
||||
|
||||
type RelayError =
|
||||
| { ok: false; kind: "network"; message: string }
|
||||
| { ok: false; kind: "http"; status: number; message: string; body?: unknown };
|
||||
|
||||
export type RelayResult<T> = ({ ok: true } & T) | RelayError;
|
||||
|
||||
async function call<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
): Promise<RelayResult<T>> {
|
||||
assertConfigured();
|
||||
try {
|
||||
const res = await fetch(`${RELAY_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
let body: unknown = null;
|
||||
const text = await res.text().catch(() => "");
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(body && typeof body === "object" && "error" in body && typeof (body as Record<string, unknown>).error === "string"
|
||||
? ((body as Record<string, unknown>).error as string)
|
||||
: `HTTP ${res.status}`);
|
||||
return { ok: false, kind: "http", status: res.status, message, body };
|
||||
}
|
||||
return { ok: true, ...(body as Record<string, unknown>) } as RelayResult<T>;
|
||||
} catch (e) {
|
||||
return { ok: false, kind: "network", message: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mint (or rotate) per-tenant bearer + HMAC secret. */
|
||||
export function mintToken(
|
||||
tenantName: string,
|
||||
): Promise<RelayResult<{ token: string; hmacSecret: string }>> {
|
||||
return call("/admin/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tenantName }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke per-tenant token. Cascades — relay also deletes all routes
|
||||
* owned by this tenant.
|
||||
*/
|
||||
export function revokeToken(
|
||||
tenantName: string,
|
||||
): Promise<RelayResult<{ deletedRoutes: number }>> {
|
||||
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function tokenExists(
|
||||
tenantName: string,
|
||||
): Promise<RelayResult<{ exists: true }>> {
|
||||
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RouteRow {
|
||||
threema_id: string;
|
||||
tenant_name: string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
export function listRoutes(
|
||||
tenantName: string,
|
||||
): Promise<RelayResult<{ routes: RouteRow[] }>> {
|
||||
return call(`/admin/routes?tenant=${encodeURIComponent(tenantName)}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route. The relay enforces uniqueness — on conflict returns
|
||||
* status 409 with body `{ ownedBy: <tenantName> | null }`. Callers
|
||||
* should inspect `kind === 'http'` + status 409 + body.ownedBy to
|
||||
* distinguish idempotent self-claim from real conflict.
|
||||
*/
|
||||
export function createRoute(
|
||||
tenantName: string,
|
||||
threemaId: string,
|
||||
): Promise<RelayResult<{ ok: true }>> {
|
||||
return call("/admin/routes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tenantName, threemaId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRoute(
|
||||
tenantName: string,
|
||||
threemaId: string,
|
||||
): Promise<RelayResult<{ ok: true }>> {
|
||||
return call(
|
||||
`/admin/routes/${encodeURIComponent(threemaId)}?tenant=${encodeURIComponent(tenantName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageBreakdown {
|
||||
tenant: string;
|
||||
from: string;
|
||||
to: string;
|
||||
totals: { in: number; out: number };
|
||||
daily: Array<{ day: string; direction: "in" | "out"; count: number }>;
|
||||
}
|
||||
|
||||
export function getUsage(
|
||||
tenantName: string,
|
||||
from?: Date,
|
||||
to?: Date,
|
||||
): Promise<RelayResult<UsageBreakdown>> {
|
||||
const qs = new URLSearchParams({ tenant: tenantName });
|
||||
if (from) qs.set("from", from.toISOString());
|
||||
if (to) qs.set("to", to.toISOString());
|
||||
return call(`/admin/usage?${qs.toString()}`, { method: "GET" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function health(): Promise<RelayResult<{ credits: number }>> {
|
||||
return call("/admin/health", { method: "GET" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for caller code
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Type guard: did this route-create fail because the ID is owned by
|
||||
* someone else? Distinguishes from "owned by SAME tenant" (idempotent).
|
||||
*/
|
||||
export function isRouteConflictForOtherTenant(
|
||||
result: RelayResult<{ ok: true }>,
|
||||
tenantName: string,
|
||||
): boolean {
|
||||
if (result.ok) return false;
|
||||
if (result.kind !== "http" || result.status !== 409) return false;
|
||||
const body = result.body as { ownedBy?: string | null } | undefined;
|
||||
return !!body?.ownedBy && body.ownedBy !== tenantName;
|
||||
}
|
||||
|
||||
export function isRouteConflictForSameTenant(
|
||||
result: RelayResult<{ ok: true }>,
|
||||
tenantName: string,
|
||||
): boolean {
|
||||
if (result.ok) return false;
|
||||
if (result.kind !== "http" || result.status !== 409) return false;
|
||||
const body = result.body as { ownedBy?: string | null } | undefined;
|
||||
return body?.ownedBy === tenantName;
|
||||
}
|
||||
@@ -86,6 +86,13 @@ export const billingAddressSchema = z
|
||||
country: z.enum(SUPPORTED_COUNTRIES, {
|
||||
message: "Please choose a country from the list",
|
||||
}),
|
||||
// Bug 35: VAT identifier. Required for company customers (B2B);
|
||||
// omitted entirely for personal customers (B2C — private
|
||||
// individuals don't have a VAT number). The schema marks it
|
||||
// optional because the same schema is used for both flows;
|
||||
// company-vs-personal enforcement happens at the API layer where
|
||||
// `user.isPersonal` is known.
|
||||
vatNumber: z.string().trim().max(50).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const pattern = POSTAL_CODE_PATTERNS[data.country];
|
||||
@@ -123,6 +130,12 @@ export const billingStepSchema = z.object({
|
||||
* Full onboarding payload. Used by the API route and by the wizard's
|
||||
* submit handler. `packageSecrets` is a free-shape map that gets
|
||||
* encrypted by the server before it touches the DB.
|
||||
*
|
||||
* Bug 35: `billingAddress` is now optional at the schema level. The
|
||||
* wizard omits it entirely when the org already has an `org_billing`
|
||||
* record. The API enforces "billing must exist by the end" by either
|
||||
* looking up the existing org_billing row OR validating the supplied
|
||||
* payload — neither path can be skipped without a 400.
|
||||
*/
|
||||
export const onboardingSchema = z.object({
|
||||
instanceName: z
|
||||
@@ -139,7 +152,7 @@ export const onboardingSchema = z.object({
|
||||
packageSecrets: z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional(),
|
||||
billingAddress: billingAddressSchema,
|
||||
billingAddress: billingAddressSchema.optional(),
|
||||
billingNotes: z.string().max(2_000).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"save": "Speichern",
|
||||
"error": "Ein Fehler ist aufgetreten",
|
||||
"register": "Registrieren",
|
||||
"team": "Team"
|
||||
"team": "Team",
|
||||
"settings": "Einstellungen",
|
||||
"optional": "optional",
|
||||
"support": "Support"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
@@ -114,7 +117,10 @@
|
||||
"dismiss": "Ausblenden",
|
||||
"dismissFailed": "Konnte nicht ausgeblendet werden.",
|
||||
"rejectionReason": "Angegebener Grund",
|
||||
"saveChanges": "Änderungen speichern"
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"billingVatNumber": "MWST-Nummer",
|
||||
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
|
||||
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -157,7 +163,21 @@
|
||||
"cancelConfirmBullet3": "Rechnungsdaten bleiben gespeichert",
|
||||
"subscriptionUpdateFailed": "Abonnement konnte nicht aktualisiert werden.",
|
||||
"suspendedTitle": "Abonnement gekündigt",
|
||||
"suspendedDescription": "Ihr Assistent ist pausiert. Konfiguration und Daten bleiben erhalten. Verwenden Sie die Reaktivierungs-Schaltfläche unten auf dieser Seite, um ihn wieder online zu bringen."
|
||||
"suspendedDescription": "Ihr Assistent ist pausiert. Konfiguration und Daten bleiben erhalten. Verwenden Sie die Reaktivierungs-Schaltfläche unten auf dieser Seite, um ihn wieder online zu bringen.",
|
||||
"requestReactivation": "Reaktivierung anfragen",
|
||||
"requestReactivationConfirmTitle": "Reaktivierung anfragen?",
|
||||
"requestReactivationConfirmDescription": "Ein Administrator prüft Ihre Anfrage und reaktiviert Ihren Tenant. Sie erhalten eine E-Mail, sobald die Anfrage genehmigt wurde.",
|
||||
"requestReactivationConfirm": "Anfrage senden",
|
||||
"cancelResumeRequest": "Anfrage stornieren",
|
||||
"resumeRequestPendingTitle": "Reaktivierungsanfrage ausstehend",
|
||||
"resumeRequestPendingDescription": "Eingereicht {when}. Ein Administrator wird die Anfrage in Kürze prüfen.",
|
||||
"resumeRequestPendingNoteAdmin": "Ein Inhaber hat eine Reaktivierung angefragt; Sie können direkt oben fortfahren oder die Anfrage in der Admin-Warteschlange bearbeiten.",
|
||||
"cancelConfirmRetentionWarning": "Ihre Daten bleiben nach der Kündigung 60 Tage lang erhalten. Danach werden alle Tenant-Daten – Konfiguration, Geheimnisse, Konversationen und Dateien – endgültig gelöscht.",
|
||||
"suspendedSince": "Gekündigt am {date}",
|
||||
"suspendedDeletionIn": "Datenlöschung in {days, plural, one {# Tag} other {# Tagen}} ({date})",
|
||||
"suspendedDeletionImminent": "Daten werden jetzt gelöscht",
|
||||
"requestReactivationNoteLabel": "Notiz an unser Team",
|
||||
"requestReactivationNotePlaceholder": "Alles, was unser Team wissen sollte – z. B. Grund der Reaktivierung, Dringlichkeit usw."
|
||||
},
|
||||
"usage": {
|
||||
"inputTokens": "Input-Tokens",
|
||||
@@ -169,7 +189,21 @@
|
||||
"last30Days": "Letzte 30 Tage",
|
||||
"noData": "Keine Nutzungsdaten verfügbar.",
|
||||
"dailyBreakdown": "Tagesübersicht",
|
||||
"requests": "Anfragen"
|
||||
"requests": "Anfragen",
|
||||
"budgetEdit": "Bearbeiten",
|
||||
"budgetEditTitle": "Budget festlegen",
|
||||
"budgetEditDescription": "Begrenzen Sie, wie viel die Assistenten dieses Tenants ausgeben können, bevor Anfragen abgelehnt werden.",
|
||||
"budgetModeUnlimited": "Kein Limit",
|
||||
"budgetModeUnlimitedDescription": "Beliebige Ausgaben, kein Limit.",
|
||||
"budgetModeCapped": "Limit festlegen",
|
||||
"budgetModeCappedDescription": "Anfragen ablehnen, sobald die Ausgaben diesen Betrag erreichen.",
|
||||
"budgetAmount": "Betrag",
|
||||
"budgetResetCadence": "Zurücksetzen",
|
||||
"budgetCadence_30d": "Alle 30 Tage",
|
||||
"budgetCadence_1mo": "Monatlich",
|
||||
"budgetCadence_1y": "Jährlich",
|
||||
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Speichern",
|
||||
@@ -180,7 +214,8 @@
|
||||
"packages": {
|
||||
"categories": {
|
||||
"channels": "Kanäle",
|
||||
"skills": "Fähigkeiten"
|
||||
"skills": "Fähigkeiten",
|
||||
"core": "Kern"
|
||||
},
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
@@ -205,29 +240,78 @@
|
||||
"botTokenLabel": "Discord Bot Token",
|
||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||
"instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine neue Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token",
|
||||
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden."
|
||||
},
|
||||
"email": {
|
||||
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
|
||||
"smtpHostLabel": "SMTP Host",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "SMTP Benutzername",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "SMTP Passwort",
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "IMAP Host",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.",
|
||||
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und dass PieCed IT auf dieses Postfach zugreifen darf."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
|
||||
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden.",
|
||||
"appIdLabel": "Discord-Anwendungs-ID",
|
||||
"appIdPlaceholder": "18–19-stellige numerische ID aus dem Developer Portal"
|
||||
},
|
||||
"statusEnabled": "aktiviert",
|
||||
"statusDisabled": "deaktiviert"
|
||||
"statusDisabled": "deaktiviert",
|
||||
"coreHeartbeat": {
|
||||
"description": "Periodischer Agentenlauf alle 30 Minuten, der es dem Assistenten erlaubt, Posteingang, Kalender und andere konfigurierte Quellen zu prüfen und proaktiv Bescheid zu geben, wenn etwas Aufmerksamkeit braucht. Ohne diese Option reagiert der Assistent nur, wenn Sie ihn ansprechen."
|
||||
},
|
||||
"coreCron": {
|
||||
"description": "Erlaubt dem Assistenten, geplante Aufgaben auszuführen (tägliche Briefings, wiederkehrende Erinnerungen, periodische Berichte). Standardmässig deaktiviert. Bei Deaktivierung bleibt das Cron-Werkzeug verfügbar, aber keine geplante Aufgabe wird ausgeführt."
|
||||
},
|
||||
"coreActiveMemory": {
|
||||
"description": "Erlaubt dem Assistenten, stabile Präferenzen, wiederkehrende Gewohnheiten und langfristigen Kontext aus früheren Gesprächen abzurufen. Nutzt einen zusätzlichen Sub-Agent-Lauf pro eingehender Nachricht, um den Memory-Store abzufragen. Nur Direktnachrichten. Kleiner Mehraufwand an Tokens im Tausch gegen Kontinuität und Personalisierung."
|
||||
},
|
||||
"coreVoice": {
|
||||
"description": "Spracherkennung für eingehende Sprachnachrichten und Sprachsynthese für Antworten, über das PieCed-LiteLLM-Gateway, damit Audiokosten pro Mandant erfasst werden. Die Laufzeit-Integration kommt im nächsten Plattform-Release; das Umschalten speichert die Auswahl für diese Auslieferung."
|
||||
},
|
||||
"gitCli": {
|
||||
"description": "Eigenständige Git-Kommandozeilenoperationen (clone, commit, branch, diff, log, status). Für private Repositories konfigurieren Sie die Zugangsdaten in Ihrem Workspace."
|
||||
},
|
||||
"github": {
|
||||
"description": "Interaktion mit GitHub-Repositories über die gh-CLI — Issues, Pull Requests, CI-Läufe, Releases, Gists. Erfordert ein persönliches Zugriffstoken.",
|
||||
"tokenLabel": "GitHub Persönliches Zugriffstoken",
|
||||
"tokenPlaceholder": "ghp_… oder github_pat_…",
|
||||
"instructions": "1. Öffnen Sie https://github.com/settings/tokens\n2. Erstellen Sie ein fein abgestimmtes persönliches Zugriffstoken mit den gewünschten Repo-Berechtigungen\n3. Kopieren Sie das Token (es wird nur einmal angezeigt)"
|
||||
},
|
||||
"gitea": {
|
||||
"description": "Interaktion mit einer Gitea-Instanz — Repositories, Issues, Pull Requests, Releases. Standardmässig die PieCed-Plattform-Gitea unter git.c5ai.ch.",
|
||||
"tokenLabel": "Gitea-Zugriffstoken",
|
||||
"tokenPlaceholder": "Erstellt unter Einstellungen → Anwendungen",
|
||||
"instructions": "1. Melden Sie sich bei Ihrer Gitea-Instanz an (Standard https://git.c5ai.ch)\n2. Gehen Sie zu Einstellungen → Anwendungen → Neues Token erstellen\n3. Vergeben Sie die gewünschten Berechtigungen (repo, issue, user)\n4. Kopieren Sie das Token"
|
||||
},
|
||||
"whisperSelfHosted": {
|
||||
"description": "Transkribieren Sie Audiodateien über die plattformeigene Whisper-Instanz. Nützlich für Ad-hoc-Transkriptionsaufgaben aus dem Chat heraus."
|
||||
},
|
||||
"searxngLocalSearch": {
|
||||
"description": "Datenschutzfreundliche Web-Suche über die interne SearXNG-Instanz der Plattform. Durchsuchen Sie Web, Bilder und News ohne externe API-Aufrufe oder Tracker."
|
||||
},
|
||||
"gog": {
|
||||
"description": "Gebündelter Zugriff auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte via Google OAuth. Setup erfordert ein Google-Cloud-Projekt — wenden Sie sich an den PieCed-Support für die Einrichtung.",
|
||||
"clientIdLabel": "Google OAuth Client-ID",
|
||||
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
|
||||
"clientSecretLabel": "Google OAuth Client-Secret",
|
||||
"clientSecretPlaceholder": "GOCSPX-…",
|
||||
"refreshTokenLabel": "Google OAuth Refresh-Token",
|
||||
"refreshTokenPlaceholder": "1//0g…",
|
||||
"instructions": "Die Google-Workspace-Integration verwendet OAuth und erfordert derzeit manuelles Onboarding. Bitte eröffnen Sie ein Support-Ticket, um den Setup-Prozess zu starten — wir tauschen die Client-Zugangsdaten und ein Refresh-Token offline aus und aktivieren dann dieses Paket für Ihren Mandanten.",
|
||||
"disclaimer": "Mit der Aktivierung der Google-Workspace-Integration autorisieren Sie PieCed, in Ihrem Namen auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte zuzugreifen. Daten fliessen über die Google-APIs, vorbehaltlich der Google-Bedingungen."
|
||||
},
|
||||
"mail": {
|
||||
"description": "E-Mails über IMAP lesen, suchen und verwalten; senden über SMTP. Funktioniert mit Gmail (mit App-Passwort), Outlook, Fastmail und jedem standardkonformen IMAP/SMTP-Host.",
|
||||
"imapHostLabel": "IMAP-Host",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"imapUserLabel": "IMAP-Benutzername",
|
||||
"imapUserPlaceholder": "benutzer@example.com",
|
||||
"imapPassLabel": "IMAP-Passwort",
|
||||
"imapPassPlaceholder": "••••••••",
|
||||
"smtpHostLabel": "SMTP-Host",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "SMTP-Benutzername",
|
||||
"smtpUserPlaceholder": "benutzer@example.com",
|
||||
"smtpPassLabel": "SMTP-Passwort",
|
||||
"smtpPassPlaceholder": "••••••••",
|
||||
"instructions": "1. Für Gmail: Aktivieren Sie die 2-Faktor-Authentifizierung, erstellen Sie dann unter https://myaccount.google.com/apppasswords ein App-Passwort und verwenden Sie es als IMAP- und SMTP-Passwort.\n2. Für Outlook / Microsoft 365 mit MFA: Generieren Sie ein App-Passwort in den Sicherheitseinstellungen Ihres Kontos.\n3. Für andere Anbieter: Konsultieren Sie deren IMAP/SMTP-Dokumentation für Hostnamen und Ports.\n4. Typische IMAP-Hosts: imap.gmail.com, outlook.office365.com.\n5. Typische SMTP-Hosts: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "Der Assistent erhält Lese- und Schreibzugriff auf das von Ihnen konfigurierte Postfach. Verwenden Sie eine dedizierte Adresse anstelle eines persönlichen Postfachs, wenn Sie den Umfang einschränken möchten."
|
||||
},
|
||||
"threema": {
|
||||
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
|
||||
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
@@ -297,7 +381,10 @@
|
||||
"loadingHealth": "Statusdaten werden geladen…",
|
||||
"statusHealthy": "OK",
|
||||
"statusDown": "Ausgefallen",
|
||||
"spendChf": "Kosten (CHF)"
|
||||
"spendChf": "Kosten (CHF)",
|
||||
"resumeRequestBadge": "Wieder",
|
||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
||||
"openclawTool": "OpenClaw-Versionen"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Autorisierte Benutzer",
|
||||
@@ -309,7 +396,15 @@
|
||||
"alreadyAdded": "Diese Benutzer-ID ist bereits autorisiert.",
|
||||
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
|
||||
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
|
||||
"emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll."
|
||||
"threemaIdHelp": "Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
|
||||
"threemaSetup": {
|
||||
"title": "Assistenten zu Threema hinzufügen",
|
||||
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
|
||||
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||
"showQr": "QR anzeigen"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
@@ -365,5 +460,96 @@
|
||||
"warnings": {
|
||||
"oneTooltip": "1 Warnung",
|
||||
"manyTooltip": "{count} Warnungen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Organisationsweite Konfiguration, die für alle Ihre Tenants gilt.",
|
||||
"billingTitle": "Abrechnung",
|
||||
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.",
|
||||
"nothingForYou": "Für Ihre Rolle gibt es hier noch nichts. Inhaber können Organisationseinstellungen verwalten.",
|
||||
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Abrechnung",
|
||||
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.",
|
||||
"companyName": "Firmenname",
|
||||
"streetAddress": "Strasse",
|
||||
"postalCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"vatNumber": "MWST-Nummer",
|
||||
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).",
|
||||
"billingEmail": "Rechnungs-E-Mail",
|
||||
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.",
|
||||
"notes": "Notizen",
|
||||
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss – MWST-Befreiung, besondere Rechnungsstellung usw.",
|
||||
"save": "Speichern",
|
||||
"saved": "Gespeichert.",
|
||||
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"lastUpdated": "Zuletzt aktualisiert {when}",
|
||||
"fullName": "Voller Name",
|
||||
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"subtitle": "Erstellen Sie ein Ticket, um eine Frage zu stellen, einen Fehler zu melden oder Feedback zu geben. Antworten gehen an Ihre registrierte E-Mail-Adresse.",
|
||||
"titleAdmin": "Support-Warteschlange",
|
||||
"subtitleAdmin": "Tickets aller Kunden, neueste Aktivität zuerst.",
|
||||
"newTicket": "Neues Ticket",
|
||||
"newTicketTitle": "Support-Ticket erstellen",
|
||||
"newTicketSubtitle": "Erzählen Sie uns, was los ist. Je mehr Details, desto schneller können wir helfen.",
|
||||
"empty": "Sie haben noch keine Tickets erstellt.",
|
||||
"emptyAdmin": "Keine Support-Tickets in der Warteschlange.",
|
||||
"fieldCategory": "Kategorie",
|
||||
"fieldTitle": "Titel",
|
||||
"fieldDescription": "Beschreibung",
|
||||
"fieldStatus": "Status",
|
||||
"titlePlaceholder": "Kurze Zusammenfassung Ihres Anliegens",
|
||||
"descriptionPlaceholder": "Beschreiben Sie, was passiert ist, was Sie erwartet haben, und alle Fehlermeldungen.",
|
||||
"descriptionHelp": "Sie können Fehlermeldungen und Logs einfügen. Bitte keine Passwörter oder andere Geheimnisse.",
|
||||
"submitTicket": "Ticket senden",
|
||||
"createFailed": "Ticket konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"category_bug": "Fehler",
|
||||
"category_feature_request": "Feature-Wunsch",
|
||||
"category_question": "Frage",
|
||||
"category_billing": "Abrechnung",
|
||||
"category_other": "Sonstiges",
|
||||
"status_open": "Offen",
|
||||
"status_in_progress": "In Bearbeitung",
|
||||
"status_waiting_for_customer": "Warten auf Ihre Antwort",
|
||||
"status_resolved": "Erledigt",
|
||||
"status_reopened": "Wieder geöffnet",
|
||||
"openedBy": "Eröffnet von {name} am {when}",
|
||||
"authorTagAdmin": "PieCed-Support",
|
||||
"replyLabel": "Antwort hinzufügen",
|
||||
"replyPlaceholder": "Ihre Nachricht…",
|
||||
"replyPlaceholderReopen": "Antwort (dies öffnet das Ticket erneut)…",
|
||||
"sendReply": "Antwort senden",
|
||||
"commentFailed": "Antwort konnte nicht gesendet werden. Bitte erneut versuchen.",
|
||||
"closeTicket": "Als erledigt markieren",
|
||||
"confirmClose": "Dieses Ticket als erledigt markieren? Sie können es später durch eine Antwort wieder öffnen.",
|
||||
"closeFailed": "Ticket konnte nicht geschlossen werden. Bitte erneut versuchen.",
|
||||
"resolvedBanner": "Dieses Ticket ist erledigt. Antworten Sie unten, falls Sie nachfragen möchten — das öffnet es erneut.",
|
||||
"adminControlsTitle": "Admin-Steuerung",
|
||||
"updateFailed": "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "OpenClaw-Versionen",
|
||||
"subtitle": "Plattform-Standard-Tag und Tenant-spezifische Overrides für das Testen neuer Releases konfigurieren.",
|
||||
"defaultSection": "Plattform-Standard",
|
||||
"defaultDescription": "Wird von jedem Tenant ohne eigenen Override verwendet.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Leer lassen, um den eingebauten Operator-Standard zu verwenden.",
|
||||
"saveDefault": "Standard speichern",
|
||||
"defaultSaved": "Standard gespeichert. Tenants ohne Override übernehmen den Wert beim nächsten Reconcile.",
|
||||
"saveFailed": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"overridesSection": "Tenant-Overrides",
|
||||
"noTenants": "Keine Tenants im Cluster.",
|
||||
"statusOverridden": "Override",
|
||||
"statusFollowsDefault": "Folgt Standard",
|
||||
"builtinFallback": "(eingebauter Fallback)",
|
||||
"defaultPrefix": "Standard:",
|
||||
"saveOverride": "Override speichern",
|
||||
"clearOverride": "Override entfernen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"save": "Save",
|
||||
"error": "An error occurred",
|
||||
"register": "Register",
|
||||
"team": "Team"
|
||||
"team": "Team",
|
||||
"settings": "Settings",
|
||||
"optional": "optional",
|
||||
"support": "Support"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
@@ -114,7 +117,10 @@
|
||||
"dismiss": "Dismiss",
|
||||
"dismissFailed": "Could not dismiss.",
|
||||
"rejectionReason": "Reason given",
|
||||
"saveChanges": "Save changes"
|
||||
"saveChanges": "Save changes",
|
||||
"billingVatNumber": "VAT number",
|
||||
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
|
||||
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -157,7 +163,21 @@
|
||||
"cancelConfirmBullet3": "Billing information is kept on file",
|
||||
"subscriptionUpdateFailed": "Could not update subscription.",
|
||||
"suspendedTitle": "Subscription cancelled",
|
||||
"suspendedDescription": "Your assistant is paused. Configuration and data are preserved. Use the Resume control at the bottom of this page to bring it back online."
|
||||
"suspendedDescription": "Your assistant is paused. Configuration and data are preserved. Use the Resume control at the bottom of this page to bring it back online.",
|
||||
"requestReactivation": "Request reactivation",
|
||||
"requestReactivationConfirmTitle": "Request reactivation?",
|
||||
"requestReactivationConfirmDescription": "An administrator will review your request and reactivate your tenant. You'll be notified by email once it's approved.",
|
||||
"requestReactivationConfirm": "Submit request",
|
||||
"cancelResumeRequest": "Cancel request",
|
||||
"resumeRequestPendingTitle": "Reactivation request pending",
|
||||
"resumeRequestPendingDescription": "Submitted {when}. An administrator will review it shortly.",
|
||||
"resumeRequestPendingNoteAdmin": "An owner has requested reactivation; you can resume directly above or process the request from the admin queue.",
|
||||
"cancelConfirmRetentionWarning": "Your data is preserved for 60 days after cancellation. After that, all tenant data — configuration, secrets, conversations, and files — will be permanently deleted.",
|
||||
"suspendedSince": "Suspended on {date}",
|
||||
"suspendedDeletionIn": "data deletion in {days, plural, one {# day} other {# days}} ({date})",
|
||||
"suspendedDeletionImminent": "data is being deleted now",
|
||||
"requestReactivationNoteLabel": "Note for our team",
|
||||
"requestReactivationNotePlaceholder": "Anything our team should know — e.g. why you want to reactivate, urgency, etc."
|
||||
},
|
||||
"usage": {
|
||||
"inputTokens": "Input Tokens",
|
||||
@@ -169,7 +189,21 @@
|
||||
"last30Days": "Last 30 Days",
|
||||
"noData": "No usage data available.",
|
||||
"dailyBreakdown": "Daily Breakdown",
|
||||
"requests": "requests"
|
||||
"requests": "requests",
|
||||
"budgetEdit": "Edit",
|
||||
"budgetEditTitle": "Set spending budget",
|
||||
"budgetEditDescription": "Cap how much this tenant's assistants can spend before requests start being declined.",
|
||||
"budgetModeUnlimited": "No limit",
|
||||
"budgetModeUnlimitedDescription": "Spend as much as needed; no cap.",
|
||||
"budgetModeCapped": "Set a cap",
|
||||
"budgetModeCappedDescription": "Stop accepting requests once spend reaches this amount.",
|
||||
"budgetAmount": "Amount",
|
||||
"budgetResetCadence": "Reset",
|
||||
"budgetCadence_30d": "Every 30 days",
|
||||
"budgetCadence_1mo": "Monthly",
|
||||
"budgetCadence_1y": "Yearly",
|
||||
"budgetInvalid": "Please enter a positive amount.",
|
||||
"budgetSaveFailed": "Could not save budget. Please try again."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Save",
|
||||
@@ -180,7 +214,8 @@
|
||||
"packages": {
|
||||
"categories": {
|
||||
"channels": "Channels",
|
||||
"skills": "Skills"
|
||||
"skills": "Skills",
|
||||
"core": "Core"
|
||||
},
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
@@ -205,29 +240,78 @@
|
||||
"botTokenLabel": "Discord Bot Token",
|
||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||
"instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token",
|
||||
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant."
|
||||
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant.",
|
||||
"appIdLabel": "Discord Application ID",
|
||||
"appIdPlaceholder": "18-19 digit numeric ID from Developer Portal"
|
||||
},
|
||||
"email": {
|
||||
"description": "Enable your AI assistant to send and receive email.",
|
||||
"statusEnabled": "enabled",
|
||||
"statusDisabled": "disabled",
|
||||
"coreHeartbeat": {
|
||||
"description": "Periodic agent run every 30 minutes that lets your assistant check inbox, calendar, and other configured sources and message you proactively when something needs attention. Without this, the assistant only responds when you message it first."
|
||||
},
|
||||
"coreCron": {
|
||||
"description": "Allow the assistant to run scheduled tasks (daily briefings, recurring reminders, periodic reports). Off by default. When off, the agent's cron tool stays available but no scheduled job ever fires."
|
||||
},
|
||||
"coreActiveMemory": {
|
||||
"description": "Lets the assistant recall stable preferences, recurring habits, and long-term context from past conversations during a chat. Uses an extra sub-agent turn per inbound message to query the memory store. Direct-message sessions only. Adds a small token cost in exchange for continuity and personalisation."
|
||||
},
|
||||
"coreVoice": {
|
||||
"description": "Speech-to-text on incoming voice notes and text-to-speech on replies, routed through the PieCed LiteLLM gateway so audio cost is tracked per tenant alongside chat. Runtime wiring lands in the next platform release; toggling now stores the preference for that rollout."
|
||||
},
|
||||
"gitCli": {
|
||||
"description": "Standalone git command-line operations (clone, commit, branch, diff, log, status). For private repositories, configure credentials in your workspace."
|
||||
},
|
||||
"github": {
|
||||
"description": "Interact with GitHub repositories via the gh CLI — issues, pull requests, CI runs, releases, gists. Requires a personal access token.",
|
||||
"tokenLabel": "GitHub Personal Access Token",
|
||||
"tokenPlaceholder": "ghp_… or github_pat_…",
|
||||
"instructions": "1. Open https://github.com/settings/tokens\n2. Generate a fine-grained personal access token with the repo scopes you want the assistant to use\n3. Copy the token (it's shown only once)"
|
||||
},
|
||||
"gitea": {
|
||||
"description": "Interact with a Gitea instance — repositories, issues, pull requests, releases. Defaults to the PieCed-platform Gitea at git.c5ai.ch.",
|
||||
"tokenLabel": "Gitea Access Token",
|
||||
"tokenPlaceholder": "Generated under Settings → Applications",
|
||||
"instructions": "1. Log in to your Gitea instance (default https://git.c5ai.ch)\n2. Go to Settings → Applications → Generate New Token\n3. Grant the scopes you want the assistant to use (repo, issue, user)\n4. Copy the token"
|
||||
},
|
||||
"whisperSelfHosted": {
|
||||
"description": "Transcribe audio files via the platform's self-hosted Whisper instance. Useful for ad-hoc transcription tasks initiated from chat."
|
||||
},
|
||||
"searxngLocalSearch": {
|
||||
"description": "Privacy-respecting web search via the platform's internal SearXNG instance. Search the web, images, and news without external API calls or trackers."
|
||||
},
|
||||
"gog": {
|
||||
"description": "Bundled access to Gmail, Calendar, Drive, Docs, Sheets, and Contacts via Google OAuth. Setup requires a Google Cloud project — contact PieCed support to onboard.",
|
||||
"clientIdLabel": "Google OAuth Client ID",
|
||||
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
|
||||
"clientSecretLabel": "Google OAuth Client Secret",
|
||||
"clientSecretPlaceholder": "GOCSPX-…",
|
||||
"refreshTokenLabel": "Google OAuth Refresh Token",
|
||||
"refreshTokenPlaceholder": "1//0g…",
|
||||
"instructions": "Google Workspace integration uses OAuth and requires manual onboarding for now. Please open a support ticket to start the setup — we'll exchange the client credentials and a refresh token offline, then enable this package on your tenant.",
|
||||
"disclaimer": "By enabling Google Workspace integration you authorize PieCed to access Gmail, Calendar, Drive, Docs, Sheets, and Contacts on your behalf. Data flows through Google's APIs subject to Google's terms."
|
||||
},
|
||||
"mail": {
|
||||
"description": "Read, search, and manage email via IMAP; send via SMTP. Works with Gmail (with an app password), Outlook, Fastmail, and any standard IMAP/SMTP host.",
|
||||
"imapHostLabel": "IMAP Host",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"imapUserLabel": "IMAP Username",
|
||||
"imapUserPlaceholder": "user@example.com",
|
||||
"imapPassLabel": "IMAP Password",
|
||||
"imapPassPlaceholder": "••••••••",
|
||||
"smtpHostLabel": "SMTP Host",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "SMTP Username",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "SMTP Password",
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "IMAP Host",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"instructions": "Provide SMTP and IMAP credentials. The assistant uses these to send and monitor messages.",
|
||||
"disclaimer": "I confirm I am authorized to use these email credentials and that PieCed IT may access this mailbox."
|
||||
"smtpPassLabel": "SMTP Password",
|
||||
"smtpPassPlaceholder": "••••••••",
|
||||
"instructions": "1. For Gmail: enable 2-Step Verification, then create an App Password at https://myaccount.google.com/apppasswords and use it as both IMAP and SMTP password.\n2. For Outlook / Microsoft 365 with MFA: generate an app password in your account's security settings.\n3. For other providers: refer to their IMAP/SMTP documentation for host names and ports.\n4. Typical IMAP hosts: imap.gmail.com, outlook.office365.com.\n5. Typical SMTP hosts: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "The assistant gains read/write access to the mailbox you configure. Consider using a dedicated address rather than a personal inbox if you want to limit scope."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Give your AI assistant the ability to search the web."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Enable document parsing, summarization, and extraction."
|
||||
},
|
||||
"statusEnabled": "enabled",
|
||||
"statusDisabled": "disabled"
|
||||
"threema": {
|
||||
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
|
||||
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
|
||||
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
@@ -297,7 +381,10 @@
|
||||
"loadingHealth": "Loading health data…",
|
||||
"statusHealthy": "Healthy",
|
||||
"statusDown": "Down",
|
||||
"spendChf": "Spend (CHF)"
|
||||
"spendChf": "Spend (CHF)",
|
||||
"resumeRequestBadge": "Resume",
|
||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
||||
"openclawTool": "OpenClaw versions"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Authorized Users",
|
||||
@@ -309,7 +396,15 @@
|
||||
"alreadyAdded": "This user ID is already authorized.",
|
||||
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
|
||||
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
|
||||
"emailIdHelp": "Enter the email address that should be authorized to interact with the assistant."
|
||||
"threemaIdHelp": "Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
|
||||
"threemaSetup": {
|
||||
"title": "Add the assistant to your Threema",
|
||||
"step1": "Open Threema on your phone.",
|
||||
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||
"step3": "Then add your own Threema ID below.",
|
||||
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
||||
"showQr": "Show QR"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
@@ -365,5 +460,96 @@
|
||||
"warnings": {
|
||||
"oneTooltip": "1 warning",
|
||||
"manyTooltip": "{count} warnings"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Manage org-level configuration that applies to all your tenants.",
|
||||
"billingTitle": "Billing",
|
||||
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.",
|
||||
"nothingForYou": "There's nothing here for your role yet. Owners can manage org settings.",
|
||||
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Billing",
|
||||
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.",
|
||||
"companyName": "Company name",
|
||||
"streetAddress": "Street address",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"vatNumber": "VAT number",
|
||||
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).",
|
||||
"billingEmail": "Billing email",
|
||||
"billingEmailHelp": "Where invoices and billing communication will be sent.",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.",
|
||||
"save": "Save",
|
||||
"saved": "Saved.",
|
||||
"saveFailed": "Could not save. Please try again.",
|
||||
"lastUpdated": "Last updated {when}",
|
||||
"fullName": "Full name",
|
||||
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"subtitle": "Open a ticket to ask a question, report a bug, or share feedback. Replies will be sent to your registered email.",
|
||||
"titleAdmin": "Support queue",
|
||||
"subtitleAdmin": "Tickets across all customers, newest activity first.",
|
||||
"newTicket": "New ticket",
|
||||
"newTicketTitle": "Open a support ticket",
|
||||
"newTicketSubtitle": "Tell us what's going on. The more detail you share, the faster we can help.",
|
||||
"empty": "You haven't opened any tickets yet.",
|
||||
"emptyAdmin": "No support tickets in the queue.",
|
||||
"fieldCategory": "Category",
|
||||
"fieldTitle": "Title",
|
||||
"fieldDescription": "Description",
|
||||
"fieldStatus": "Status",
|
||||
"titlePlaceholder": "Short summary of what you need",
|
||||
"descriptionPlaceholder": "Describe what happened, what you expected, and any error messages you saw.",
|
||||
"descriptionHelp": "You can paste error messages and logs. Don't include passwords or other secrets.",
|
||||
"submitTicket": "Submit ticket",
|
||||
"createFailed": "Could not create ticket. Please try again.",
|
||||
"category_bug": "Bug",
|
||||
"category_feature_request": "Feature request",
|
||||
"category_question": "Question",
|
||||
"category_billing": "Billing",
|
||||
"category_other": "Other",
|
||||
"status_open": "Open",
|
||||
"status_in_progress": "In progress",
|
||||
"status_waiting_for_customer": "Awaiting your reply",
|
||||
"status_resolved": "Resolved",
|
||||
"status_reopened": "Reopened",
|
||||
"openedBy": "Opened by {name} on {when}",
|
||||
"authorTagAdmin": "PieCed support",
|
||||
"replyLabel": "Add a reply",
|
||||
"replyPlaceholder": "Your message…",
|
||||
"replyPlaceholderReopen": "Reply (this will reopen the ticket)…",
|
||||
"sendReply": "Send reply",
|
||||
"commentFailed": "Could not send reply. Please try again.",
|
||||
"closeTicket": "Mark as resolved",
|
||||
"confirmClose": "Mark this ticket as resolved? You can reopen it later by replying.",
|
||||
"closeFailed": "Could not close the ticket. Please try again.",
|
||||
"resolvedBanner": "This ticket is resolved. Reply below if you need to follow up — that will reopen it.",
|
||||
"adminControlsTitle": "Admin controls",
|
||||
"updateFailed": "Could not save changes. Please try again."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "OpenClaw versions",
|
||||
"subtitle": "Configure the platform-default OpenClaw image tag and per-tenant overrides for testing new releases.",
|
||||
"defaultSection": "Platform default",
|
||||
"defaultDescription": "Used by every tenant that doesn't have its own override.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Leave empty to fall back to the operator's built-in default.",
|
||||
"saveDefault": "Save default",
|
||||
"defaultSaved": "Default saved. Tenants without overrides will pick this up on the next reconcile.",
|
||||
"saveFailed": "Could not save. Please try again.",
|
||||
"overridesSection": "Tenant overrides",
|
||||
"noTenants": "No tenants in the cluster.",
|
||||
"statusOverridden": "Override",
|
||||
"statusFollowsDefault": "Follows default",
|
||||
"builtinFallback": "(operator built-in fallback)",
|
||||
"defaultPrefix": "Default:",
|
||||
"saveOverride": "Save override",
|
||||
"clearOverride": "Clear override"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"save": "Enregistrer",
|
||||
"error": "Une erreur est survenue",
|
||||
"register": "S'inscrire",
|
||||
"team": "Équipe"
|
||||
"team": "Équipe",
|
||||
"settings": "Paramètres",
|
||||
"optional": "facultatif",
|
||||
"support": "Support"
|
||||
},
|
||||
"login": {
|
||||
"title": "Portail PieCed",
|
||||
@@ -114,7 +117,10 @@
|
||||
"dismiss": "Masquer",
|
||||
"dismissFailed": "Impossible de masquer.",
|
||||
"rejectionReason": "Motif indiqué",
|
||||
"saveChanges": "Enregistrer les modifications"
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"billingVatNumber": "Numéro de TVA",
|
||||
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
|
||||
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -157,7 +163,21 @@
|
||||
"cancelConfirmBullet3": "Les informations de facturation sont conservées",
|
||||
"subscriptionUpdateFailed": "Impossible de mettre à jour l'abonnement.",
|
||||
"suspendedTitle": "Abonnement annulé",
|
||||
"suspendedDescription": "Votre assistant est en pause. La configuration et les données sont préservées. Utilisez le contrôle Reprendre en bas de cette page pour le remettre en ligne."
|
||||
"suspendedDescription": "Votre assistant est en pause. La configuration et les données sont préservées. Utilisez le contrôle Reprendre en bas de cette page pour le remettre en ligne.",
|
||||
"requestReactivation": "Demander la réactivation",
|
||||
"requestReactivationConfirmTitle": "Demander la réactivation ?",
|
||||
"requestReactivationConfirmDescription": "Un administrateur examinera votre demande et réactivera votre locataire. Vous recevrez un e-mail dès que la demande sera approuvée.",
|
||||
"requestReactivationConfirm": "Envoyer la demande",
|
||||
"cancelResumeRequest": "Annuler la demande",
|
||||
"resumeRequestPendingTitle": "Demande de réactivation en attente",
|
||||
"resumeRequestPendingDescription": "Soumise {when}. Un administrateur l'examinera sous peu.",
|
||||
"resumeRequestPendingNoteAdmin": "Un propriétaire a demandé la réactivation ; vous pouvez reprendre directement ci-dessus ou traiter la demande depuis la file d'attente d'administration.",
|
||||
"cancelConfirmRetentionWarning": "Vos données sont conservées pendant 60 jours après l'annulation. Passé ce délai, toutes les données du locataire — configuration, secrets, conversations et fichiers — seront définitivement supprimées.",
|
||||
"suspendedSince": "Suspendu le {date}",
|
||||
"suspendedDeletionIn": "suppression des données dans {days, plural, one {# jour} other {# jours}} ({date})",
|
||||
"suspendedDeletionImminent": "les données sont en cours de suppression",
|
||||
"requestReactivationNoteLabel": "Note pour notre équipe",
|
||||
"requestReactivationNotePlaceholder": "Tout ce que notre équipe devrait savoir — par exemple, pourquoi vous voulez réactiver, urgence, etc."
|
||||
},
|
||||
"usage": {
|
||||
"inputTokens": "Tokens d'entrée",
|
||||
@@ -169,7 +189,21 @@
|
||||
"last30Days": "30 derniers jours",
|
||||
"noData": "Aucune donnée d'utilisation disponible.",
|
||||
"dailyBreakdown": "Détail journalier",
|
||||
"requests": "requêtes"
|
||||
"requests": "requêtes",
|
||||
"budgetEdit": "Modifier",
|
||||
"budgetEditTitle": "Définir un budget",
|
||||
"budgetEditDescription": "Limitez la dépense des assistants de ce locataire avant que les requêtes ne soient refusées.",
|
||||
"budgetModeUnlimited": "Aucune limite",
|
||||
"budgetModeUnlimitedDescription": "Dépense libre, sans plafond.",
|
||||
"budgetModeCapped": "Définir un plafond",
|
||||
"budgetModeCappedDescription": "Refuser les requêtes une fois ce montant atteint.",
|
||||
"budgetAmount": "Montant",
|
||||
"budgetResetCadence": "Réinitialisation",
|
||||
"budgetCadence_30d": "Tous les 30 jours",
|
||||
"budgetCadence_1mo": "Mensuelle",
|
||||
"budgetCadence_1y": "Annuelle",
|
||||
"budgetInvalid": "Veuillez saisir un montant positif.",
|
||||
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Enregistrer",
|
||||
@@ -180,7 +214,8 @@
|
||||
"packages": {
|
||||
"categories": {
|
||||
"channels": "Canaux",
|
||||
"skills": "Compétences"
|
||||
"skills": "Compétences",
|
||||
"core": "Cœur"
|
||||
},
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
@@ -205,29 +240,78 @@
|
||||
"botTokenLabel": "Token du bot Discord",
|
||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot",
|
||||
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA."
|
||||
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA.",
|
||||
"appIdLabel": "ID d'application Discord",
|
||||
"appIdPlaceholder": "ID numérique de 18–19 chiffres depuis le Developer Portal"
|
||||
},
|
||||
"email": {
|
||||
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.",
|
||||
"statusEnabled": "activé",
|
||||
"statusDisabled": "désactivé",
|
||||
"coreHeartbeat": {
|
||||
"description": "Exécution périodique de l'agent toutes les 30 minutes pour vérifier votre boîte mail, votre agenda et d'autres sources configurées, et vous notifier de manière proactive lorsqu'une attention est requise. Sans cette option, l'assistant ne répond que lorsque vous lui écrivez."
|
||||
},
|
||||
"coreCron": {
|
||||
"description": "Permet à l'assistant d'exécuter des tâches programmées (briefings quotidiens, rappels récurrents, rapports périodiques). Désactivé par défaut. Lorsqu'il est désactivé, l'outil cron reste disponible mais aucune tâche planifiée ne s'exécute."
|
||||
},
|
||||
"coreActiveMemory": {
|
||||
"description": "Permet à l'assistant de se rappeler des préférences stables, des habitudes récurrentes et du contexte à long terme issu de conversations passées. Utilise un tour de sous-agent supplémentaire par message entrant pour interroger la mémoire. Uniquement en messages directs. Légère consommation de tokens supplémentaire en échange de continuité et de personnalisation."
|
||||
},
|
||||
"coreVoice": {
|
||||
"description": "Reconnaissance vocale sur les notes vocales entrantes et synthèse vocale sur les réponses, via la passerelle PieCed LiteLLM pour un suivi du coût audio par tenant. L'intégration runtime arrive dans la prochaine version de la plateforme ; basculer le commutateur enregistre dès maintenant la préférence."
|
||||
},
|
||||
"gitCli": {
|
||||
"description": "Opérations git en ligne de commande autonomes (clone, commit, branch, diff, log, status). Pour les dépôts privés, configurez les identifiants dans votre espace de travail."
|
||||
},
|
||||
"github": {
|
||||
"description": "Interagissez avec les dépôts GitHub via la CLI gh — issues, pull requests, exécutions CI, releases, gists. Nécessite un jeton d'accès personnel.",
|
||||
"tokenLabel": "Jeton d'accès personnel GitHub",
|
||||
"tokenPlaceholder": "ghp_… ou github_pat_…",
|
||||
"instructions": "1. Ouvrez https://github.com/settings/tokens\n2. Générez un jeton d'accès personnel fin avec les portées de dépôt souhaitées\n3. Copiez le jeton (il n'est affiché qu'une fois)"
|
||||
},
|
||||
"gitea": {
|
||||
"description": "Interagissez avec une instance Gitea — dépôts, issues, pull requests, releases. Par défaut, l'instance Gitea PieCed à git.c5ai.ch.",
|
||||
"tokenLabel": "Jeton d'accès Gitea",
|
||||
"tokenPlaceholder": "Généré sous Paramètres → Applications",
|
||||
"instructions": "1. Connectez-vous à votre instance Gitea (par défaut https://git.c5ai.ch)\n2. Allez dans Paramètres → Applications → Générer un nouveau jeton\n3. Accordez les portées souhaitées (repo, issue, user)\n4. Copiez le jeton"
|
||||
},
|
||||
"whisperSelfHosted": {
|
||||
"description": "Transcrivez des fichiers audio via l'instance Whisper auto-hébergée de la plateforme. Utile pour les transcriptions ad hoc initiées depuis le chat."
|
||||
},
|
||||
"searxngLocalSearch": {
|
||||
"description": "Recherche web respectueuse de la vie privée via l'instance SearXNG interne de la plateforme. Recherchez le web, les images et les actualités sans appels d'API externes ni traqueurs."
|
||||
},
|
||||
"gog": {
|
||||
"description": "Accès groupé à Gmail, Agenda, Drive, Docs, Sheets et Contacts via Google OAuth. La configuration nécessite un projet Google Cloud — contactez le support PieCed pour l'intégration.",
|
||||
"clientIdLabel": "ID client Google OAuth",
|
||||
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
|
||||
"clientSecretLabel": "Secret client Google OAuth",
|
||||
"clientSecretPlaceholder": "GOCSPX-…",
|
||||
"refreshTokenLabel": "Jeton de rafraîchissement Google OAuth",
|
||||
"refreshTokenPlaceholder": "1//0g…",
|
||||
"instructions": "L'intégration de Google Workspace utilise OAuth et nécessite actuellement une intégration manuelle. Veuillez ouvrir un ticket de support pour démarrer la configuration — nous échangerons hors ligne les identifiants client et un jeton de rafraîchissement, puis activerons ce package sur votre tenant.",
|
||||
"disclaimer": "En activant l'intégration de Google Workspace, vous autorisez PieCed à accéder à Gmail, Agenda, Drive, Docs, Sheets et Contacts en votre nom. Les données transitent par les API de Google, soumises aux conditions de Google."
|
||||
},
|
||||
"mail": {
|
||||
"description": "Lisez, recherchez et gérez vos e-mails via IMAP ; envoyez via SMTP. Compatible avec Gmail (avec un mot de passe d'application), Outlook, Fastmail et tout hôte IMAP/SMTP standard.",
|
||||
"imapHostLabel": "Hôte IMAP",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"imapUserLabel": "Nom d'utilisateur IMAP",
|
||||
"imapUserPlaceholder": "utilisateur@example.com",
|
||||
"imapPassLabel": "Mot de passe IMAP",
|
||||
"imapPassPlaceholder": "••••••••",
|
||||
"smtpHostLabel": "Hôte SMTP",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "Nom d'utilisateur SMTP",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "Mot de passe SMTP",
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "Hôte IMAP",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"instructions": "Fournissez les identifiants SMTP et IMAP. L'assistant les utilise pour envoyer et surveiller les messages.",
|
||||
"disclaimer": "Je confirme que je suis autorisé à utiliser ces identifiants e-mail et que PieCed IT peut accéder à cette boîte mail."
|
||||
"smtpUserPlaceholder": "utilisateur@example.com",
|
||||
"smtpPassLabel": "Mot de passe SMTP",
|
||||
"smtpPassPlaceholder": "••••••••",
|
||||
"instructions": "1. Pour Gmail : activez la validation en deux étapes, puis créez un mot de passe d'application sur https://myaccount.google.com/apppasswords et utilisez-le comme mot de passe IMAP et SMTP.\n2. Pour Outlook / Microsoft 365 avec MFA : générez un mot de passe d'application dans les paramètres de sécurité de votre compte.\n3. Pour les autres fournisseurs : consultez leur documentation IMAP/SMTP pour les noms d'hôte et les ports.\n4. Hôtes IMAP typiques : imap.gmail.com, outlook.office365.com.\n5. Hôtes SMTP typiques : smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "L'assistant obtient un accès en lecture/écriture à la boîte aux lettres que vous configurez. Envisagez d'utiliser une adresse dédiée plutôt qu'une boîte personnelle si vous souhaitez limiter la portée."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Donnez à votre assistant IA la capacité de rechercher sur le web."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Activez l'analyse, le résumé et l'extraction de documents."
|
||||
},
|
||||
"statusEnabled": "activé",
|
||||
"statusDisabled": "désactivé"
|
||||
"threema": {
|
||||
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
|
||||
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
|
||||
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin plateforme",
|
||||
@@ -297,7 +381,10 @@
|
||||
"loadingHealth": "Chargement des données de santé…",
|
||||
"statusHealthy": "OK",
|
||||
"statusDown": "Hors service",
|
||||
"spendChf": "Coûts (CHF)"
|
||||
"spendChf": "Coûts (CHF)",
|
||||
"resumeRequestBadge": "Reprise",
|
||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
||||
"openclawTool": "Versions OpenClaw"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utilisateurs autorisés",
|
||||
@@ -309,7 +396,15 @@
|
||||
"alreadyAdded": "Cet identifiant est déjà autorisé.",
|
||||
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
|
||||
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
|
||||
"emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant."
|
||||
"threemaIdHelp": "Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
|
||||
"threemaSetup": {
|
||||
"title": "Ajouter l'assistant à Threema",
|
||||
"step1": "Ouvrez Threema sur votre téléphone.",
|
||||
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
||||
"showQr": "Afficher le QR"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
@@ -365,5 +460,96 @@
|
||||
"warnings": {
|
||||
"oneTooltip": "1 avertissement",
|
||||
"manyTooltip": "{count} avertissements"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"subtitle": "Gérez la configuration au niveau de l'organisation, qui s'applique à tous vos locataires.",
|
||||
"billingTitle": "Facturation",
|
||||
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.",
|
||||
"nothingForYou": "Il n'y a rien ici pour votre rôle pour le moment. Les propriétaires peuvent gérer les paramètres de l'organisation.",
|
||||
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Facturation",
|
||||
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.",
|
||||
"companyName": "Nom de l'entreprise",
|
||||
"streetAddress": "Adresse",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"country": "Pays",
|
||||
"vatNumber": "Numéro de TVA",
|
||||
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).",
|
||||
"billingEmail": "E-mail de facturation",
|
||||
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Tout ce que la comptabilité doit savoir – exonération de TVA, modalités de facturation particulières, etc.",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Enregistré.",
|
||||
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.",
|
||||
"lastUpdated": "Dernière mise à jour {when}",
|
||||
"fullName": "Nom complet",
|
||||
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"subtitle": "Ouvrez un ticket pour poser une question, signaler un bug ou partager un commentaire. Les réponses seront envoyées à l'adresse e-mail enregistrée.",
|
||||
"titleAdmin": "File d'attente du support",
|
||||
"subtitleAdmin": "Tickets de tous les clients, activité la plus récente en premier.",
|
||||
"newTicket": "Nouveau ticket",
|
||||
"newTicketTitle": "Ouvrir un ticket de support",
|
||||
"newTicketSubtitle": "Dites-nous ce qui se passe. Plus vous donnez de détails, plus nous pouvons aider rapidement.",
|
||||
"empty": "Vous n'avez pas encore ouvert de ticket.",
|
||||
"emptyAdmin": "Aucun ticket de support dans la file d'attente.",
|
||||
"fieldCategory": "Catégorie",
|
||||
"fieldTitle": "Titre",
|
||||
"fieldDescription": "Description",
|
||||
"fieldStatus": "Statut",
|
||||
"titlePlaceholder": "Bref résumé de votre besoin",
|
||||
"descriptionPlaceholder": "Décrivez ce qui s'est passé, ce que vous attendiez et tout message d'erreur observé.",
|
||||
"descriptionHelp": "Vous pouvez coller des messages d'erreur et des logs. Pas de mots de passe ni d'autres secrets.",
|
||||
"submitTicket": "Envoyer le ticket",
|
||||
"createFailed": "Impossible de créer le ticket. Veuillez réessayer.",
|
||||
"category_bug": "Bug",
|
||||
"category_feature_request": "Demande de fonctionnalité",
|
||||
"category_question": "Question",
|
||||
"category_billing": "Facturation",
|
||||
"category_other": "Autre",
|
||||
"status_open": "Ouvert",
|
||||
"status_in_progress": "En cours",
|
||||
"status_waiting_for_customer": "En attente de votre réponse",
|
||||
"status_resolved": "Résolu",
|
||||
"status_reopened": "Rouvert",
|
||||
"openedBy": "Ouvert par {name} le {when}",
|
||||
"authorTagAdmin": "Support PieCed",
|
||||
"replyLabel": "Ajouter une réponse",
|
||||
"replyPlaceholder": "Votre message…",
|
||||
"replyPlaceholderReopen": "Réponse (cela rouvrira le ticket)…",
|
||||
"sendReply": "Envoyer la réponse",
|
||||
"commentFailed": "Impossible d'envoyer la réponse. Veuillez réessayer.",
|
||||
"closeTicket": "Marquer comme résolu",
|
||||
"confirmClose": "Marquer ce ticket comme résolu ? Vous pourrez le rouvrir plus tard en répondant.",
|
||||
"closeFailed": "Impossible de fermer le ticket. Veuillez réessayer.",
|
||||
"resolvedBanner": "Ce ticket est résolu. Répondez ci-dessous si vous avez besoin d'un suivi — cela le rouvrira.",
|
||||
"adminControlsTitle": "Contrôles admin",
|
||||
"updateFailed": "Impossible d'enregistrer les modifications. Veuillez réessayer."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "Versions OpenClaw",
|
||||
"subtitle": "Configurer le tag par défaut de la plateforme et les surcharges par locataire pour tester les nouvelles versions.",
|
||||
"defaultSection": "Défaut de la plateforme",
|
||||
"defaultDescription": "Utilisé par tous les locataires sans surcharge propre.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Laisser vide pour utiliser le défaut intégré de l'opérateur.",
|
||||
"saveDefault": "Enregistrer le défaut",
|
||||
"defaultSaved": "Défaut enregistré. Les locataires sans surcharge l'appliqueront au prochain réconcile.",
|
||||
"saveFailed": "Échec de l'enregistrement. Veuillez réessayer.",
|
||||
"overridesSection": "Surcharges par locataire",
|
||||
"noTenants": "Aucun locataire dans le cluster.",
|
||||
"statusOverridden": "Surcharge",
|
||||
"statusFollowsDefault": "Suit le défaut",
|
||||
"builtinFallback": "(repli intégré)",
|
||||
"defaultPrefix": "Défaut :",
|
||||
"saveOverride": "Enregistrer la surcharge",
|
||||
"clearOverride": "Supprimer la surcharge"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"save": "Salva",
|
||||
"error": "Si è verificato un errore",
|
||||
"register": "Registrati",
|
||||
"team": "Team"
|
||||
"team": "Team",
|
||||
"settings": "Impostazioni",
|
||||
"optional": "facoltativo",
|
||||
"support": "Supporto"
|
||||
},
|
||||
"login": {
|
||||
"title": "Portale PieCed",
|
||||
@@ -114,7 +117,10 @@
|
||||
"dismiss": "Nascondi",
|
||||
"dismissFailed": "Impossibile nascondere.",
|
||||
"rejectionReason": "Motivo indicato",
|
||||
"saveChanges": "Salva modifiche"
|
||||
"saveChanges": "Salva modifiche",
|
||||
"billingVatNumber": "Partita IVA",
|
||||
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
||||
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -157,7 +163,21 @@
|
||||
"cancelConfirmBullet3": "Le informazioni di fatturazione sono mantenute",
|
||||
"subscriptionUpdateFailed": "Impossibile aggiornare l'abbonamento.",
|
||||
"suspendedTitle": "Abbonamento annullato",
|
||||
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprendi in fondo a questa pagina per riportarlo online."
|
||||
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprendi in fondo a questa pagina per riportarlo online.",
|
||||
"requestReactivation": "Richiedi riattivazione",
|
||||
"requestReactivationConfirmTitle": "Richiedere la riattivazione?",
|
||||
"requestReactivationConfirmDescription": "Un amministratore esaminerà la tua richiesta e riattiverà il tuo tenant. Riceverai un'email non appena la richiesta sarà approvata.",
|
||||
"requestReactivationConfirm": "Invia richiesta",
|
||||
"cancelResumeRequest": "Annulla richiesta",
|
||||
"resumeRequestPendingTitle": "Richiesta di riattivazione in sospeso",
|
||||
"resumeRequestPendingDescription": "Inviata {when}. Un amministratore la esaminerà a breve.",
|
||||
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; puoi riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
||||
"cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
|
||||
"suspendedSince": "Sospeso il {date}",
|
||||
"suspendedDeletionIn": "eliminazione dei dati tra {days, plural, one {# giorno} other {# giorni}} ({date})",
|
||||
"suspendedDeletionImminent": "i dati vengono eliminati ora",
|
||||
"requestReactivationNoteLabel": "Nota per il nostro team",
|
||||
"requestReactivationNotePlaceholder": "Qualsiasi cosa il nostro team dovrebbe sapere — ad es. il motivo della riattivazione, l'urgenza, ecc."
|
||||
},
|
||||
"usage": {
|
||||
"inputTokens": "Token di input",
|
||||
@@ -169,7 +189,21 @@
|
||||
"last30Days": "Ultimi 30 giorni",
|
||||
"noData": "Nessun dato di utilizzo disponibile.",
|
||||
"dailyBreakdown": "Dettaglio giornaliero",
|
||||
"requests": "richieste"
|
||||
"requests": "richieste",
|
||||
"budgetEdit": "Modifica",
|
||||
"budgetEditTitle": "Imposta budget",
|
||||
"budgetEditDescription": "Limita quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
|
||||
"budgetModeUnlimited": "Nessun limite",
|
||||
"budgetModeUnlimitedDescription": "Spesa libera, nessun tetto.",
|
||||
"budgetModeCapped": "Imposta un tetto",
|
||||
"budgetModeCappedDescription": "Rifiuta le richieste una volta raggiunto questo importo.",
|
||||
"budgetAmount": "Importo",
|
||||
"budgetResetCadence": "Ripristino",
|
||||
"budgetCadence_30d": "Ogni 30 giorni",
|
||||
"budgetCadence_1mo": "Mensile",
|
||||
"budgetCadence_1y": "Annuale",
|
||||
"budgetInvalid": "Inserisci un importo positivo.",
|
||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Salva",
|
||||
@@ -180,7 +214,8 @@
|
||||
"packages": {
|
||||
"categories": {
|
||||
"channels": "Canali",
|
||||
"skills": "Capacità"
|
||||
"skills": "Capacità",
|
||||
"core": "Core"
|
||||
},
|
||||
"enable": "Attiva",
|
||||
"disable": "Disattiva",
|
||||
@@ -205,29 +240,78 @@
|
||||
"botTokenLabel": "Token bot Discord",
|
||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot",
|
||||
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
||||
},
|
||||
"email": {
|
||||
"description": "Permetti al tuo assistente IA di inviare e ricevere e-mail.",
|
||||
"smtpHostLabel": "Host SMTP",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "Nome utente SMTP",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "Password SMTP",
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "Host IMAP",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"instructions": "Fornisci le credenziali SMTP e IMAP. L'assistente le usa per inviare e monitorare i messaggi.",
|
||||
"disclaimer": "Confermo di essere autorizzato a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Dai al tuo assistente IA la capacità di cercare nel web."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
|
||||
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA.",
|
||||
"appIdLabel": "ID applicazione Discord",
|
||||
"appIdPlaceholder": "ID numerico di 18–19 cifre dal Developer Portal"
|
||||
},
|
||||
"statusEnabled": "abilitato",
|
||||
"statusDisabled": "disabilitato"
|
||||
"statusDisabled": "disabilitato",
|
||||
"coreHeartbeat": {
|
||||
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarti proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
|
||||
},
|
||||
"coreCron": {
|
||||
"description": "Consente all'assistente di eseguire attività pianificate (briefing giornalieri, promemoria ricorrenti, report periodici). Disattivato per impostazione predefinita. Quando è disattivato, lo strumento cron resta disponibile ma nessuna attività pianificata viene eseguita."
|
||||
},
|
||||
"coreActiveMemory": {
|
||||
"description": "Consente all'assistente di richiamare preferenze stabili, abitudini ricorrenti e contesto a lungo termine dalle conversazioni precedenti. Utilizza un turno extra di sub-agente per ogni messaggio in entrata per interrogare lo store di memoria. Solo messaggi diretti. Aggiunge un piccolo costo in token in cambio di continuità e personalizzazione."
|
||||
},
|
||||
"coreVoice": {
|
||||
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salva la preferenza per quel rilascio."
|
||||
},
|
||||
"gitCli": {
|
||||
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel tuo workspace."
|
||||
},
|
||||
"github": {
|
||||
"description": "Interagisci con repository GitHub tramite la CLI gh — issue, pull request, esecuzioni CI, release, gist. Richiede un token di accesso personale.",
|
||||
"tokenLabel": "Token di accesso personale GitHub",
|
||||
"tokenPlaceholder": "ghp_… o github_pat_…",
|
||||
"instructions": "1. Apri https://github.com/settings/tokens\n2. Genera un token di accesso personale fine con gli ambiti repo desiderati\n3. Copia il token (viene mostrato una sola volta)"
|
||||
},
|
||||
"gitea": {
|
||||
"description": "Interagisci con un'istanza Gitea — repository, issue, pull request, release. Per impostazione predefinita, l'istanza Gitea PieCed su git.c5ai.ch.",
|
||||
"tokenLabel": "Token di accesso Gitea",
|
||||
"tokenPlaceholder": "Generato in Impostazioni → Applicazioni",
|
||||
"instructions": "1. Accedi alla tua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vai a Impostazioni → Applicazioni → Genera nuovo token\n3. Concedi gli ambiti desiderati (repo, issue, user)\n4. Copia il token"
|
||||
},
|
||||
"whisperSelfHosted": {
|
||||
"description": "Trascrivi file audio tramite l'istanza Whisper auto-ospitata della piattaforma. Utile per attività di trascrizione ad hoc avviate dalla chat."
|
||||
},
|
||||
"searxngLocalSearch": {
|
||||
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerca sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
|
||||
},
|
||||
"gog": {
|
||||
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatta il supporto PieCed per l'onboarding.",
|
||||
"clientIdLabel": "ID client Google OAuth",
|
||||
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
|
||||
"clientSecretLabel": "Client secret Google OAuth",
|
||||
"clientSecretPlaceholder": "GOCSPX-…",
|
||||
"refreshTokenLabel": "Token di refresh Google OAuth",
|
||||
"refreshTokenPlaceholder": "1//0g…",
|
||||
"instructions": "L'integrazione con Google Workspace utilizza OAuth e richiede attualmente un onboarding manuale. Apri un ticket di supporto per avviare la configurazione — scambieremo le credenziali del client e un token di refresh offline, quindi abiliteremo questo pacchetto sul tuo tenant.",
|
||||
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
|
||||
},
|
||||
"mail": {
|
||||
"description": "Leggi, cerca e gestisci le e-mail via IMAP; invia tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
|
||||
"imapHostLabel": "Host IMAP",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"imapUserLabel": "Username IMAP",
|
||||
"imapUserPlaceholder": "utente@example.com",
|
||||
"imapPassLabel": "Password IMAP",
|
||||
"imapPassPlaceholder": "••••••••",
|
||||
"smtpHostLabel": "Host SMTP",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "Username SMTP",
|
||||
"smtpUserPlaceholder": "utente@example.com",
|
||||
"smtpPassLabel": "Password SMTP",
|
||||
"smtpPassPlaceholder": "••••••••",
|
||||
"instructions": "1. Per Gmail: abilita la verifica in due passaggi, quindi crea una password per app su https://myaccount.google.com/apppasswords e usala come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: genera una password per app nelle impostazioni di sicurezza del tuo account.\n3. Per altri provider: consulta la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuoi limitare la portata."
|
||||
},
|
||||
"threema": {
|
||||
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
|
||||
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin piattaforma",
|
||||
@@ -297,7 +381,10 @@
|
||||
"loadingHealth": "Caricamento dati di stato…",
|
||||
"statusHealthy": "OK",
|
||||
"statusDown": "Non disponibile",
|
||||
"spendChf": "Costi (CHF)"
|
||||
"spendChf": "Costi (CHF)",
|
||||
"resumeRequestBadge": "Ripresa",
|
||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
||||
"openclawTool": "Versioni OpenClaw"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
@@ -309,7 +396,15 @@
|
||||
"alreadyAdded": "Questo ID utente è già autorizzato.",
|
||||
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
|
||||
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
|
||||
"emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente."
|
||||
"threemaIdHelp": "Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
|
||||
"threemaSetup": {
|
||||
"title": "Aggiungi l'assistente a Threema",
|
||||
"step1": "Apri Threema sul tuo telefono.",
|
||||
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
|
||||
"showQr": "Mostra QR"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
@@ -365,5 +460,96 @@
|
||||
"warnings": {
|
||||
"oneTooltip": "1 avviso",
|
||||
"manyTooltip": "{count} avvisi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"subtitle": "Gestisci la configurazione a livello di organizzazione, valida per tutti i tuoi tenant.",
|
||||
"billingTitle": "Fatturazione",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Fatturazione",
|
||||
"subtitle": "Acquisita una sola volta al primo onboarding e riutilizzata per ogni tenant della tua organizzazione. Aggiorna qui ogni volta che i dati di fatturazione cambiano.",
|
||||
"companyName": "Ragione sociale",
|
||||
"streetAddress": "Indirizzo",
|
||||
"postalCode": "CAP",
|
||||
"city": "Città",
|
||||
"country": "Paese",
|
||||
"vatNumber": "Partita IVA",
|
||||
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).",
|
||||
"billingEmail": "E-mail di fatturazione",
|
||||
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.",
|
||||
"notes": "Note",
|
||||
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.",
|
||||
"save": "Salva",
|
||||
"saved": "Salvato.",
|
||||
"saveFailed": "Impossibile salvare. Riprova.",
|
||||
"lastUpdated": "Ultimo aggiornamento {when}",
|
||||
"fullName": "Nome completo",
|
||||
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
|
||||
},
|
||||
"support": {
|
||||
"title": "Supporto",
|
||||
"subtitle": "Apri un ticket per fare una domanda, segnalare un bug o condividere un feedback. Le risposte verranno inviate alla tua email registrata.",
|
||||
"titleAdmin": "Coda supporto",
|
||||
"subtitleAdmin": "Ticket di tutti i clienti, attività più recente per prima.",
|
||||
"newTicket": "Nuovo ticket",
|
||||
"newTicketTitle": "Apri un ticket di supporto",
|
||||
"newTicketSubtitle": "Raccontaci cosa succede. Più dettagli ci dai, più velocemente possiamo aiutarti.",
|
||||
"empty": "Non hai ancora aperto ticket.",
|
||||
"emptyAdmin": "Nessun ticket di supporto in coda.",
|
||||
"fieldCategory": "Categoria",
|
||||
"fieldTitle": "Titolo",
|
||||
"fieldDescription": "Descrizione",
|
||||
"fieldStatus": "Stato",
|
||||
"titlePlaceholder": "Breve riassunto della tua richiesta",
|
||||
"descriptionPlaceholder": "Descrivi cosa è successo, cosa ti aspettavi e qualsiasi messaggio d'errore visto.",
|
||||
"descriptionHelp": "Puoi incollare messaggi d'errore e log. Niente password o altri segreti.",
|
||||
"submitTicket": "Invia ticket",
|
||||
"createFailed": "Impossibile creare il ticket. Riprova.",
|
||||
"category_bug": "Bug",
|
||||
"category_feature_request": "Richiesta funzionalità",
|
||||
"category_question": "Domanda",
|
||||
"category_billing": "Fatturazione",
|
||||
"category_other": "Altro",
|
||||
"status_open": "Aperto",
|
||||
"status_in_progress": "In corso",
|
||||
"status_waiting_for_customer": "In attesa della tua risposta",
|
||||
"status_resolved": "Risolto",
|
||||
"status_reopened": "Riaperto",
|
||||
"openedBy": "Aperto da {name} il {when}",
|
||||
"authorTagAdmin": "Supporto PieCed",
|
||||
"replyLabel": "Aggiungi una risposta",
|
||||
"replyPlaceholder": "Il tuo messaggio…",
|
||||
"replyPlaceholderReopen": "Risposta (questo riaprirà il ticket)…",
|
||||
"sendReply": "Invia risposta",
|
||||
"commentFailed": "Impossibile inviare la risposta. Riprova.",
|
||||
"closeTicket": "Segna come risolto",
|
||||
"confirmClose": "Segnare questo ticket come risolto? Potrai riaprirlo in seguito rispondendo.",
|
||||
"closeFailed": "Impossibile chiudere il ticket. Riprova.",
|
||||
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
|
||||
"adminControlsTitle": "Controlli admin",
|
||||
"updateFailed": "Impossibile salvare le modifiche. Riprova."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "Versioni OpenClaw",
|
||||
"subtitle": "Configura il tag predefinito della piattaforma e gli override per tenant per testare nuove release.",
|
||||
"defaultSection": "Predefinito piattaforma",
|
||||
"defaultDescription": "Usato da ogni tenant senza override proprio.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Lascia vuoto per usare il predefinito integrato dell'operatore.",
|
||||
"saveDefault": "Salva predefinito",
|
||||
"defaultSaved": "Predefinito salvato. I tenant senza override lo applicheranno al prossimo reconcile.",
|
||||
"saveFailed": "Salvataggio fallito. Riprova.",
|
||||
"overridesSection": "Override per tenant",
|
||||
"noTenants": "Nessun tenant nel cluster.",
|
||||
"statusOverridden": "Override",
|
||||
"statusFollowsDefault": "Segue predefinito",
|
||||
"builtinFallback": "(fallback integrato)",
|
||||
"defaultPrefix": "Predefinito:",
|
||||
"saveOverride": "Salva override",
|
||||
"clearOverride": "Rimuovi override"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,18 @@ export interface PiecedTenantSpec {
|
||||
workspaceFiles?: Record<string, string>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
suspend?: boolean;
|
||||
/**
|
||||
* Per-tenant OpenClaw image override (tag). Set only by platform
|
||||
* admins via the portal admin UI. Customers never see this field.
|
||||
* When unset or with empty Tag, the operator uses the platform
|
||||
* default from the pieced-openclaw-config ConfigMap.
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale (single
|
||||
* image-selector field avoids SSA field-ownership ambiguity).
|
||||
*/
|
||||
openClawImage?: {
|
||||
tag?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiecedTenantStatus {
|
||||
@@ -103,6 +115,16 @@ export interface PiecedTenantStatus {
|
||||
litellmKeyAlias?: string;
|
||||
tenantNamespace?: string;
|
||||
enabledPackages?: string[];
|
||||
/**
|
||||
* RFC3339 timestamp of when the tenant first transitioned to
|
||||
* suspended (Bug 37). Stamped by the operator on the first reconcile
|
||||
* with `spec.suspend=true` and cleared when the tenant resumes. Used
|
||||
* by the portal to render the "deleted in N days" countdown in the
|
||||
* suspended banner. The retention policy is 60 days from this
|
||||
* timestamp; see operator's `retentionAfterSuspend` constant for the
|
||||
* authoritative value.
|
||||
*/
|
||||
suspendedAt?: string;
|
||||
/**
|
||||
* Non-fatal issues from downstream resources surfaced by the operator
|
||||
* (e.g. an OpenClawInstance sub-condition reporting failure). The
|
||||
@@ -134,6 +156,15 @@ export interface PiecedTenant {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
/**
|
||||
* Set by the API server when something issues a Delete on the CR.
|
||||
* The CR continues to exist while finalizers run cleanup; once
|
||||
* they all remove themselves, the API server permanently removes
|
||||
* the CR. Used by the portal's status sync to detect tenants
|
||||
* being torn down — the customer should see "Deleted" rather
|
||||
* than "Ready" while the cleanup runs.
|
||||
*/
|
||||
deletionTimestamp?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
@@ -177,6 +208,41 @@ export interface BillingAddress {
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
/**
|
||||
* VAT identifier. Required for new submissions (Bug 35); older
|
||||
* tenant_requests rows in the audit table may have this absent.
|
||||
*/
|
||||
vatNumber?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-scoped billing record (Bug 35). One per ZITADEL org. Captured
|
||||
* during the first tenant request, editable afterwards via the
|
||||
* /settings/billing page. All future tenant requests in the same org
|
||||
* reuse this without prompting again.
|
||||
*
|
||||
* Personal orgs (`isPersonal=true` in their context) currently don't
|
||||
* fill this in — the wizard skips the step and the onboarding
|
||||
* endpoint doesn't enforce it. If they later want billing on file
|
||||
* (e.g. for invoices), they can fill the settings page manually.
|
||||
*
|
||||
* `vatNumber` is required for company orgs at write time, optional
|
||||
* for personal. The API enforces this; the type itself keeps it
|
||||
* optional because it's nullable in the DB and may be unset for
|
||||
* personal orgs.
|
||||
*/
|
||||
export interface OrgBilling {
|
||||
zitadelOrgId: string;
|
||||
companyName: string;
|
||||
streetAddress: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
vatNumber?: string | null;
|
||||
billingEmail: string;
|
||||
notes?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type TenantRequestStatus =
|
||||
@@ -219,6 +285,13 @@ export interface TenantRequest {
|
||||
* domain-uniqueness check on subsequent registrations.
|
||||
*/
|
||||
isPersonal?: boolean;
|
||||
/**
|
||||
* Feature 6: free-form note from the customer, attached at request
|
||||
* creation time. Currently used by resume requests (customer's
|
||||
* explanation of why they want reactivation); kept optional and
|
||||
* generic so future flows can reuse without schema work.
|
||||
*/
|
||||
customerNotes?: string | null;
|
||||
/**
|
||||
* Bug 13: when set, the customer has explicitly dismissed a rejected
|
||||
* request from their dashboard. Used by `listActiveTenantRequestsByOrgId`
|
||||
@@ -227,6 +300,19 @@ export interface TenantRequest {
|
||||
* login). Always null for non-rejected statuses.
|
||||
*/
|
||||
dismissedAt?: string | null;
|
||||
/**
|
||||
* Bug 37a: discriminator between provision (initial tenant creation,
|
||||
* the original purpose of this table) and resume (admin-gated
|
||||
* reactivation of a suspended tenant). Default 'provision' for all
|
||||
* pre-existing rows; resume rows have most provision fields null
|
||||
* but tenant_name set to the tenant being requested.
|
||||
*
|
||||
* Optional on the TS type so provision-only callers (like the
|
||||
* onboarding wizard's create flow) don't need to know about resume
|
||||
* requests. The DB column is NOT NULL DEFAULT 'provision', so rows
|
||||
* loaded via `mapRow` always have a value populated.
|
||||
*/
|
||||
requestType?: "provision" | "resume";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -245,6 +331,84 @@ export interface OnboardingInput {
|
||||
soulMd?: string;
|
||||
agentsMd?: string;
|
||||
packages?: string[];
|
||||
billingAddress: BillingAddress;
|
||||
/**
|
||||
* Bug 35: optional at the type level because the wizard skips the
|
||||
* billing step entirely when the org already has an `org_billing`
|
||||
* record. The onboarding API enforces "billing must be resolved by
|
||||
* the end" — either from `org_billing` lookup or from this field —
|
||||
* via runtime checks; the type just allows both paths.
|
||||
*/
|
||||
billingAddress?: BillingAddress;
|
||||
billingNotes?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 5: support tickets (lightweight customer support / feedback channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SupportTicketCategory =
|
||||
| "bug"
|
||||
| "feature_request"
|
||||
| "question"
|
||||
| "billing"
|
||||
| "other";
|
||||
|
||||
export type SupportTicketStatus =
|
||||
| "open" // new, awaiting first admin response
|
||||
| "in_progress" // admin is actively working on it
|
||||
| "waiting_for_customer" // admin replied, customer's turn
|
||||
| "resolved" // closed
|
||||
| "reopened"; // customer replied to a resolved ticket → flipped back
|
||||
|
||||
/**
|
||||
* Tickets are scoped strictly per-user, not per-org. A customer's
|
||||
* coworkers (even within the same org) cannot see each other's
|
||||
* tickets — confirmed design choice from Feature 5 discussion. This
|
||||
* is enforced both at the DB query layer (filter by zitadel_user_id)
|
||||
* and at the API layer (authorization checks).
|
||||
*
|
||||
* `contactEmail` and `contactName` are frozen at creation time so
|
||||
* the email-thread reply addresses still work after a user changes
|
||||
* their display name or email in ZITADEL. Standard ticketing pattern.
|
||||
*/
|
||||
export interface SupportTicket {
|
||||
id: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: SupportTicketCategory;
|
||||
status: SupportTicketStatus;
|
||||
contactEmail: string;
|
||||
contactName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type SupportTicketCommentAuthorKind = "customer" | "admin";
|
||||
|
||||
/**
|
||||
* Comment on a support ticket. Public (visible to both ends) — no
|
||||
* internal-notes feature in v1. `authorKind` drives styling (customer
|
||||
* vs admin bubble) and which email goes out.
|
||||
*
|
||||
* `authorName` is frozen at write time. If a user later changes their
|
||||
* display name, old comments still render with the name they had at
|
||||
* the time of writing — which is what you usually want for an audit
|
||||
* trail of conversations.
|
||||
*/
|
||||
export interface SupportTicketComment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
authorUserId: string;
|
||||
authorName: string;
|
||||
authorKind: SupportTicketCommentAuthorKind;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Detail view: the ticket plus its full chronological comment thread. */
|
||||
export interface SupportTicketDetail {
|
||||
ticket: SupportTicket;
|
||||
comments: SupportTicketComment[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user