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 (
{t("agent")}: {tenant.spec.agentName}
)} {tenant.metadata.creationTimestamp && ({t("provisioned")}{" "} {formatRelative(tenant.metadata.creationTimestamp, f)}{" "} ({formatDateTime(tenant.metadata.creationTimestamp, f)})
)}{isSuspended ? t("subscriptionDescriptionSuspended") : t("subscriptionDescriptionActive")}