From 219b4c836588b58cd4178cb7b7ea7d44480cec1d Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 29 Apr 2026 22:13:08 +0200 Subject: [PATCH] Group D fixes --- src/app/[locale]/dashboard/edit/[id]/page.tsx | 87 ++++++ src/app/[locale]/dashboard/page.tsx | 6 +- src/app/api/onboarding/[id]/dismiss/route.ts | 65 +++++ src/app/api/onboarding/[id]/route.ts | 207 +++++++++++++++ src/app/api/onboarding/route.ts | 18 ++ src/components/onboarding/onboarding-flow.tsx | 10 + .../onboarding/provisioning-status.tsx | 249 ++++++++++++++++-- src/components/onboarding/wizard.tsx | 112 ++++++-- src/components/ui/status-badge.tsx | 8 + src/lib/db.ts | 121 ++++++++- src/messages/de.json | 19 +- src/messages/en.json | 19 +- src/messages/fr.json | 19 +- src/messages/it.json | 19 +- src/types/index.ts | 10 + 15 files changed, 914 insertions(+), 55 deletions(-) create mode 100644 src/app/[locale]/dashboard/edit/[id]/page.tsx create mode 100644 src/app/api/onboarding/[id]/dismiss/route.ts create mode 100644 src/app/api/onboarding/[id]/route.ts diff --git a/src/app/[locale]/dashboard/edit/[id]/page.tsx b/src/app/[locale]/dashboard/edit/[id]/page.tsx new file mode 100644 index 0000000..a44f2c2 --- /dev/null +++ b/src/app/[locale]/dashboard/edit/[id]/page.tsx @@ -0,0 +1,87 @@ +import { getSessionUser, canMutate } from "@/lib/session"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getTenantRequestById } from "@/lib/db"; +import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; +import { BackLink } from "@/components/ui/back-link"; + +/** + * /dashboard/edit/[id] — re-opens the onboarding wizard with the + * fields of a still-pending request pre-filled (Bug 6). On submit, + * the wizard PATCHes /api/onboarding/[id] instead of POSTing to + * /api/onboarding. + * + * Hard guards + * ----------- + * - Logged-in customer owner (or platform user) only — same as the + * /dashboard/new page. + * - Request must exist, belong to the caller's org, and be in 'pending' + * status. Editing approved/provisioning rows would race against the + * operator; we redirect such cases back to the dashboard rather than + * render an invalid wizard. + * + * Pre-fill + * -------- + * The wizard takes a single `editingRequest` prop — when present, it + * (a) pre-populates state from those values and (b) targets the PATCH + * endpoint on submit. When absent, it behaves exactly as today (POST + * to /api/onboarding). + * + * Note on encrypted secrets + * ------------------------- + * Per-package secrets are NEVER decrypted server-side and exposed to + * the client (would be a clear security regression). When editing, + * the wizard opens with empty secret fields and the user re-enters + * any they want to change. If they don't touch the package-secrets + * UI, the existing encrypted blob in the DB is preserved by the + * PATCH endpoint (it only re-encrypts when the wizard sends a + * non-empty secrets payload). + */ +export default async function EditRequestPage({ + params, +}: { + params: Promise<{ id: string; locale: string }>; +}) { + const { id } = await params; + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (user.isPlatform) redirect("/dashboard"); + if (!canMutate(user)) redirect("/dashboard"); + + const tr = await getTenantRequestById(id); + if (!tr) redirect("/dashboard"); + if (tr.zitadelOrgId !== user.orgId) redirect("/dashboard"); + if (tr.status !== "pending") redirect("/dashboard"); + + const t = await getTranslations("dashboard"); + const tOnboarding = await getTranslations("onboarding"); + + return ( +
+
+ +

+ {tOnboarding("editRequestTitle")} +

+

+ {tOnboarding("editRequestDescription")} +

+
+ +
+ ); +} diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index bc3105e..06637e9 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -315,7 +315,11 @@ export default async function DashboardPage() {
{inflightRequests.map((r) => ( - + ))}
diff --git a/src/app/api/onboarding/[id]/dismiss/route.ts b/src/app/api/onboarding/[id]/dismiss/route.ts new file mode 100644 index 0000000..5f6dd8b --- /dev/null +++ b/src/app/api/onboarding/[id]/dismiss/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/onboarding/[id]/route.ts b/src/app/api/onboarding/[id]/route.ts new file mode 100644 index 0000000..d3c00fe --- /dev/null +++ b/src/app/api/onboarding/[id]/route.ts @@ -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>; } +> { + 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 } + ); + } +} diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 7274c39..0907981 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -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, }; diff --git a/src/components/onboarding/onboarding-flow.tsx b/src/components/onboarding/onboarding-flow.tsx index a086156..4bc33dc 100644 --- a/src/components/onboarding/onboarding-flow.tsx +++ b/src/components/onboarding/onboarding-flow.tsx @@ -12,6 +12,14 @@ interface OnboardingFlowProps { */ userName?: string; userEmail?: string; + /** + * Bug 6: when present, the wizard is rendered in edit mode against + * the given pending request. See `OnboardingWizard` for the full + * shape and behavioural contract. + */ + editingRequest?: React.ComponentProps< + typeof OnboardingWizard + >["editingRequest"]; } /** @@ -29,6 +37,7 @@ export function OnboardingFlow({ orgName, userName, userEmail, + editingRequest, }: OnboardingFlowProps) { const router = useRouter(); @@ -37,6 +46,7 @@ export function OnboardingFlow({ orgName={orgName} userName={userName} userEmail={userEmail} + editingRequest={editingRequest} onComplete={() => { // Navigate back to /dashboard and re-fetch on the server. The // parent server component will see the new `pending` row and diff --git a/src/components/onboarding/provisioning-status.tsx b/src/components/onboarding/provisioning-status.tsx index 908e9f1..ec703fd 100644 --- a/src/components/onboarding/provisioning-status.tsx +++ b/src/components/onboarding/provisioning-status.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useTranslations, useFormatter } from "next-intl"; import { Card } from "@/components/ui/card"; import { StatusBadge } from "@/components/ui/status-badge"; @@ -14,6 +16,7 @@ interface RequestSummary { status: string; adminNotes?: string; tenantName?: string; + dismissedAt?: string | null; createdAt?: string; updatedAt?: string; } @@ -36,21 +39,42 @@ interface SingleRequestState { tenant: TenantSummary | null; } +interface Props { + requestId: string; + /** + * Whether the viewer can act on this request — cancel a pending one, + * dismiss a rejected one, etc. True for owner + platform; false for + * `user`-role customers (who shouldn't see in-flight requests at all, + * but defence in depth — `canSeeInflightRequests` already gates the + * dashboard side). + */ + canAct: boolean; +} + /** * 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. + * 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. + * Slice 7 / Bug 6 + 13: + * - pending → cancel + edit buttons + * - rejected → admin notes block + dismiss button + * - cancelled → small acknowledgement card + dismiss button + * - terminal Ready/Active states unchanged */ -export function ProvisioningStatus({ requestId }: { requestId: string }) { +export function ProvisioningStatus({ requestId, canAct }: Props) { const t = useTranslations("onboarding"); + const tCommon = useTranslations("common"); const f = useFormatter(); + const router = useRouter(); + const [data, setData] = useState(null); const [error, setError] = useState(""); + const [actionPending, setActionPending] = useState(false); + const [confirmCancel, setConfirmCancel] = useState(false); const poll = useCallback(async () => { try { @@ -67,11 +91,11 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { useEffect(() => { poll(); - const status = data?.request?.status; const phase = data?.tenant?.phase; const terminal = status === "rejected" || + status === "cancelled" || status === "active" || phase === "Ready" || phase === "Running"; @@ -82,7 +106,54 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { return () => clearInterval(interval); }, [poll, data?.request?.status, data?.tenant?.phase]); - if (error) { + const handleCancel = async () => { + setActionPending(true); + setError(""); + try { + const res = await fetch( + `/api/onboarding/${encodeURIComponent(requestId)}`, + { method: "DELETE" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || t("cancelFailed")); + } + setConfirmCancel(false); + // Re-poll so the card transitions to "cancelled" state without a + // full route refresh — the dashboard's surrounding tenant cards + // are unaffected. + await poll(); + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setActionPending(false); + } + }; + + const handleDismiss = async () => { + setActionPending(true); + setError(""); + try { + const res = await fetch( + `/api/onboarding/${encodeURIComponent(requestId)}/dismiss`, + { method: "POST" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || t("dismissFailed")); + } + // Server-rendered list query (`listActiveTenantRequestsByOrgId`) + // filters out dismissed rows — refresh to drop this card. + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setActionPending(false); + } + }; + + if (error && !data) { return (
{error}
@@ -107,7 +178,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { data.request.tenantName || data.request.agentName; - // Pending admin approval + // ─── Pending: awaiting admin approval ─────────────────────────────── if (status === "pending") { return ( @@ -131,7 +202,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { {t("pendingTitle")} {label && ( -

{label}

+

+ {label} +

)}

{t("pendingDescription")} @@ -150,12 +223,76 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {

)} + + {/* Bug 6 — owner-only edit + cancel actions while still + pending. Once admin acts, both buttons disappear (the + status branch changes). */} + {canAct && ( +
+ + {t("editRequest")} + + +
+ )} + {error && ( +

{error}

+ )} + + {confirmCancel && ( +
{ + if (e.target === e.currentTarget) setConfirmCancel(false); + }} + > +
+

+ {t("cancelConfirmRequestTitle")} +

+

+ {t("cancelConfirmRequestDescription")} +

+
+ + +
+
+
+ )}
); } - // Rejected + // ─── Rejected: admin declined ─────────────────────────────────────── if (status === "rejected") { return ( @@ -179,22 +316,94 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { {t("rejectedTitle")} {label && ( -

{label}

+

+ {label} +

)}

{t("rejectedDescription")}

{data.request.adminNotes && ( -

- {data.request.adminNotes} -

+
+
+ {t("rejectionReason")} +
+
+ {data.request.adminNotes} +
+
)} + {/* Bug 13: dismiss removes this card from the dashboard but + keeps the row in the DB for audit. The customer can also + just resubmit via the wizard — both paths are valid. */} + {canAct && ( +
+ +
+ )} + {error &&

{error}

}
); } - // Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready) + // ─── Cancelled: customer cancelled before admin acted (Bug 6) ────── + if (status === "cancelled") { + return ( + +
+
+ + + +
+

+ {t("cancelledTitle")} +

+ {label && ( +

+ {label} +

+ )} +

+ {t("cancelledDescription")} +

+ {canAct && ( +
+ +
+ )} + {error &&

{error}

} +
+
+ ); + } + + // ─── Provisioning: approved, operator working ────────────────────── if ( status === "approved" || status === "provisioning" || @@ -213,7 +422,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { {t("provisioningTitle")} {label && ( -

