312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getSessionUser, canMutate } from "@/lib/session";
|
|
import {
|
|
createTenantRequest,
|
|
getTenantRequestById,
|
|
listTenantRequestsByOrgId,
|
|
listActiveTenantRequestsByOrgId,
|
|
getMostRecentApprovedRequestForOrg,
|
|
} from "@/lib/db";
|
|
import { getTenant, listTenants } from "@/lib/k8s";
|
|
import {
|
|
listVisibleTenants,
|
|
canUserSeeTenant,
|
|
canSeeInflightRequests,
|
|
} from "@/lib/visibility";
|
|
import { sendAdminNotificationEmail } from "@/lib/email";
|
|
import { encryptSecrets } from "@/lib/crypto";
|
|
import { isPersonalOrgName } from "@/lib/personal-org";
|
|
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(),
|
|
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(),
|
|
city: z.string().optional(),
|
|
postalCode: z.string().optional(),
|
|
country: z.string().optional(),
|
|
}),
|
|
billingNotes: z.string().max(2_000).optional(),
|
|
});
|
|
|
|
/**
|
|
* Helper: shape a TenantRequest row for client consumption.
|
|
* Hides server-only fields (encryptedSecrets, internal db ids).
|
|
*/
|
|
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=<requestId>`: 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
|
|
* `<ProvisioningStatus>`, updated in lockstep.
|
|
*/
|
|
export async function GET(req: NextRequest) {
|
|
const user = await getSessionUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const requestedId = req.nextUrl.searchParams.get("id");
|
|
|
|
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 });
|
|
}
|
|
// Slice 6: a `user`-role customer doesn't see in-flight requests
|
|
// even within their own org — they can't act on them and showing
|
|
// the row would be a permanent "pending" state with no exit. Owner
|
|
// and platform skip this gate.
|
|
if (!canSeeInflightRequests(user)) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
let tenant: PiecedTenant | null = null;
|
|
if (tr.tenantName) {
|
|
tenant = (await getTenant(tr.tenantName)) ?? null;
|
|
// If a request is already linked to a tenant CR and the caller
|
|
// can't see that tenant (assignment scope), don't expose it via
|
|
// the request endpoint either. canSeeInflightRequests above
|
|
// already shortcuts this for `user`-role, but defense in depth.
|
|
if (tenant && !(await canUserSeeTenant(user, tenant))) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
}
|
|
return NextResponse.json({
|
|
request: publicRequestShape(tr),
|
|
tenant: tenant ? publicTenantShape(tenant) : null,
|
|
});
|
|
}
|
|
|
|
// List view: requests + tenants for this org, filtered by visibility.
|
|
// For owner/platform, this returns the same data as pre-Slice-6.
|
|
// For user-role, requests is forced to [] and tenants is narrowed to
|
|
// assignments.
|
|
const [requests, allTenants] = await Promise.all([
|
|
listActiveTenantRequestsByOrgId(user.orgId),
|
|
listTenants(),
|
|
]);
|
|
|
|
const visibleTenants = await listVisibleTenants(user, allTenants);
|
|
const visibleRequests = canSeeInflightRequests(user) ? requests : [];
|
|
|
|
return NextResponse.json({
|
|
requests: visibleRequests.map(publicRequestShape),
|
|
tenants: visibleTenants.map(publicTenantShape),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /api/onboarding
|
|
*
|
|
* 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();
|
|
if (!user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
// Slice 5: only owners (or platform users) may create new instances.
|
|
// A `user`-role member of an existing org cannot self-provision.
|
|
if (!canMutate(user)) {
|
|
return NextResponse.json(
|
|
{ error: "Only the organization owner can create new instances." },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const body = await request.json();
|
|
const parsed = onboardingSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "Invalid input", details: parsed.error.flatten() },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const input: OnboardingInput & {
|
|
packageSecrets?: Record<string, Record<string, string>>;
|
|
} = 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);
|
|
|
|
// Slice 4: detect personal-account orgs by the canonical " (Personal)"
|
|
// suffix on the ZITADEL org name. Set at registration, stable for the
|
|
// lifetime of the org. Persisted on the row so admin views and the
|
|
// approve handler don't have to re-derive it.
|
|
//
|
|
// If any prior row has is_personal set, prefer that — it's the same
|
|
// org and the value can't change. (The prior-row check is defensive;
|
|
// the org-name check should agree.)
|
|
const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName);
|
|
|
|
// Bug 5: personal accounts are 1-instance by design. If there's
|
|
// already an active tenant or an in-flight request for this user's
|
|
// org, reject the submission outright. Server-side only check;
|
|
// matching UI guards live on /dashboard (button hidden) and
|
|
// /dashboard/new (server-redirect to /dashboard).
|
|
if (isPersonal) {
|
|
const [allTenants, activeRequests] = await Promise.all([
|
|
listTenants(),
|
|
listActiveTenantRequestsByOrgId(user.orgId),
|
|
]);
|
|
const ownTenants = allTenants.filter(
|
|
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
|
);
|
|
if (ownTenants.length > 0 || activeRequests.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"Personal accounts are limited to one instance. Cancel your existing request or contact support to change plan.",
|
|
code: "personal_account_at_capacity",
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
instanceName: input.instanceName,
|
|
contactName,
|
|
contactEmail,
|
|
agentName: input.agentName,
|
|
soulMd: input.soulMd,
|
|
agentsMd: input.agentsMd,
|
|
packages: input.packages ?? [],
|
|
billingAddress,
|
|
billingNotes,
|
|
encryptedSecrets,
|
|
isPersonal,
|
|
});
|
|
|
|
// 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.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: publicRequestShape(tenantRequest),
|
|
orgRequestCount: allRequests.length,
|
|
},
|
|
{ status: 201 }
|
|
);
|
|
}
|