Files
pieced-portal/src/app/[locale]/tenants/[name]/page.tsx
admin a31d05b7c2
All checks were successful
Build and Push / build (push) Successful in 1m26s
Team UI
2026-04-26 23:07:47 +02:00

145 lines
5.4 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 { StatusBadge } from "@/components/ui/status-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 { 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);
const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) =>
CHANNEL_PACKAGES.includes(pkg)
);
const channelUsers = tenant.spec.channelUsers || {};
// Admins inspecting another tenant's usage: pass teamId AND keyAlias so
// the backend filters spend logs by this specific tenant's virtual key.
// Without keyAlias the response would include sibling tenants in the
// same org, since teams are now shared (Slice 2).
// Customers viewing their own: pass nothing — backend resolves both
// from the session-bound tenant.
const usageTeamId = user.isPlatform
? tenant.status?.litellmTeamId || undefined
: undefined;
const usageKeyAlias = user.isPlatform
? tenant.status?.litellmKeyAlias || undefined
: undefined;
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"} />
</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>
{/* 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 teamId={usageTeamId} keyAlias={usageKeyAlias} />
</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. */}
<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>
</div>
);
}