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, listSkillActivationRequestsForTenant, listSkillPricing, } 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"; import { CHANNEL_PACKAGE_IDS } from "@/lib/packages"; // CHANNEL_PACKAGES used to be a hardcoded literal here // (`["telegram", "discord", "email"]`). It now derives from the // portal-side catalog so adding a new channel anywhere only requires // editing src/lib/packages.ts. The `email` channel was dropped as // part of the Phase A package-model rework — IMAP/SMTP is now the // `mail` skill instead. const CHANNEL_PACKAGES = CHANNEL_PACKAGE_IDS; 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 || {}; // Phase 2.5: surface pending and most-recently-rejected skill // activation requests so PackageCard can render the inline // "Manual review pending" / "Activation rejected" states. // Pricing drives the cost-disclosure dialog before enable. // Both fetches are best-effort — an empty list is the safe // fallback if the DB call fails (cards just show normal toggles). const [activationRequests, skillPricing] = await Promise.all([ listSkillActivationRequestsForTenant(name).catch(() => []), listSkillPricing().catch(() => []), ]); // 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 (
{/* Header */}

{tenant.spec.displayName || name}

{tenant.spec.agentName && (

{t("agent")}: {tenant.spec.agentName}

)} {tenant.metadata.creationTimestamp && (

{t("provisioned")}{" "} {formatRelative(tenant.metadata.creationTimestamp, f)}{" "} ({formatDateTime(tenant.metadata.creationTimestamp, f)})

)}
{/* 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 && (
{t("suspendedTitle")}
{t("suspendedDescription")}
{/* 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 (
{t("suspendedSince", { date: formatDateTime( tenant.status.suspendedAt, f ), })} {" · "} {daysRemaining > 0 ? t("suspendedDeletionIn", { days: daysRemaining, date: formatDateTime( deletionAt.toISOString(), f ), }) : t("suspendedDeletionImminent")}
); })()}
)} {/* Usage */}

{t("usage")}

{/* Packages */}

{t("packages")}

{/* Channel Users (authorized users per channel) */} {enabledChannels.length > 0 && (
)} {/* Workspace files */}

{t("workspaceFiles")}

{/* 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 && (

{t("assignedUsers")}

)} {/* 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 && (

{t("subscriptionTitle")}

{isSuspended ? t("subscriptionDescriptionSuspended") : t("subscriptionDescriptionActive")}

)}
); }