145 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|