From a5812dca9a5aa7841e73ae2681480778c166642b Mon Sep 17 00:00:00 2001
From: admin
Date: Fri, 1 May 2026 18:07:00 +0200
Subject: [PATCH] Suspendedremoval
---
src/app/[locale]/dashboard/page.tsx | 11 +-
src/app/[locale]/tenants/[name]/page.tsx | 22 +-
.../api/admin/requests/[id]/approve/route.ts | 80 ++++-
.../api/admin/requests/[id]/reject/route.ts | 28 ++
src/app/api/onboarding/[id]/route.ts | 20 ++
.../tenants/[name]/resume-request/route.ts | 154 ++++++++++
src/app/api/tenants/[name]/suspend/route.ts | 93 ++++--
src/components/admin/admin-panel.tsx | 21 +-
.../tenants/subscription-toggle.tsx | 286 +++++++++++++++---
src/lib/db.ts | 144 +++++++++
src/lib/k8s.ts | 43 +++
src/messages/de.json | 15 +-
src/messages/en.json | 15 +-
src/messages/fr.json | 15 +-
src/messages/it.json | 15 +-
src/types/index.ts | 8 +
16 files changed, 880 insertions(+), 90 deletions(-)
create mode 100644 src/app/api/tenants/[name]/resume-request/route.ts
diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx
index 57e0f03..fad0c31 100644
--- a/src/app/[locale]/dashboard/page.tsx
+++ b/src/app/[locale]/dashboard/page.tsx
@@ -174,7 +174,16 @@ export default async function DashboardPage() {
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
const inflightRequests = orgRequests.filter(
- (r) => !r.tenantName || !orgScopedTenants.some((t) => t.metadata.name === r.tenantName)
+ (r) =>
+ // Only show provision (initial creation) requests on the
+ // dashboard. Resume requests (Bug 37a) belong with their
+ // specific tenant — the SubscriptionToggle on the tenant
+ // detail page renders the pending state there. Showing them
+ // on the dashboard too would duplicate the surface and
+ // confuse customers about which tenant they refer to.
+ r.requestType !== "resume" &&
+ (!r.tenantName ||
+ !orgScopedTenants.some((t) => t.metadata.name === r.tenantName))
);
// Slice 5: only owners (and platform users, who'd typically be using
diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx
index 4c00a47..f86c862 100644
--- a/src/app/[locale]/tenants/[name]/page.tsx
+++ b/src/app/[locale]/tenants/[name]/page.tsx
@@ -3,6 +3,7 @@ import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
+import { getPendingResumeRequestForTenant } from "@/lib/db";
import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
@@ -47,6 +48,13 @@ export default async function TenantDetailPage({
// The current state comes from spec.suspend on the CR.
const isSuspended = Boolean(tenant.spec.suspend);
+ // Bug 37a: when the tenant is suspended, an owner can request
+ // reactivation (admin-gated). Look up whether one is in flight so
+ // the SubscriptionToggle can render the right state.
+ const pendingResumeRequest = isSuspended
+ ? await getPendingResumeRequestForTenant(name)
+ : null;
+
// Bug 7: assigned-users panel is meaningless for personal tenants
// (sole-owner by definition; the only "assignee" is the owner
// themselves). We hide the panel when EITHER the CR carries the
@@ -208,7 +216,19 @@ export default async function TenantDetailPage({
? t("subscriptionDescriptionSuspended")
: t("subscriptionDescriptionActive")}
-
+
)}
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts
index 7f52ebc..1a7cdc1 100644
--- a/src/app/api/admin/requests/[id]/approve/route.ts
+++ b/src/app/api/admin/requests/[id]/approve/route.ts
@@ -5,7 +5,7 @@ import {
updateTenantRequestStatus,
clearEncryptedSecrets,
} from "@/lib/db";
-import { createTenant } from "@/lib/k8s";
+import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao";
@@ -19,14 +19,26 @@ import { safeError } from "@/lib/errors";
/**
* POST /api/admin/requests/[id]/approve
- * 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. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
- * 5. Create PiecedTenant CR
- * 6. Update request status, notify customer.
- * Also supports re-approving a previously rejected request (clears admin notes).
+ *
+ * Approve a request. Two paths depending on request_type:
+ *
+ * Provision (the original purpose):
+ * 1. Decrypt stored package secrets (if any)
+ * 2. Write each package's secrets to OpenBao
+ * 3. Null the encrypted_secrets column
+ * 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
+ * 5. Create PiecedTenant CR
+ * 6. Update request status, notify customer.
+ * Supports re-approving a previously rejected request (clears admin notes).
+ *
+ * Resume (Bug 37a):
+ * 1. PATCH spec.suspend=false on the existing PiecedTenant CR.
+ * 2. Clear the `pieced.ch/resume-request-pending` annotation so the
+ * operator knows the request is settled (and doesn't pause its
+ * 60-day TTL forever — though now that the tenant isn't suspended,
+ * the timer is moot).
+ * 3. Mark request approved, notify customer.
+ * No CR creation, no secret materialisation, no workspace files.
*/
export async function POST(
request: Request,
@@ -60,6 +72,56 @@ export async function POST(
);
}
+ // Resume request: short path. Just patch the existing tenant, clear
+ // the annotation, mark approved.
+ if (tenantRequest.requestType === "resume") {
+ if (!tenantRequest.tenantName) {
+ // Shouldn't happen — resume requests are created with tenant_name
+ // set. Defensive 500 if it does.
+ return NextResponse.json(
+ { error: "Resume request has no tenant_name" },
+ { status: 500 }
+ );
+ }
+ try {
+ await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
+ // Clear the annotation that pauses the operator's 60-day TTL.
+ // Best-effort — annotation cleanup is also done by the operator
+ // when it sees suspend=false on the next reconcile (it clears
+ // status.suspendedAt), but explicitly clearing here keeps the
+ // CR clean.
+ try {
+ await setTenantAnnotation(
+ tenantRequest.tenantName,
+ "pieced.ch/resume-request-pending",
+ null
+ );
+ } catch (e) {
+ console.warn(
+ "post-approve annotation clear failed; not blocking",
+ e
+ );
+ }
+
+ await updateTenantRequestStatus(id, "approved", adminNotes);
+
+ await sendApprovalEmail(tenantRequest, tenantRequest.tenantName).catch(
+ (e) => console.error("approval email failed:", e)
+ );
+
+ return NextResponse.json({
+ message: "Resume approved. Tenant is reactivating.",
+ tenantName: tenantRequest.tenantName,
+ });
+ } catch (e: any) {
+ console.error("Resume approval failed:", e);
+ return NextResponse.json(
+ { error: safeError(e, "Failed to approve resume") },
+ { status: 500 }
+ );
+ }
+ }
+
const isReApproval = tenantRequest.status === "rejected";
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
diff --git a/src/app/api/admin/requests/[id]/reject/route.ts b/src/app/api/admin/requests/[id]/reject/route.ts
index 14faecb..016114b 100644
--- a/src/app/api/admin/requests/[id]/reject/route.ts
+++ b/src/app/api/admin/requests/[id]/reject/route.ts
@@ -1,11 +1,19 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
+import { setTenantAnnotation } from "@/lib/k8s";
import { sendRejectionEmail } from "@/lib/email";
/**
* POST /api/admin/requests/[id]/reject
* Reject a tenant request and notify the customer.
+ *
+ * For resume requests (Bug 37a): also clears the
+ * `pieced.ch/resume-request-pending` annotation on the tenant CR.
+ * The operator's 60-day TTL then resumes counting from the original
+ * suspendedAt — rejection doesn't reset it. The customer can submit
+ * a fresh resume request later if circumstances change, but that
+ * starts a new pending row and re-stamps the annotation.
*/
export async function POST(
request: Request,
@@ -37,6 +45,26 @@ export async function POST(
adminNotes,
});
+ // Resume rejection: clear the annotation so the operator's TTL
+ // resumes. Best-effort — failure is logged, not propagated.
+ if (
+ tenantRequest.requestType === "resume" &&
+ tenantRequest.tenantName
+ ) {
+ try {
+ await setTenantAnnotation(
+ tenantRequest.tenantName,
+ "pieced.ch/resume-request-pending",
+ null
+ );
+ } catch (e) {
+ console.warn(
+ "post-reject annotation clear failed; operator's TTL will pause until annotation removed by admin",
+ e
+ );
+ }
+ }
+
// Notify customer
await sendRejectionEmail(
tenantRequest.contactEmail,
diff --git a/src/app/api/onboarding/[id]/route.ts b/src/app/api/onboarding/[id]/route.ts
index d3c00fe..b6679c1 100644
--- a/src/app/api/onboarding/[id]/route.ts
+++ b/src/app/api/onboarding/[id]/route.ts
@@ -6,6 +6,7 @@ import {
updateTenantRequestEditableFields,
} from "@/lib/db";
import { encryptSecrets } from "@/lib/crypto";
+import { setTenantAnnotation } from "@/lib/k8s";
import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors";
@@ -91,6 +92,25 @@ export async function DELETE(
try {
await updateTenantRequestStatus(id, "cancelled");
+
+ // Customer cancels their own pending resume request: clear the
+ // operator-side annotation so the 60-day TTL resumes counting.
+ // Best-effort — the operator handles missing annotation gracefully.
+ if (tr.requestType === "resume" && tr.tenantName) {
+ try {
+ await setTenantAnnotation(
+ tr.tenantName,
+ "pieced.ch/resume-request-pending",
+ null
+ );
+ } catch (e) {
+ console.warn(
+ "post-cancel annotation clear failed; not blocking",
+ e
+ );
+ }
+ }
+
return NextResponse.json({ message: "Request cancelled.", id });
} catch (e: any) {
console.error("Failed to cancel request:", e);
diff --git a/src/app/api/tenants/[name]/resume-request/route.ts b/src/app/api/tenants/[name]/resume-request/route.ts
new file mode 100644
index 0000000..a3c1db2
--- /dev/null
+++ b/src/app/api/tenants/[name]/resume-request/route.ts
@@ -0,0 +1,154 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSessionUser, canMutate } from "@/lib/session";
+import { getTenant, setTenantAnnotation } from "@/lib/k8s";
+import { canUserSeeTenant } from "@/lib/visibility";
+import {
+ createResumeRequest,
+ getPendingResumeRequestForTenant,
+ getTenantRequestByTenantName,
+} from "@/lib/db";
+import { safeError } from "@/lib/errors";
+
+/**
+ * POST /api/tenants/[name]/resume-request
+ *
+ * Owner-initiated request to reactivate a suspended tenant (Bug 37a).
+ * Creates a pending tenant_request of type 'resume' for admin review,
+ * and stamps the PiecedTenant CR with an annotation that pauses the
+ * operator's 60-day deletion timer.
+ *
+ * Why a request flow at all
+ * -------------------------
+ * Customers can self-serve cancel; resume requires admin oversight.
+ * Reactivation may involve re-validating billing, confirming the
+ * customer still wants to be active, or other manual steps. The
+ * request flow gives admins a queue to review, with the same approve/
+ * reject UX as initial provision requests.
+ *
+ * Authorization
+ * -------------
+ * Owners and platform admins. Platform admins shouldn't normally use
+ * this endpoint — they have direct PATCH suspend access — but it's
+ * permissive in case admin tooling pivots.
+ *
+ * Validation
+ * ----------
+ * - Tenant must exist and be visible to the caller.
+ * - Tenant must be currently suspended. Resuming an active tenant
+ * is meaningless.
+ * - At most one pending resume request per tenant. Enforced by the
+ * DB's partial unique index, but we also check explicitly here to
+ * return a friendly 409 instead of a 500.
+ *
+ * Side effects on success
+ * -----------------------
+ * - INSERT into tenant_requests (request_type='resume', status='pending')
+ * - PATCH annotation `pieced.ch/resume-request-pending=` on
+ * the CR. This is the operator's signal to pause its 60-day deletion
+ * timer until the request transitions to terminal.
+ *
+ * The annotation set is best-effort: if the K8s PATCH fails after the
+ * DB insert, the row exists without the annotation. The customer
+ * sees the request as pending; admin can still approve. The only
+ * functional consequence is the 60-day timer doesn't pause until the
+ * next request transition, which is fine in practice (admin response
+ * times are dramatically shorter than 60 days).
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ name: 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 { name } = await params;
+ const tenant = await getTenant(name);
+ if (!tenant) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (!(await canUserSeeTenant(user, tenant))) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ if (!tenant.spec.suspend) {
+ return NextResponse.json(
+ { error: "Tenant is not suspended; nothing to resume." },
+ { status: 409 }
+ );
+ }
+
+ // Already a pending request? Don't duplicate.
+ const existing = await getPendingResumeRequestForTenant(name);
+ if (existing) {
+ return NextResponse.json(
+ {
+ error: "A resume request for this tenant is already pending.",
+ request: { id: existing.id, createdAt: existing.createdAt },
+ },
+ { status: 409 }
+ );
+ }
+
+ // Pull traceability fields (companyName, agentName) from the original
+ // provision request. The schema marks these NOT NULL, so we have to
+ // populate them; copying from the provision row keeps the resume
+ // row navigable in the admin UI without making up values.
+ const provision = await getTenantRequestByTenantName(name);
+
+ try {
+ const resumeRequest = await createResumeRequest({
+ tenantName: name,
+ zitadelOrgId: tenant.metadata.labels?.[
+ "pieced.ch/zitadel-org-id"
+ ] ?? user.zitadelOrgId,
+ zitadelUserId: user.id,
+ contactName: user.name ?? user.email ?? "Unknown",
+ contactEmail: user.email ?? "unknown@example.invalid",
+ companyName: provision?.companyName ?? tenant.spec.displayName ?? name,
+ agentName: provision?.agentName ?? "Assistant",
+ });
+
+ // Stamp the annotation so the operator pauses its TTL. If this
+ // fails the request still exists; surface the error so admin
+ // tooling can re-stamp if needed, but don't roll back.
+ try {
+ await setTenantAnnotation(
+ name,
+ "pieced.ch/resume-request-pending",
+ resumeRequest.id
+ );
+ } catch (e) {
+ console.warn(
+ "resume request created but annotation could not be set; operator's 60-day timer will not pause until next reconcile triggered by request transition",
+ e
+ );
+ }
+
+ return NextResponse.json(
+ {
+ message: "Resume request submitted. An admin will review shortly.",
+ request: { id: resumeRequest.id, status: resumeRequest.status },
+ },
+ { status: 201 }
+ );
+ } catch (e: any) {
+ // Unique violation (a pending row already exists for this tenant)
+ // is friendly-handled above; this catches everything else.
+ if (e.code === "23505") {
+ return NextResponse.json(
+ { error: "A resume request for this tenant is already pending." },
+ { status: 409 }
+ );
+ }
+ console.error("Resume request creation failed:", e);
+ return NextResponse.json(
+ { error: safeError(e, "Failed to submit resume request") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/tenants/[name]/suspend/route.ts b/src/app/api/tenants/[name]/suspend/route.ts
index 91de2c4..c177dab 100644
--- a/src/app/api/tenants/[name]/suspend/route.ts
+++ b/src/app/api/tenants/[name]/suspend/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
-import { getTenant, patchTenantSpec } from "@/lib/k8s";
+import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { safeError } from "@/lib/errors";
@@ -12,37 +12,38 @@ const patchSchema = z.object({
/**
* PATCH /api/tenants/[name]/suspend
*
- * Customer-side "Cancel subscription" / "Resume" toggle (Bug 31).
+ * Direct suspend control on the PiecedTenant CR. Sets `spec.suspend`
+ * to true (cancel) or false (resume).
*
- * Sets `spec.suspend` on the PiecedTenant CR. The operator interprets
- * this flag as "stop reconciling this tenant" — workloads, packages,
- * and channel-user changes are no longer applied. Existing data is
- * preserved (namespace, ConfigMaps, OpenBao secrets, CNPG database,
- * billing records). Resuming sets the flag back to false and the
- * operator picks up reconciliation on the next loop.
+ * Authorization (Bug 37a)
+ * -----------------------
+ * - suspend=true → owners and platform admins may call.
+ * - suspend=false → platform admins ONLY. Owners must go through the
+ * resume-request flow (POST /api/tenants/[name]/resume-request),
+ * which creates a pending request for admin approval. This
+ * asymmetry is by design: cancellation is self-service (low risk;
+ * reversible by request); reactivation requires admin oversight
+ * (e.g. to re-validate billing, confirm intent).
*
- * Authorization
- * -------------
- * - Customer-side: only an `owner` of the tenant's org may call this.
- * `canMutate` is the right gate (mirrors the rest of the customer
- * API surface). User-role members cannot cancel a subscription.
- * - Platform staff: allowed via `canMutate`'s isPlatform branch, but
- * in practice they should use admin tooling for this — the action
- * is exposed here for the customer's benefit.
+ * Customer flow:
+ * - Cancel: PATCH suspend=true here
+ * - Resume: POST /resume-request — creates a 'resume' tenant_request,
+ * admin approves via /api/admin/requests/[id]/approve which
+ * then PATCHes suspend=false here as a platform user.
*
- * Visibility check is via `canUserSeeTenant` — same notFound() trick
- * as the detail page, so we don't leak existence of tenants the
- * caller can't see.
+ * Workload behaviour
+ * ------------------
+ * On suspend=true the operator deletes the OpenClawInstance, stopping
+ * the pod within seconds. Tenant data — namespace, ConfigMaps,
+ * OpenBao secrets, CNPG database, LiteLLM team — is retained.
*
- * Note on workload teardown
- * -------------------------
- * As of this writing, the operator's `suspend` handling is "skip
- * reconciliation and set status.phase to Suspended". The underlying
- * StatefulSet keeps running until next reconciliation, which won't
- * happen while suspended. Group D will add scale-to-zero so cancelled
- * subscriptions actually stop incurring compute. Until then, an
- * operator following up with a `kubectl scale` is the workaround.
- * Customer data is preserved either way.
+ * Suspended tenants enter a 60-day retention window (operator
+ * constant `retentionAfterSuspend`); after that, the tenant is fully
+ * deleted unless a pending resume request exists. The operator
+ * checks the `pieced.ch/resume-request-pending` annotation to know
+ * about pending requests; we set it here when admin approves the
+ * resume (transitively, via the admin-approve endpoint), and clear
+ * it when the request reaches a terminal state.
*/
export async function PATCH(
req: NextRequest,
@@ -76,6 +77,18 @@ export async function PATCH(
}
const { suspend } = parsed.data;
+ // Bug 37a: resume (suspend=false) is platform-admin only via this
+ // endpoint. Owners must go through the resume-request flow.
+ if (!suspend && !user.isPlatform) {
+ return NextResponse.json(
+ {
+ error:
+ "Resume requires platform-admin approval. Submit a resume request via /api/tenants/[name]/resume-request.",
+ },
+ { status: 403 }
+ );
+ }
+
// No-op early exit. Avoids a needless K8s patch + status churn when
// the user double-clicks the button or the UI is briefly out of sync.
if (Boolean(tenant.spec.suspend) === suspend) {
@@ -87,10 +100,32 @@ export async function PATCH(
try {
await patchTenantSpec(name, { suspend });
+
+ // On admin-side resume, also clear the pending-resume-request
+ // annotation if it exists. Belt-and-suspenders: the admin-approve
+ // endpoint already clears it on its happy path, but a platform
+ // user resuming directly via this endpoint shouldn't leave the
+ // annotation behind. Best-effort: failure to clear the annotation
+ // is logged but doesn't fail the resume.
+ if (!suspend) {
+ try {
+ await setTenantAnnotation(
+ name,
+ "pieced.ch/resume-request-pending",
+ null
+ );
+ } catch (e) {
+ console.warn(
+ "failed to clear resume-request-pending annotation; operator will see it stale until next request transition",
+ e
+ );
+ }
+ }
+
return NextResponse.json(
{
message: suspend
- ? "Subscription cancelled. Your data is preserved."
+ ? "Subscription cancelled. Your data is preserved for 60 days."
: "Subscription resumed.",
suspend,
},
diff --git a/src/components/admin/admin-panel.tsx b/src/components/admin/admin-panel.tsx
index c469851..0de7816 100644
--- a/src/components/admin/admin-panel.tsx
+++ b/src/components/admin/admin-panel.tsx
@@ -362,9 +362,28 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
>
-
+
{req.companyName}
+ {/* Bug 37a: distinguish resume requests in the
+ queue. Provision and resume share status
+ semantics but very different action
+ consequences — a resume approval just
+ un-suspends an existing tenant, no
+ provisioning workflow runs. */}
+ {req.requestType === "resume" && (
+
+ {t("resumeRequestBadge")}
+
+ )}
diff --git a/src/components/tenants/subscription-toggle.tsx b/src/components/tenants/subscription-toggle.tsx
index e1624ab..8b8ed5a 100644
--- a/src/components/tenants/subscription-toggle.tsx
+++ b/src/components/tenants/subscription-toggle.tsx
@@ -2,49 +2,73 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
+import { useTranslations, useFormatter } from "next-intl";
import { Modal } from "@/components/ui/modal";
interface Props {
tenantName: string;
/**
- * Current suspend state — server-derived. The control toggles this
- * via PATCH /api/tenants/[name]/suspend, then refreshes the route
- * so server-component-side data (status badge, suspended notice)
- * re-renders.
+ * Current suspend state — server-derived. Drives which control the
+ * customer sees: "Cancel subscription" while active, the
+ * resume-request flow while suspended.
*/
suspended: boolean;
+ /**
+ * True when the viewer has platform admin role. Platform users are
+ * the only ones who can directly resume a tenant via PATCH; owners
+ * must go through the resume-request flow. We use this in the
+ * suspended branch to decide whether to render a direct "Resume"
+ * button or the "Request reactivation" workflow.
+ */
+ isPlatform: boolean;
+ /**
+ * If a resume request is currently pending for this tenant, its
+ * id and submitted-at. The component renders an info card with
+ * a cancel-request button instead of the request-reactivation
+ * button. Only meaningful when `suspended === true`.
+ */
+ pendingResumeRequest: { id: string; createdAt: string } | null;
}
/**
- * SubscriptionToggle — owner-side cancel/resume control (Bug 31).
+ * SubscriptionToggle — owner-side cancel/resume control.
*
- * Renders a single button that toggles between "Cancel subscription"
- * (when active) and "Resume subscription" (when suspended). Cancellation
- * is gated behind a confirmation modal because it's destructive
- * looking from the user's POV — even though no data is lost, the
- * AI assistant becomes unavailable until they resume. Resume has no
- * modal; it's a strict subset of cancellation in terms of risk.
+ * Three render states:
+ * 1. Active: "Cancel subscription" button + confirmation modal
+ * (mentions 60-day retention before permanent deletion).
+ * 2. Suspended, no pending resume request: "Request reactivation"
+ * button + simple confirmation modal explaining admin review.
+ * 3. Suspended, pending resume request: status card "Reactivation
+ * requested X days ago" + "Cancel request" button.
*
- * The control intentionally lives at the bottom of the tenant detail
- * page rather than next to the status badge — putting it near the
- * top would invite mis-clicks. Customers who want to cancel scroll
- * past the running configuration, billing-relevant info, and assigned
- * users first; that's the right friction level.
+ * Platform admins viewing a suspended tenant get a fourth state in
+ * place of #2/#3: a direct "Resume now" button (no admin queue, no
+ * request flow). This is the admin escape hatch.
*
- * Suspended tenants render a top-of-page banner separately (see the
- * detail page); this component focuses on the action itself.
+ * The control intentionally lives at the bottom of the tenant
+ * detail page rather than near the top — putting it next to the
+ * status badge would invite mis-clicks.
*/
-export function SubscriptionToggle({ tenantName, suspended }: Props) {
+export function SubscriptionToggle({
+ tenantName,
+ suspended,
+ isPlatform,
+ pendingResumeRequest,
+}: Props) {
const t = useTranslations("tenantDetail");
const tCommon = useTranslations("common");
+ const f = useFormatter();
const router = useRouter();
- const [confirmOpen, setConfirmOpen] = useState(false);
+ const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
+ const [confirmResumeOpen, setConfirmResumeOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
- const toggleSuspend = async (next: boolean) => {
+ // Customer-side cancel: PATCH suspend=true. Same path as before.
+ // The 60-day retention copy in the modal is the new bit (Bug 37b);
+ // mechanics are unchanged.
+ const cancel = async () => {
setSubmitting(true);
setError("");
try {
@@ -53,18 +77,14 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ suspend: next }),
+ body: JSON.stringify({ suspend: true }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
- setConfirmOpen(false);
- // The status badge + suspended banner are server-rendered, so
- // a route refresh is the simplest way to reflect the new state.
- // Optimistic local toggle would diverge from the actual CR if
- // the operator hasn't observed the patch yet.
+ setConfirmCancelOpen(false);
router.refresh();
} catch (e: any) {
setError(e.message);
@@ -73,39 +93,217 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
}
};
+ // Owner-side resume request: POST a 'resume' tenant_request that
+ // sits pending until admin acts. Different from cancel: no PATCH
+ // on the CR — that happens only when admin approves.
+ const requestResume = async () => {
+ setSubmitting(true);
+ setError("");
+ try {
+ const res = await fetch(
+ `/api/tenants/${encodeURIComponent(tenantName)}/resume-request`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || t("subscriptionUpdateFailed"));
+ }
+ setConfirmResumeOpen(false);
+ router.refresh();
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Customer cancels their own pending resume request.
+ const cancelResumeRequest = async () => {
+ if (!pendingResumeRequest) return;
+ setSubmitting(true);
+ setError("");
+ try {
+ const res = await fetch(
+ `/api/onboarding/${pendingResumeRequest.id}`,
+ { method: "DELETE" }
+ );
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || t("subscriptionUpdateFailed"));
+ }
+ router.refresh();
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Platform admin: direct resume, bypassing the request flow.
+ const adminResume = async () => {
+ setSubmitting(true);
+ setError("");
+ try {
+ const res = await fetch(
+ `/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ suspend: false }),
+ }
+ );
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || t("subscriptionUpdateFailed"));
+ }
+ router.refresh();
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // ─── Suspended branch ───────────────────────────────────────────────
+
if (suspended) {
+ // Platform admin sees direct resume. Independent of pending
+ // resume — admin can always resume immediately.
+ if (isPlatform) {
+ return (
+
+
+ {pendingResumeRequest && (
+
+ {t("resumeRequestPendingNoteAdmin")}
+
+ )}
+ {error &&
{error}
}
+
+ );
+ }
+
+ // Owner with pending resume request: render the request status
+ // card with cancel.
+ if (pendingResumeRequest) {
+ const submittedDate = new Date(pendingResumeRequest.createdAt);
+ return (
+
+ {/* Bug 37b: 60-day retention warning. Distinct paragraph so it
+ reads as a separate, more serious commitment than the
+ regular bullets above. */}
+