434 lines
17 KiB
TypeScript
434 lines
17 KiB
TypeScript
import { getSessionUser, canMutate } from "@/lib/session";
|
|
import { getTranslations, getFormatter } from "next-intl/server";
|
|
import { redirect } from "next/navigation";
|
|
import { listTenants } from "@/lib/k8s";
|
|
import {
|
|
listActiveTenantRequestsByOrgId,
|
|
syncProvisioningStatuses,
|
|
getOrgBilling,
|
|
} from "@/lib/db";
|
|
import {
|
|
listVisibleTenants,
|
|
canSeeInflightRequests,
|
|
isUserScoped,
|
|
} from "@/lib/visibility";
|
|
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
|
import { Card, CardHeader } from "@/components/ui/card";
|
|
import { StatusBadge } from "@/components/ui/status-badge";
|
|
import { WarningBadge } from "@/components/ui/warning-badge";
|
|
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
|
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import Link from "next/link";
|
|
|
|
export default async function DashboardPage() {
|
|
const user = await getSessionUser();
|
|
if (!user) redirect("/login");
|
|
|
|
const t = await getTranslations("dashboard");
|
|
const tAdmin = await getTranslations("admin");
|
|
const f = await getFormatter();
|
|
|
|
const allTenants = await listTenants();
|
|
|
|
// Platform users see overview of all tenants — unchanged from pre-Slice-3.
|
|
if (user.isPlatform) {
|
|
const phaseCount = allTenants.reduce<Record<string, number>>((acc, t) => {
|
|
const phase = t.status?.phase ?? "Pending";
|
|
acc[phase] = (acc[phase] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8 animate-in">
|
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
|
{t("title")}
|
|
</h1>
|
|
<p className="text-text-secondary text-sm mt-4">
|
|
{t("welcome", { name: user.name || user.email })}
|
|
</p>
|
|
</div>
|
|
|
|
<Link
|
|
href="/admin"
|
|
className="inline-flex items-center gap-1.5 mb-6 text-xs font-medium text-accent hover:text-accent-dim transition-colors animate-in animate-in-delay-1"
|
|
>
|
|
<span>→</span> {tAdmin("title")}
|
|
</Link>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8 animate-in animate-in-delay-1">
|
|
<Card>
|
|
<CardHeader>{tAdmin("allTenants")}</CardHeader>
|
|
<span className="font-display text-3xl font-semibold text-text-primary tabular-nums">
|
|
{allTenants.length}
|
|
</span>
|
|
</Card>
|
|
{Object.entries(phaseCount).map(([phase, count]) => (
|
|
<Card key={phase}>
|
|
<CardHeader>{phase}</CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-display text-3xl font-semibold text-text-primary tabular-nums">
|
|
{count}
|
|
</span>
|
|
<StatusBadge phase={phase} />
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tenant table */}
|
|
{allTenants.length > 0 && (
|
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden animate-in animate-in-delay-2">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-left">
|
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{tAdmin("name")}
|
|
</th>
|
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{tAdmin("phase")}
|
|
</th>
|
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{tAdmin("packages")}
|
|
</th>
|
|
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{tAdmin("created")}
|
|
</th>
|
|
<th className="px-5 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allTenants.map((tenant) => (
|
|
<tr
|
|
key={tenant.metadata.name}
|
|
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
|
>
|
|
<td className="px-5 py-3">
|
|
<div className="font-mono text-xs text-accent">
|
|
{tenant.metadata.name}
|
|
</div>
|
|
{tenant.spec.displayName && (
|
|
<div className="text-xs text-text-secondary">
|
|
{tenant.spec.displayName}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
|
|
</td>
|
|
<td className="px-5 py-3 text-xs text-text-secondary font-mono">
|
|
{tenant.spec.packages?.join(", ") || "—"}
|
|
</td>
|
|
<td className="px-5 py-3 text-xs text-text-muted tabular-nums">
|
|
{formatDateTime(tenant.metadata.creationTimestamp, f)}
|
|
</td>
|
|
<td className="px-5 py-3 text-right">
|
|
<Link
|
|
href={`/tenants/${tenant.metadata.name}`}
|
|
className="text-xs font-medium text-accent hover:text-accent-dim transition-colors"
|
|
>
|
|
{t("manage")} →
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Customer view (Slice 3 multi-tenant + Slice 6 visibility scoping)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Slice 6: orgTenants becomes "visible tenants for this user". For an
|
|
// owner that's all of the org's tenants; for a `user`-role member
|
|
// it's only the tenants they've been assigned to via
|
|
// tenant_user_assignments. The dashboard renders fewer cards in the
|
|
// user-role case but otherwise uses the same template.
|
|
const orgTenants = await listVisibleTenants(user, allTenants);
|
|
|
|
// For the "no instances yet" empty state, we want to know whether
|
|
// this user is being scoped down. A `user`-role with 0 visible
|
|
// tenants gets a different message than an owner with 0 tenants
|
|
// (the user might just need an assignment; the owner needs to
|
|
// create one).
|
|
const userScoped = isUserScoped(user);
|
|
|
|
// Pending/in-flight requests are only shown to roles that can act on
|
|
// them. `user`-role customers see no request cards.
|
|
//
|
|
// syncProvisioningStatuses runs on every dashboard load: it walks
|
|
// active and provisioning rows and reconciles them against the
|
|
// current cluster state. Without this, the operator-initiated
|
|
// 60-day TTL deletion (Bug 37b) leaves the portal showing "Your
|
|
// assistant is ready!" cards for tenants that no longer exist —
|
|
// the operator deletes the CR, but the DB row stays at active=true
|
|
// until something updates it. Running the sync at every dashboard
|
|
// load keeps the portal eventually consistent with the cluster
|
|
// without needing a separate cron/job.
|
|
//
|
|
// Cost: one K8s GET per row in (active, provisioning) status. At
|
|
// pilot scale this is small; if it grows we'd cache or move to a
|
|
// periodic background job.
|
|
if (canSeeInflightRequests(user)) {
|
|
await syncProvisioningStatuses();
|
|
}
|
|
const orgRequests = canSeeInflightRequests(user)
|
|
? await listActiveTenantRequestsByOrgId(user.orgId)
|
|
: [];
|
|
|
|
// Bug 35: orgs that already have a billing record skip the wizard's
|
|
// billing step. Fetched here so the dashboard's empty-state mount of
|
|
// OnboardingFlow knows what to do; for the additional-tenant flow at
|
|
// /dashboard/new we fetch the same flag in that route's own server
|
|
// component.
|
|
const orgBilling = await getOrgBilling(user.orgId);
|
|
const hasOrgBilling = orgBilling !== null;
|
|
|
|
// Pending requests that don't yet have a tenant CR. Once the CR
|
|
// exists, the tenant card carries the live phase, so a separate
|
|
// "request" card would just duplicate it. We compare against
|
|
// *all* org tenants here (not just visible ones) — otherwise a
|
|
// request whose tenant is invisible to the caller would erroneously
|
|
// show as in-flight.
|
|
const orgScopedTenants = allTenants.filter(
|
|
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
|
);
|
|
const inflightRequests = orgRequests.filter(
|
|
(r) =>
|
|
// Only show provision (initial creation) requests on the
|
|
// dashboard. Resume requests (Bug 37a) belong with their
|
|
// specific tenant — the SubscriptionToggle on the tenant
|
|
// detail page renders the pending state there. Showing them
|
|
// on the dashboard too would duplicate the surface and
|
|
// confuse customers about which tenant they refer to.
|
|
r.requestType !== "resume" &&
|
|
(!r.tenantName ||
|
|
!orgScopedTenants.some((t) => t.metadata.name === r.tenantName))
|
|
);
|
|
|
|
// Slice 5: only owners (and platform users, who'd typically be using
|
|
// the admin panel anyway) see the "Create new instance" link. A
|
|
// `user`-role member sees the dashboard but not the create flow —
|
|
// they need to ask an owner.
|
|
//
|
|
// Bug 5: personal accounts are 1-instance by design. Once a personal
|
|
// account has either an active tenant OR an in-flight request, the
|
|
// create button must disappear. The matching server-side guard is
|
|
// in `/api/onboarding` so direct POSTs are also rejected.
|
|
const personalAtCapacity = personalAccountAtCapacity(
|
|
user.isPersonal,
|
|
orgScopedTenants.length,
|
|
inflightRequests.length
|
|
);
|
|
const canCreate = canMutate(user) && !personalAtCapacity;
|
|
|
|
// First-time / no-visibility branch.
|
|
//
|
|
// Three sub-cases:
|
|
// 1. owner / platform with 0 tenants and 0 requests → show wizard.
|
|
// 2. owner / platform with 0 visibility but the org HAS tenants →
|
|
// shouldn't happen (owners see all org tenants). Defensive
|
|
// fall-through to the wizard.
|
|
// 3. user-role with 0 visible tenants → show "ask your owner"
|
|
// message, with copy distinguishing whether the org has any
|
|
// tenants at all.
|
|
if (orgTenants.length === 0 && inflightRequests.length === 0) {
|
|
if (userScoped) {
|
|
// Slice 6 empty state for `user` role. The org might or might
|
|
// not have tenants — either way this user has none assigned.
|
|
// The two messages are subtly different: "no instances exist"
|
|
// means owner needs to create one; "you're not assigned" means
|
|
// owner needs to grant access.
|
|
const orgHasTenants = orgScopedTenants.length > 0;
|
|
return (
|
|
<div>
|
|
<div className="mb-8 animate-in">
|
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
|
{t("title")}
|
|
</h1>
|
|
<p className="text-text-secondary text-sm mt-4">
|
|
{t("welcome", { name: user.name || user.email })}
|
|
</p>
|
|
</div>
|
|
<Card className="animate-in animate-in-delay-1">
|
|
<div className="text-center py-6">
|
|
<h2 className="font-display text-base font-semibold text-text-primary mb-2">
|
|
{orgHasTenants
|
|
? t("noAssignmentsTitle")
|
|
: t("noInstancesYetTitle")}
|
|
</h2>
|
|
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
|
{orgHasTenants
|
|
? t("noAssignmentsDescription")
|
|
: t("noInstancesYetDescription")}
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!canCreate) {
|
|
// Belt-and-braces: any role that's neither owner-with-create nor
|
|
// user-scope ends up here (e.g. weird cases like a session with
|
|
// no roles at all). Same generic message as before.
|
|
return (
|
|
<div>
|
|
<div className="mb-8 animate-in">
|
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
|
{t("title")}
|
|
</h1>
|
|
<p className="text-text-secondary text-sm mt-4">
|
|
{t("welcome", { name: user.name || user.email })}
|
|
</p>
|
|
</div>
|
|
<Card className="animate-in animate-in-delay-1">
|
|
<p className="text-sm text-text-secondary text-center py-6">
|
|
{t("noAccessNoInstances")}
|
|
</p>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8 animate-in">
|
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
|
{t("title")}
|
|
</h1>
|
|
<p className="text-text-secondary text-sm mt-4">
|
|
{t("welcome", { name: user.name || user.email })}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="animate-in animate-in-delay-1">
|
|
<OnboardingFlow
|
|
orgName={user.orgName}
|
|
userName={user.name}
|
|
userEmail={user.email}
|
|
hasOrgBilling={hasOrgBilling}
|
|
existingOrgBilling={orgBilling}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Returning customer: list of tenants + in-flight requests, plus
|
|
// a button to add another instance (owners only).
|
|
return (
|
|
<div>
|
|
<div className="mb-8 animate-in flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
|
{t("title")}
|
|
</h1>
|
|
<p className="text-text-secondary text-sm mt-4">
|
|
{t("welcome", { name: user.name || user.email })}
|
|
</p>
|
|
</div>
|
|
|
|
{canCreate && (
|
|
<Link
|
|
href="/dashboard/new"
|
|
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
|
>
|
|
<span>+</span> {t("createInstance")}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
{/* In-flight (pending/approved/provisioning/rejected) requests */}
|
|
{inflightRequests.length > 0 && (
|
|
<div className="mb-8 animate-in animate-in-delay-1">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("inflightRequests")}
|
|
</h2>
|
|
<div className="space-y-3">
|
|
{inflightRequests.map((r) => (
|
|
<ProvisioningStatus
|
|
key={r.id}
|
|
requestId={r.id}
|
|
canAct={canMutate(user)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Active tenants */}
|
|
{orgTenants.length > 0 && (
|
|
<div className="animate-in animate-in-delay-2">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("instances")}
|
|
</h2>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{orgTenants.map((tenant) => (
|
|
<Link
|
|
key={tenant.metadata.name}
|
|
href={`/tenants/${tenant.metadata.name}`}
|
|
className="block group"
|
|
>
|
|
<Card className="h-full hover:border-accent/40 transition-colors">
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-text-primary truncate">
|
|
{tenant.spec.displayName || tenant.metadata.name}
|
|
</div>
|
|
<div className="font-mono text-xs text-text-muted truncate mt-0.5">
|
|
{tenant.metadata.name}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
|
|
<WarningBadge warnings={tenant.status?.warnings ?? []} />
|
|
</div>
|
|
</div>
|
|
|
|
{tenant.spec.agentName && (
|
|
<div className="text-xs text-text-secondary mb-2">
|
|
{tenant.spec.agentName}
|
|
</div>
|
|
)}
|
|
|
|
{tenant.spec.packages && tenant.spec.packages.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{tenant.spec.packages.slice(0, 4).map((pkg) => (
|
|
<span
|
|
key={pkg}
|
|
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
|
|
>
|
|
{pkg}
|
|
</span>
|
|
))}
|
|
{tenant.spec.packages.length > 4 && (
|
|
<span className="text-xs text-text-muted">
|
|
+{tenant.spec.packages.length - 4}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-xs font-medium text-accent group-hover:text-accent-dim transition-colors">
|
|
{t("manage")} →
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|