Group C+ fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s

This commit is contained in:
2026-04-29 21:34:52 +02:00
parent 49d81190d4
commit 9c50c9f054
12 changed files with 556 additions and 43 deletions

View File

@@ -9,6 +9,7 @@ import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -40,6 +41,11 @@ export default async function TenantDetailPage({
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
// Bug 31: customer-side cancel/resume control. Same gate as canEdit
// — only owners (or platform staff) may toggle the subscription.
// The current state comes from spec.suspend on the CR.
const isSuspended = Boolean(tenant.spec.suspend);
// 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
@@ -102,6 +108,41 @@ export default async function TenantDetailPage({
)}
</div>
{/* Bug 31: prominent banner when the subscription is cancelled.
Sits between header and content so it's the first thing the
owner sees. Says clearly what state means, and that data is
preserved. The Resume action lives in the SubscriptionToggle
at the bottom — duplicating it here would clutter the banner
for the much-more-common active case. */}
{isSuspended && (
<div className="mb-8 animate-in animate-in-delay-1 bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
/>
</svg>
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-300">
{t("suspendedTitle")}
</div>
<div className="text-xs text-text-secondary mt-1">
{t("suspendedDescription")}
</div>
</div>
</div>
</div>
)}
{/* Usage */}
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
@@ -155,6 +196,25 @@ export default async function TenantDetailPage({
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
</section>
)}
{/* Bug 31: subscription cancel/resume — owners + platform staff
only. Lives at the bottom of the page (rather than near the
status badge) to add deliberate friction; mis-clicking
"Cancel subscription" from the top would be too easy. The
control itself opens a confirmation modal before sending. */}
{canEdit && (
<section className="mt-12 pt-8 border-t border-border animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("subscriptionTitle")}
</h2>
<p className="text-sm text-text-secondary mb-4">
{isSuspended
? t("subscriptionDescriptionSuspended")
: t("subscriptionDescriptionActive")}
</p>
<SubscriptionToggle tenantName={name} suspended={isSuspended} />
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
suspend: z.boolean(),
});
/**
* PATCH /api/tenants/[name]/suspend
*
* Customer-side "Cancel subscription" / "Resume" toggle (Bug 31).
*
* 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
* -------------
* - 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.
*
* 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.
*
* 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.
*/
export async function PATCH(
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 });
}
// Identical pattern to the detail page — don't leak existence.
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { suspend } = parsed.data;
// 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) {
return NextResponse.json(
{ message: "No change.", suspend },
{ status: 200 }
);
}
try {
await patchTenantSpec(name, { suspend });
return NextResponse.json(
{
message: suspend
? "Subscription cancelled. Your data is preserved."
: "Subscription resumed.",
suspend,
},
{ status: 200 }
);
} catch (e: any) {
console.error("Suspend toggle failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update subscription") },
{ status: e.statusCode || 500 }
);
}
}