This commit is contained in:
65
src/app/api/onboarding/[id]/dismiss/route.ts
Normal file
65
src/app/api/onboarding/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { dismissTenantRequest, getTenantRequestById } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/[id]/dismiss
|
||||
*
|
||||
* Customer-side acknowledgement of a rejected or cancelled request
|
||||
* (Bug 13). Sets `dismissed_at = now()` so the row stops appearing
|
||||
* in the dashboard's `listActiveTenantRequestsByOrgId` query. The
|
||||
* row itself is preserved for audit.
|
||||
*
|
||||
* Authorization mirrors the GET / DELETE / PATCH endpoints on this
|
||||
* resource: customer owners (or platform staff) of the row's org.
|
||||
*
|
||||
* Idempotent: dismissing an already-dismissed request returns 200
|
||||
* with no change. We refuse to dismiss non-terminal rows (pending,
|
||||
* approved, provisioning, active) — those are still actionable, and
|
||||
* "hiding" them would stash live state from the customer.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const tr = await getTenantRequestById(id);
|
||||
if (!tr) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (tr.status !== "rejected" && tr.status !== "cancelled") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Only rejected or cancelled requests can be dismissed. Active requests stay visible.",
|
||||
code: "not_dismissable",
|
||||
currentStatus: tr.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await dismissTenantRequest(id);
|
||||
return NextResponse.json({ message: "Dismissed.", id });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to dismiss request:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to dismiss request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
207
src/app/api/onboarding/[id]/route.ts
Normal file
207
src/app/api/onboarding/[id]/route.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
updateTenantRequestEditableFields,
|
||||
} from "@/lib/db";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Customer-side controls for a single tenant_request row.
|
||||
*
|
||||
* - DELETE /api/onboarding/[id] → cancel a still-pending request
|
||||
* - PATCH /api/onboarding/[id] → edit fields of a still-pending
|
||||
* request (Bug 6)
|
||||
*
|
||||
* Both endpoints share the same authorization check: the caller must
|
||||
* be a customer owner (or platform staff) of the request's org. We
|
||||
* also enforce status === 'pending' on the row — once an admin has
|
||||
* acted on it, the customer can no longer mutate it from the portal.
|
||||
*
|
||||
* Reading these is via the existing GET /api/onboarding?id=... handler.
|
||||
*/
|
||||
|
||||
async function loadAuthorized(
|
||||
id: string
|
||||
): Promise<
|
||||
| { error: NextResponse }
|
||||
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
const tr = await getTenantRequestById(id);
|
||||
if (!tr) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
// Customers may only read their own org's requests; platform users
|
||||
// may read any. Same scope as `GET /api/onboarding?id=...`.
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
return { req: tr };
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/onboarding/[id]
|
||||
*
|
||||
* Customer cancels a still-pending request. Status flips to 'cancelled';
|
||||
* the row is preserved for audit. The customer can dismiss the
|
||||
* cancelled card afterwards (Bug 13 reuse — same dismissal mechanism).
|
||||
*
|
||||
* Once admin has approved/provisioned/rejected, this endpoint refuses
|
||||
* (409). Cancelling a tenant that's already running goes through the
|
||||
* subscription-suspend flow on the tenant detail page, not here.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const loaded = await loadAuthorized(id);
|
||||
if ("error" in loaded) return loaded.error;
|
||||
const tr = loaded.req!;
|
||||
|
||||
if (tr.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.",
|
||||
code: "not_pending",
|
||||
currentStatus: tr.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTenantRequestStatus(id, "cancelled");
|
||||
return NextResponse.json({ message: "Request cancelled.", id });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to cancel request:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to cancel request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/onboarding/[id]
|
||||
*
|
||||
* Customer edits a still-pending request. Validation is the same as on
|
||||
* POST /api/onboarding (shared schema). Only customer-input fields are
|
||||
* editable; status/tenant_name/admin_notes/etc. are server-managed.
|
||||
*
|
||||
* Note on company-level fields
|
||||
* ----------------------------
|
||||
* For a follow-up instance (org has prior approved rows), the POST
|
||||
* handler intentionally ignores the wizard's billingAddress and uses
|
||||
* the on-file value instead. We mirror that here: company-level fields
|
||||
* (companyName, contactName, contactEmail, billingAddress) on a
|
||||
* follow-up edit are NOT updated through this endpoint. The customer
|
||||
* should use a future settings page (Bug 11) for those. For now,
|
||||
* editing only mutates per-instance fields — agent name, instance
|
||||
* name, packages, soulMd, agentsMd, billingNotes, packageSecrets.
|
||||
*
|
||||
* For the FIRST instance (no prior approved rows), billingAddress IS
|
||||
* editable here, since the customer is still defining their company's
|
||||
* billing data.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const loaded = await loadAuthorized(id);
|
||||
if ("error" in loaded) return loaded.error;
|
||||
const tr = loaded.req!;
|
||||
|
||||
if (tr.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Only pending requests can be edited.",
|
||||
code: "not_pending",
|
||||
currentStatus: tr.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = onboardingSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
// Re-encrypt package secrets if present in the patch body. When the
|
||||
// user re-opens the wizard to edit, the secrets array is populated
|
||||
// afresh from the wizard (we never decrypt and return existing
|
||||
// secrets — that'd be a security regression). If the user didn't
|
||||
// touch any secret-bearing package, the wizard sends no
|
||||
// packageSecrets and we leave the existing encrypted blob alone.
|
||||
let encryptedSecrets: Buffer | null | 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only first-instance edits get billingAddress; follow-ups inherit
|
||||
// company billing from the on-file approved row.
|
||||
const isFirstInstance = !tr.tenantName; // approximation; covers the
|
||||
// "no prior approved row for this org" case the POST handler treats
|
||||
// identically. A more rigorous check would call
|
||||
// getMostRecentApprovedRequestForOrg, but in practice an org with
|
||||
// an approved row for some other tenant has a tenantName on those
|
||||
// rows, not on the pending one being edited — so the simple check
|
||||
// here is fine for the only state the endpoint accepts (pending).
|
||||
|
||||
try {
|
||||
const updated = await updateTenantRequestEditableFields(id, {
|
||||
instanceName: input.instanceName,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress: isFirstInstance ? input.billingAddress : undefined,
|
||||
billingNotes: input.billingNotes,
|
||||
encryptedSecrets,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ message: "Request updated.", id });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to edit request:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to edit request") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,33 @@ import { z } from "zod";
|
||||
* Helper: shape a TenantRequest row for client consumption.
|
||||
* Hides server-only fields (encryptedSecrets, internal db ids).
|
||||
*/
|
||||
/**
|
||||
* Helper: shape a TenantRequest row for client consumption.
|
||||
* Hides server-only fields (encryptedSecrets, internal db ids).
|
||||
*
|
||||
* Slice 7 / Bug 6: surfaces enough fields for the customer-side edit
|
||||
* flow to pre-fill the wizard. soulMd, agentsMd, billingAddress,
|
||||
* billingNotes were previously kept off the public shape because the
|
||||
* pre-Slice-3 dashboard didn't render them. Edit needs them.
|
||||
*
|
||||
* Bug 13: surfaces dismissedAt so the dashboard can distinguish
|
||||
* "freshly rejected, show prominently" from "rejected and acknowledged,
|
||||
* keep hidden" without an extra API call.
|
||||
*/
|
||||
function publicRequestShape(r: TenantRequest) {
|
||||
return {
|
||||
id: r.id,
|
||||
instanceName: r.instanceName,
|
||||
agentName: r.agentName,
|
||||
soulMd: r.soulMd,
|
||||
agentsMd: r.agentsMd,
|
||||
packages: r.packages,
|
||||
billingAddress: r.billingAddress,
|
||||
billingNotes: r.billingNotes,
|
||||
status: r.status,
|
||||
adminNotes: r.adminNotes,
|
||||
tenantName: r.tenantName,
|
||||
dismissedAt: r.dismissedAt ?? null,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user