Role split and owner gating
All checks were successful
Build and Push / build (push) Successful in 1m24s

This commit is contained in:
2026-04-26 22:45:38 +02:00
parent 3521a0ff4f
commit 7c4e20099d
18 changed files with 347 additions and 91 deletions

View File

@@ -1,4 +1,4 @@
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
@@ -16,11 +16,17 @@ import Link from "next/link";
*
* Platform admins are redirected to /dashboard — they shouldn't be
* creating tenant instances under their own org.
*
* Slice 5: customer-side `user` role is also redirected — only owners
* may create new instances. The server-side POST handler enforces the
* same; this redirect is purely UX so /user-role members don't land on
* a wizard that will 403 on submit.
*/
export default async function NewInstancePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
const t = await getTranslations("dashboard");

View File

@@ -1,4 +1,4 @@
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
@@ -149,8 +149,39 @@ export default async function DashboardPage() {
(r) => !r.tenantName || !orgTenants.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.
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.
if (orgTenants.length === 0 && inflightRequests.length === 0) {
if (!canCreate) {
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">
@@ -170,7 +201,7 @@ export default async function DashboardPage() {
}
// Returning customer: list of tenants + in-flight requests, plus
// a button to add another instance.
// a button to add another instance (owners only).
return (
<div>
<div className="mb-8 animate-in flex items-start justify-between gap-4">
@@ -183,12 +214,14 @@ export default async function DashboardPage() {
</p>
</div>
<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>
{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 */}

View File

@@ -1,4 +1,4 @@
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
@@ -34,6 +34,11 @@ export default async function TenantDetailPage({
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) =>
@@ -100,6 +105,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
/>
</section>
@@ -110,6 +116,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledChannels={enabledChannels}
initialChannelUsers={channelUsers}
canEdit={canEdit}
/>
</section>
)}
@@ -119,7 +126,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("workspaceFiles")}
</h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} />
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
</section>
</div>
);