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

@@ -199,7 +199,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
throw new Error(data.error || "Delete failed");
}
setDeleteModal(null);
await fetchTenants();
// Bug 32: K8s deletion is asynchronous — the resource enters a
// Terminating phase with a deletionTimestamp set, finalizers run,
// then the resource is fully removed. fetchTenants() right
// after the API call would race the K8s store and often still
// include the just-deleted row. Two complementary fixes:
// 1. Optimistically drop the row from local state so the UI
// reflects the user's intent immediately.
// 2. Schedule a delayed refetch (1.5s) to pick up any side
// effects (cascaded request rows, freshly-released names).
// The immediate fetchTenants() is kept as a "best chance" — if
// K8s does report the deletion synchronously (rare), we get the
// freshest data. If it doesn't, the optimistic update has us
// covered until the delayed refetch lands.
setTenants((prev) => prev.filter((t) => t.metadata.name !== name));
fetchTenants();
setTimeout(() => fetchTenants(), 1500);
} catch (e: any) {
setError(e.message);
} finally {

View File

@@ -13,8 +13,13 @@ function NavBar() {
const pathname = usePathname();
const user = (session as any)?.platformUser;
const isLogin = pathname === "/login";
if (isLogin) return null;
// Hide the nav entirely on auth-only routes. These pages have no
// session yet — showing "Dashboard" / "Sign Out" is misleading at
// best (the buttons would 401 or redirect-loop). Keep this list
// narrow and route-exact: anything else we add to the auth flow
// (e.g. password reset) needs to be added here too.
const isAuthRoute = pathname === "/login" || pathname === "/register";
if (isAuthRoute) return null;
return (
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">

View File

@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
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.
*/
suspended: boolean;
}
/**
* SubscriptionToggle — owner-side cancel/resume control (Bug 31).
*
* 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.
*
* 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.
*
* Suspended tenants render a top-of-page banner separately (see the
* detail page); this component focuses on the action itself.
*/
export function SubscriptionToggle({ tenantName, suspended }: Props) {
const t = useTranslations("tenantDetail");
const tCommon = useTranslations("common");
const router = useRouter();
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const toggleSuspend = async (next: boolean) => {
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suspend: next }),
}
);
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.
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
if (suspended) {
return (
<div>
<button
type="button"
onClick={() => toggleSuspend(false)}
disabled={submitting}
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors disabled:opacity-50"
>
{submitting ? tCommon("loading") : t("resumeSubscription")}
</button>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
</div>
);
}
return (
<div>
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("cancelSubscription")}
</button>
{error && !confirmOpen && (
<p className="text-xs text-red-400 mt-2">{error}</p>
)}
{confirmOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmTitle")}
</h3>
<p className="text-sm text-text-secondary mb-3">
{t("cancelConfirmDescription")}
</p>
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
<li>{t("cancelConfirmBullet1")}</li>
<li>{t("cancelConfirmBullet2")}</li>
<li>{t("cancelConfirmBullet3")}</li>
</ul>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,18 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Visual treatment per phase. Each entry is a Tailwind class string
* applied to the badge. The `Pending` style is also used as a fallback
* for unknown phases — it's the most neutral colour.
*
* Slice 7 / Bug 31 added `Suspended`. It uses an amber-on-muted scheme
* to read as "intentionally paused" — distinct from `Error` (red) and
* `Deleting` (mute grey).
*/
const phaseStyles: Record<string, string> = {
Running:
"bg-success/10 text-success border-success/20",
Provisioning:
"bg-warning/10 text-warning border-warning/20",
Pending:
"bg-text-muted/10 text-text-secondary border-border",
Error:
"bg-error/10 text-error border-error/20",
Deleting:
"bg-text-muted/10 text-text-muted border-border",
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",
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",
Deleting: "bg-text-muted/10 text-text-muted border-border",
};
export function StatusBadge({ phase }: { phase: string }) {
const t = useTranslations("phase");
const style = phaseStyles[phase] ?? phaseStyles.Pending;
// Translation lookup with fallback to the raw phase. Keeps things
// working if a new operator-side phase ships before the portal has
// a label for it.
const label = (() => {
try {
return t(phase);
} catch {
return phase;
}
})();
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style}`}
@@ -23,7 +44,7 @@ export function StatusBadge({ phase }: { phase: string }) {
{phase === "Provisioning" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{phase}
{label}
</span>
);
}