Files
pieced-portal/src/app/[locale]/tenants/[name]/page.tsx
admin 46369fda01
All checks were successful
Build and Push / build (push) Successful in 1m26s
Show suspended since and new emails for suspend continue approve and rejection
2026-05-01 22:37:23 +02:00

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>
);
}