diff --git a/scripts/verify-role-gates.mjs b/scripts/verify-role-gates.mjs new file mode 100644 index 0000000..715ca93 --- /dev/null +++ b/scripts/verify-role-gates.mjs @@ -0,0 +1,38 @@ +// Standalone JS port of `lib/session.ts::canMutate` and `isCustomerOwner` +// for offline verification. +// +// SessionUser shape mirrors the TypeScript interface: +// { roles: Role[], isPlatform: boolean, ... } + +function canMutate(user) { + return user.isPlatform || user.roles.includes("owner"); +} + +function isCustomerOwner(user) { + return !user.isPlatform && user.roles.includes("owner"); +} + +const cases = [ + // [user, fn, expected, note] + [{ isPlatform: true, roles: ["platform_admin"] }, canMutate, true, "platform admin can mutate"], + [{ isPlatform: true, roles: ["platform_operator"] }, canMutate, true, "platform operator can mutate"], + [{ isPlatform: false, roles: ["owner"] }, canMutate, true, "customer owner can mutate"], + [{ isPlatform: false, roles: ["user"] }, canMutate, false, "customer user cannot mutate"], + [{ isPlatform: false, roles: [] }, canMutate, false, "no roles cannot mutate"], + [{ isPlatform: false, roles: ["owner", "user"] }, canMutate, true, "owner+user (owner wins)"], + + [{ isPlatform: true, roles: ["platform_admin", "owner"] }, isCustomerOwner, false, "platform user with owner role is NOT customerOwner"], + [{ isPlatform: false, roles: ["owner"] }, isCustomerOwner, true, "pure customer owner"], + [{ isPlatform: false, roles: ["user"] }, isCustomerOwner, false, "customer user is not customerOwner"], + [{ isPlatform: false, roles: [] }, isCustomerOwner, false, "empty roles is not customerOwner"], +]; + +let pass = 0, fail = 0; +for (const [user, fn, expected, note] of cases) { + const got = fn(user); + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`); + if (ok) pass++; else fail++; +} +console.log(`\n${pass} pass, ${fail} fail`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/app/[locale]/dashboard/new/page.tsx b/src/app/[locale]/dashboard/new/page.tsx index 9ad31f2..97d8d0b 100644 --- a/src/app/[locale]/dashboard/new/page.tsx +++ b/src/app/[locale]/dashboard/new/page.tsx @@ -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"); diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index 3274cf1..ec8cf8e 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -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 ( +
+
+

+ {t("title")} +

+

+ {t("welcome", { name: user.name || user.email })} +

+
+ +

+ {t("noAccessNoInstances")} +

