diff --git a/src/app/[locale]/dashboard/new/page.tsx b/src/app/[locale]/dashboard/new/page.tsx new file mode 100644 index 0000000..9ad31f2 --- /dev/null +++ b/src/app/[locale]/dashboard/new/page.tsx @@ -0,0 +1,49 @@ +import { getSessionUser } from "@/lib/session"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; +import Link from "next/link"; + +/** + * /dashboard/new — wizard for creating an additional instance for an + * existing customer. Reachable from the dashboard "+ Create new instance" + * link. + * + * Slice 3: this page is the entry point for follow-up instances. The + * first-instance case is still served inline on /dashboard. Both paths + * mount the same ; the API resolves the difference + * server-side based on whether prior approved rows exist for the org. + * + * Platform admins are redirected to /dashboard — they shouldn't be + * creating tenant instances under their own org. + */ +export default async function NewInstancePage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (user.isPlatform) redirect("/dashboard"); + + const t = await getTranslations("dashboard"); + + return ( +
+
+ + {t("title")} + +

+ {t("createInstance")} +

+

+ {t("createInstanceDescription")} +

+
+ +
+ +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index 79fef48..3274cf1 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -2,11 +2,11 @@ import { getSessionUser } from "@/lib/session"; import { getTranslations, getFormatter } from "next-intl/server"; import { redirect } from "next/navigation"; import { listTenants } from "@/lib/k8s"; -import { getTenantRequestByOrgId } from "@/lib/db"; +import { listActiveTenantRequestsByOrgId } from "@/lib/db"; import { Card, CardHeader } from "@/components/ui/card"; import { StatusBadge } from "@/components/ui/status-badge"; -import { UsageDisplay } from "@/components/dashboard/usage-display"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; +import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"; import { formatDateTime } from "@/lib/format"; import Link from "next/link"; @@ -20,7 +20,7 @@ export default async function DashboardPage() { const allTenants = await listTenants(); - // Platform users see overview of all tenants + // Platform users see overview of all tenants — unchanged from pre-Slice-3. if (user.isPlatform) { const phaseCount = allTenants.reduce>((acc, t) => { const phase = t.status?.phase ?? "Pending"; @@ -133,20 +133,24 @@ export default async function DashboardPage() { ); } - // Regular user: find their tenant - const myTenant = allTenants.find( + // --------------------------------------------------------------------- + // Customer view (Slice 3 multi-tenant) + // --------------------------------------------------------------------- + + const orgTenants = allTenants.filter( (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId ); + const orgRequests = await listActiveTenantRequestsByOrgId(user.orgId); - // No tenant → check for existing request, show onboarding flow - if (!myTenant) { - const existingRequest = await getTenantRequestByOrgId(user.orgId); - // Treat "deleted" as no request — customer can re-onboard - const initialState = - !existingRequest || existingRequest.status === "deleted" - ? "no_request" - : existingRequest.status; + // Pending/in-flight requests that don't yet have a tenant CR. Once the + // CR exists, the tenant card carries the live phase, so a separate + // "request" card would just duplicate it. + const inflightRequests = orgRequests.filter( + (r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName) + ); + // First-time user: empty company. Show the onboarding wizard inline. + if (orgTenants.length === 0 && inflightRequests.length === 0) { return (
@@ -159,70 +163,107 @@ export default async function DashboardPage() {
- +
); } - const tenantName = myTenant.metadata.name; - + // Returning customer: list of tenants + in-flight requests, plus + // a button to add another instance. return (
-
-

- {t("title")} -

-

- {t("welcome", { name: user.name || user.email })} -

+
+
+

+ {t("title")} +

+

+ {t("welcome", { name: user.name || user.email })} +

