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 && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+ })}
+
{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;