+
+
+ ); + } + return (
@@ -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 (
@@ -183,12 +214,14 @@ export default async function DashboardPage() {

- - + {t("createInstance")} - + {canCreate && ( + + + {t("createInstance")} + + )}
{/* In-flight (pending/approved/provisioning/rejected) requests */} diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx index 486858c..6898ef6 100644 --- a/src/app/[locale]/tenants/[name]/page.tsx +++ b/src/app/[locale]/tenants/[name]/page.tsx @@ -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} /> @@ -110,6 +116,7 @@ export default async function TenantDetailPage({ tenantName={name} enabledChannels={enabledChannels} initialChannelUsers={channelUsers} + canEdit={canEdit} /> )} @@ -119,7 +126,7 @@ export default async function TenantDetailPage({

{t("workspaceFiles")}

- +
); diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 99896f1..bd9c60b 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSessionUser } from "@/lib/session"; +import { getSessionUser, canMutate } from "@/lib/session"; import { createTenantRequest, getTenantRequestById, @@ -157,6 +157,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + // Slice 5: only owners (or platform users) may create new instances. + // A `user`-role member of an existing org cannot self-provision. + if (!canMutate(user)) { + return NextResponse.json( + { error: "Only the organization owner can create new instances." }, + { status: 403 } + ); + } + const body = await request.json(); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/tenants/[name]/route.ts b/src/app/api/tenants/[name]/route.ts index 0f06453..f46e3e4 100644 --- a/src/app/api/tenants/[name]/route.ts +++ b/src/app/api/tenants/[name]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSessionUser } from "@/lib/session"; +import { getSessionUser, canMutate } from "@/lib/session"; import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getPackageDef } from "@/lib/packages"; import { safeError } from "@/lib/errors"; @@ -46,7 +46,7 @@ export async function PATCH( if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (!user.isPlatform && !user.roles.includes("owner")) { + if (!canMutate(user)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/src/app/api/tenants/[name]/secrets/route.ts b/src/app/api/tenants/[name]/secrets/route.ts index bc687f5..2d87771 100644 --- a/src/app/api/tenants/[name]/secrets/route.ts +++ b/src/app/api/tenants/[name]/secrets/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSessionUser } from "@/lib/session"; +import { getSessionUser, canMutate } from "@/lib/session"; import { getTenant } from "@/lib/k8s"; import { writePackageSecrets } from "@/lib/openbao"; import { getPackageDef } from "@/lib/packages"; @@ -12,7 +12,7 @@ export async function POST( if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (!user.isPlatform && !user.roles.includes("owner")) { + if (!canMutate(user)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/src/components/channel-users/channel-users.tsx b/src/components/channel-users/channel-users.tsx index a4ca580..346af90 100644 --- a/src/components/channel-users/channel-users.tsx +++ b/src/components/channel-users/channel-users.tsx @@ -17,12 +17,15 @@ interface ChannelUsersProps { enabledChannels: string[]; /** Current channelUsers from the PiecedTenant spec */ initialChannelUsers: Record; + /** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */ + canEdit?: boolean; } export function ChannelUsers({ tenantName, enabledChannels, initialChannelUsers, + canEdit = true, }: ChannelUsersProps) { const t = useTranslations("channelUsers"); const router = useRouter(); @@ -146,44 +149,48 @@ export function ChannelUsers({ className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full" > {userId} - + {canEdit && ( + + )} ))}
)} - {/* Add user */} -
- - setInputValues((prev) => ({ - ...prev, - [channel]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleAdd(channel); - }} - placeholder={t("placeholder")} - className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" - /> - -
+ {/* Add user — hidden in read-only mode */} + {canEdit && ( +
+ + setInputValues((prev) => ({ + ...prev, + [channel]: e.target.value, + })) + } + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(channel); + }} + placeholder={t("placeholder")} + className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" + /> + +
+ )} ); })} diff --git a/src/components/packages/package-card.tsx b/src/components/packages/package-card.tsx index 7d6c103..e309173 100644 --- a/src/components/packages/package-card.tsx +++ b/src/components/packages/package-card.tsx @@ -10,9 +10,18 @@ interface Props { status?: "pending" | "active" | "error"; tenantName: string; onToggled: () => void; + /** Slice 5: when false, the enable/disable button is hidden. */ + canEdit?: boolean; } -export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) { +export function PackageCard({ + pkg, + enabled, + status, + tenantName, + onToggled, + canEdit = true, +}: Props) { const t = useTranslations(); const [showModal, setShowModal] = useState(false); const [secrets, setSecrets] = useState>({}); @@ -113,17 +122,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro {pkg.requiresSecrets && ( {t("packages.requiresApiKey")} )} - + {canEdit ? ( + + ) : ( + // Slice 5: read-only viewers see a static badge instead of a + // toggle. The status badge above the divider already conveys + // "active/pending/error"; this just clarifies "you can't change + // it" without duplicating the status colour. + + {enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")} + + )} diff --git a/src/components/packages/package-list.tsx b/src/components/packages/package-list.tsx index efed5ad..6fa8208 100644 --- a/src/components/packages/package-list.tsx +++ b/src/components/packages/package-list.tsx @@ -10,6 +10,8 @@ interface Props { enabledPackages: string[]; conditions?: Array<{ type: string; status: string; reason?: string }>; onRefresh?: () => void; + /** Slice 5: when false, package toggles and edit affordances are hidden. */ + canEdit?: boolean; } const CATEGORIES = [ @@ -30,7 +32,13 @@ function getPackageStatus( return "error"; } -export function PackageList({ tenantName, enabledPackages, conditions, onRefresh }: Props) { +export function PackageList({ + tenantName, + enabledPackages, + conditions, + onRefresh, + canEdit = true, +}: Props) { const t = useTranslations("packages"); const router = useRouter(); const handleRefresh = onRefresh || (() => router.refresh()); @@ -55,6 +63,7 @@ export function PackageList({ tenantName, enabledPackages, conditions, onRefresh status={getPackageStatus(pkg.id, enabledPackages.includes(pkg.id), conditions)} tenantName={tenantName} onToggled={handleRefresh} + canEdit={canEdit} /> ))} diff --git a/src/components/packages/workspace-editor.tsx b/src/components/packages/workspace-editor.tsx index 7e00dea..7901a65 100644 --- a/src/components/packages/workspace-editor.tsx +++ b/src/components/packages/workspace-editor.tsx @@ -8,9 +8,11 @@ const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const; interface Props { tenantName: string; files: Record; + /** Slice 5: when false, save button hidden and textarea is read-only. */ + canEdit?: boolean; } -export function WorkspaceEditor({ tenantName, files }: Props) { +export function WorkspaceEditor({ tenantName, files, canEdit = true }: Props) { const t = useTranslations("workspace"); const [activeTab, setActiveTab] = useState("SOUL.md"); const [localFiles, setLocalFiles] = useState>(files); @@ -19,6 +21,7 @@ export function WorkspaceEditor({ tenantName, files }: Props) { const [error, setError] = useState(null); function handleChange(content: string) { + if (!canEdit) return; setLocalFiles((prev) => ({ ...prev, [activeTab]: content })); setDirty(true); } @@ -62,20 +65,25 @@ export function WorkspaceEditor({ tenantName, files }: Props) { ))} - + {canEdit && ( + + )}