All the UI fixes for now
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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<string, Record<string, string>> } = 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Step>("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<string, Record<string, string>>
|
||||
>({});
|
||||
// Per-package disclaimer acceptance
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
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<string, Record<string, string>> = {};
|
||||
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) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Packages — grouped by category */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-2">
|
||||
{t("packages")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_PACKAGES.map((pkg) => (
|
||||
<button
|
||||
key={pkg}
|
||||
type="button"
|
||||
onClick={() => togglePackage(pkg)}
|
||||
className={`text-left px-3 py-2 border rounded-lg text-xs transition-colors ${
|
||||
config.packages.includes(pkg)
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{pkg}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{CATEGORIES.map(({ key, labelKey }) => {
|
||||
const packages = PACKAGE_CATALOG.filter(
|
||||
(p) => p.category === key
|
||||
);
|
||||
if (packages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="mb-4">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-text-muted/70 mb-1.5">
|
||||
{tPkg(labelKey)}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{packages.map((pkg) => {
|
||||
const isSelected = config.packages.includes(pkg.id);
|
||||
const secrets = packageSecrets[pkg.id] || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{/* Toggle row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePackage(pkg.id)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-surface-3/30 transition-colors"
|
||||
>
|
||||
<div className="text-left">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected
|
||||
? "text-accent"
|
||||
: "text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{pkg.name}
|
||||
</span>
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="ml-1.5 text-[10px] text-text-muted">
|
||||
({tPkg("requiresApiKey")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 ml-3 h-5 w-9 rounded-full transition-colors ${
|
||||
isSelected ? "bg-accent" : "bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full bg-white shadow-sm mt-0.5 transition-transform ${
|
||||
isSelected
|
||||
? "translate-x-4"
|
||||
: "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Inline credential inputs — expand when selected + requires secrets */}
|
||||
{isSelected && pkg.requiresSecrets && (
|
||||
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
|
||||
{pkg.instructionsKey && (
|
||||
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{tPkg(
|
||||
pkg.instructionsKey.replace(
|
||||
"packages.",
|
||||
""
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pkg.secrets || []).map((field) => (
|
||||
<label key={field.key} className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
{tPkg(
|
||||
field.labelKey.replace("packages.", "")
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={tPkg(
|
||||
field.placeholderKey.replace(
|
||||
"packages.",
|
||||
""
|
||||
)
|
||||
)}
|
||||
value={secrets[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
updateSecret(
|
||||
pkg.id,
|
||||
field.key,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{pkg.disclaimerKey && (
|
||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
disclaimerAccepted[pkg.id] || false
|
||||
}
|
||||
onChange={(e) =>
|
||||
setDisclaimerAccepted((prev) => ({
|
||||
...prev,
|
||||
[pkg.id]: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="mt-0.5 accent-accent"
|
||||
/>
|
||||
<span>
|
||||
{tPkg(
|
||||
pkg.disclaimerKey.replace(
|
||||
"packages.",
|
||||
""
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("packagesHint")}
|
||||
</p>
|
||||
@@ -247,7 +434,8 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
disabled={!packageCredentialsValid()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
@@ -436,9 +624,23 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{config.packages.some((id) =>
|
||||
PACKAGE_CATALOG.find((p) => p.id === id)?.requiresSecrets
|
||||
) && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">
|
||||
{t("credentialsProvided")}
|
||||
</span>
|
||||
<span className="text-emerald-400 text-xs font-medium">
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{config.billingAddress.company && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("billingCompany")}</span>
|
||||
<span className="text-text-muted">
|
||||
{t("billingCompany")}
|
||||
</span>
|
||||
<span className="text-text-primary">
|
||||
{config.billingAddress.company}
|
||||
</span>
|
||||
@@ -455,9 +657,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("confirmNote")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PACKAGE_CATALOG.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
enabled={enabledPackages.includes(pkg.id)}
|
||||
status={enabledPackages.includes(pkg.id) ? getStatus(pkg.id) : undefined}
|
||||
tenantName={tenantName}
|
||||
onToggled={() => router.refresh()}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-6">
|
||||
{CATEGORIES.map(({ key, labelKey }) => {
|
||||
const packages = PACKAGE_CATALOG.filter((p) => p.category === key);
|
||||
if (packages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t(labelKey)}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{packages.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
enabled={enabledPackages.includes(pkg.id)}
|
||||
status={getPackageStatus(pkg.id, enabledPackages.includes(pkg.id), conditions)}
|
||||
tenantName={tenantName}
|
||||
onToggled={handleRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/lib/crypto.ts
Normal file
71
src/lib/crypto.ts
Normal file
@@ -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<Buffer> {
|
||||
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<string, Record<string, string>>
|
||||
): Promise<Buffer> {
|
||||
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<Record<string, Record<string, string>>> {
|
||||
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"));
|
||||
}
|
||||
@@ -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<void> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createTenantRequest(
|
||||
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt">
|
||||
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt"> & {
|
||||
encryptedSecrets?: Buffer;
|
||||
}
|
||||
): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
`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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -39,6 +39,28 @@ async function authenticate(): Promise<string> {
|
||||
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<Record<string, string> | 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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user