From c67259ebe032ec3d88afadf3ec4e0fc1d1af1a19 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 11 Apr 2026 17:21:52 +0200 Subject: [PATCH] All the UI fixes for now --- src/app/[locale]/dashboard/page.tsx | 6 +- .../api/admin/requests/[id]/approve/route.ts | 27 +- .../api/admin/tenants/[name]/delete/route.ts | 10 + src/app/api/onboarding/route.ts | 37 ++- src/app/api/tenants/[name]/secrets/route.ts | 3 +- src/components/onboarding/wizard.tsx | 274 +++++++++++++++--- src/components/packages/package-list.tsx | 72 +++-- src/lib/crypto.ts | 71 +++++ src/lib/db.ts | 92 ++++-- src/lib/openbao.ts | 22 ++ src/messages/de.json | 11 +- src/messages/en.json | 11 +- src/messages/fr.json | 11 +- src/messages/it.json | 11 +- src/types/index.ts | 19 +- 15 files changed, 565 insertions(+), 112 deletions(-) create mode 100644 src/lib/crypto.ts diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index 6c88a7e..a416ba7 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -141,7 +141,11 @@ export default async function DashboardPage() { // No tenant → check for existing request, show onboarding flow if (!myTenant) { const existingRequest = await getTenantRequestByOrgId(user.orgId); - const initialState = existingRequest?.status ?? "no_request"; + // Treat "deleted" as no request — customer can re-onboard + const initialState = + !existingRequest || existingRequest.status === "deleted" + ? "no_request" + : existingRequest.status; return (
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 137ea91..90201d9 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -1,12 +1,19 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; -import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; +import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets } from "@/lib/db"; import { createTenant } from "@/lib/k8s"; import { sendApprovalEmail } from "@/lib/email"; +import { decryptSecrets } from "@/lib/crypto"; +import { writePackageSecrets } from "@/lib/openbao"; /** * POST /api/admin/requests/[id]/approve - * Approve a tenant request: create the PiecedTenant CR, update status, notify customer. + * 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. Create PiecedTenant CR + * 5. Update request status, notify customer. * Also supports re-approving a previously rejected request (clears admin notes). */ export async function POST( @@ -48,7 +55,17 @@ export async function POST( .slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`; try { - // Create the PiecedTenant CR + // Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard) + if (tenantRequest.encryptedSecrets) { + const secrets = await decryptSecrets(tenantRequest.encryptedSecrets); + for (const [packageId, pkgSecrets] of Object.entries(secrets)) { + await writePackageSecrets(`tenant-${tenantName}`, packageId, pkgSecrets); + } + // Step 2: Null the encrypted column — secrets are now safely in OpenBao + await clearEncryptedSecrets(id); + } + + // Step 3: Create the PiecedTenant CR await createTenant( tenantName, { @@ -64,14 +81,14 @@ export async function POST( } ); - // Update request status — clear admin notes on re-approval + // Step 4: Update request status — clear admin notes on re-approval const updated = await updateTenantRequestStatus(id, "provisioning", { adminNotes: isReApproval ? null : adminNotes, tenantName, clearAdminNotes: isReApproval, }); - // Notify customer + // Step 5: Notify customer await sendApprovalEmail( tenantRequest.contactEmail, tenantRequest.contactName, diff --git a/src/app/api/admin/tenants/[name]/delete/route.ts b/src/app/api/admin/tenants/[name]/delete/route.ts index 4c94bd9..851870d 100644 --- a/src/app/api/admin/tenants/[name]/delete/route.ts +++ b/src/app/api/admin/tenants/[name]/delete/route.ts @@ -1,11 +1,14 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenant, deleteTenant } from "@/lib/k8s"; +import { markTenantRequestDeletedByTenantName } from "@/lib/db"; /** * POST /api/admin/tenants/[name]/delete * Delete a PiecedTenant CR. The operator handles cleanup * (namespace, vault, litellm team, etc.). + * Also marks the associated tenant_request as "deleted" so the + * customer can re-submit the onboarding wizard. */ export async function POST( _request: Request, @@ -26,6 +29,13 @@ export async function POST( try { await deleteTenant(name); + + // Mark the associated tenant_request as "deleted" so the customer + // sees the wizard again instead of a stale "active" status + await markTenantRequestDeletedByTenantName(name).catch((e) => + console.error("Failed to update tenant request after delete:", e) + ); + return NextResponse.json({ message: "Tenant deletion initiated. The operator will clean up all resources.", }); diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 8a969f3..30f937a 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -3,9 +3,11 @@ import { getSessionUser } from "@/lib/session"; import { createTenantRequest, getTenantRequestByOrgId, + deleteTenantRequest, } from "@/lib/db"; import { getTenant, listTenants } from "@/lib/k8s"; import { sendAdminNotificationEmail } from "@/lib/email"; +import { encryptSecrets } from "@/lib/crypto"; import type { OnboardingInput } from "@/types"; import { z } from "zod"; @@ -13,6 +15,9 @@ const onboardingSchema = z.object({ agentName: z.string().min(1).max(50), soulMd: z.string().max(10_000).optional(), packages: z.array(z.string()).optional(), + packageSecrets: z + .record(z.string(), z.record(z.string(), z.string())) + .optional(), billingAddress: z.object({ company: z.string().optional(), street: z.string().optional(), @@ -54,7 +59,7 @@ export async function GET() { // Check if there's a pending request const request = await getTenantRequestByOrgId(user.orgId); - if (!request) { + if (!request || request.status === "deleted") { return NextResponse.json({ state: "no_request" }); } @@ -88,7 +93,11 @@ export async function GET() { * POST /api/onboarding * Submit the onboarding wizard. Creates a tenant_request with status "pending". * The actual PiecedTenant CR is NOT created yet — admin approval required. - * Sends a notification email to the admin. + * + * If packageSecrets are provided (for packages requiring credentials like + * Telegram, Discord, Email), they are encrypted with AES-256-GCM and stored + * as a BYTEA blob. They are decrypted only during admin approval to write + * to OpenBao. */ export async function POST(request: Request) { const user = await getSessionUser(); @@ -97,13 +106,18 @@ export async function POST(request: Request) { // Check for existing request const existing = await getTenantRequestByOrgId(user.orgId); - if (existing) { + if (existing && existing.status !== "deleted") { return NextResponse.json( { error: "Onboarding request already submitted.", request: existing }, { status: 409 } ); } + // If previous request was deleted, remove it so a fresh one can be created + if (existing && existing.status === "deleted") { + await deleteTenantRequest(existing.id); + } + // Check for existing tenant const allTenants = await listTenants(); const myTenant = allTenants.find( @@ -125,7 +139,21 @@ export async function POST(request: Request) { ); } - const input: OnboardingInput = parsed.data; + const input: OnboardingInput & { packageSecrets?: Record> } = parsed.data; + + // Encrypt package secrets if provided + let encryptedSecrets: Buffer | undefined; + if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { + try { + encryptedSecrets = await encryptSecrets(input.packageSecrets); + } catch (e: any) { + console.error("Failed to encrypt package secrets:", e); + return NextResponse.json( + { error: "Failed to secure credentials. Please try again." }, + { status: 500 } + ); + } + } const tenantRequest = await createTenantRequest({ zitadelOrgId: user.orgId, @@ -138,6 +166,7 @@ export async function POST(request: Request) { packages: input.packages ?? [], billingAddress: input.billingAddress, billingNotes: input.billingNotes, + encryptedSecrets, }); // Notify admin about the new request diff --git a/src/app/api/tenants/[name]/secrets/route.ts b/src/app/api/tenants/[name]/secrets/route.ts index 6b13d98..bc687f5 100644 --- a/src/app/api/tenants/[name]/secrets/route.ts +++ b/src/app/api/tenants/[name]/secrets/route.ts @@ -60,7 +60,8 @@ export async function POST( return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - await writePackageSecrets(name, packageId, secrets); + // Use tenant-{name} to match the operator's vault path convention + await writePackageSecrets(`tenant-${name}`, packageId, secrets); return NextResponse.json({ ok: true }); } catch (e: any) { console.error("Secret write error:", e.message); diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index 2441227..775495b 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/components/ui/card"; +import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages"; type Step = "welcome" | "configure" | "billing" | "confirm"; @@ -19,13 +20,10 @@ You are a helpful AI assistant for {company}. You are professional, concise, and - Respect privacy and confidentiality `; -const AVAILABLE_PACKAGES = [ - "telegram", - "discord", - "email", - "web-search", - "document-processing", -]; +const CATEGORIES = [ + { key: "channel" as const, labelKey: "categories.channels" }, + { key: "skill" as const, labelKey: "categories.skills" }, +] as const; interface WizardProps { orgName: string; @@ -34,6 +32,7 @@ interface WizardProps { export function OnboardingWizard({ orgName, onComplete }: WizardProps) { const t = useTranslations("onboarding"); + const tPkg = useTranslations("packages"); const tCommon = useTranslations("common"); const [step, setStep] = useState("welcome"); @@ -54,6 +53,15 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { billingNotes: "", }); + // Per-package collected secrets: { "telegram": { "bot-token": "123:ABC" }, ... } + const [packageSecrets, setPackageSecrets] = useState< + Record> + >({}); + // Per-package disclaimer acceptance + const [disclaimerAccepted, setDisclaimerAccepted] = useState< + Record + >({}); + const stepIndex = STEPS.indexOf(step); const goNext = () => { @@ -64,13 +72,52 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { if (stepIndex > 0) setStep(STEPS[stepIndex - 1]); }; - const togglePackage = (pkg: string) => { - setConfig((prev) => ({ - ...prev, - packages: prev.packages.includes(pkg) - ? prev.packages.filter((p) => p !== pkg) - : [...prev.packages, pkg], - })); + const togglePackage = useCallback((pkgId: string) => { + setConfig((prev) => { + const removing = prev.packages.includes(pkgId); + if (removing) { + setPackageSecrets((s) => { + const next = { ...s }; + delete next[pkgId]; + return next; + }); + setDisclaimerAccepted((d) => { + const next = { ...d }; + delete next[pkgId]; + return next; + }); + } + return { + ...prev, + packages: removing + ? prev.packages.filter((p) => p !== pkgId) + : [...prev.packages, pkgId], + }; + }); + }, []); + + const updateSecret = useCallback( + (pkgId: string, key: string, value: string) => { + setPackageSecrets((prev) => ({ + ...prev, + [pkgId]: { ...(prev[pkgId] || {}), [key]: value }, + })); + }, + [] + ); + + // Validate that all secret-requiring enabled packages have complete credentials + const packageCredentialsValid = (): boolean => { + for (const pkgId of config.packages) { + const def = PACKAGE_CATALOG.find((p) => p.id === pkgId); + if (!def?.requiresSecrets) continue; + const secrets = packageSecrets[pkgId] || {}; + for (const field of def.secrets || []) { + if (!secrets[field.key]?.trim()) return false; + } + if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false; + } + return true; }; const handleSubmit = async () => { @@ -78,10 +125,25 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { setError(""); try { + // Build secrets payload — only for packages that require them + const secretsPayload: Record> = {}; + for (const pkgId of config.packages) { + const def = PACKAGE_CATALOG.find((p) => p.id === pkgId); + if (def?.requiresSecrets && packageSecrets[pkgId]) { + secretsPayload[pkgId] = packageSecrets[pkgId]; + } + } + const res = await fetch("/api/onboarding", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), + body: JSON.stringify({ + ...config, + packageSecrets: + Object.keys(secretsPayload).length > 0 + ? secretsPayload + : undefined, + }), }); if (!res.ok) { @@ -212,26 +274,151 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {

+ {/* Packages — grouped by category */}
-
- {AVAILABLE_PACKAGES.map((pkg) => ( - - ))} -
+ + {CATEGORIES.map(({ key, labelKey }) => { + const packages = PACKAGE_CATALOG.filter( + (p) => p.category === key + ); + if (packages.length === 0) return null; + + return ( +
+

+ {tPkg(labelKey)} +

+
+ {packages.map((pkg) => { + const isSelected = config.packages.includes(pkg.id); + const secrets = packageSecrets[pkg.id] || {}; + + return ( +
+ {/* Toggle row */} + + + {/* Inline credential inputs — expand when selected + requires secrets */} + {isSelected && pkg.requiresSecrets && ( +
+ {pkg.instructionsKey && ( +
+ {tPkg( + pkg.instructionsKey.replace( + "packages.", + "" + ) + )} +
+ )} + + {(pkg.secrets || []).map((field) => ( + + ))} + + {pkg.disclaimerKey && ( + + )} +
+ )} +
+ ); + })} +
+
+ ); + })} +

{t("packagesHint")}

@@ -247,7 +434,8 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { @@ -436,9 +624,23 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
)} + {config.packages.some((id) => + PACKAGE_CATALOG.find((p) => p.id === id)?.requiresSecrets + ) && ( +
+ + {t("credentialsProvided")} + + + ✓ + +
+ )} {config.billingAddress.company && (
- {t("billingCompany")} + + {t("billingCompany")} + {config.billingAddress.company} @@ -455,9 +657,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { )}
-

- {t("confirmNote")} -

+

{t("confirmNote")}

{error && ( diff --git a/src/components/packages/package-list.tsx b/src/components/packages/package-list.tsx index ac48f67..efed5ad 100644 --- a/src/components/packages/package-list.tsx +++ b/src/components/packages/package-list.tsx @@ -1,40 +1,66 @@ "use client"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { PACKAGE_CATALOG } from "@/lib/packages"; import { PackageCard } from "./package-card"; -import type { PiecedTenantStatus } from "@/types"; interface Props { tenantName: string; enabledPackages: string[]; - conditions?: PiecedTenantStatus["conditions"]; + conditions?: Array<{ type: string; status: string; reason?: string }>; + onRefresh?: () => void; } -export function PackageList({ tenantName, enabledPackages, conditions }: Props) { - const router = useRouter(); +const CATEGORIES = [ + { key: "channel" as const, labelKey: "categories.channels" }, + { key: "skill" as const, labelKey: "categories.skills" }, +] as const; - function getStatus(pkgId: string): "pending" | "active" | "error" | undefined { - if (!conditions) return enabledPackages.includes(pkgId) ? "pending" : undefined; - const cond = conditions.find((c) => c.type === `Package/${pkgId}`); - if (!cond) return enabledPackages.includes(pkgId) ? "pending" : undefined; - if (cond.status === "True") return "active"; - if (cond.status === "False") return "error"; - return "pending"; - } +function getPackageStatus( + pkgId: string, + enabled: boolean, + conditions?: Props["conditions"] +): "pending" | "active" | "error" | undefined { + if (!enabled) return undefined; + const cond = conditions?.find((c) => c.type === `Package/${pkgId}`); + if (!cond) return "pending"; + if (cond.status === "True") return "active"; + if (cond.reason === "SecretReady") return "active"; + return "error"; +} + +export function PackageList({ tenantName, enabledPackages, conditions, onRefresh }: Props) { + const t = useTranslations("packages"); + const router = useRouter(); + const handleRefresh = onRefresh || (() => router.refresh()); return ( -
- {PACKAGE_CATALOG.map((pkg) => ( - router.refresh()} - /> - ))} +
+ {CATEGORIES.map(({ key, labelKey }) => { + const packages = PACKAGE_CATALOG.filter((p) => p.category === key); + if (packages.length === 0) return null; + + return ( +
+

+ {t(labelKey)} +

+
+ {packages.map((pkg) => ( + + ))} +
+
+ ); + })}
); } diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..33fd3e8 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,71 @@ +/** + * AES-256-GCM encryption for tenant package credentials. + * + * Credentials are encrypted before storage in tenant_requests.encrypted_secrets + * and decrypted only during admin approval to write to OpenBao tenant paths. + * + * Format: [12-byte IV][ciphertext][16-byte auth tag] as a single Buffer. + * + * Provision the key: + * bao kv put pieced/portal/encryption-key key="$(openssl rand -hex 32)" + */ + +import { randomBytes, createCipheriv, createDecipheriv } from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +let cachedKey: Buffer | null = null; + +async function getEncryptionKey(): Promise { + if (cachedKey) return cachedKey; + + const { readSecret } = await import("./openbao"); + const data = await readSecret("pieced/portal/encryption-key"); + const hex = data?.key; + if (!hex || typeof hex !== "string" || hex.length !== 64) { + throw new Error( + "Invalid encryption key at secret/data/pieced/portal/encryption-key" + ); + } + cachedKey = Buffer.from(hex, "hex"); + return cachedKey; +} + +export async function encryptSecrets( + secrets: Record> +): Promise { + const key = await getEncryptionKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const plaintext = JSON.stringify(secrets); + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, encrypted, tag]); +} + +export async function decryptSecrets( + blob: Buffer +): Promise>> { + const key = await getEncryptionKey(); + + const iv = blob.subarray(0, IV_LENGTH); + const tag = blob.subarray(blob.length - TAG_LENGTH); + const ciphertext = blob.subarray(IV_LENGTH, blob.length - TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + return JSON.parse(decrypted.toString("utf8")); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 805e610..255b6bb 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -34,27 +34,31 @@ function getPool(): Pool { // --------------------------------------------------------------------------- const MIGRATION_SQL = ` -CREATE TABLE IF NOT EXISTS tenant_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - zitadel_org_id TEXT NOT NULL UNIQUE, - zitadel_user_id TEXT NOT NULL, - company_name TEXT NOT NULL, - contact_name TEXT NOT NULL, - contact_email TEXT NOT NULL, - agent_name TEXT NOT NULL DEFAULT 'Assistant', - soul_md TEXT, - packages TEXT[] DEFAULT '{}', - billing_address JSONB DEFAULT '{}', - billing_notes TEXT, - status TEXT NOT NULL DEFAULT 'pending', - admin_notes TEXT, - tenant_name TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); + CREATE TABLE IF NOT EXISTS tenant_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + zitadel_org_id TEXT NOT NULL UNIQUE, + zitadel_user_id TEXT NOT NULL, + company_name TEXT NOT NULL, + contact_name TEXT NOT NULL, + contact_email TEXT NOT NULL, + agent_name TEXT NOT NULL DEFAULT 'Assistant', + soul_md TEXT, + packages TEXT[] DEFAULT '{}', + billing_address JSONB DEFAULT '{}', + billing_notes TEXT, + status TEXT NOT NULL DEFAULT 'pending', + admin_notes TEXT, + tenant_name TEXT, + encrypted_secrets BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); -CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status); -CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id); + CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status); + CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id); + + -- Idempotent column add for existing databases + ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA; `; let migrated = false; @@ -70,14 +74,17 @@ export async function ensureSchema(): Promise { // --------------------------------------------------------------------------- export async function createTenantRequest( - params: Omit + params: Omit & { + encryptedSecrets?: Buffer; + } ): Promise { 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, soul_md, packages, billing_address, billing_notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + (zitadel_org_id, zitadel_user_id, company_name, contact_name, + contact_email, agent_name, soul_md, packages, billing_address, + billing_notes, encrypted_secrets) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ params.zitadelOrgId, @@ -90,6 +97,7 @@ export async function createTenantRequest( params.packages, JSON.stringify(params.billingAddress), params.billingNotes, + params.encryptedSecrets ?? null, ] ); return mapRow(result.rows[0]); @@ -154,6 +162,41 @@ export async function updateTenantRequestStatus( return mapRow(result.rows[0]); } +/** + * Clear the encrypted_secrets column after secrets have been written to OpenBao. + * Called during admin approval after successful vault writes. + */ +export async function clearEncryptedSecrets(requestId: string): Promise { + await ensureSchema(); + await getPool().query( + "UPDATE tenant_requests SET encrypted_secrets = NULL, updated_at = now() WHERE id = $1", + [requestId] + ); +} + +/** + * Mark a tenant request as "deleted" when the associated tenant CR is deleted. + * This allows the customer to re-submit the onboarding wizard. + */ +export async function markTenantRequestDeletedByTenantName( + tenantName: string +): Promise { + await ensureSchema(); + await getPool().query( + "UPDATE tenant_requests SET status = 'deleted', tenant_name = NULL, updated_at = now() WHERE tenant_name = $1", + [tenantName] + ); +} + +/** + * Delete a tenant request row entirely. Used when a customer re-submits + * after their previous tenant was deleted by admin. + */ +export async function deleteTenantRequest(id: string): Promise { + await ensureSchema(); + await getPool().query("DELETE FROM tenant_requests WHERE id = $1", [id]); +} + /** * Sync provisioning statuses: for all requests with status "provisioning", * check if the PiecedTenant CR has reached "Ready" and update to "active". @@ -205,6 +248,7 @@ function mapRow(row: any): TenantRequest { status: row.status as TenantRequestStatus, adminNotes: row.admin_notes, tenantName: row.tenant_name, + encryptedSecrets: row.encrypted_secrets ?? null, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; diff --git a/src/lib/openbao.ts b/src/lib/openbao.ts index a4a1d67..e558116 100644 --- a/src/lib/openbao.ts +++ b/src/lib/openbao.ts @@ -39,6 +39,28 @@ async function authenticate(): Promise { return token; } +/** + * Read a KV v2 secret. Path relative to KV mount. + * Returns .data.data object or null if 404. + */ +export async function readSecret( + path: string +): Promise | null> { + const token = await authenticate(); + const res = await fetch(`${OPENBAO_ADDR}/v1/secret/data/${path}`, { + headers: { "X-Vault-Token": token }, + }); + + if (res.status === 404) return null; + if (!res.ok) { + const body = await res.text(); + throw new Error(`OpenBao read failed: ${res.status} ${body}`); + } + + const json = await res.json(); + return json.data?.data ?? null; +} + export async function writePackageSecrets( tenantId: string, packageId: string, diff --git a/src/messages/de.json b/src/messages/de.json index d4ca1a3..504e246 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -50,7 +50,7 @@ "soulMd": "Persönlichkeit (SOUL.md)", "soulMdHint": "Definiert das Verhalten Ihres Assistenten. Markdown-Format. Kann später bearbeitet werden.", "packages": "Pakete", - "packagesHint": "Optionale Integrationen. Können auch später aktiviert werden.", + "packagesHint": "Optionale Integrationen. Pakete mit Zugangsdaten werden diese inline abfragen. Können auch später aktiviert werden.", "billingTitle": "Rechnungsinformationen", "billingDescription": "Wir benötigen Ihre Rechnungsadresse für die Fakturierung. Ein Zahlungsanbieter wird zukünftig integriert.", "billingCompany": "Firma", @@ -63,6 +63,7 @@ "confirmTitle": "Überprüfen & absenden", "confirmDescription": "Bitte überprüfen Sie Ihre Einstellungen. Ihr Antrag wird von unserem Team geprüft, bevor die Bereitstellung beginnt.", "confirmNote": "Nach dem Absenden prüft unser Team Ihren Antrag und die Rechnungsangaben. Sie erhalten Zugang nach Genehmigung — normalerweise innerhalb eines Werktages.", + "credentialsProvided": "Zugangsdaten hinterlegt", "submitRequest": "Antrag absenden", "back": "Zurück", "next": "Weiter", @@ -113,6 +114,10 @@ "seedingNote": "Workspace-Dateien werden beim ersten Start geladen. Eine Aktualisierung auf einer bestehenden Instanz löst ein ConfigMap-Update und Pod-Neustart aus." }, "packages": { + "categories": { + "channels": "Kanäle", + "skills": "Fähigkeiten" + }, "enable": "Aktivieren", "disable": "Deaktivieren", "enableAndSave": "Aktivieren & Speichern", @@ -208,6 +213,8 @@ "deleteTitle": "Mandant löschen", "deleteWarning": "Dies löscht den Mandanten, seinen Namespace, Secrets und alle zugehörigen Daten unwiderruflich.", "confirmDelete": "Endgültig löschen", - "loadingTenants": "Mandanten werden geladen…" + "loadingTenants": "Mandanten werden geladen…", + "filter_deleted": "Gelöscht", + "filter_active": "Aktiv" } } diff --git a/src/messages/en.json b/src/messages/en.json index 8cbe27d..226e831 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -50,7 +50,7 @@ "soulMd": "Personality (SOUL.md)", "soulMdHint": "This defines how your assistant behaves. Markdown format. You can edit this later.", "packages": "Packages", - "packagesHint": "Optional integrations. You can enable these later too.", + "packagesHint": "Optional integrations. Packages requiring credentials will ask for them inline. You can also enable these later.", "billingTitle": "Billing information", "billingDescription": "We need your billing address to set up invoicing. A payment provider will be integrated in the future.", "billingCompany": "Company", @@ -63,6 +63,7 @@ "confirmTitle": "Review & submit", "confirmDescription": "Please review your setup. Your request will be reviewed by our team before provisioning.", "confirmNote": "After submission, our team will review your request and billing details. You'll receive access once approved — typically within one business day.", + "credentialsProvided": "Credentials provided", "submitRequest": "Submit request", "back": "Back", "next": "Next", @@ -113,6 +114,10 @@ "seedingNote": "Workspace files are seeded on first boot. Updating on an existing instance triggers a ConfigMap update and pod restart." }, "packages": { + "categories": { + "channels": "Channels", + "skills": "Skills" + }, "enable": "Enable", "disable": "Disable", "enableAndSave": "Enable & Save", @@ -208,6 +213,8 @@ "deleteTitle": "Delete tenant", "deleteWarning": "This will permanently delete the tenant, its namespace, secrets, and all associated data. This action cannot be undone.", "confirmDelete": "Delete permanently", - "loadingTenants": "Loading tenants…" + "loadingTenants": "Loading tenants…", + "filter_deleted": "Deleted", + "filter_active": "Active" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 8650e07..ad24354 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -50,7 +50,7 @@ "soulMd": "Personnalité (SOUL.md)", "soulMdHint": "Définit le comportement de votre assistant. Format Markdown. Modifiable ultérieurement.", "packages": "Paquets", - "packagesHint": "Intégrations optionnelles. Vous pouvez aussi les activer plus tard.", + "packagesHint": "Intégrations optionnelles. Les paquets nécessitant des identifiants les demanderont en ligne. Vous pouvez aussi les activer plus tard.", "billingTitle": "Informations de facturation", "billingDescription": "Nous avons besoin de votre adresse de facturation. Un prestataire de paiement sera intégré à l'avenir.", "billingCompany": "Entreprise", @@ -63,6 +63,7 @@ "confirmTitle": "Vérifier et envoyer", "confirmDescription": "Veuillez vérifier votre configuration. Votre demande sera examinée par notre équipe avant la mise en service.", "confirmNote": "Après l'envoi, notre équipe examinera votre demande et vos informations de facturation. Vous recevrez l'accès après approbation — généralement dans un délai d'un jour ouvrable.", + "credentialsProvided": "Identifiants fournis", "submitRequest": "Envoyer la demande", "back": "Retour", "next": "Suivant", @@ -113,6 +114,10 @@ "seedingNote": "Les fichiers workspace sont initialisés au premier démarrage. Une mise à jour sur une instance existante déclenche une mise à jour du ConfigMap et un redémarrage du pod." }, "packages": { + "categories": { + "channels": "Canaux", + "skills": "Compétences" + }, "enable": "Activer", "disable": "Désactiver", "enableAndSave": "Activer et enregistrer", @@ -208,6 +213,8 @@ "deleteTitle": "Supprimer le locataire", "deleteWarning": "Cela supprimera définitivement le locataire, son namespace, ses secrets et toutes les données associées. Cette action est irréversible.", "confirmDelete": "Supprimer définitivement", - "loadingTenants": "Chargement des locataires…" + "loadingTenants": "Chargement des locataires…", + "filter_deleted": "Supprimé", + "filter_active": "Actif" } } diff --git a/src/messages/it.json b/src/messages/it.json index 6157009..fa04ff5 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -50,7 +50,7 @@ "soulMd": "Personalità (SOUL.md)", "soulMdHint": "Definisce il comportamento del tuo assistente. Formato Markdown. Modificabile in seguito.", "packages": "Pacchetti", - "packagesHint": "Integrazioni opzionali. Puoi attivarle anche in seguito.", + "packagesHint": "Integrazioni opzionali. I pacchetti che richiedono credenziali le chiederanno inline. Puoi attivarli anche in seguito.", "billingTitle": "Informazioni di fatturazione", "billingDescription": "Abbiamo bisogno del tuo indirizzo di fatturazione. Un fornitore di pagamento verrà integrato in futuro.", "billingCompany": "Azienda", @@ -63,6 +63,7 @@ "confirmTitle": "Verifica e invia", "confirmDescription": "Verifica la tua configurazione. La tua richiesta verrà esaminata dal nostro team prima dell'attivazione.", "confirmNote": "Dopo l'invio, il nostro team esaminerà la tua richiesta e i dati di fatturazione. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.", + "credentialsProvided": "Credenziali fornite", "submitRequest": "Invia richiesta", "back": "Indietro", "next": "Avanti", @@ -113,6 +114,10 @@ "seedingNote": "I file workspace vengono inizializzati al primo avvio. Un aggiornamento su un'istanza esistente attiva un aggiornamento del ConfigMap e un riavvio del pod." }, "packages": { + "categories": { + "channels": "Canali", + "skills": "Capacità" + }, "enable": "Attiva", "disable": "Disattiva", "enableAndSave": "Attiva e salva", @@ -208,6 +213,8 @@ "deleteTitle": "Elimina tenant", "deleteWarning": "Questo eliminerà permanentemente il tenant, il suo namespace, i secrets e tutti i dati associati. Questa azione non può essere annullata.", "confirmDelete": "Elimina definitivamente", - "loadingTenants": "Caricamento tenant…" + "loadingTenants": "Caricamento tenant…", + "filter_deleted": "Eliminato", + "filter_active": "Attivo" } } diff --git a/src/types/index.ts b/src/types/index.ts index 4fe42d4..31ad973 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,10 +69,7 @@ export interface UsageSummary { period: string; } -// --------------------------------------------------------------------------- -// Registration & Onboarding -// --------------------------------------------------------------------------- - +// Registration export interface RegistrationInput { companyName: string; givenName: string; @@ -81,6 +78,7 @@ export interface RegistrationInput { preferredLanguage?: string; } +// Billing address export interface BillingAddress { company?: string; street?: string; @@ -90,11 +88,12 @@ export interface BillingAddress { } export type TenantRequestStatus = - | "pending" // Submitted, awaiting admin approval - | "approved" // Admin approved, provisioning will start - | "provisioning" // PiecedTenant CR created, operator reconciling - | "active" // Tenant running - | "rejected"; // Admin rejected + | "pending" // Submitted, awaiting admin approval + | "approved" // Admin approved, provisioning will start + | "provisioning" // PiecedTenant CR created, operator reconciling + | "active" // Tenant running + | "rejected" // Admin rejected + | "deleted"; // Tenant was deleted by admin export interface TenantRequest { id: string; @@ -111,10 +110,12 @@ export interface TenantRequest { status: TenantRequestStatus; adminNotes?: string; tenantName?: string; + encryptedSecrets?: Buffer | null; createdAt: string; updatedAt: string; } +// Onboarding wizard input export interface OnboardingInput { agentName: string; soulMd?: string;