diff --git a/deploy/README-threema.md b/deploy/README-threema.md new file mode 100644 index 0000000..374ba4d --- /dev/null +++ b/deploy/README-threema.md @@ -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//threema`. +3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`. +4. Handler writes them to OpenBao at `secret/data/tenants/tenant-/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//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//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//threema`. +3. Handler calls relay `DELETE /admin/tokens/` (cascades to all routes for this tenant). +4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-/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. diff --git a/deploy/patch-i18n-threema.mjs b/deploy/patch-i18n-threema.mjs new file mode 100644 index 0000000..2d7f8e0 --- /dev/null +++ b/deploy/patch-i18n-threema.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Run: node deploy/patch-i18n-threema.mjs + * + * Idempotently injects: + * - packages.threema.{description, instructions, disclaimer} + * - channelUsers.threemaIdHelp + * + * into all four message files. Run from the pieced-portal repo root. + */ +import { readFileSync, writeFileSync } from "fs"; + +const i18n = { + en: { + pkg: { + description: + "Threema messaging routed through the PieCed central gateway. No Gateway account of your own required — PieCed mints credentials when you enable this package.", + instructions: + "1. Enable this package — PieCed provisions a central-gateway slot for your tenant.\n2. Add the Threema IDs you want to talk to under Authorized Users → threema.\n3. Each Threema ID can only belong to one PieCed tenant; if a registration fails, that ID is already in use elsewhere.", + disclaimer: + "Messages are end-to-end encrypted at the Threema boundary by the PieCed central gateway. Inbound and outbound message counts are logged per tenant for billing.", + }, + channelHelp: + "Enter the 8-character Threema ID (uppercase letters and digits, no asterisk) of the person you want to talk to. The * prefix is for Gateway accounts, which PieCed manages on your behalf.", + }, + de: { + pkg: { + description: + "Threema-Messaging über das zentrale PieCed-Gateway. Sie benötigen kein eigenes Gateway-Konto — PieCed stellt die Anmeldedaten beim Aktivieren dieses Pakets bereit.", + instructions: + "1. Aktivieren Sie dieses Paket — PieCed richtet einen zentralen Gateway-Slot für Ihren Tenant ein.\n2. Fügen Sie die Threema-IDs, mit denen Sie kommunizieren wollen, unter Autorisierte Benutzer → threema hinzu.\n3. Jede Threema-ID kann nur einem PieCed-Tenant zugeordnet sein; wenn die Registrierung fehlschlägt, ist die ID bereits anderweitig vergeben.", + disclaimer: + "Die Nachrichten werden am Threema-Übergang vom zentralen PieCed-Gateway Ende-zu-Ende verschlüsselt. Eingehende und ausgehende Nachrichten werden pro Tenant für die Abrechnung gezählt.", + }, + channelHelp: + "Geben Sie die 8-stellige Threema-ID (Großbuchstaben und Ziffern, ohne Sternchen) der Person ein, mit der Sie kommunizieren möchten. Das *-Präfix gehört zu Gateway-Konten, die PieCed für Sie verwaltet.", + }, + fr: { + pkg: { + description: + "Messagerie Threema via la passerelle centrale PieCed. Aucun compte Gateway personnel requis — PieCed génère les identifiants à l'activation du package.", + instructions: + "1. Activez ce package — PieCed approvisionne un slot de passerelle centrale pour votre tenant.\n2. Ajoutez les identifiants Threema avec lesquels vous souhaitez échanger sous Utilisateurs autorisés → threema.\n3. Chaque identifiant Threema ne peut appartenir qu'à un seul tenant PieCed ; si l'enregistrement échoue, l'identifiant est déjà utilisé ailleurs.", + disclaimer: + "Les messages sont chiffrés de bout en bout côté Threema par la passerelle centrale PieCed. Les volumes entrant et sortant sont consignés par tenant pour la facturation.", + }, + channelHelp: + "Saisissez l'identifiant Threema à 8 caractères (lettres majuscules et chiffres, sans astérisque) de la personne avec qui vous souhaitez communiquer. Le préfixe * concerne les comptes Gateway, gérés par PieCed pour vous.", + }, + it: { + pkg: { + description: + "Messaggistica Threema instradata tramite il gateway centrale PieCed. Non è necessario un account Gateway proprio — PieCed crea le credenziali quando attivi il pacchetto.", + instructions: + "1. Attiva questo pacchetto — PieCed predispone uno slot del gateway centrale per il tuo tenant.\n2. Aggiungi gli ID Threema con cui vuoi comunicare sotto Utenti autorizzati → threema.\n3. Ogni ID Threema può appartenere a un solo tenant PieCed; se la registrazione fallisce, l'ID è già usato altrove.", + disclaimer: + "I messaggi sono cifrati end-to-end al confine con Threema dal gateway centrale PieCed. I conteggi di messaggi in ingresso e uscita vengono registrati per tenant ai fini della fatturazione.", + }, + channelHelp: + "Inserisci l'ID Threema di 8 caratteri (lettere maiuscole e cifre, senza asterisco) della persona con cui vuoi comunicare. Il prefisso * appartiene agli account Gateway, gestiti da PieCed per te.", + }, +}; + +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; + + writeFileSync(path, JSON.stringify(json, null, 2) + "\n"); + console.log(`Patched ${path} — added packages.threema and channelUsers.threemaIdHelp`); +} diff --git a/src/app/api/tenants/[name]/threema/route.ts b/src/app/api/tenants/[name]/threema/route.ts new file mode 100644 index 0000000..9bdbed2 --- /dev/null +++ b/src/app/api/tenants/[name]/threema/route.ts @@ -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//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/ 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 }, + ); + } +} diff --git a/src/app/api/tenants/[name]/threema/routes/route.ts b/src/app/api/tenants/[name]/threema/routes/route.ts new file mode 100644 index 0000000..a1642ef --- /dev/null +++ b/src/app/api/tenants/[name]/threema/routes/route.ts @@ -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>) { + 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, + }); + } 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, + }); + } 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 }); +} diff --git a/src/components/channel-users/channel-users.tsx b/src/components/channel-users/channel-users.tsx index 1c90569..06b4c0a 100644 --- a/src/components/channel-users/channel-users.tsx +++ b/src/components/channel-users/channel-users.tsx @@ -8,12 +8,26 @@ import { useRouter } from "next/navigation"; const CHANNEL_ID_HELP: Record = { telegram: "telegramIdHelp", discord: "discordIdHelp", + threema: "threemaIdHelp", // email entry dropped in the Phase A rework — IMAP/SMTP is handled by // the `mail` skill (category=skill, not channel), so it never appears // in `enabledChannels`. If a future channel is added to the catalog, // give it an entry here so the help blurb renders. }; +/** + * Channels whose user list is managed through a dedicated endpoint + * instead of the generic PATCH /api/tenants/:name flow. + * + * Threema is the only one today — adding/removing a Threema ID has to + * synchronise with the central pieced-threema-gateway relay's `routes` + * table (uniqueness enforced there, not in K8s). The + * /api/tenants/:name/threema/routes endpoint owns that two-step + * coordination (relay first, then K8s, with compensation on K8s + * failure). Other channels just patch the K8s spec directly. + */ +const RELAY_MANAGED_CHANNELS = new Set(["threema"]); + interface ChannelUsersProps { tenantName: string; /** Currently enabled channel packages (e.g. ["telegram", "discord"]) */ @@ -63,6 +77,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(); @@ -74,18 +152,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, @@ -93,7 +180,7 @@ export function ChannelUsers({ }; updateChannelUsers(updated); }, - [channelUsers, updateChannelUsers] + [channelUsers, updateChannelUsers, removeFromRelayChannel] ); if (enabledChannels.length === 0) return null; diff --git a/src/components/packages/package-card.tsx b/src/components/packages/package-card.tsx index e309173..99c032d 100644 --- a/src/components/packages/package-card.tsx +++ b/src/components/packages/package-card.tsx @@ -30,6 +30,26 @@ export function PackageCard({ const [error, setError] = useState(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 ? (