TenantAssignment and readside filtering
All checks were successful
Build and Push / build (push) Successful in 1m23s

This commit is contained in:
2026-04-26 22:58:30 +02:00
parent 7c4e20099d
commit 22fd5fb2cc
14 changed files with 598 additions and 54 deletions

View File

@@ -3,6 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
import {
listVisibleTenants,
canSeeInflightRequests,
isUserScoped,
} from "@/lib/visibility";
import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
@@ -134,19 +139,40 @@ export default async function DashboardPage() {
}
// ---------------------------------------------------------------------
// Customer view (Slice 3 multi-tenant)
// Customer view (Slice 3 multi-tenant + Slice 6 visibility scoping)
// ---------------------------------------------------------------------
const orgTenants = allTenants.filter(
// 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.
const orgRequests = canSeeInflightRequests(user)
? await listActiveTenantRequestsByOrgId(user.orgId)
: [];
// 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 orgRequests = await listActiveTenantRequestsByOrgId(user.orgId);
// Pending/in-flight 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.
const inflightRequests = orgRequests.filter(
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName)
(r) => !r.tenantName || !orgScopedTenants.some((t) => t.metadata.name === r.tenantName)
);
// Slice 5: only owners (and platform users, who'd typically be using
@@ -155,14 +181,56 @@ export default async function DashboardPage() {
// they need to ask an owner.
const canCreate = canMutate(user);
// First-time user: empty company. Show the onboarding wizard inline.
// Note: the registering user is always granted `owner` on their new
// org by registerCustomer, so this branch is only reachable by an
// owner — no role check needed here. But a customer-side `user`
// promoted into a fresh empty org (Slice 7 invites) would also land
// here without permission to submit. Belt-and-braces gate.
// 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">

View File

@@ -2,6 +2,7 @@ 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";
@@ -26,11 +27,10 @@ export default async function TenantDetailPage({
const tenant = await getTenant(name);
if (!tenant) notFound();
// Scope check
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
// 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();
}