All the UI fixes for now

This commit is contained in:
2026-04-11 17:21:52 +02:00
parent 1bd51ecb5d
commit c67259ebe0
15 changed files with 565 additions and 112 deletions

View File

@@ -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,

View File

@@ -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.",
});

View File

@@ -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

View File

@@ -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);