284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
import { getSessionUser, canMutate } from "@/lib/session";
|
|
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";
|
|
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"];
|
|
|
|
export default async function TenantDetailPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ name: string; locale: string }>;
|
|
}) {
|
|
const user = await getSessionUser();
|
|
if (!user) redirect("/login");
|
|
|
|
const { name } = await params;
|
|
const t = await getTranslations("tenantDetail");
|
|
const f = await getFormatter();
|
|
|
|
const tenant = await getTenant(name);
|
|
if (!tenant) notFound();
|
|
|
|
// Slice 6: visibility check encompasses org membership AND, for
|
|
// user-role members, the tenant_user_assignments check. notFound()
|
|
// (404) rather than redirect/403 to avoid leaking tenant existence.
|
|
if (!(await canUserSeeTenant(user, tenant))) {
|
|
notFound();
|
|
}
|
|
|
|
// Slice 5: editable surface gated on owner role. Platform users always
|
|
// can edit; customer-side, only `owner` may. `user`-role members see
|
|
// 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 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
|
|
// `pieced.ch/personal=true` label (set at approve time for new
|
|
// personal tenants) OR the viewer is on a personal account (covers
|
|
// legacy tenants approved before the label was added; the customer
|
|
// sees their own personal tenant). Platform admins viewing a legacy
|
|
// unlabeled personal tenant are the only case where this falls
|
|
// through to "show panel" — operators can `kubectl label` to fix.
|
|
const isPersonalTenant =
|
|
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
|
|
user.isPersonal;
|
|
|
|
const enabledPackages = tenant.spec.packages || [];
|
|
const workspaceFiles = tenant.spec.workspaceFiles || {};
|
|
const enabledChannels = enabledPackages.filter((pkg) =>
|
|
CHANNEL_PACKAGES.includes(pkg)
|
|
);
|
|
const channelUsers = tenant.spec.channelUsers || {};
|
|
|
|
// Bug 19 fix: every viewer (customer or admin) passes the tenant
|
|
// name to UsageDisplay. The /api/usage route resolves team+alias
|
|
// from the tenant CR's status and applies the visibility check, so
|
|
// no per-role branching is needed here. Previous version only
|
|
// passed identifiers for platform admins; customers got "the first
|
|
// visible tenant" by API fallback, mingling siblings.
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="mb-8 animate-in">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
|
{tenant.spec.displayName || name}
|
|
</h1>
|
|
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
|
|
<WarningBadge warnings={tenant.status?.warnings ?? []} />
|
|
</div>
|
|
{tenant.spec.agentName && (
|
|
<p className="text-sm text-text-secondary mt-3">
|
|
{t("agent")}: {tenant.spec.agentName}
|
|
</p>
|
|
)}
|
|
{tenant.metadata.creationTimestamp && (
|
|
<p
|
|
className="text-xs text-text-muted mt-1"
|
|
title={formatDateTime(tenant.metadata.creationTimestamp, f)}
|
|
>
|
|
{t("provisioned")}{" "}
|
|
{formatRelative(tenant.metadata.creationTimestamp, f)}{" "}
|
|
<span className="text-text-muted/60">
|
|
({formatDateTime(tenant.metadata.creationTimestamp, f)})
|
|
</span>
|
|
</p>
|
|
)}
|
|
</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>
|
|
{/* Retention countdown. suspendedAt is stamped by the
|
|
operator on first transition to suspended; missing
|
|
values fall through silently rather than rendering
|
|
garbage (operator hasn't reconciled yet, edge case).
|
|
The 60-day window is the operator's
|
|
retentionAfterSuspend constant; if you change one,
|
|
change both. We don't expose the constant via API —
|
|
the value rarely changes and duplicating it here
|
|
beats fetching a single int over the network. */}
|
|
{tenant.status?.suspendedAt && (() => {
|
|
const suspendedAt = new Date(tenant.status.suspendedAt);
|
|
const deletionAt = new Date(suspendedAt);
|
|
deletionAt.setDate(deletionAt.getDate() + 60);
|
|
const now = new Date();
|
|
const msRemaining = deletionAt.getTime() - now.getTime();
|
|
const daysRemaining = Math.max(
|
|
0,
|
|
Math.ceil(msRemaining / (1000 * 60 * 60 * 24))
|
|
);
|
|
// < 7 days: red/critical to draw attention. Otherwise
|
|
// amber, matching the banner.
|
|
const urgent = daysRemaining < 7;
|
|
return (
|
|
<div
|
|
className={`text-xs mt-2 ${
|
|
urgent ? "text-red-400" : "text-text-muted"
|
|
}`}
|
|
>
|
|
{t("suspendedSince", {
|
|
date: formatDateTime(
|
|
tenant.status.suspendedAt,
|
|
f
|
|
),
|
|
})}
|
|
{" · "}
|
|
{daysRemaining > 0
|
|
? t("suspendedDeletionIn", {
|
|
days: daysRemaining,
|
|
date: formatDateTime(
|
|
deletionAt.toISOString(),
|
|
f
|
|
),
|
|
})
|
|
: t("suspendedDeletionImminent")}
|
|
</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">
|
|
{t("usage")}
|
|
</h2>
|
|
<UsageDisplay tenant={name} />
|
|
</section>
|
|
|
|
{/* Packages */}
|
|
<section className="mb-8 animate-in animate-in-delay-2">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("packages")}
|
|
</h2>
|
|
<PackageList
|
|
tenantName={name}
|
|
enabledPackages={enabledPackages}
|
|
conditions={tenant.status?.conditions}
|
|
canEdit={canEdit}
|
|
/>
|
|
</section>
|
|
|
|
{/* Channel Users (authorized users per channel) */}
|
|
{enabledChannels.length > 0 && (
|
|
<section className="mb-8 animate-in animate-in-delay-3">
|
|
<ChannelUsers
|
|
tenantName={name}
|
|
enabledChannels={enabledChannels}
|
|
initialChannelUsers={channelUsers}
|
|
canEdit={canEdit}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{/* Workspace files */}
|
|
<section className="animate-in animate-in-delay-4">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("workspaceFiles")}
|
|
</h2>
|
|
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
|
|
</section>
|
|
|
|
{/* Slice 7: Assigned users — visible to anyone who can see the
|
|
tenant, editable only by owners/platform users. The component
|
|
fetches its own data so the page doesn't need to await.
|
|
Bug 7: hidden entirely for personal tenants. */}
|
|
{!isPersonalTenant && (
|
|
<section className="mt-8 animate-in animate-in-delay-4">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("assignedUsers")}
|
|
</h2>
|
|
<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}
|
|
isPlatform={user.isPlatform}
|
|
pendingResumeRequest={
|
|
pendingResumeRequest
|
|
? {
|
|
id: pendingResumeRequest.id,
|
|
createdAt: pendingResumeRequest.createdAt,
|
|
}
|
|
: null
|
|
}
|
|
/>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|