{label}

+

+ {label} +

)}

{t("provisioningDescription")} @@ -249,7 +460,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { ); } - // Active / Ready + // ─── Active / Ready ───────────────────────────────────────────────── if (status === "active") { return ( @@ -273,7 +484,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) { {t("readyTitle")} {label && ( -

{label}

+

+ {label} +

)}

{t("readyDescription")} diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index 47052b6..97808a3 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -64,6 +64,35 @@ interface WizardProps { */ userName?: string; userEmail?: string; + /** + * Bug 6: when present, the wizard renders in "edit" mode — fields + * are pre-populated from the request, the SOUL.md auto-fetch is + * skipped (we trust the existing values), and the submit button + * PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding. + * + * Per-package secrets are deliberately NOT pre-filled, even if the + * customer originally supplied them — server-side decryption to + * the client would be a security regression. The user re-enters + * any secrets they want to change; if they leave them blank, the + * existing encrypted blob in the DB is preserved by the PATCH + * endpoint. + */ + editingRequest?: { + id: string; + instanceName: string; + agentName: string; + soulMd: string; + agentsMd: string; + packages: string[]; + billingAddress: { + company?: string; + street?: string; + city?: string; + postalCode?: string; + country?: string; + }; + billingNotes: string; + }; onComplete: () => void; } @@ -71,6 +100,7 @@ export function OnboardingWizard({ orgName, userName, userEmail, + editingRequest, onComplete, }: WizardProps) { const t = useTranslations("onboarding"); @@ -91,30 +121,55 @@ export function OnboardingWizard({ orgName, isPersonal, }); + const isEditing = Boolean(editingRequest); - const [step, setStep] = useState("welcome"); + // Edit mode jumps straight to the configure step — the welcome step + // is a first-time onboarding affordance and only adds friction when + // the customer is fixing a typo. + const [step, setStep] = useState(isEditing ? "configure" : "welcome"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const [advancedOpen, setAdvancedOpen] = useState(false); - const [defaultsLoaded, setDefaultsLoaded] = useState(false); + // In edit mode we already have soulMd/agentsMd from the request; + // skip the workspace-defaults round trip that would overwrite them. + const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing); - const [config, setConfig] = useState({ - instanceName: "", - agentName: "Assistant", - soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName), - agentsMd: FALLBACK_AGENTS, - packages: [] as string[], - billingAddress: { - // For personal accounts, leave the company field empty — it'll - // appear on invoices. The user can still type something if they - // want to. - company: isPersonal ? "" : displayOrgName, - street: "", - city: "", - postalCode: "", - country: "CH", - }, - billingNotes: "", + const [config, setConfig] = useState(() => { + if (editingRequest) { + return { + instanceName: editingRequest.instanceName, + agentName: editingRequest.agentName, + soulMd: editingRequest.soulMd, + agentsMd: editingRequest.agentsMd, + packages: editingRequest.packages, + billingAddress: { + company: editingRequest.billingAddress.company ?? "", + street: editingRequest.billingAddress.street ?? "", + city: editingRequest.billingAddress.city ?? "", + postalCode: editingRequest.billingAddress.postalCode ?? "", + country: editingRequest.billingAddress.country ?? "CH", + }, + billingNotes: editingRequest.billingNotes, + }; + } + return { + instanceName: "", + agentName: "Assistant", + soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName), + agentsMd: FALLBACK_AGENTS, + packages: [] as string[], + billingAddress: { + // For personal accounts, leave the company field empty — it'll + // appear on invoices. The user can still type something if they + // want to. + company: isPersonal ? "" : displayOrgName, + street: "", + city: "", + postalCode: "", + country: "CH", + }, + billingNotes: "", + }; }); // TOOLS.md preview — readonly, auto-generated @@ -308,8 +363,17 @@ export function OnboardingWizard({ } } - const res = await fetch("/api/onboarding", { - method: "POST", + // Bug 6: edit mode targets the per-row endpoint with PATCH; + // create mode targets the collection endpoint with POST. Body + // shape is the same — both routes parse it through + // onboardingSchema. + const url = editingRequest + ? `/api/onboarding/${encodeURIComponent(editingRequest.id)}` + : "/api/onboarding"; + const method = editingRequest ? "PATCH" : "POST"; + + const res = await fetch(url, { + method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...config, @@ -1017,7 +1081,11 @@ export function OnboardingWizard({ disabled={submitting} className="py-2.5 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" > - {submitting ? tCommon("loading") : t("submitRequest")} + {submitting + ? tCommon("loading") + : isEditing + ? t("saveChanges") + : t("submitRequest")} diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx index c839414..b2ebe82 100644 --- a/src/components/ui/status-badge.tsx +++ b/src/components/ui/status-badge.tsx @@ -15,6 +15,11 @@ const phaseStyles: Record = { Running: "bg-success/10 text-success border-success/20", Ready: "bg-success/10 text-success border-success/20", Provisioning: "bg-warning/10 text-warning border-warning/20", + // Reconfiguring shares the warning palette (yellow pulse) but renders + // a distinct label, so customers see it differently from first-time + // provisioning. Useful when packages or channel-users change and the + // pod restarts mid-life. + Reconfiguring: "bg-warning/10 text-warning border-warning/20", Pending: "bg-text-muted/10 text-text-secondary border-border", Suspended: "bg-amber-500/10 text-amber-400 border-amber-500/30", Error: "bg-error/10 text-error border-error/20", @@ -44,6 +49,9 @@ export function StatusBadge({ phase }: { phase: string }) { {phase === "Provisioning" && ( )} + {phase === "Reconfiguring" && ( + + )} {label} ); diff --git a/src/lib/db.ts b/src/lib/db.ts index 9cd6187..ec52ea9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,5 @@ import { Pool } from "pg"; -import type { TenantRequest, TenantRequestStatus } from "@/types"; +import type { BillingAddress, TenantRequest, TenantRequestStatus } from "@/types"; import { listTenants, getTenant } from "./k8s"; // --------------------------------------------------------------------------- @@ -72,6 +72,11 @@ const MIGRATION_SQL = ` ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE; + -- Bug 13: customer-side dismissal of rejected requests. NULL means "still + -- visible on the dashboard"; non-null means "customer clicked Dismiss". + -- Pending/approved/active rows keep this NULL by definition — the field + -- is only meaningful for `rejected` and `cancelled` rows. + ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ; -- Slice 3: drop the legacy 1-org-1-request constraint if it exists ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key; @@ -250,10 +255,21 @@ export async function listTenantRequestsByOrgId( } /** - * As {@link listTenantRequestsByOrgId} but excludes terminal-failed states - * (rejected, deleted). Useful for the dashboard which wants to show - * pending/approved/provisioning/active tenants and pending requests, not - * historical rejections. + * As {@link listTenantRequestsByOrgId} but tuned for the customer's + * dashboard view. + * + * Returns: + * - All non-terminal rows (pending, approved, provisioning, active), + * because the customer needs to see what's in flight. + * - Terminal-failed rows (rejected, cancelled) that the customer + * hasn't dismissed yet (Bug 13). Without this, a rejection that + * happens while the customer isn't online would only be + * communicated by email — easy to miss. + * + * Excludes: + * - `deleted` rows (admin tore down the tenant — historical, not + * actionable). + * - Dismissed rejected/cancelled rows. */ export async function listActiveTenantRequestsByOrgId( orgId: string @@ -262,7 +278,8 @@ export async function listActiveTenantRequestsByOrgId( const result = await getPool().query( `SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 - AND status NOT IN ('deleted', 'rejected') + AND status <> 'deleted' + AND (status NOT IN ('rejected', 'cancelled') OR dismissed_at IS NULL) ORDER BY created_at DESC`, [orgId] ); @@ -354,6 +371,96 @@ export async function clearEncryptedSecrets(requestId: string): Promise { ); } +/** + * Set dismissed_at = now() on a request row. Used when a customer + * clicks "Dismiss" on a rejected/cancelled card on their dashboard + * (Bug 13). The row stays in the database for history/audit but + * stops appearing in `listActiveTenantRequestsByOrgId`. + * + * Idempotent: dismissing an already-dismissed row is a no-op. + * Caller is responsible for verifying the row belongs to the user's + * org before calling. + */ +export async function dismissTenantRequest(id: string): Promise { + await ensureSchema(); + await getPool().query( + `UPDATE tenant_requests + SET dismissed_at = COALESCE(dismissed_at, now()), + updated_at = now() + WHERE id = $1`, + [id] + ); +} + +/** + * Update editable fields of a still-pending tenant request. Bug 6 — a + * customer who notices a typo or wants to add a package after submitting + * the wizard should be able to fix it without admin involvement. + * + * Only the customer-input fields are updateable. `status`, `tenant_name`, + * `admin_notes`, `encrypted_secrets`, `is_personal`, `zitadel_*` and + * timestamps are managed elsewhere and intentionally not here. + * + * The caller is responsible for: + * - verifying the row belongs to the user's org + * - verifying status === 'pending' (editing approved/provisioning rows + * would race against the operator) + * + * Returns the updated row, or null if the id didn't match anything. + */ +export async function updateTenantRequestEditableFields( + id: string, + fields: { + instanceName?: string | null; + agentName?: string; + soulMd?: string; + agentsMd?: string | null; + packages?: string[]; + billingAddress?: BillingAddress; + billingNotes?: string; + encryptedSecrets?: Buffer | null; + } +): Promise { + await ensureSchema(); + + const sets: string[] = ["updated_at = now()"]; + const values: any[] = [id]; + let idx = 2; + + // Map JS field names to SQL columns. Each entry is gated on + // `!== undefined` so passing only some fields just updates those. + const colMap: Array<[keyof typeof fields, string]> = [ + ["instanceName", "instance_name"], + ["agentName", "agent_name"], + ["soulMd", "soul_md"], + ["agentsMd", "agents_md"], + ["packages", "packages"], + ["billingAddress", "billing_address"], + ["billingNotes", "billing_notes"], + ["encryptedSecrets", "encrypted_secrets"], + ]; + for (const [jsField, sqlCol] of colMap) { + const v = fields[jsField]; + if (v === undefined) continue; + sets.push(`${sqlCol} = $${idx}`); + values.push(v); + idx++; + } + + if (sets.length === 1) { + // No editable fields supplied — return the row unchanged rather + // than running a useless UPDATE that just bumps updated_at. + const cur = await getTenantRequestById(id); + return cur; + } + + const result = await getPool().query( + `UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, + values + ); + return result.rows[0] ? mapRow(result.rows[0]) : null; +} + /** * Wrapper around domain-check.ts that injects the portal's connection pool. * Kept here so route handlers don't need direct access to the pool. @@ -446,6 +553,8 @@ function mapRow(row: any): TenantRequest { tenantName: row.tenant_name, encryptedSecrets: row.encrypted_secrets ?? null, isPersonal: row.is_personal ?? false, + dismissedAt: + row.dismissed_at?.toISOString?.() ?? row.dismissed_at ?? null, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; diff --git a/src/messages/de.json b/src/messages/de.json index 51c97aa..8f87508 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -100,7 +100,21 @@ "reviewInstanceDefault": "(Standard — verwendet Firmenname)", "reviewNoPackages": "Keine ausgewählt", "reviewBillingTo": "Rechnungsempfänger", - "reviewContactEmail": "Kontakt-E-Mail" + "reviewContactEmail": "Kontakt-E-Mail", + "editRequestTitle": "Anfrage bearbeiten", + "editRequestDescription": "Passen Sie die Konfiguration an, bevor unser Team sie prüft.", + "editRequest": "Bearbeiten", + "cancelRequest": "Anfrage stornieren", + "cancelRequestConfirm": "Ja, Anfrage stornieren", + "cancelConfirmRequestTitle": "Diese Anfrage stornieren?", + "cancelConfirmRequestDescription": "Ihre ausstehende Anfrage wird als storniert markiert und aus der Warteschlange entfernt. Sie können jederzeit eine neue Anfrage einreichen.", + "cancelFailed": "Anfrage konnte nicht storniert werden.", + "cancelledTitle": "Anfrage storniert", + "cancelledDescription": "Sie haben diese Anfrage vor der Bearbeitung storniert. Es wurde keine Instanz erstellt.", + "dismiss": "Ausblenden", + "dismissFailed": "Konnte nicht ausgeblendet werden.", + "rejectionReason": "Angegebener Grund", + "saveChanges": "Änderungen speichern" }, "dashboard": { "title": "Dashboard", @@ -345,6 +359,7 @@ "Ready": "Bereit", "Suspended": "Pausiert", "Error": "Fehler", - "Deleting": "Wird gelöscht" + "Deleting": "Wird gelöscht", + "Reconfiguring": "Wird neu konfiguriert" } } diff --git a/src/messages/en.json b/src/messages/en.json index 52c02c6..6cc7e37 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -100,7 +100,21 @@ "reviewInstanceDefault": "(default — uses company name)", "reviewNoPackages": "None selected", "reviewBillingTo": "Billing to", - "reviewContactEmail": "Contact email" + "reviewContactEmail": "Contact email", + "editRequestTitle": "Edit your request", + "editRequestDescription": "Adjust the configuration before our team reviews it.", + "editRequest": "Edit", + "cancelRequest": "Cancel request", + "cancelRequestConfirm": "Yes, cancel request", + "cancelConfirmRequestTitle": "Cancel this request?", + "cancelConfirmRequestDescription": "Your pending request will be marked as cancelled and removed from the review queue. You can submit a new request at any time.", + "cancelFailed": "Could not cancel request.", + "cancelledTitle": "Request cancelled", + "cancelledDescription": "You cancelled this request before it was processed. No instance was created.", + "dismiss": "Dismiss", + "dismissFailed": "Could not dismiss.", + "rejectionReason": "Reason given", + "saveChanges": "Save changes" }, "dashboard": { "title": "Dashboard", @@ -345,6 +359,7 @@ "Ready": "Ready", "Suspended": "Suspended", "Error": "Error", - "Deleting": "Deleting" + "Deleting": "Deleting", + "Reconfiguring": "Reconfiguring" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index f997f7c..08f3418 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -100,7 +100,21 @@ "reviewInstanceDefault": "(par défaut — utilise le nom de l'entreprise)", "reviewNoPackages": "Aucun sélectionné", "reviewBillingTo": "Facturer à", - "reviewContactEmail": "E-mail de contact" + "reviewContactEmail": "E-mail de contact", + "editRequestTitle": "Modifier votre demande", + "editRequestDescription": "Ajustez la configuration avant que notre équipe ne l'examine.", + "editRequest": "Modifier", + "cancelRequest": "Annuler la demande", + "cancelRequestConfirm": "Oui, annuler la demande", + "cancelConfirmRequestTitle": "Annuler cette demande ?", + "cancelConfirmRequestDescription": "Votre demande en attente sera marquée comme annulée et retirée de la file. Vous pouvez soumettre une nouvelle demande à tout moment.", + "cancelFailed": "Impossible d'annuler la demande.", + "cancelledTitle": "Demande annulée", + "cancelledDescription": "Vous avez annulé cette demande avant son traitement. Aucune instance n'a été créée.", + "dismiss": "Masquer", + "dismissFailed": "Impossible de masquer.", + "rejectionReason": "Motif indiqué", + "saveChanges": "Enregistrer les modifications" }, "dashboard": { "title": "Tableau de bord", @@ -345,6 +359,7 @@ "Ready": "Prêt", "Suspended": "Suspendu", "Error": "Erreur", - "Deleting": "Suppression" + "Deleting": "Suppression", + "Reconfiguring": "Reconfiguration" } } diff --git a/src/messages/it.json b/src/messages/it.json index 7946b03..315fe85 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -100,7 +100,21 @@ "reviewInstanceDefault": "(predefinito — usa il nome dell'azienda)", "reviewNoPackages": "Nessuno selezionato", "reviewBillingTo": "Fatturare a", - "reviewContactEmail": "Email di contatto" + "reviewContactEmail": "Email di contatto", + "editRequestTitle": "Modifica la sua richiesta", + "editRequestDescription": "Modifichi la configurazione prima che il nostro team la esamini.", + "editRequest": "Modifica", + "cancelRequest": "Annulla richiesta", + "cancelRequestConfirm": "Sì, annulla la richiesta", + "cancelConfirmRequestTitle": "Annullare questa richiesta?", + "cancelConfirmRequestDescription": "La sua richiesta in attesa sarà contrassegnata come annullata e rimossa dalla coda di revisione. Può inviare una nuova richiesta in qualsiasi momento.", + "cancelFailed": "Impossibile annullare la richiesta.", + "cancelledTitle": "Richiesta annullata", + "cancelledDescription": "Lei ha annullato questa richiesta prima dell'elaborazione. Nessuna istanza è stata creata.", + "dismiss": "Nascondi", + "dismissFailed": "Impossibile nascondere.", + "rejectionReason": "Motivo indicato", + "saveChanges": "Salva modifiche" }, "dashboard": { "title": "Dashboard", @@ -345,6 +359,7 @@ "Ready": "Pronto", "Suspended": "Sospeso", "Error": "Errore", - "Deleting": "Eliminazione" + "Deleting": "Eliminazione", + "Reconfiguring": "Riconfigurazione" } } diff --git a/src/types/index.ts b/src/types/index.ts index 7a463a7..9cd7aeb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -83,6 +83,7 @@ export interface PiecedTenantStatus { | "Provisioning" | "Running" | "Ready" + | "Reconfiguring" | "Suspended" | "Error" | "Deleting"; @@ -169,6 +170,7 @@ export type TenantRequestStatus = | "provisioning" // PiecedTenant CR created, operator reconciling | "active" // Tenant running | "rejected" // Admin rejected + | "cancelled" // Customer cancelled before admin acted on it (Bug 6) | "deleted"; // Tenant was deleted by admin export interface TenantRequest { @@ -202,6 +204,14 @@ export interface TenantRequest { * domain-uniqueness check on subsequent registrations. */ isPersonal?: boolean; + /** + * Bug 13: when set, the customer has explicitly dismissed a rejected + * request from their dashboard. Used by `listActiveTenantRequestsByOrgId` + * to keep showing rejected rows until they're dismissed (so a customer + * who wasn't online when the rejection happened still sees it on next + * login). Always null for non-rejected statuses. + */ + dismissedAt?: string | null; createdAt: string; updatedAt: string; }