+
+ + + + {t("createInstance")} +
- {/* Instance status card */} -
- - {t("instanceStatus")} -
- - {myTenant.spec.agentName && ( - - {myTenant.spec.agentName} - - )} + {/* In-flight (pending/approved/provisioning/rejected) requests */} + {inflightRequests.length > 0 && ( +
+

+ {t("inflightRequests")} +

+
+ {inflightRequests.map((r) => ( + + ))}
- {myTenant.spec.packages && myTenant.spec.packages.length > 0 && ( -
- {myTenant.spec.packages.map((pkg) => ( - - {pkg} - - ))} -
- )} - -
+
+ )} - {/* Usage — no teamId passed, backend resolves from session */} -
-

- {t("usage")} -

- -
+ {/* Active tenants */} + {orgTenants.length > 0 && ( +
+

+ {t("instances")} +

+
+ {orgTenants.map((tenant) => ( + + +
+
+
+ {tenant.spec.displayName || tenant.metadata.name} +
+
+ {tenant.metadata.name} +
+
+ +
- {/* Link to tenant detail */} - - {t("manage")} - + {tenant.spec.agentName && ( +
+ {tenant.spec.agentName} +
+ )} + + {tenant.spec.packages && tenant.spec.packages.length > 0 && ( +
+ {tenant.spec.packages.slice(0, 4).map((pkg) => ( + + {pkg} + + ))} + {tenant.spec.packages.length > 4 && ( + + +{tenant.spec.packages.length - 4} + + )} +
+ )} + +
+ {t("manage")} → +
+
+ + ))} +
+
+ )}
); } diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index dfef446..c6842f3 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -100,11 +100,19 @@ export async function POST( "TOOLS.md": toolsMd, }; - // Step 4: Create the PiecedTenant CR + // Step 4: Create the PiecedTenant CR. + // displayName: prefer the customer-chosen instance name; fall back to + // the company name. With multi-tenant per org, instanceName is what + // distinguishes "Acme Production" from "Acme Dev" on the dashboard. + const displayName = + tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0 + ? tenantRequest.instanceName.trim() + : tenantRequest.companyName; + await createTenant( tenantName, { - displayName: tenantRequest.companyName, + displayName, agentName: tenantRequest.agentName, packages, workspaceFiles, diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 748fc23..1b4671b 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -1,17 +1,26 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; import { createTenantRequest, - getTenantRequestByOrgId, - deleteTenantRequest, + getTenantRequestById, + listTenantRequestsByOrgId, + listActiveTenantRequestsByOrgId, + getMostRecentApprovedRequestForOrg, } 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 type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types"; import { z } from "zod"; const onboardingSchema = z.object({ + instanceName: z + .string() + .trim() + .max(80) + .optional() + // Empty string from a form input → drop to undefined so the DB stores NULL + .transform((v) => (v && v.length > 0 ? v : undefined)), agentName: z.string().min(1).max(50), soulMd: z.string().max(10_000).optional(), agentsMd: z.string().max(10_000).optional(), @@ -30,59 +39,116 @@ const onboardingSchema = z.object({ }); /** - * GET /api/onboarding - * Check the current onboarding state for the logged-in user's org. + * Helper: shape a TenantRequest row for client consumption. + * Hides server-only fields (encryptedSecrets, internal db ids). */ -export async function GET() { +function publicRequestShape(r: TenantRequest) { + return { + id: r.id, + instanceName: r.instanceName, + agentName: r.agentName, + packages: r.packages, + status: r.status, + adminNotes: r.adminNotes, + tenantName: r.tenantName, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +function publicTenantShape(t: PiecedTenant) { + return { + name: t.metadata.name, + displayName: t.spec.displayName, + phase: t.status?.phase ?? "Pending", + suspended: t.spec.suspend ?? false, + packages: t.spec.packages ?? [], + creationTimestamp: t.metadata.creationTimestamp, + conditions: t.status?.conditions ?? [], + }; +} + +/** + * GET /api/onboarding + * + * Two response shapes depending on the `?id=` query: + * + * - With `?id=`: returns the single request's status plus + * the linked tenant's phase if approved. Used by ProvisioningStatus + * to poll a specific request. The id is validated against the + * caller's orgId so admins-and-only-admins can read across orgs. + * + * - Without `id`: returns lists of all in-flight requests and active + * tenants for the caller's org. Used by the dashboard to render the + * multi-tenant view. + * + * Slice 3 note: this replaces the old single-state response shape + * (`{ state: "...", request: {...} }`). Pre-Slice-3 callers will see + * the new shape and need to be updated. The only known caller is + * ``, updated in lockstep. + */ +export async function GET(req: NextRequest) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Check if there's already a running tenant for this org - const allTenants = await listTenants(); - const myTenant = allTenants.find( - (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId - ); + const requestedId = req.nextUrl.searchParams.get("id"); - if (myTenant) { + if (requestedId) { + const tr = await getTenantRequestById(requestedId); + if (!tr) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + // Customers may only read their own org's requests; platform + // admins/operators may read any. + if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + let tenant: PiecedTenant | null = null; + if (tr.tenantName) { + tenant = (await getTenant(tr.tenantName)) ?? null; + } return NextResponse.json({ - state: "active", - tenantName: myTenant.metadata.name, - phase: myTenant.status?.phase ?? "Unknown", + request: publicRequestShape(tr), + tenant: tenant ? publicTenantShape(tenant) : null, }); } - // Check if there's a pending request - const request = await getTenantRequestByOrgId(user.orgId); + // List view: requests + tenants for this org + const [requests, allTenants] = await Promise.all([ + listActiveTenantRequestsByOrgId(user.orgId), + listTenants(), + ]); - if (!request || request.status === "deleted") { - return NextResponse.json({ state: "no_request" }); - } + const orgTenants = allTenants.filter( + (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId + ); return NextResponse.json({ - state: request.status, - request: { - id: request.id, - agentName: request.agentName, - packages: request.packages, - status: request.status, - adminNotes: request.adminNotes, - tenantName: request.tenantName, - createdAt: request.createdAt, - }, + requests: requests.map(publicRequestShape), + tenants: orgTenants.map(publicTenantShape), }); } /** * 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. * - * 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. + * Always creates a NEW tenant_request row, regardless of how many other + * rows already exist for this org. The pre-Slice-3 409 ("you already + * have a request") is gone — multi-tenant is the design now. + * + * For additional instances in an existing company, the customer's prior + * approved row is used to seed billing/contact info, so the wizard + * doesn't need to re-collect data already on file. The wizard *does* + * still send a billingAddress payload (the field is required by the + * schema), but in practice the client can pre-fill it from + * `getMostRecentApprovedRequestForOrg`. + * + * Encrypted package secrets, if provided, are AES-256-GCM-sealed 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(); @@ -99,40 +165,17 @@ export async function POST(request: Request) { ); } - // Check for existing request - const existing = await getTenantRequestByOrgId(user.orgId); - 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( - (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId - ); - - if (myTenant) { - return NextResponse.json( - { - error: "You already have a tenant provisioned.", - tenantName: myTenant.metadata.name, - }, - { status: 409 } - ); - } - const input: OnboardingInput & { packageSecrets?: Record>; } = parsed.data; + // Look up an existing approved request for this org to inherit + // company-level billing data. For brand-new orgs (first registration), + // there is no prior row and we use the form-supplied billingAddress + // verbatim. For follow-up requests, we ignore the form-supplied + // company line in favour of the recorded company name. + const prior = await getMostRecentApprovedRequestForOrg(user.orgId); + // Encrypt package secrets if provided let encryptedSecrets: Buffer | undefined; if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { @@ -147,34 +190,55 @@ export async function POST(request: Request) { } } + // For follow-up instances, prefer the on-file company name and contact + // details; the user can't change those by re-typing them in the wizard. + const companyName = prior?.companyName ?? user.orgName; + const contactName = prior?.contactName ?? user.name; + const contactEmail = prior?.contactEmail ?? user.email; + const billingAddress = prior?.billingAddress ?? input.billingAddress; + const billingNotes = input.billingNotes ?? prior?.billingNotes; + const tenantRequest = await createTenantRequest({ zitadelOrgId: user.orgId, zitadelUserId: user.id, - companyName: user.orgName, - contactName: user.name, - contactEmail: user.email, + companyName, + instanceName: input.instanceName, + contactName, + contactEmail, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], - billingAddress: input.billingAddress, - billingNotes: input.billingNotes, + billingAddress, + billingNotes, encryptedSecrets, }); - // Notify admin about the new request + // Notify admin about the new request. For follow-up instances, include + // the instance name in the notification so the admin sees what's + // being requested without opening the panel. try { await sendAdminNotificationEmail( tenantRequest.contactEmail, tenantRequest.contactName, - tenantRequest.companyName + tenantRequest.instanceName + ? `${tenantRequest.companyName} (${tenantRequest.instanceName})` + : tenantRequest.companyName ); } catch (e) { console.error("Failed to send admin notification:", e); } + // For diagnostics: how many other in-flight requests does this org + // already have? Useful for the admin queue. + const allRequests = await listTenantRequestsByOrgId(user.orgId); + return NextResponse.json( - { message: "Request submitted.", request: tenantRequest }, + { + message: "Request submitted.", + request: publicRequestShape(tenantRequest), + orgRequestCount: allRequests.length, + }, { status: 201 } ); } diff --git a/src/components/onboarding/onboarding-flow.tsx b/src/components/onboarding/onboarding-flow.tsx index 0cc5be6..b5892a6 100644 --- a/src/components/onboarding/onboarding-flow.tsx +++ b/src/components/onboarding/onboarding-flow.tsx @@ -1,31 +1,36 @@ "use client"; -import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import { OnboardingWizard } from "./wizard"; -import { ProvisioningStatus } from "./provisioning-status"; interface OnboardingFlowProps { orgName: string; - initialState: "no_request" | "pending" | "approved" | "provisioning" | "rejected"; } /** - * Orchestrates the onboarding experience: - * - no_request → show wizard - * - pending/approved/provisioning/rejected → show status - * - After wizard submission → switch to status polling + * Wraps the onboarding wizard. On successful submission, refreshes the + * router so the parent server component re-renders with the new pending + * request visible in the dashboard list. + * + * Slice 3: this component used to manage the no_request → pending → + * provisioning → active state machine, with conditional rendering of + * ``. That state is now reflected at the dashboard + * level (which renders one `` per pending request), + * so this wrapper does just one thing: show the wizard, then navigate. */ -export function OnboardingFlow({ orgName, initialState }: OnboardingFlowProps) { - const [showWizard, setShowWizard] = useState(initialState === "no_request"); +export function OnboardingFlow({ orgName }: OnboardingFlowProps) { + const router = useRouter(); - if (showWizard) { - return ( - setShowWizard(false)} - /> - ); - } - - return ; + return ( + { + // Navigate back to /dashboard and re-fetch on the server. The + // parent server component will see the new `pending` row and + // render its `` card automatically. + router.push("/dashboard"); + router.refresh(); + }} + /> + ); } diff --git a/src/components/onboarding/provisioning-status.tsx b/src/components/onboarding/provisioning-status.tsx index 940dc21..908e9f1 100644 --- a/src/components/onboarding/provisioning-status.tsx +++ b/src/components/onboarding/provisioning-status.tsx @@ -6,64 +6,81 @@ import { Card } from "@/components/ui/card"; import { StatusBadge } from "@/components/ui/status-badge"; import { formatDateTime, formatRelative } from "@/lib/format"; -interface OnboardingState { - state: string; - request?: { - id: string; - status: string; - companyName: string; - agentName: string; - adminNotes?: string; - createdAt?: string; - }; - tenant?: { - name: string; - phase: string; - message?: string; - conditions?: Array<{ - type: string; - status: string; - reason?: string; - message?: string; - lastTransitionTime?: string; - }>; - }; +interface RequestSummary { + id: string; + instanceName?: string | null; + agentName: string; + packages: string[]; + status: string; + adminNotes?: string; + tenantName?: string; + createdAt?: string; + updatedAt?: string; } -export function ProvisioningStatus() { +interface TenantSummary { + name: string; + displayName: string; + phase: string; + conditions: Array<{ + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }>; +} + +interface SingleRequestState { + request: RequestSummary; + tenant: TenantSummary | null; +} + +/** + * ProvisioningStatus + * + * Polls /api/onboarding?id= every 5s until the request reaches + * a terminal state. Slice 3: takes a `requestId` prop so multiple of these + * can render on the same dashboard for different in-flight requests. + * + * The pre-Slice-3 version polled /api/onboarding with no params and + * assumed one-request-per-org — that endpoint shape is gone now. + */ +export function ProvisioningStatus({ requestId }: { requestId: string }) { const t = useTranslations("onboarding"); const f = useFormatter(); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [error, setError] = useState(""); const poll = useCallback(async () => { try { - const res = await fetch("/api/onboarding"); + const res = await fetch( + `/api/onboarding?id=${encodeURIComponent(requestId)}` + ); if (!res.ok) throw new Error("Failed to fetch status"); const json = await res.json(); setData(json); } catch (err: any) { setError(err.message); } - }, []); + }, [requestId]); useEffect(() => { poll(); - // Poll every 5 seconds while not in a terminal state - const interval = setInterval(() => { - if ( - data?.state === "provisioned" || - data?.state === "rejected" || - data?.state === "active" - ) { - return; - } - poll(); - }, 5000); + const status = data?.request?.status; + const phase = data?.tenant?.phase; + const terminal = + status === "rejected" || + status === "active" || + phase === "Ready" || + phase === "Running"; + if (terminal) return; + + const interval = setInterval(poll, 5000); return () => clearInterval(interval); - }, [poll, data?.state]); + }, [poll, data?.request?.status, data?.tenant?.phase]); if (error) { return ( @@ -84,8 +101,14 @@ export function ProvisioningStatus() { ); } + const status = data.request.status; + const label = + data.request.instanceName || + data.request.tenantName || + data.request.agentName; + // Pending admin approval - if (data.state === "pending") { + if (status === "pending") { return (
@@ -107,10 +130,13 @@ export function ProvisioningStatus() {

{t("pendingTitle")}

+ {label && ( +

{label}

+ )}

{t("pendingDescription")}

- {data.request?.createdAt && ( + {data.request.createdAt && (

@@ -152,10 +178,13 @@ export function ProvisioningStatus() {

{t("rejectedTitle")}

+ {label && ( +

{label}

+ )}

{t("rejectedDescription")}

- {data.request?.adminNotes && ( + {data.request.adminNotes && (

{data.request.adminNotes}

@@ -165,10 +194,11 @@ export function ProvisioningStatus() { ); } - // Provisioning in progress + // Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready) if ( - data.state === "approved" || - data.state === "provisioning" + status === "approved" || + status === "provisioning" || + (status === "active" && data.tenant && data.tenant.phase !== "Ready") ) { const phase = data.tenant?.phase ?? "Pending"; const conditions = data.tenant?.conditions ?? []; @@ -182,6 +212,9 @@ export function ProvisioningStatus() {

{t("provisioningTitle")}

+ {label && ( +

{label}

+ )}

{t("provisioningDescription")}

@@ -216,8 +249,8 @@ export function ProvisioningStatus() { ); } - // Provisioned / Running - if (data.state === "provisioned") { + // Active / Ready + if (status === "active") { return (
@@ -239,6 +272,9 @@ export function ProvisioningStatus() {

{t("readyTitle")}

+ {label && ( +

{label}

+ )}

{t("readyDescription")}

diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index 6b02988..b132ef1 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -62,6 +62,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { const [defaultsLoaded, setDefaultsLoaded] = useState(false); const [config, setConfig] = useState({ + instanceName: "", agentName: "Assistant", soulMd: FALLBACK_SOUL.replace("{company}", orgName), agentsMd: FALLBACK_AGENTS, @@ -306,6 +307,24 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {

+
+ + + setConfig((prev) => ({ ...prev, instanceName: e.target.value })) + } + placeholder={t("instanceNamePlaceholder")} + className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" + /> +

+ {t("instanceNameHint")} +

+
+