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}
- handleRemove(channel, userId)}
- disabled={saving}
- className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
- title={t("remove")}
- >
- ✕
-
+ {canEdit && (
+ handleRemove(channel, userId)}
+ disabled={saving}
+ className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
+ title={t("remove")}
+ >
+ ✕
+
+ )}
))}
)}
- {/* 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"
- />
- handleAdd(channel)}
- disabled={saving || !inputValues[channel]?.trim()}
- className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {saving ? "…" : t("add")}
-
-
+ {/* 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"
+ />
+ handleAdd(channel)}
+ disabled={saving || !inputValues[channel]?.trim()}
+ className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {saving ? "…" : t("add")}
+
+
+ )}
);
})}
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")}
)}
- togglePackage(false) : handleEnable}
- disabled={saving}
- className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
- enabled
- ? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
- : "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
- } disabled:opacity-50`}
- >
- {saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
-
+ {canEdit ? (
+ togglePackage(false) : handleEnable}
+ disabled={saving}
+ className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
+ enabled
+ ? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
+ : "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
+ } disabled:opacity-50`}
+ >
+ {saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
+
+ ) : (
+ // 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) {
))}
-
- {saving ? "…" : t("save")}
-
+ {canEdit && (
+
+ {saving ? "…" : t("save")}
+
+ )}