diff --git a/packages/control-plane/src/routes/rbac.ts b/packages/control-plane/src/routes/rbac.ts
index 33a2fcb3e..78ccb528b 100644
--- a/packages/control-plane/src/routes/rbac.ts
+++ b/packages/control-plane/src/routes/rbac.ts
@@ -1,4 +1,14 @@
-import { AuthorizationError, AuthorizationService } from "../authorization/service";
+import { isCanonicalUserId } from "@open-inspect/shared/user-id";
+import {
+ replaceMemberRoleInputSchema,
+ replaceMemberStatusInputSchema,
+} from "@open-inspect/shared/rbac";
+import { ZodError } from "zod";
+import {
+ AuthorizationError,
+ AuthorizationService,
+ RbacConflictError,
+} from "../authorization/service";
import type { Env } from "../types";
import type { Route } from "./shared";
import {
@@ -7,6 +17,7 @@ import {
defineRoutes,
error,
json,
+ parseJsonBody,
requirePermission,
type UserRouteContext,
} from "./shared";
@@ -22,9 +33,21 @@ function rbacErrorResponse(cause: unknown): Response {
cause.status
);
}
+ if (cause instanceof RbacConflictError) {
+ return json({ error: cause.message, code: "rbac_conflict" }, 409);
+ }
+ if (cause instanceof ZodError) return error("Invalid request body", 400);
return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
}
+function decodePathSegment(value: string): string | null {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+}
+
async function handleGetCurrentAuthorization(
_request: Request,
_env: Env,
@@ -61,10 +84,11 @@ async function handleGetRole(
): Promise {
const service = new AuthorizationService(ctx.db);
try {
- const role = await service.getRole(decodeURIComponent(match.groups!.id));
+ const roleId = decodePathSegment(match.groups!.id);
+ if (roleId === null) return error("Invalid role ID", 400);
+ const role = await service.getRole(roleId);
return role ? json(role) : error("Role not found", 404);
} catch (cause) {
- if (cause instanceof URIError) return error("Invalid role ID", 400);
return rbacErrorResponse(cause);
}
}
@@ -83,6 +107,60 @@ async function handleListMembers(
}
}
+async function handleReplaceMemberRole(
+ request: Request,
+ _env: Env,
+ match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const targetUserId = decodePathSegment(match.groups!.id);
+ if (targetUserId === null || !isCanonicalUserId(targetUserId)) {
+ return error("Invalid user ID", 400);
+ }
+ const body = await parseJsonBody(request);
+ if (body instanceof Response) return body;
+ const service = new AuthorizationService(ctx.db);
+ try {
+ const parsed = replaceMemberRoleInputSchema.parse(body);
+ await service.replaceMemberRole({
+ targetUserId,
+ roleId: parsed.roleId,
+ actorUserId: ctx.principal.userId,
+ requestId: ctx.request_id,
+ });
+ return new Response(null, { status: 204 });
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
+async function handleReplaceMemberStatus(
+ request: Request,
+ _env: Env,
+ match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const targetUserId = decodePathSegment(match.groups!.id);
+ if (targetUserId === null || !isCanonicalUserId(targetUserId)) {
+ return error("Invalid user ID", 400);
+ }
+ const body = await parseJsonBody(request);
+ if (body instanceof Response) return body;
+ const service = new AuthorizationService(ctx.db);
+ try {
+ const parsed = replaceMemberStatusInputSchema.parse(body);
+ await service.replaceMemberStatus({
+ targetUserId,
+ suspended: parsed.suspended,
+ actorUserId: ctx.principal.userId,
+ requestId: ctx.request_id,
+ });
+ return new Response(null, { status: 204 });
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
{
method: "GET",
@@ -112,4 +190,18 @@ export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
cacheControl: "private, no-store",
handler: handleListMembers,
},
+ {
+ method: "PUT",
+ pattern: /^\/members\/(?[^/]+)\/role$/,
+ authorization: requirePermission("workspace.members.manage"),
+ cacheControl: "private, no-store",
+ handler: handleReplaceMemberRole,
+ },
+ {
+ method: "PUT",
+ pattern: /^\/members\/(?[^/]+)\/status$/,
+ authorization: requirePermission("workspace.members.manage"),
+ cacheControl: "private, no-store",
+ handler: handleReplaceMemberStatus,
+ },
]);
diff --git a/packages/control-plane/test/integration/rbac-routes.test.ts b/packages/control-plane/test/integration/rbac-routes.test.ts
new file mode 100644
index 000000000..2130689b7
--- /dev/null
+++ b/packages/control-plane/test/integration/rbac-routes.test.ts
@@ -0,0 +1,558 @@
+import { env } from "cloudflare:test";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { AuthorizationService } from "../../src/authorization/service";
+import { UserStore } from "../../src/db/user-store";
+import { mergeUsers } from "../../src/db/user-merge";
+import { cleanD1Tables } from "./cleanup";
+import { serviceFetch, sqlDatabase } from "./helpers";
+
+describe("RBAC routes", () => {
+ beforeEach(cleanD1Tables);
+ afterEach(cleanD1Tables);
+
+ async function seedOwner(): Promise {
+ expect((await serviceFetch("https://cp.test/me/authorization")).status).toBe(200);
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{
+ id: string;
+ }>();
+ if (!user) throw new Error("Browser user was not seeded");
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ )
+ .bind(user.id)
+ .run();
+ return user.id;
+ }
+
+ it("keeps ordinary browser users as Member without an Owner assignment", async () => {
+ const first = await serviceFetch("https://cp.test/me/authorization", {
+ initialUserRole: "member",
+ });
+ expect(first.status).toBe(200);
+ await expect(first.json()).resolves.toMatchObject({
+ suspendedAt: null,
+ role: { key: "member" },
+ });
+ });
+
+ it("assigns Member to identities created after the migration boundary", async () => {
+ const user = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "New Member",
+ email: "member@example.com",
+ emailVerified: true,
+ });
+
+ const assignment = await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(user.id)
+ .first();
+ expect(assignment).toEqual({ key: "member" });
+ });
+
+ it("assigns Member at the database boundary for Better Auth and old-worker inserts", async () => {
+ const userId = "22222222222222222222222222222222";
+ await env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES (?, 'Direct User', 'direct@example.com', 1, NULL, 1, 1)`
+ )
+ .bind(userId)
+ .run();
+
+ expect(
+ await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(userId)
+ .first()
+ ).toEqual({ key: "member" });
+ });
+
+ it("suspends an emailed member without an Owner assignment", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const actor = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const member = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Suspendable Member",
+ email: "member@example.com",
+ emailVerified: true,
+ });
+ const service = new AuthorizationService(sqlDatabase(env.DB));
+
+ await service.replaceMemberStatus({
+ targetUserId: member.id,
+ suspended: true,
+ actorUserId: actor!.id,
+ requestId: "suspend-without-bootstrap",
+ });
+
+ await expect(service.getEffectiveAuthorization(member.id)).resolves.toMatchObject({
+ suspendedAt: expect.any(Number),
+ });
+ });
+
+ it("fails closed when an existing user has no role assignment", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?")
+ .bind(user!.id)
+ .run();
+
+ const response = await serviceFetch("https://cp.test/me/authorization");
+ const personalRoute = await serviceFetch("https://cp.test/keyboard-shortcuts");
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "assignment_required" });
+ expect(personalRoute.status).toBe(403);
+ await expect(personalRoute.json()).resolves.toMatchObject({ code: "assignment_required" });
+ expect(
+ await env.DB.prepare("SELECT * FROM user_role_assignments WHERE user_id = ?")
+ .bind(user!.id)
+ .first()
+ ).toBeNull();
+ });
+
+ it("uses code-owned permissions for built-in role authorization", async () => {
+ await serviceFetch("https://cp.test/me/authorization", { initialUserRole: "member" });
+ const member = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const permission = "workspace.roles.read";
+ await env.DB.prepare(
+ "INSERT INTO role_permissions (role_id, permission_id) VALUES ('role_builtin_member', ?)"
+ )
+ .bind(permission)
+ .run();
+
+ try {
+ const authorization = await new AuthorizationService(
+ sqlDatabase(env.DB)
+ ).getEffectiveAuthorization(member!.id);
+ expect(authorization.permissions).not.toContain(permission);
+ expect((await serviceFetch("https://cp.test/roles")).status).toBe(403);
+ } finally {
+ await env.DB.prepare(
+ "DELETE FROM role_permissions WHERE role_id = 'role_builtin_member' AND permission_id = ?"
+ )
+ .bind(permission)
+ .run();
+ }
+ });
+
+ it("never resolves ownership transfer from a custom role", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const roleId = "role_custom_owner";
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES (?, NULL, 'Custom Owner', 'custom owner', NULL, 0)`
+ ).bind(roleId),
+ env.DB.prepare(
+ `INSERT INTO role_permissions (role_id, permission_id)
+ VALUES (?, 'workspace.roles.read'), (?, 'workspace.transfer_ownership')`
+ ).bind(roleId, roleId),
+ env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind(
+ roleId,
+ user!.id
+ ),
+ ]);
+
+ const authorization = await new AuthorizationService(
+ sqlDatabase(env.DB)
+ ).getEffectiveAuthorization(user!.id);
+ expect(authorization.permissions).toContain("workspace.roles.read");
+ expect(authorization.permissions).not.toContain("workspace.transfer_ownership");
+ });
+
+ it("requires sessions.create in addition to parent collaboration when spawning a child", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const roleId = "role_child_collaborator";
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES (?, NULL, 'Child Collaborator', 'child collaborator', NULL, 0)`
+ ).bind(roleId),
+ env.DB.prepare(
+ "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'sessions.collaborate')"
+ ).bind(roleId),
+ env.DB.prepare(`UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?`).bind(
+ roleId,
+ user!.id
+ ),
+ ]);
+
+ const response = await serviceFetch("https://cp.test/sessions/parent/children", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt: "Investigate" }),
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "sessions.create",
+ });
+ });
+
+ it("denies sensitive business mutations to Viewer", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?"
+ )
+ .bind(user!.id)
+ .run();
+
+ const response = await serviceFetch("https://cp.test/secrets", {
+ method: "PUT",
+ body: JSON.stringify({ secrets: { SHOULD_NOT_WRITE: "secret" } }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "global_secrets.manage",
+ });
+
+ await env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at)
+ VALUES ('viewer-session', 'acme', 'app', 'completed', 1, 1)`
+ ).run();
+ const sessionDelete = await serviceFetch("https://cp.test/sessions/viewer-session", {
+ method: "DELETE",
+ });
+ expect(sessionDelete.status).toBe(403);
+ await expect(sessionDelete.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "sessions.delete",
+ });
+
+ const read = await serviceFetch("https://cp.test/sessions/viewer-session");
+ expect(read.status).not.toBe(403);
+
+ for (const [path, method, permission, body] of [
+ ["/sessions", "POST", "sessions.create", { title: "Denied", model: "test/model" }],
+ ["/sessions/viewer-session/prompt", "POST", "sessions.collaborate", { content: "Denied" }],
+ ["/sessions/viewer-session/stop", "POST", "sessions.lifecycle", undefined],
+ ["/sessions/viewer-session/sandbox-access", "GET", "sessions.sandbox_access", undefined],
+ ["/skill-profiles", "GET", "skill_profiles.manage_own", undefined],
+ ["/skill-profiles", "POST", "skill_profiles.manage_own", { name: "Denied", skillIds: [] }],
+ ["/skill-profiles/profile-1", "PATCH", "skill_profiles.manage_own", { name: "Denied" }],
+ ["/skill-profiles/profile-1", "DELETE", "skill_profiles.manage_own", undefined],
+ ["/model-provider-accounts", "GET", "provider_accounts.read", undefined],
+ ["/model-provider-account-defaults", "GET", "provider_accounts.read", undefined],
+ ["/model-provider-accounts/legacy-credentials", "GET", "provider_accounts.read", undefined],
+ ] as const) {
+ const denied = await serviceFetch(`https://cp.test${path}`, {
+ method,
+ ...(body
+ ? { body: JSON.stringify(body), headers: { "Content-Type": "application/json" } }
+ : {}),
+ });
+ expect(denied.status, path).toBe(403);
+ await expect(denied.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission,
+ });
+ }
+ });
+
+ it("allows Members to discover and delete sessions workspace-wide", async () => {
+ await serviceFetch("https://cp.test/me/authorization", { initialUserRole: "member" });
+ const member = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const other = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Other" });
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES ('member-session', 'acme', 'app', 'completed', 1, 1, ?)`
+ ).bind(member!.id),
+ env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES ('other-session', 'acme', 'app', 'completed', 2, 2, ?)`
+ ).bind(other.id),
+ env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES ('unjoined-session', 'acme', 'app', 'completed', 3, 3, ?)`
+ ).bind(other.id),
+ ]);
+
+ const listed = await serviceFetch("https://cp.test/sessions");
+ const lifecycle = await serviceFetch("https://cp.test/sessions/other-session/stop", {
+ method: "POST",
+ });
+ const sandboxAccess = await serviceFetch(
+ "https://cp.test/sessions/other-session/sandbox-access"
+ );
+ const otherDelete = await serviceFetch("https://cp.test/sessions/other-session", {
+ method: "DELETE",
+ });
+ const ownDelete = await serviceFetch("https://cp.test/sessions/member-session", {
+ method: "DELETE",
+ });
+
+ expect(listed.status).toBe(200);
+ expect(lifecycle.status).not.toBe(403);
+ expect(sandboxAccess.status).not.toBe(403);
+ await expect(listed.json()).resolves.toMatchObject({
+ sessions: [{ id: "unjoined-session" }, { id: "other-session" }, { id: "member-session" }],
+ });
+ expect(otherDelete.status).toBe(200);
+ expect(ownDelete.status).toBe(200);
+ expect(
+ await env.DB.prepare("SELECT id FROM sessions WHERE id = 'other-session'").first()
+ ).toBeNull();
+ });
+
+ it("does not let the last unsuspended Owner be suspended", async () => {
+ await seedOwner();
+ const owner = await env.DB.prepare(
+ `SELECT u.id
+ FROM users u
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ WHERE r.key = 'owner'`
+ ).first<{ id: string }>();
+ expect(owner).not.toBeNull();
+
+ const response = await serviceFetch(`https://cp.test/members/${owner!.id}/status`, {
+ method: "PUT",
+ body: JSON.stringify({ suspended: true }),
+ headers: { "Content-Type": "application/json" },
+ });
+ expect(response.status).toBe(409);
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(owner!.id).first()
+ ).toEqual({ suspended_at: null });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'"
+ ).first()
+ ).toEqual({ count: 0 });
+ });
+
+ it("lets an Owner suspend themselves when another unsuspended Owner exists", async () => {
+ const ownerId = await seedOwner();
+ const otherOwner = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Other Owner",
+ });
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ )
+ .bind(otherOwner.id)
+ .run();
+
+ const response = await serviceFetch(`https://cp.test/members/${ownerId}/status`, {
+ method: "PUT",
+ body: JSON.stringify({ suspended: true }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(204);
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(ownerId).first()
+ ).toEqual({ suspended_at: expect.any(Number) });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'"
+ ).first()
+ ).toEqual({ count: 1 });
+ });
+
+ it("lets an Owner demote themselves when another unsuspended Owner exists", async () => {
+ const ownerId = await seedOwner();
+ const otherOwner = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Other Owner",
+ });
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ )
+ .bind(otherOwner.id)
+ .run();
+
+ const response = await serviceFetch(`https://cp.test/members/${ownerId}/role`, {
+ method: "PUT",
+ body: JSON.stringify({ roleId: "role_builtin_administrator" }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(204);
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_role_updated'"
+ ).first()
+ ).toEqual({ count: 1 });
+ });
+
+ it("does not let the last unsuspended Owner demote themselves", async () => {
+ const ownerId = await seedOwner();
+
+ const response = await serviceFetch(`https://cp.test/members/${ownerId}/role`, {
+ method: "PUT",
+ body: JSON.stringify({ roleId: "role_builtin_administrator" }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(409);
+ expect(
+ await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(ownerId)
+ .first()
+ ).toEqual({ key: "owner" });
+ });
+
+ it.each(["role", "status"] as const)(
+ "rejects malformed percent encoding in member %s routes",
+ async (operation) => {
+ await seedOwner();
+
+ const response = await serviceFetch(`https://cp.test/members/%/${operation}`, {
+ method: "PUT",
+ body: JSON.stringify(
+ operation === "role" ? { roleId: "role_builtin_member" } : { suspended: true }
+ ),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({ error: "Invalid user ID" });
+ }
+ );
+
+ it("requires an explicit unsuspended Owner assignment before merging an Owner", async () => {
+ const store = new UserStore(sqlDatabase(env.DB));
+ const survivor = await store.createUser({ displayName: "Survivor" });
+ const loser = await store.createUser({ displayName: "Owner" });
+ await env.DB.batch([
+ env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(survivor.id),
+ env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ ).bind(loser.id),
+ ]);
+
+ await expect(
+ mergeUsers(sqlDatabase(env.DB), {
+ survivorId: survivor.id,
+ loserId: loser.id,
+ dryRun: false,
+ })
+ ).rejects.toThrow("Resolve conflicting user roles before merging");
+ });
+
+ it("rejects privileged mutations when the actor authorization changes", async () => {
+ const ownerId = await seedOwner();
+ const member = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Target Member",
+ });
+ const service = new AuthorizationService(sqlDatabase(env.DB));
+ await service.requirePermission(ownerId, "workspace.members.manage");
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_member' WHERE user_id = ?"
+ )
+ .bind(ownerId)
+ .run();
+ await expect(
+ service.replaceMemberRole({
+ targetUserId: member.id,
+ roleId: "role_builtin_administrator",
+ actorUserId: ownerId,
+ requestId: "stale-member-role-request",
+ })
+ ).rejects.toThrow("Actor authorization changed");
+ await expect(
+ service.replaceMemberStatus({
+ targetUserId: member.id,
+ suspended: true,
+ actorUserId: ownerId,
+ requestId: "stale-member-request",
+ })
+ ).rejects.toThrow("Actor authorization changed");
+
+ expect(await service.getEffectiveAuthorization(member.id)).toMatchObject({
+ role: { key: "member" },
+ });
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(member.id).first()
+ ).toEqual({ suspended_at: null });
+ expect(
+ await env.DB.prepare(
+ `SELECT COUNT(*) AS count FROM authorization_audit_events
+ WHERE request_id IN (
+ 'stale-member-role-request', 'stale-member-request'
+ )`
+ ).first()
+ ).toEqual({ count: 0 });
+ });
+
+ it("returns authorization unavailable for an unexpected mutation database failure", async () => {
+ await seedOwner();
+ await env.DB.prepare(
+ `CREATE TRIGGER fail_member_audit
+ BEFORE INSERT ON authorization_audit_events
+ WHEN NEW.action = 'workspace.member_status_updated'
+ BEGIN
+ SELECT RAISE(ABORT, 'forced database failure');
+ END`
+ ).run();
+
+ try {
+ const member = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Member" });
+ const response = await serviceFetch(`https://cp.test/members/${member.id}/status`, {
+ method: "PUT",
+ body: JSON.stringify({ suspended: true }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(503);
+ await expect(response.json()).resolves.toEqual({
+ error: "Authorization unavailable",
+ code: "authorization_unavailable",
+ });
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(member.id).first()
+ ).toEqual({ suspended_at: null });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'"
+ ).first()
+ ).toEqual({ count: 0 });
+ } finally {
+ await env.DB.prepare("DROP TRIGGER fail_member_audit").run();
+ }
+ });
+
+ it("rejects suspended users at the backend after reauthentication", async () => {
+ await seedOwner();
+ await env.DB.prepare("UPDATE users SET suspended_at = 1").run();
+
+ const response = await serviceFetch("https://cp.test/repos");
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toEqual({
+ error: "Forbidden",
+ code: "active_user_required",
+ });
+ });
+});
diff --git a/packages/web/src/app/(app)/settings/page.test.tsx b/packages/web/src/app/(app)/settings/page.test.tsx
index 131d37a54..cc618d8bc 100644
--- a/packages/web/src/app/(app)/settings/page.test.tsx
+++ b/packages/web/src/app/(app)/settings/page.test.tsx
@@ -7,12 +7,16 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import SettingsPage from "./page";
import { SettingsViewportProvider } from "@/components/settings/settings-viewport-context";
+import { PERMISSION_IDS } from "@open-inspect/shared/rbac";
expect.extend(matchers);
const mocks = vi.hoisted(() => ({
tab: null as string | null,
repoImagesEnabled: true,
+ allowedPermissions: new Set(),
+ authorization: { permissions: [] as string[] },
+ hasPermission: (permission: string) => mocks.allowedPermissions.has(permission),
}));
vi.mock("next/navigation", () => ({
@@ -23,6 +27,14 @@ vi.mock("@/lib/sandbox-provider", () => ({
supportsRepoImages: () => mocks.repoImagesEnabled,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ authorization: mocks.authorization,
+ loading: false,
+ hasPermission: mocks.hasPermission,
+ }),
+}));
+
vi.mock("@/components/settings/secrets-settings", () => ({
SecretsSettings: () => Secrets panel
,
}));
@@ -66,6 +78,8 @@ vi.mock("@/components/settings/mcp-servers-settings", () => ({
beforeEach(() => {
mocks.tab = null;
mocks.repoImagesEnabled = true;
+ mocks.allowedPermissions = new Set(PERMISSION_IDS);
+ mocks.authorization.permissions = [...PERMISSION_IDS];
window.history.replaceState(null, "", "/settings");
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
@@ -94,8 +108,8 @@ describe("SettingsPage mobile navigation", () => {
await user.click(screen.getByRole("button", { name: /Appearance/ }));
- expect(screen.getByRole("heading", { name: "Appearance" })).toHaveFocus();
- expect(screen.getByText("Appearance panel")).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Appearance" })).toHaveFocus();
+ expect(await screen.findByText("Appearance panel")).toBeInTheDocument();
expect(window.location.href).toContain("/settings?tab=appearance");
expect(window.history.state).toMatchObject({ openInspectSettingsDetail: true });
@@ -139,6 +153,42 @@ describe("SettingsPage mobile navigation", () => {
expect(window.location.search).toBe("");
});
+ it("redirects an unauthorized deep link to the first available panel", () => {
+ mocks.tab = "secrets";
+ mocks.allowedPermissions = new Set();
+ mocks.authorization.permissions = [];
+ window.history.replaceState(null, "", "/settings?tab=secrets");
+
+ renderSettingsPage();
+
+ expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
+ expect(screen.getByText("Appearance panel")).toBeInTheDocument();
+ expect(screen.queryByText("Secrets panel")).not.toBeInTheDocument();
+ });
+
+ it("allows repository secret managers to open secrets", async () => {
+ mocks.tab = "secrets";
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage", "repositories.read"]);
+ mocks.authorization.permissions = ["repositories.secrets.manage", "repositories.read"];
+ window.history.replaceState(null, "", "/settings?tab=secrets");
+
+ renderSettingsPage();
+
+ expect(await screen.findByText("Secrets panel")).toBeInTheDocument();
+ });
+
+ it("rejects repository secret managers without repository read access", async () => {
+ mocks.tab = "secrets";
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage"]);
+ mocks.authorization.permissions = ["repositories.secrets.manage"];
+ window.history.replaceState(null, "", "/settings?tab=secrets");
+
+ renderSettingsPage();
+
+ expect(await screen.findByText("Appearance panel")).toBeInTheDocument();
+ expect(screen.queryByText("Secrets panel")).not.toBeInTheDocument();
+ });
+
it("uses browser history for the in-app back action", async () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => undefined);
const user = userEvent.setup();
diff --git a/packages/web/src/app/(app)/settings/page.tsx b/packages/web/src/app/(app)/settings/page.tsx
index ee1f3d8f8..c42f5c9f2 100644
--- a/packages/web/src/app/(app)/settings/page.tsx
+++ b/packages/web/src/app/(app)/settings/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Suspense, useEffect, useRef, useState, type ComponentType } from "react";
+import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
DEFAULT_SETTINGS_CATEGORY,
@@ -11,45 +11,17 @@ import {
} from "@/components/settings/settings-nav";
import { SettingsMobileHeader } from "@/components/settings/settings-mobile-header";
import { useSettingsIsMobile } from "@/components/settings/settings-viewport-context";
-import { SecretsSettings } from "@/components/settings/secrets-settings";
-import { EnvironmentsSettings } from "@/components/settings/environments-settings";
-import { ModelsSettings } from "@/components/settings/models-settings";
-import { DataControlsSettings } from "@/components/settings/data-controls-settings";
-import { KeyboardShortcutsSettings } from "@/components/settings/keyboard-shortcuts-settings";
-import { IntegrationsSettings } from "@/components/settings/integrations-settings";
-import { SandboxSettingsPage } from "@/components/settings/sandbox-settings";
-import { ScmSettingsPage } from "@/components/settings/scm-settings";
-import { ImagesSettings } from "@/components/settings/images-settings";
-import { McpServersSettings } from "@/components/settings/mcp-servers-settings";
-import { AppearanceSettings } from "@/components/settings/appearance-settings";
-import { ProviderAccountsSettings } from "@/components/settings/provider-accounts-settings";
-import { SkillsSettings } from "@/components/settings/skills-settings";
import { supportsRepoImages } from "@/lib/sandbox-provider";
-
-const SETTINGS_PANELS: Record = {
- appearance: AppearanceSettings,
- "keyboard-shortcuts": KeyboardShortcutsSettings,
- models: ModelsSettings,
- "provider-accounts": ProviderAccountsSettings,
- skills: SkillsSettings,
- environments: EnvironmentsSettings,
- secrets: SecretsSettings,
- scm: ScmSettingsPage,
- sandbox: SandboxSettingsPage,
- images: ImagesSettings,
- integrations: IntegrationsSettings,
- "mcp-servers": McpServersSettings,
- "data-controls": DataControlsSettings,
-};
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { getSettingsPanel, resolveSettingsCategory } from "@/components/settings/settings-registry";
function SettingsPageContent() {
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const repoImagesEnabled = supportsRepoImages();
const isMobile = useSettingsIsMobile();
- const initialCategory = isSettingsCategory(tabParam, repoImagesEnabled)
- ? tabParam
- : DEFAULT_SETTINGS_CATEGORY;
+ const { hasPermission, loading } = useCurrentUserAuthorization();
+ const initialCategory = resolveSettingsCategory(tabParam, repoImagesEnabled, hasPermission);
const [activeCategory, setActiveCategoryRaw] = useState(initialCategory);
function selectCategory(category: SettingsCategory, trigger: HTMLButtonElement) {
@@ -101,7 +73,7 @@ function SettingsPageContent() {
const syncFromHistory = () => {
const requestedCategory = new URLSearchParams(window.location.search).get("tab");
const nextCategory = isSettingsCategory(requestedCategory, repoImagesEnabled)
- ? requestedCategory
+ ? resolveSettingsCategory(requestedCategory, repoImagesEnabled, hasPermission)
: null;
if (nextCategory) {
setActiveCategoryRaw(nextCategory);
@@ -121,27 +93,34 @@ function SettingsPageContent() {
window.addEventListener("popstate", syncFromHistory);
return () => window.removeEventListener("popstate", syncFromHistory);
- }, [isMobile, repoImagesEnabled]);
+ }, [hasPermission, isMobile, repoImagesEnabled]);
// Sync state when searchParams change via client-side navigation
useEffect(() => {
if (isSettingsCategory(tabParam, repoImagesEnabled)) {
- setActiveCategoryRaw(tabParam);
+ setActiveCategoryRaw(resolveSettingsCategory(tabParam, repoImagesEnabled, hasPermission));
setMobileView("detail");
return;
}
if (!isMobile || !mobileTriggerRef.current) {
- setActiveCategoryRaw(DEFAULT_SETTINGS_CATEGORY);
+ setActiveCategoryRaw(resolveSettingsCategory(null, repoImagesEnabled, hasPermission));
}
setMobileView("list");
- }, [isMobile, repoImagesEnabled, tabParam]);
+ }, [hasPermission, isMobile, repoImagesEnabled, tabParam]);
- const renderedCategory = isSettingsCategory(activeCategory, repoImagesEnabled)
- ? activeCategory
- : DEFAULT_SETTINGS_CATEGORY;
- const ActivePanel = SETTINGS_PANELS[renderedCategory];
- const content = ;
+ if (loading) return null;
+ const renderedCategory = resolveSettingsCategory(
+ activeCategory,
+ repoImagesEnabled,
+ hasPermission
+ );
+ const ActivePanel = getSettingsPanel(renderedCategory);
+ const content = (
+
+
+
+ );
if (isMobile) {
return (
diff --git a/packages/web/src/app/api/me/authorization/route.ts b/packages/web/src/app/api/me/authorization/route.ts
new file mode 100644
index 000000000..33ea39ecc
--- /dev/null
+++ b/packages/web/src/app/api/me/authorization/route.ts
@@ -0,0 +1,6 @@
+import { controlPlaneJsonGetProxy } from "@/lib/control-plane-json-proxy";
+
+export const { GET } = controlPlaneJsonGetProxy(
+ () => "/me/authorization",
+ "current user authorization"
+);
diff --git a/packages/web/src/app/api/members/[userId]/role/route.ts b/packages/web/src/app/api/members/[userId]/role/route.ts
new file mode 100644
index 000000000..e1df2734b
--- /dev/null
+++ b/packages/web/src/app/api/members/[userId]/role/route.ts
@@ -0,0 +1,6 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { PUT } = settingsProxy(
+ ({ userId }: { userId: string }) => `/members/${encodeURIComponent(userId)}/role`,
+ "member role"
+);
diff --git a/packages/web/src/app/api/members/[userId]/status/route.ts b/packages/web/src/app/api/members/[userId]/status/route.ts
new file mode 100644
index 000000000..5085d3013
--- /dev/null
+++ b/packages/web/src/app/api/members/[userId]/status/route.ts
@@ -0,0 +1,6 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { PUT } = settingsProxy(
+ ({ userId }: { userId: string }) => `/members/${encodeURIComponent(userId)}/status`,
+ "member status"
+);
diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts
new file mode 100644
index 000000000..ebf90baa5
--- /dev/null
+++ b/packages/web/src/app/api/members/route.ts
@@ -0,0 +1,3 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { GET } = settingsProxy(() => "/members", "members");
diff --git a/packages/web/src/app/api/roles/[id]/route.ts b/packages/web/src/app/api/roles/[id]/route.ts
new file mode 100644
index 000000000..c1aadbf3c
--- /dev/null
+++ b/packages/web/src/app/api/roles/[id]/route.ts
@@ -0,0 +1,6 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { GET } = settingsProxy(
+ ({ id }: { id: string }) => `/roles/${encodeURIComponent(id)}`,
+ "role"
+);
diff --git a/packages/web/src/app/api/roles/route.ts b/packages/web/src/app/api/roles/route.ts
new file mode 100644
index 000000000..36fe74458
--- /dev/null
+++ b/packages/web/src/app/api/roles/route.ts
@@ -0,0 +1,3 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { GET } = settingsProxy(() => "/roles", "roles");
diff --git a/packages/web/src/components/app-auth-boundary.test.tsx b/packages/web/src/components/app-auth-boundary.test.tsx
index 80a2bfaa6..f627da84a 100644
--- a/packages/web/src/components/app-auth-boundary.test.tsx
+++ b/packages/web/src/components/app-auth-boundary.test.tsx
@@ -6,16 +6,33 @@ import * as matchers from "@testing-library/jest-dom/matchers";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAuthSession } from "@/lib/auth-session";
import { AppAuthBoundary } from "./app-auth-boundary";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
expect.extend(matchers);
vi.mock("@/lib/auth-session", () => ({
useAuthSession: vi.fn(),
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: vi.fn(),
+}));
+
+const activeAuthorization = {
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ permissions: ["repositories.read" as const],
+};
afterEach(() => {
cleanup();
vi.clearAllMocks();
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: () => false,
+ });
});
describe("AppAuthBoundary", () => {
@@ -24,6 +41,12 @@ describe("AppAuthBoundary", () => {
data: { user: { id: "user-1", name: "Test User" } },
status: "authenticated",
});
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: activeAuthorization,
+ loading: false,
+ error: null,
+ hasPermission: () => true,
+ });
render(Session );
@@ -59,6 +82,23 @@ describe("AppAuthBoundary", () => {
expect(screen.queryByRole("link", { name: "Sign in" })).not.toBeInTheDocument();
});
+ it("fails closed when workspace access is suspended", () => {
+ vi.mocked(useAuthSession).mockReturnValue({
+ data: { user: { id: "user-1", name: "Test User" } },
+ status: "authenticated",
+ });
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: { ...activeAuthorization, suspendedAt: 1, permissions: [] },
+ loading: false,
+ error: null,
+ hasPermission: () => false,
+ });
+
+ render(Session );
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Your workspace access is disabled.");
+ });
+
it("fails closed for an unhandled authentication state", () => {
vi.mocked(useAuthSession).mockReturnValue({
data: null,
diff --git a/packages/web/src/components/app-auth-boundary.tsx b/packages/web/src/components/app-auth-boundary.tsx
index 68c5e978b..0ae752692 100644
--- a/packages/web/src/components/app-auth-boundary.tsx
+++ b/packages/web/src/components/app-auth-boundary.tsx
@@ -5,9 +5,14 @@ import { useAuthSession } from "@/lib/auth-session";
import { APP_NAME } from "@/lib/site-config";
import { Button } from "@/components/ui/button";
import { ErrorBanner } from "@/components/ui/error-banner";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+/**
+ * Renders application children only for authenticated, active workspace users after authorization resolves.
+ */
export function AppAuthBoundary({ children }: { children: React.ReactNode }) {
const { status } = useAuthSession();
+ const { authorization, loading: authorizationLoading, error } = useCurrentUserAuthorization();
if (status === "loading") {
return (
@@ -47,6 +52,31 @@ export function AppAuthBoundary({ children }: { children: React.ReactNode }) {
}
if (status === "authenticated") {
+ if (authorizationLoading) {
+ return (
+
+ );
+ }
+ if (error || !authorization) {
+ return (
+
+ Authorization is temporarily unavailable.
+
+ );
+ }
+ if (authorization.suspendedAt !== null) {
+ return (
+
+ Your workspace access is disabled.
+
+ );
+ }
return children;
}
diff --git a/packages/web/src/components/app-destinations.ts b/packages/web/src/components/app-destinations.ts
index 4b78dd323..2e418cd7a 100644
--- a/packages/web/src/components/app-destinations.ts
+++ b/packages/web/src/components/app-destinations.ts
@@ -1,11 +1,21 @@
import { AutomationsIcon, DataControlsIcon, SettingsIcon } from "@/components/ui/icons";
+import type { PermissionId } from "@open-inspect/shared/rbac";
+import type { ComponentType } from "react";
+
+export interface AppDestination {
+ label: string;
+ description: string;
+ href: string;
+ icon: ComponentType<{ className?: string }>;
+ requiredPermission?: PermissionId;
+}
export const SETTINGS_DESTINATION = {
label: "Settings",
description: "Configure Open Inspect",
href: "/settings",
icon: SettingsIcon,
-} as const;
+} as const satisfies AppDestination;
export const PRIMARY_APP_DESTINATIONS = [
{
@@ -13,13 +23,15 @@ export const PRIMARY_APP_DESTINATIONS = [
description: "Manage scheduled and event-triggered work",
href: "/automations",
icon: AutomationsIcon,
+ requiredPermission: "automations.read",
},
{
label: "Analytics",
description: "View usage across sessions, repositories, and users",
href: "/analytics",
icon: DataControlsIcon,
+ requiredPermission: "analytics.read",
},
-] as const;
+] as const satisfies readonly AppDestination[];
export const APP_DESTINATIONS = [SETTINGS_DESTINATION, ...PRIMARY_APP_DESTINATIONS] as const;
diff --git a/packages/web/src/components/global-command-menu.test.tsx b/packages/web/src/components/global-command-menu.test.tsx
index c6e7baa6d..0a8531c62 100644
--- a/packages/web/src/components/global-command-menu.test.tsx
+++ b/packages/web/src/components/global-command-menu.test.tsx
@@ -15,7 +15,10 @@ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
value: vi.fn(),
});
-const mocks = vi.hoisted(() => ({ repoImagesEnabled: true }));
+const mocks = vi.hoisted(() => ({
+ repoImagesEnabled: true,
+ allowedPermissions: null as Set | null,
+}));
vi.mock("@/hooks/use-keyboard-shortcuts", () => ({
useKeyboardShortcuts: () => ({ labels: { "new-session": "Cmd/Ctrl+Shift+O" } }),
@@ -25,6 +28,13 @@ vi.mock("@/lib/sandbox-provider", () => ({
supportsRepoImages: () => mocks.repoImagesEnabled,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
+
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
@@ -39,6 +49,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
mocks.repoImagesEnabled = true;
+ mocks.allowedPermissions = null;
vi.unstubAllGlobals();
});
@@ -70,6 +81,26 @@ describe("GlobalCommandMenu", () => {
).toBeInTheDocument();
});
+ it("omits session creation destinations without session creation permission", () => {
+ mocks.allowedPermissions = new Set();
+
+ renderMenu();
+
+ expect(screen.queryByText("New session")).not.toBeInTheDocument();
+ expect(screen.queryByText("Home")).not.toBeInTheDocument();
+ expect(screen.queryByText("Start a coding session")).not.toBeInTheDocument();
+ });
+
+ it("omits application destinations without their read permissions", () => {
+ mocks.allowedPermissions = new Set();
+
+ renderMenu();
+
+ expect(screen.queryByText("Automations")).not.toBeInTheDocument();
+ expect(screen.queryByText("Analytics")).not.toBeInTheDocument();
+ expect(screen.getByText("Configure Open Inspect")).toBeInTheDocument();
+ });
+
it("selects Analytics from the keyboard", async () => {
const user = userEvent.setup();
const { onNavigate, onOpenChange } = renderMenu();
@@ -161,6 +192,27 @@ describe("GlobalCommandMenu", () => {
expect(screen.queryByText("Images")).not.toBeInTheDocument();
});
+ it("omits settings destinations the user cannot view", () => {
+ mocks.allowedPermissions = new Set(["models.preferences.manage"]);
+ renderMenu();
+
+ expect(screen.getByText("Appearance")).toBeInTheDocument();
+ expect(screen.getByText("Models")).toBeInTheDocument();
+ expect(screen.queryByText("Secrets")).not.toBeInTheDocument();
+ });
+
+ it("requires repository read access for the repository secrets destination", () => {
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage"]);
+ const { rerender, props } = renderMenu();
+
+ expect(screen.queryByText("Secrets")).not.toBeInTheDocument();
+
+ mocks.allowedPermissions.add("repositories.read");
+ rerender( );
+
+ expect(screen.getByText("Secrets")).toBeInTheDocument();
+ });
+
it("preserves order-independent session search", async () => {
const user = userEvent.setup();
renderMenu([
diff --git a/packages/web/src/components/global-command-menu.tsx b/packages/web/src/components/global-command-menu.tsx
index e341a7588..aa073671c 100644
--- a/packages/web/src/components/global-command-menu.tsx
+++ b/packages/web/src/components/global-command-menu.tsx
@@ -11,6 +11,7 @@ import { BranchIcon, PlusIcon } from "@/components/ui/icons";
import { AppIcon } from "@/components/ui/app-icon";
import { APP_DESTINATIONS } from "@/components/app-destinations";
import { getSettingsGroups } from "@/components/settings/settings-registry";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import {
Command,
CommandDialog,
@@ -72,6 +73,9 @@ function CommandMenuFooter() {
);
}
+/**
+ * Provides global navigation and search while exposing only settings destinations the user may access.
+ */
export function GlobalCommandMenu({
open,
onOpenChange,
@@ -80,11 +84,13 @@ export function GlobalCommandMenu({
sessions,
}: GlobalCommandMenuProps) {
const { labels } = useKeyboardShortcuts();
+ const { hasPermission } = useCurrentUserAuthorization();
const searchableSessions = useMemo(
() => sessions.filter((session) => session.status !== "archived"),
[sessions]
);
- const settingsGroups = getSettingsGroups();
+ const settingsGroups = getSettingsGroups({ hasPermission });
+ const canCreateSession = hasPermission("sessions.create");
const handleSelect = (callback: () => void) => {
onOpenChange(false);
@@ -92,21 +98,28 @@ export function GlobalCommandMenu({
};
const navigationItems = [
- {
- label: "New session",
- description: "Start a coding session",
- Icon: PlusIcon,
- onSelect: onNewSession,
- shortcut: labels["new-session"],
- },
- {
- label: "Home",
- description: "Ask a question or describe what you want to build",
- Icon: AppIcon,
- onSelect: () => onNavigate("/"),
- shortcut: undefined,
- },
- ...APP_DESTINATIONS.map(({ label, description, href, icon: Icon }) => ({
+ ...(canCreateSession
+ ? [
+ {
+ label: "New session",
+ description: "Start a coding session",
+ Icon: PlusIcon,
+ onSelect: onNewSession,
+ shortcut: labels["new-session"],
+ },
+ {
+ label: "Home",
+ description: "Ask a question or describe what you want to build",
+ Icon: AppIcon,
+ onSelect: () => onNavigate("/"),
+ shortcut: undefined,
+ },
+ ]
+ : []),
+ ...APP_DESTINATIONS.filter(
+ (destination) =>
+ !("requiredPermission" in destination) || hasPermission(destination.requiredPermission)
+ ).map(({ label, description, href, icon: Icon }) => ({
label,
description,
Icon,
diff --git a/packages/web/src/components/session-sidebar.test.tsx b/packages/web/src/components/session-sidebar.test.tsx
index 2d2738a39..ecdcb57ee 100644
--- a/packages/web/src/components/session-sidebar.test.tsx
+++ b/packages/web/src/components/session-sidebar.test.tsx
@@ -8,8 +8,9 @@ import { SessionSidebar } from "./session-sidebar";
expect.extend(matchers);
-const { mockHook } = vi.hoisted(() => ({
+const { mockHook, authorization } = vi.hoisted(() => ({
mockHook: vi.fn(),
+ authorization: { permissions: null as Set | null },
}));
vi.mock("@/hooks/use-sidebar-sessions", () => ({ useSidebarSessions: mockHook }));
@@ -19,6 +20,12 @@ vi.mock("@/lib/auth-session", () => ({
}));
vi.mock("@/hooks/use-media-query", () => ({ useIsMobile: () => false }));
vi.mock("@/hooks/use-environments", () => ({ useEnvironments: () => ({ environments: [] }) }));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ authorization.permissions === null || authorization.permissions.has(permission),
+ }),
+}));
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({ push: vi.fn() }),
@@ -60,6 +67,7 @@ const noPagination = {
};
beforeEach(() => {
+ authorization.permissions = null;
const attention = session("attention", "Needs review");
const running = session("running", "Implementing inbox");
const child = session("child", "Checking tests", running.id);
@@ -101,6 +109,16 @@ describe("SessionSidebar", () => {
expect(screen.getByRole("link", { name: "Analytics" })).toHaveAttribute("href", "/analytics");
});
+ it("hides application destinations without their canonical read permission", () => {
+ authorization.permissions = new Set(["automations.read"]);
+
+ render( );
+
+ expect(screen.getByRole("link", { name: "Automations" })).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: "Analytics" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /New session/ })).not.toBeInTheDocument();
+ });
+
it("renders server-classified sections and nested descendants", () => {
render( );
diff --git a/packages/web/src/components/session-sidebar.tsx b/packages/web/src/components/session-sidebar.tsx
index a86fd705a..1394a1adc 100644
--- a/packages/web/src/components/session-sidebar.tsx
+++ b/packages/web/src/components/session-sidebar.tsx
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { useEnvironments } from "@/hooks/use-environments";
import { SessionWithChildren } from "@/components/session-with-children";
import { UserMenu } from "@/components/sidebar-user-menu";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
export type { SessionItem } from "@/hooks/use-sidebar-sessions";
@@ -72,6 +73,7 @@ export function SessionSidebar({
const pathname = usePathname();
const router = useRouter();
const isMobile = useIsMobile();
+ const { hasPermission } = useCurrentUserAuthorization();
const currentSessionId = pathname?.startsWith("/session/") ? pathname.split("/")[2] : null;
@@ -202,7 +204,7 @@ export function SessionSidebar({
-
+ {hasPermission("sessions.create") &&
}
- {PRIMARY_APP_DESTINATIONS.map(({ href, label, icon: Icon }) => (
+ {PRIMARY_APP_DESTINATIONS.filter((destination) =>
+ hasPermission(destination.requiredPermission)
+ ).map(({ href, label, icon: Icon }) => (
({
+const { toastMock, authorizationMock } = vi.hoisted(() => ({
toastMock: {
success: vi.fn(),
error: vi.fn(),
},
+ authorizationMock: { permissions: null as Set
| null },
}));
vi.mock("sonner", () => ({
toast: toastMock,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ authorizationMock.permissions === null || authorizationMock.permissions.has(permission),
+ }),
+}));
+
vi.mock("next/link", () => ({
default: ({ children, href, ...props }: React.ComponentProps<"a">) => (
@@ -143,9 +151,20 @@ afterEach(async () => {
vi.restoreAllMocks();
toastMock.success.mockReset();
toastMock.error.mockReset();
+ authorizationMock.permissions = null;
});
describe("DataControlsSettings — unarchive flow", () => {
+ it("keeps archived sessions readable without exposing unarchive to read-only roles", async () => {
+ authorizationMock.permissions = new Set(["sessions.read"]);
+ installFetch({ archivedSessions: [createArchivedSession(1)] });
+
+ renderComponent();
+
+ expect(await screen.findByText("Session 1")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Unarchive" })).not.toBeInTheDocument();
+ });
+
it("removes the row when the unarchive request succeeds", async () => {
installFetch({
archivedSessions: [createArchivedSession(1)],
diff --git a/packages/web/src/components/settings/data-controls-settings.tsx b/packages/web/src/components/settings/data-controls-settings.tsx
index 357957ecc..f8eab3002 100644
--- a/packages/web/src/components/settings/data-controls-settings.tsx
+++ b/packages/web/src/components/settings/data-controls-settings.tsx
@@ -17,6 +17,8 @@ import {
} from "@/lib/session-list";
import { formatRelativeTime } from "@/lib/time";
import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { canUseSettingsCapability } from "./settings-registry";
const PAGE_SIZE = 20;
const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({
@@ -26,6 +28,12 @@ const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({
});
export function DataControlsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canUnarchive = canUseSettingsCapability(
+ "data-controls",
+ "unarchiveSessions",
+ hasPermission
+ );
const [extraSessions, setExtraSessions] = useState([]);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
@@ -126,6 +134,7 @@ export function DataControlsSettings() {
))}
@@ -150,9 +159,11 @@ export function DataControlsSettings() {
function ArchivedSessionRow({
session,
+ canUnarchive,
onUnarchive,
}: {
session: SessionListItem;
+ canUnarchive: boolean;
onUnarchive: (id: string) => void;
}) {
const repoInfo = formatRepoLabel(session.repoOwner, session.repoName);
@@ -169,14 +180,16 @@ function ArchivedSessionRow({
{repoInfo}
- onUnarchive(session.id)}
- className="flex-shrink-0 opacity-0 group-hover:opacity-100"
- >
- Unarchive
-
+ {canUnarchive && (
+ onUnarchive(session.id)}
+ className="flex-shrink-0 opacity-0 group-hover:opacity-100"
+ >
+ Unarchive
+
+ )}
);
}
diff --git a/packages/web/src/components/settings/environment-integration-settings.tsx b/packages/web/src/components/settings/environment-integration-settings.tsx
index f63681b53..77061aefd 100644
--- a/packages/web/src/components/settings/environment-integration-settings.tsx
+++ b/packages/web/src/components/settings/environment-integration-settings.tsx
@@ -38,9 +38,11 @@ const ENABLEMENT_CHOICES: Array<{ value: EnablementChoice; label: string }> = [
export function EnvironmentIntegrationSettings({
environmentId,
repositories,
+ canManage,
}: {
environmentId: string;
repositories: EnvironmentRepository[];
+ canManage: boolean;
}) {
const primary = repositories[0];
const primaryLabel = primary
@@ -54,36 +56,38 @@ export function EnvironmentIntegrationSettings({
left unset inherits from {primaryLabel}'s settings.
-
-
-
-
-
Sandbox
-
- Inherited values are shown as the current settings; saving only pins the fields you
- change.
-
-
+
-
+
+
+
+
Sandbox
+
+ Inherited values are shown as the current settings; saving only pins the fields you
+ change.
+
+
+
+
);
}
diff --git a/packages/web/src/components/settings/environments-settings.tsx b/packages/web/src/components/settings/environments-settings.tsx
index 8949d5abe..2ed017607 100644
--- a/packages/web/src/components/settings/environments-settings.tsx
+++ b/packages/web/src/components/settings/environments-settings.tsx
@@ -21,16 +21,28 @@ import { EnvironmentIntegrationSettings } from "./environment-integration-settin
import { EnvironmentSecretsImport } from "./environment-secrets-import";
import { ImageBuildStatus } from "./image-build-status";
import { SecretsEditor } from "@/components/secrets-editor";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type View =
| { mode: "list" }
| { mode: "create" }
| { mode: "edit"; environmentId: string; tab: "configuration" | "secrets" | "overrides" };
+/**
+ * Presents environments with configuration, secrets, settings, and image actions gated independently by permission.
+ */
export function EnvironmentsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("environments.manage");
+ const canManageSecrets = hasPermission("environments.secrets.manage");
+ const canManageRepoSecrets = hasPermission("repositories.secrets.manage");
+ const canManageSettings = hasPermission("environments.settings.manage");
+ const canManageImages = hasPermission("environments.images.manage");
+ const canReadImages = hasPermission("image_builds.read");
+ const canReadSettings = hasPermission("integrations.read");
const { environments, loading } = useEnvironments();
const { data: imageBuildsFeed, error: imageBuildsError } = useImageBuilds(
- environments.some((environment) => environment.prebuildEnabled)
+ canReadImages && environments.some((environment) => environment.prebuildEnabled)
);
const [view, setView] = useState({ mode: "list" });
const [submitting, setSubmitting] = useState(false);
@@ -208,20 +220,27 @@ export function EnvironmentsSettings() {
- {(["configuration", "secrets", "overrides"] as const).map((tab) => (
- setView({ ...view, tab })}
- className={`px-3 py-2 text-sm capitalize transition border-b-2 -mb-px ${
- view.tab === tab
- ? "border-accent text-foreground font-medium"
- : "border-transparent text-muted-foreground hover:text-foreground"
- }`}
- >
- {tab}
-
- ))}
+ {(["configuration", "secrets", "overrides"] as const)
+ .filter(
+ (tab) =>
+ (tab === "configuration" && canManage) ||
+ (tab === "secrets" && canManageSecrets) ||
+ (tab === "overrides" && canReadSettings)
+ )
+ .map((tab) => (
+ setView({ ...view, tab })}
+ className={`px-3 py-2 text-sm capitalize transition border-b-2 -mb-px ${
+ view.tab === tab
+ ? "border-accent text-foreground font-medium"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {tab}
+
+ ))}
{error && {error} }
@@ -242,10 +261,12 @@ export function EnvironmentsSettings() {
and triggers a rebuild.
-
+ {canManageRepoSecrets && (
+
+ )}
setView({ mode: "list" })}>
Back to environments
@@ -257,6 +278,7 @@ export function EnvironmentsSettings() {
setView({ mode: "list" })}>
@@ -274,9 +296,11 @@ export function EnvironmentsSettings() {
Environments
- setView({ mode: "create" })}>
- New environment
-
+ {canManage && (
+ setView({ mode: "create" })}>
+ New environment
+
+ )}
Named repository sets that launch together in one workspace, with their own secrets
@@ -319,82 +343,100 @@ export function EnvironmentsSettings() {
- {prebuildsSupported && (
+ {prebuildsSupported && (canReadImages || canManage || canManageImages) && (
<>
-
- image.scopeKind === "environment" && image.scopeId === environment.id
- )}
- feedUnavailable={Boolean(imageBuildsError) && !imageBuildsFeed}
- />
-
-
-
-
- handlePrebuildToggle(environment, checked)
- }
- disabled={isToggling}
- aria-label={`Toggle prebuilt images for ${environment.name}`}
- />
-
-
- Prebuild images
-
- handleRebuild(environment)}
- disabled={!environment.prebuildEnabled || isTriggering}
- title="Rebuild image"
- >
-
+ image.scopeKind === "environment" &&
+ image.scopeId === environment.id
+ )}
+ feedUnavailable={Boolean(imageBuildsError) && !imageBuildsFeed}
/>
-
+ )}
+ {canManage && (
+
+
+
+
+ handlePrebuildToggle(environment, checked)
+ }
+ disabled={isToggling}
+ aria-label={`Toggle prebuilt images for ${environment.name}`}
+ />
+
+
+ Prebuild images
+
+ )}
+ {canManageImages && (
+ handleRebuild(environment)}
+ disabled={!environment.prebuildEnabled || isTriggering}
+ title="Rebuild image"
+ >
+
+
+ )}
>
)}
-
- setView({
- mode: "edit",
- environmentId: environment.id,
- tab: "configuration",
- })
- }
- >
- Edit
-
- {confirmDeleteId === environment.id ? (
-
- {
- handleDelete(environment);
- setConfirmDeleteId(null);
- }}
- >
- Confirm
-
- setConfirmDeleteId(null)}>
- Cancel
-
-
- ) : (
+ {(canManage || canManageSecrets || canReadSettings) && (
setConfirmDeleteId(environment.id)}
+ onClick={() =>
+ setView({
+ mode: "edit",
+ environmentId: environment.id,
+ tab: canManage
+ ? "configuration"
+ : canManageSecrets
+ ? "secrets"
+ : "overrides",
+ })
+ }
>
- Delete
+ Edit
)}
+ {canManage &&
+ (confirmDeleteId === environment.id ? (
+
+ {
+ handleDelete(environment);
+ setConfirmDeleteId(null);
+ }}
+ >
+ Confirm
+
+ setConfirmDeleteId(null)}
+ >
+ Cancel
+
+
+ ) : (
+ setConfirmDeleteId(environment.id)}
+ >
+ Delete
+
+ ))}
diff --git a/packages/web/src/components/settings/images-settings.test.tsx b/packages/web/src/components/settings/images-settings.test.tsx
index 9adc1e180..980fe7b2a 100644
--- a/packages/web/src/components/settings/images-settings.test.tsx
+++ b/packages/web/src/components/settings/images-settings.test.tsx
@@ -11,6 +11,15 @@ import { ImagesSettings } from "./images-settings";
expect.extend(matchers);
+const mocks = vi.hoisted(() => ({ allowedPermissions: null as Set | null }));
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
+
vi.mock("@/hooks/use-repos", () => ({
useRepos: () => ({
repos: [
@@ -52,6 +61,7 @@ function renderWithFeed(feed: ImageBuildsFeed) {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
+ mocks.allowedPermissions = null;
});
describe("ImagesSettings", () => {
@@ -127,6 +137,20 @@ describe("ImagesSettings", () => {
).not.toBeChecked();
});
+ it("keeps image state visible but disables mutations for a read-only role", () => {
+ mocks.allowedPermissions = new Set(["image_builds.read"]);
+ renderWithFeed({
+ units: [],
+ enabledRepos: [{ repoOwner: "acme", repoName: "web" }],
+ images: [],
+ });
+
+ expect(
+ screen.getByRole("switch", { name: "Toggle pre-built images for acme/web" })
+ ).toBeDisabled();
+ expect(screen.queryByTitle("Rebuild image")).not.toBeInTheDocument();
+ });
+
it("shows an error instead of unchecked toggles when the feed fails", async () => {
render(
handleToggle(repo.owner, repo.name, checked)}
- disabled={isToggling}
+ disabled={!canManage || isToggling}
aria-label={`Toggle pre-built images for ${repo.owner}/${repo.name}`}
/>
@@ -180,15 +186,17 @@ export function ImagesSettings() {
}
}
/>
- handleTrigger(repo.owner, repo.name)}
- disabled={!isEnabled || isTriggering || image?.status === "building"}
- title="Rebuild image"
- >
-
-
+ {canManage && (
+ handleTrigger(repo.owner, repo.name)}
+ disabled={!isEnabled || isTriggering || image?.status === "building"}
+ title="Rebuild image"
+ >
+
+
+ )}
);
diff --git a/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx b/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx
index 7c0c11f69..24ce403b4 100644
--- a/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx
@@ -8,6 +8,10 @@ import * as matchers from "@testing-library/jest-dom/matchers";
import { CommitSigningSettings } from "./commit-signing-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
const { useSWRMock, mutateMock } = vi.hoisted(() => ({
diff --git a/packages/web/src/components/settings/integrations/commit-signing-settings.tsx b/packages/web/src/components/settings/integrations/commit-signing-settings.tsx
index b3564457f..9271e8ca5 100644
--- a/packages/web/src/components/settings/integrations/commit-signing-settings.tsx
+++ b/packages/web/src/components/settings/integrations/commit-signing-settings.tsx
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const SETTINGS_KEY = "/api/commit-signing";
@@ -22,7 +23,12 @@ const STATUS_LABELS: Record = {
enabled: "Configured",
};
+/**
+ * Displays commit-signing settings and makes the configuration read-only without management permission.
+ */
export function CommitSigningSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("commit_signing.manage");
const { data: rawData, error, isLoading, mutate } = useSWR(SETTINGS_KEY);
const viewState = useMemo(() => {
if (isLoading) return { kind: "loading" } as const;
@@ -140,7 +146,7 @@ export function CommitSigningSettings() {
)}
-
+
Committer name
)}
-
+
);
}
diff --git a/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx
index 4c1c76478..25f376f0a 100644
--- a/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx
@@ -9,6 +9,15 @@ import type { EnrichedRepository } from "@open-inspect/shared/types/repository-c
import { CodeServerIntegrationSettings } from "./code-server-integration-settings";
import { VncIntegrationSettings } from "./vnc-integration-settings";
+const allowedPermissions = vi.hoisted(() => ({ value: null as Set | null }));
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ allowedPermissions.value === null || allowedPermissions.value.has(permission),
+ }),
+}));
+
expect.extend(matchers);
const { useSWRMock, mutateMock } = vi.hoisted(() => ({
@@ -103,6 +112,7 @@ beforeEach(() => {
toastError.mockReset();
mutateMock.mockReset();
useSWRMock.mockReset();
+ allowedPermissions.value = null;
vi.stubGlobal("fetch", fetchMock);
});
@@ -135,6 +145,21 @@ describe("code-server enablement integration settings", () => {
);
});
+ it("separates global and repository mutation permissions", () => {
+ setupSWR(id, {
+ global: { defaults: { enabled: true } },
+ repos: [{ repo: nestedRepo, settings: { enabled: true } }],
+ });
+ allowedPermissions.value = new Set(["integrations.read", "repositories.settings.manage"]);
+
+ render( );
+
+ expect(screen.getByRole("checkbox", { name: new RegExp(`^${enableLabel}`) })).toBeDisabled();
+ expect(
+ within(overrideRow(nestedRepo)).getByRole("checkbox", { name: /enabled/i })
+ ).toBeEnabled();
+ });
+
it("resets global settings", async () => {
const user = userEvent.setup();
setupSWR(id, { global: { defaults: { enabled: true } } });
diff --git a/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx b/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx
index e6b322a08..9ce6d7969 100644
--- a/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx
@@ -31,6 +31,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
interface EnablementSettings {
enabled?: boolean;
@@ -70,7 +71,13 @@ interface ReposResponse {
repos: EnrichedRepository[];
}
+/**
+ * Renders global and repository enablement settings with each scope editable only by authorized users.
+ */
export function EnablementIntegrationSettings({ copy }: { copy: EnablementIntegrationCopy }) {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const globalSettingsKey = `/api/integration-settings/${copy.id}` as const;
const repoSettingsKey = `/api/integration-settings/${copy.id}/repos` as const;
const { data: globalData, isLoading: globalLoading } = useSWR(globalSettingsKey);
@@ -91,20 +98,24 @@ export function EnablementIntegrationSettings({ copy }: { copy: EnablementIntegr
{copy.title}
{copy.intro}
-
-
-
-
+
+
+
+
+
+
+
);
diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx
index b63475b70..5ab884cb5 100644
--- a/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx
@@ -13,6 +13,10 @@ import {
} from "@open-inspect/shared/types/integrations";
import { GitHubIntegrationSettings } from "./github-integration-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
interface RepoSettingsEntry {
diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.tsx
index c78c39006..5756e26d8 100644
--- a/packages/web/src/components/settings/integrations/github-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/github-integration-settings.tsx
@@ -12,6 +12,7 @@ import { CommitSigningSettings } from "./commit-signing-settings";
import { GlobalSettingsSection } from "./github-global-settings-section";
import { RepoOverridesSection, type RepoSettingsEntry } from "./github-repo-overrides-section";
import { IntegrationSettingsSection } from "./integration-settings-section";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SETTINGS_KEY = "/api/integration-settings/github";
const REPO_SETTINGS_KEY = "/api/integration-settings/github/repos";
@@ -28,7 +29,13 @@ interface ReposResponse {
repos: EnrichedRepository[];
}
+/**
+ * Displays GitHub integration settings with global and repository edits gated by their respective permissions.
+ */
export function GitHubIntegrationSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const { data: globalData, isLoading: globalLoading } =
useSWR(GLOBAL_SETTINGS_KEY);
const { data: repoSettingsData, isLoading: repoSettingsLoading } =
@@ -74,23 +81,27 @@ export function GitHubIntegrationSettings() {
-
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/integrations/linear-integration-settings.tsx b/packages/web/src/components/settings/integrations/linear-integration-settings.tsx
index eb27e3ecd..91c53664d 100644
--- a/packages/web/src/components/settings/integrations/linear-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/linear-integration-settings.tsx
@@ -46,6 +46,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ModelReasoningDefaultsFields } from "./model-reasoning-defaults-fields";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SETTINGS_KEY = "/api/integration-settings/linear";
const REPO_SETTINGS_KEY = "/api/integration-settings/linear/repos";
@@ -67,7 +68,13 @@ interface ReposResponse {
repos: EnrichedRepository[];
}
+/**
+ * Displays Linear integration settings with global and repository edits gated by their respective permissions.
+ */
export function LinearIntegrationSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const { data: globalData, isLoading: globalLoading } =
useSWR(GLOBAL_SETTINGS_KEY);
const { data: repoSettingsData, isLoading: repoSettingsLoading } =
@@ -108,21 +115,25 @@ export function LinearIntegrationSettings() {
)}
-
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
index 21b2d5829..b9cdf21ee 100644
--- a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
@@ -14,6 +14,10 @@ import {
} from "@open-inspect/shared/types/integrations";
import { SlackIntegrationSettings } from "./slack-integration-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
interface RepoSettingsEntry {
diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx
index b78d378ee..51db92585 100644
--- a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx
@@ -53,6 +53,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SETTINGS_KEY = "/api/integration-settings/slack";
const REPO_SETTINGS_KEY = "/api/integration-settings/slack/repos";
@@ -114,7 +115,13 @@ function mergedGlobalDefaults(
return defaults;
}
+/**
+ * Displays Slack integration settings with global and repository edits gated by their respective permissions.
+ */
export function SlackIntegrationSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const { data: globalData, isLoading: globalLoading } =
useSWR(GLOBAL_SETTINGS_KEY);
const { data: repoSettingsData, isLoading: repoSettingsLoading } =
@@ -154,21 +161,27 @@ export function SlackIntegrationSettings() {
-
-
-
+
+
+
+
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/mcp-servers-settings.test.tsx b/packages/web/src/components/settings/mcp-servers-settings.test.tsx
index c59cb9a8d..0df7e49f7 100644
--- a/packages/web/src/components/settings/mcp-servers-settings.test.tsx
+++ b/packages/web/src/components/settings/mcp-servers-settings.test.tsx
@@ -13,6 +13,7 @@ expect.extend(matchers);
const mocks = vi.hoisted(() => ({
mutate: vi.fn(),
updateMcpServer: vi.fn(),
+ allowedPermissions: null as Set | null,
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -25,6 +26,12 @@ vi.mock("@/hooks/use-mcp-servers", () => ({
updateMcpServer: mocks.updateMcpServer,
deleteMcpServer: vi.fn(),
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
const servers: McpServerMetadata[] = [
{
@@ -54,9 +61,22 @@ const servers: McpServerMetadata[] = [
afterEach(() => {
cleanup();
vi.clearAllMocks();
+ mocks.allowedPermissions = null;
});
describe("McpServersSettings", () => {
+ it("shows servers but no mutation entry points with read-only permission", () => {
+ mocks.allowedPermissions = new Set(["mcp_servers.read"]);
+
+ render( );
+
+ expect(screen.getByText("Server A")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add Server" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Server A/ })).toBeDisabled();
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+ });
+
it("does not close a newer draft when an older save completes", async () => {
let resolveSave!: (server: McpServerMetadata) => void;
mocks.updateMcpServer.mockReturnValue(
diff --git a/packages/web/src/components/settings/mcp-servers-settings.tsx b/packages/web/src/components/settings/mcp-servers-settings.tsx
index d8bf0fd75..594042369 100644
--- a/packages/web/src/components/settings/mcp-servers-settings.tsx
+++ b/packages/web/src/components/settings/mcp-servers-settings.tsx
@@ -32,6 +32,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type ScopeMode = "global" | "selected";
type Editor =
@@ -438,7 +439,12 @@ function McpServerForm({
);
}
+/**
+ * Lists workspace MCP servers and exposes create, edit, and delete controls only to authorized users.
+ */
export function McpServersSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("mcp_servers.manage");
const { servers, loading, mutate } = useMcpServers();
const { repos, loading: loadingRepos } = useRepos();
const [editor, setEditor] = useState(null);
@@ -564,12 +570,14 @@ export function McpServersSettings() {
MCP Servers
-
-
-
- Add Server
-
-
+ {canManage && (
+
+
+
+ Add Server
+
+
+ )}
Configure Model Context Protocol servers that are available to agent sessions.
@@ -635,7 +643,8 @@ export function McpServersSettings() {
startEdit(server)}
+ onClick={() => canManage && startEdit(server)}
+ disabled={!canManage}
>
-
- handleToggle(server)}
- aria-label={server.enabled ? "Disable" : "Enable"}
- />
- setDeleteTarget(server.id)}
- className="px-2 py-1 text-xs text-destructive hover:text-destructive/80 transition"
- >
- Delete
-
-
+ {canManage && (
+
+ handleToggle(server)}
+ aria-label={server.enabled ? "Disable" : "Enable"}
+ />
+ setDeleteTarget(server.id)}
+ className="px-2 py-1 text-xs text-destructive hover:text-destructive/80 transition"
+ >
+ Delete
+
+
+ )}
{/* Expanded edit form */}
@@ -713,7 +724,7 @@ export function McpServersSettings() {
)}
{/* Delete confirmation dialog */}
- setDeleteTarget(null)}>
+ setDeleteTarget(null)}>
Delete MCP server
diff --git a/packages/web/src/components/settings/provider-accounts-settings.test.tsx b/packages/web/src/components/settings/provider-accounts-settings.test.tsx
index 763fd18dc..28fb17384 100644
--- a/packages/web/src/components/settings/provider-accounts-settings.test.tsx
+++ b/packages/web/src/components/settings/provider-accounts-settings.test.tsx
@@ -43,6 +43,14 @@ const account = {
};
let accountsResult: ModelProviderAccount[];
let defaultsResult: ModelProviderAccountDefault[];
+let allowedPermissions: Set | null;
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ allowedPermissions === null || allowedPermissions.has(permission),
+ }),
+}));
vi.mock("@/hooks/use-provider-accounts", () => ({
useProviderAccounts: () => ({
@@ -88,6 +96,7 @@ describe("ProviderAccountsSettings", () => {
reconnectAccount.mockResolvedValue(undefined);
accountsResult = [account];
defaultsResult = [];
+ allowedPermissions = null;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
@@ -98,6 +107,18 @@ describe("ProviderAccountsSettings", () => {
};
});
+ it("keeps read-only account details visible without management actions", () => {
+ allowedPermissions = new Set(["provider_accounts.read"]);
+
+ render( );
+
+ expect(screen.getByText("Team ChatGPT")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add account" })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "More actions for Team ChatGPT" })
+ ).not.toBeInTheDocument();
+ });
+
it("reconnects OpenAI through device authorization with the selected account id", async () => {
render( );
fireEvent.pointerDown(screen.getByRole("button", { name: "More actions for Team ChatGPT" }), {
diff --git a/packages/web/src/components/settings/provider-accounts-settings.tsx b/packages/web/src/components/settings/provider-accounts-settings.tsx
index 0a7c76498..d34fbb1d6 100644
--- a/packages/web/src/components/settings/provider-accounts-settings.tsx
+++ b/packages/web/src/components/settings/provider-accounts-settings.tsx
@@ -54,6 +54,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type Confirm = { account: ModelProviderAccount; action: "disable" | "archive" } | null;
type Connection =
@@ -174,7 +175,12 @@ function LegacyReconnectForm({
);
}
+/**
+ * Displays provider accounts while restricting connection and account-management actions by permission.
+ */
export function ProviderAccountsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("provider_accounts.manage");
const { providers, accounts, defaults, loading, error, refresh } = useProviderAccounts();
const legacyCredentials = useLegacyProviderCredentials();
const [connection, setConnection] = useState(null);
@@ -256,32 +262,34 @@ export function ProviderAccountsSettings() {
Connected accounts
-
-
-
-
- Add account
-
-
-
- Subscriptions
- {providers.map((provider) => (
-
- beginConnection(CONNECTION_STRATEGIES[provider.provider].add())
- }
- >
-
- {provider.subscriptionName}
-
- ))}
-
-
+ {canManage && (
+
+
+
+
+ Add account
+
+
+
+ Subscriptions
+ {providers.map((provider) => (
+
+ beginConnection(CONNECTION_STRATEGIES[provider.provider].add())
+ }
+ >
+
+ {provider.subscriptionName}
+
+ ))}
+
+
+ )}
{accounts.length === 0 ? (
@@ -356,135 +364,139 @@ export function ProviderAccountsSettings() {
-
- {account.status === "reconnect_required" && (
-
- beginConnection(
- CONNECTION_STRATEGIES[account.provider].reconnect(account)
- )
- }
- >
- Reconnect
-
- )}
- {account.status === "disabled" && (
-
- void run(
- () => runProviderAccountAction(account.id, "enable"),
- "Account enabled"
- )
- }
- >
- Enable
-
- )}
-
-
+ {canManage && (
+
+ {account.status === "reconnect_required" && (
+ beginConnection(
+ CONNECTION_STRATEGIES[account.provider].reconnect(account)
+ )
+ }
>
-
+ Reconnect
-
-
- {account.status !== "reconnect_required" && (
-
- beginConnection(
- CONNECTION_STRATEGIES[account.provider].reconnect(account)
- )
- }
- >
- Reconnect
-
- )}
-
+ )}
+ {account.status === "disabled" && (
+
void run(
- () => runProviderAccountAction(account.id, "verify"),
- "Account verified"
+ () => runProviderAccountAction(account.id, "enable"),
+ "Account enabled"
)
}
>
- Verify
-
- {account.status === "active" && !isDefault && (
-
+ )}
+
+
+
+
+
+
+
+ {account.status !== "reconnect_required" && (
+
+ beginConnection(
+ CONNECTION_STRATEGIES[account.provider].reconnect(account)
+ )
+ }
+ >
+ Reconnect
+
+ )}
+
void run(
- () =>
- setProviderAccountDefault(
- account.provider,
- account.id,
- providerDefault?.unattendedMode ?? "provider_account"
- ),
- "Default updated"
+ () => runProviderAccountAction(account.id, "verify"),
+ "Account verified"
)
}
>
- Make default
+ Verify
- )}
- {
- if (operationInFlightRef.current) return;
- const displayName = window
- .prompt("Account name", account.displayName)
- ?.trim();
- if (displayName)
- void run(
- () => renameProviderAccount(account.id, displayName),
- "Account renamed"
- );
- }}
- >
- Rename
-
- {externalAccountId && (
+ {account.status === "active" && !isDefault && (
+
+ void run(
+ () =>
+ setProviderAccountDefault(
+ account.provider,
+ account.id,
+ providerDefault?.unattendedMode ?? "provider_account"
+ ),
+ "Default updated"
+ )
+ }
+ >
+ Make default
+
+ )}
- void navigator.clipboard
- .writeText(externalAccountId)
- .then(() => toast.success("Account ID copied"))
- .catch(() => toast.error("Failed to copy account ID"))
- }
+ disabled={saving}
+ onSelect={() => {
+ if (operationInFlightRef.current) return;
+ const displayName = window
+ .prompt("Account name", account.displayName)
+ ?.trim();
+ if (displayName)
+ void run(
+ () => renameProviderAccount(account.id, displayName),
+ "Account renamed"
+ );
+ }}
>
- Copy account ID
+ Rename
- )}
-
- {account.status === "active" && (
+ {externalAccountId && (
+
+ void navigator.clipboard
+ .writeText(externalAccountId)
+ .then(() => toast.success("Account ID copied"))
+ .catch(() => toast.error("Failed to copy account ID"))
+ }
+ >
+ Copy account ID
+
+ )}
+
+ {account.status === "active" && (
+
+ beginConfirmation({ account, action: "disable" })
+ }
+ >
+ Disable
+
+ )}
beginConfirmation({ account, action: "disable" })}
+ onSelect={() => beginConfirmation({ account, action: "archive" })}
>
- Disable
+ Archive
- )}
- beginConfirmation({ account, action: "archive" })}
- >
- Archive
-
-
-
-
+
+
+
+ )}
{account.status !== "active" && (
{
if (!operationInFlightRef.current)
@@ -586,7 +598,7 @@ export function ProviderAccountsSettings() {
>
)}
- {connection?.kind === "device" && (
+ {canManage && connection?.kind === "device" && (
)}
- {connection?.kind === "legacy-xai" && (
+ {canManage && connection?.kind === "legacy-xai" && (
)}
- !open && setConfirm(null)}>
+ !open && setConfirm(null)}>
diff --git a/packages/web/src/components/settings/sandbox-settings.test.tsx b/packages/web/src/components/settings/sandbox-settings.test.tsx
index 6053d95b2..40a146081 100644
--- a/packages/web/src/components/settings/sandbox-settings.test.tsx
+++ b/packages/web/src/components/settings/sandbox-settings.test.tsx
@@ -14,6 +14,10 @@ import {
} from "@open-inspect/shared/types/integrations";
import { SandboxSettingsEditor, SandboxSettingsPage } from "./sandbox-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
const reposMock = vi.hoisted(() => ({
diff --git a/packages/web/src/components/settings/sandbox-settings.tsx b/packages/web/src/components/settings/sandbox-settings.tsx
index 7d28155d6..16be4d3e7 100644
--- a/packages/web/src/components/settings/sandbox-settings.tsx
+++ b/packages/web/src/components/settings/sandbox-settings.tsx
@@ -29,6 +29,7 @@ import {
sandboxTimeoutMinutesFromMs,
sandboxTimeoutMsFromMinutes,
} from "./sandbox-timeout";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SCOPE = "__global__";
type ResourceField = "cpuCores" | "memoryMib";
@@ -217,6 +218,9 @@ function useSandboxSettingsScope(
};
}
+/**
+ * Edits inherited sandbox settings for one scope, becoming read-only without that scope's management permission.
+ */
export function SandboxSettingsEditor({
scope,
owner,
@@ -233,7 +237,15 @@ export function SandboxSettingsEditor({
name?: string;
environmentId?: string;
}) {
+ const { hasPermission } = useCurrentUserAuthorization();
const isGlobal = scope === "global";
+ const canManage = hasPermission(
+ scope === "global"
+ ? "integrations.manage"
+ : scope === "repo"
+ ? "repositories.settings.manage"
+ : "environments.settings.manage"
+ );
const { apiUrl, ownSettings, baseDefaults, enabledRepos, isLoading, mutate } =
useSandboxSettingsScope(scope, owner, name, environmentId);
@@ -585,7 +597,7 @@ export function SandboxSettingsEditor({
}
return (
-
+
{/* Web Terminal toggle */}
@@ -876,7 +888,7 @@ export function SandboxSettingsEditor({
{success && Saved }
-
+
);
}
diff --git a/packages/web/src/components/settings/scm-settings.test.tsx b/packages/web/src/components/settings/scm-settings.test.tsx
index 1508dfc13..672561181 100644
--- a/packages/web/src/components/settings/scm-settings.test.tsx
+++ b/packages/web/src/components/settings/scm-settings.test.tsx
@@ -10,6 +10,10 @@ import type { EnrichedRepository } from "@open-inspect/shared/types/repository-c
import { parseRepositoryFullName } from "@open-inspect/shared/types/repositories";
import { ScmSettingsPage } from "./scm-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
interface RepoSettingsEntry {
diff --git a/packages/web/src/components/settings/scm-settings.tsx b/packages/web/src/components/settings/scm-settings.tsx
index 1dc7d0673..1750f207d 100644
--- a/packages/web/src/components/settings/scm-settings.tsx
+++ b/packages/web/src/components/settings/scm-settings.tsx
@@ -36,6 +36,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const DEFAULT_ALWAYS_USE_DRAFT_MODE = false;
const DEFAULT_PULL_REQUEST_LABEL = "";
@@ -98,7 +99,12 @@ function isRepoListResponse(value: unknown): value is RepoListResponse {
);
}
+/**
+ * Displays source-control defaults and disables editing for users without SCM settings management permission.
+ */
export function ScmSettingsPage() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("scm_settings.manage");
const {
data: globalData,
error: globalError,
@@ -141,18 +147,22 @@ export function ScmSettingsPage() {
Defaults for pull and merge requests opened by coding sessions.
-
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/secrets-settings.test.tsx b/packages/web/src/components/settings/secrets-settings.test.tsx
new file mode 100644
index 000000000..08cdffa66
--- /dev/null
+++ b/packages/web/src/components/settings/secrets-settings.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+///
+
+import { cleanup, render, screen } from "@testing-library/react";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { SecretsSettings } from "./secrets-settings";
+
+expect.extend(matchers);
+
+Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: vi.fn(),
+});
+
+const mocks = vi.hoisted(() => ({
+ permissions: new Set(),
+ useRepos: vi.fn(),
+}));
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => mocks.permissions.has(permission),
+ }),
+}));
+vi.mock("@/hooks/use-repos", () => ({ useRepos: mocks.useRepos }));
+vi.mock("@/components/secrets-editor", () => ({
+ SecretsEditor: ({ scope, owner, name }: { scope: string; owner?: string; name?: string }) => (
+ {`${scope} secrets editor${owner && name ? ` for ${owner}/${name}` : ""}`}
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+ mocks.permissions = new Set();
+ mocks.useRepos.mockReset();
+});
+
+describe("SecretsSettings", () => {
+ it("keeps the existing global default for users with both permissions", () => {
+ mocks.permissions = new Set([
+ "global_secrets.manage",
+ "repositories.secrets.manage",
+ "repositories.read",
+ ]);
+ mocks.useRepos.mockReturnValue({ repos: [], loading: false });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(true);
+ expect(screen.getByText("global secrets editor")).toBeInTheDocument();
+ expect(screen.getByText("All Repositories (Global)")).toBeInTheDocument();
+ });
+
+ it("does not fetch repositories or mount a repo editor for global-only users", () => {
+ mocks.permissions = new Set(["global_secrets.manage"]);
+ mocks.useRepos.mockReturnValue({ repos: [], loading: false });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(false);
+ expect(screen.getByText("global secrets editor")).toBeInTheDocument();
+ expect(screen.queryByText("repo secrets editor")).not.toBeInTheDocument();
+ });
+
+ it("lets authorized repository managers select a repository", async () => {
+ const user = userEvent.setup();
+ mocks.permissions = new Set(["repositories.secrets.manage", "repositories.read"]);
+ mocks.useRepos.mockReturnValue({
+ repos: [
+ {
+ id: 1,
+ fullName: "open-inspect/background-agents",
+ owner: "open-inspect",
+ name: "background-agents",
+ description: null,
+ private: true,
+ defaultBranch: "main",
+ },
+ ],
+ loading: false,
+ });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(true);
+ expect(screen.getByText("repo secrets editor")).toBeInTheDocument();
+ expect(screen.queryByText("global secrets editor")).not.toBeInTheDocument();
+ expect(screen.queryByText("All Repositories (Global)")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Repository Select a repository" }));
+ await user.click(screen.getByRole("option", { name: /background-agents/ }));
+
+ expect(
+ screen.getByText("repo secrets editor for open-inspect/background-agents")
+ ).toBeInTheDocument();
+ });
+
+ it("does not fetch or offer repository scope without repository read access", () => {
+ mocks.permissions = new Set(["repositories.secrets.manage"]);
+ mocks.useRepos.mockReturnValue({ repos: [], loading: false });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(false);
+ expect(screen.queryByText("repo secrets editor")).not.toBeInTheDocument();
+ expect(screen.queryByText("global secrets editor")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/components/settings/secrets-settings.tsx b/packages/web/src/components/settings/secrets-settings.tsx
index b4421b93f..dcf2b4913 100644
--- a/packages/web/src/components/settings/secrets-settings.tsx
+++ b/packages/web/src/components/settings/secrets-settings.tsx
@@ -5,12 +5,20 @@ import { useRepos } from "@/hooks/use-repos";
import { useState } from "react";
import { ChevronDownIcon, CheckIcon } from "@/components/ui/icons";
import { Combobox } from "@/components/ui/combobox";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SCOPE = "__global__";
+/**
+ * Exposes global and repository secret editors only for scopes the user is authorized to manage.
+ */
export function SecretsSettings() {
- const { repos, loading: loadingRepos } = useRepos();
- const [selectedRepo, setSelectedRepo] = useState(GLOBAL_SCOPE);
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("global_secrets.manage");
+ const canManageRepos =
+ hasPermission("repositories.secrets.manage") && hasPermission("repositories.read");
+ const { repos, loading: loadingRepos } = useRepos(canManageRepos);
+ const [selectedRepo, setSelectedRepo] = useState(canManageGlobal ? GLOBAL_SCOPE : "");
const selectedRepoObj = repos.find((r) => r.fullName === selectedRepo);
const isGlobal = selectedRepo === GLOBAL_SCOPE;
@@ -43,7 +51,7 @@ export function SecretsSettings() {
labelId="secrets-repository-label"
value={selectedRepo}
onChange={setSelectedRepo}
- items={repos.map((repo) => ({
+ items={(canManageRepos ? repos : []).map((repo) => ({
value: repo.fullName,
label: repo.name,
description: `${repo.owner}${repo.private ? " \u2022 private" : ""}`,
@@ -57,44 +65,46 @@ export function SecretsSettings() {
}
direction="down"
dropdownWidth="w-full max-w-sm"
- disabled={loadingRepos}
+ disabled={loadingRepos || (!canManageGlobal && !canManageRepos)}
triggerClassName="w-full max-w-sm flex items-center justify-between px-3 py-2 text-sm border border-border bg-input text-foreground hover:border-foreground/30 disabled:opacity-50 disabled:cursor-not-allowed transition"
- prependContent={({ select }) => (
- <>
- select(GLOBAL_SCOPE)}
- className={`w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted transition ${
- isGlobal ? "text-foreground" : "text-muted-foreground"
- }`}
- >
-
- All Repositories (Global)
-
- Shared across all repositories
-
-
- {isGlobal && }
-
- {repos.length > 0 &&
}
- >
- )}
+ prependContent={({ select }) =>
+ canManageGlobal ? (
+ <>
+ select(GLOBAL_SCOPE)}
+ className={`w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted transition ${
+ isGlobal ? "text-foreground" : "text-muted-foreground"
+ }`}
+ >
+
+ All Repositories (Global)
+
+ Shared across all repositories
+
+
+ {isGlobal && }
+
+ {repos.length > 0 &&
}
+ >
+ ) : null
+ }
>
{displayRepoName}
- {isGlobal ? (
-
- ) : (
+ {isGlobal && canManageGlobal ? (
+
+ ) : canManageRepos ? (
- )}
+ ) : null}
);
}
diff --git a/packages/web/src/components/settings/settings-nav.test.tsx b/packages/web/src/components/settings/settings-nav.test.tsx
index 8adec9ddc..9f5c9c6c8 100644
--- a/packages/web/src/components/settings/settings-nav.test.tsx
+++ b/packages/web/src/components/settings/settings-nav.test.tsx
@@ -8,22 +8,32 @@ import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsNav } from "./settings-nav";
import { SettingsViewportProvider } from "./settings-viewport-context";
+import { resolveSettingsCategory } from "./settings-registry";
expect.extend(matchers);
const mocks = vi.hoisted(() => ({
isMobile: false,
repoImagesEnabled: true,
+ allowedPermissions: null as Set | null,
}));
vi.mock("@/lib/sandbox-provider", () => ({
supportsRepoImages: () => mocks.repoImagesEnabled,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
+
afterEach(() => {
cleanup();
mocks.isMobile = false;
mocks.repoImagesEnabled = true;
+ mocks.allowedPermissions = null;
});
function renderSettingsNav(
@@ -39,6 +49,20 @@ function renderSettingsNav(
}
describe("SettingsNav", () => {
+ it("resolves defaults and deep links to an authorized category", () => {
+ const hasNoWorkspacePermissions = () => false;
+
+ expect(resolveSettingsCategory(null, true, hasNoWorkspacePermissions)).toBe("appearance");
+ expect(resolveSettingsCategory("secrets", true, hasNoWorkspacePermissions)).toBe("appearance");
+ expect(
+ resolveSettingsCategory(
+ "environments",
+ true,
+ (permission) => permission === "environments.read"
+ )
+ ).toBe("environments");
+ });
+
it("groups settings and filters labels, descriptions, and keywords", async () => {
const user = userEvent.setup();
renderSettingsNav({ activeCategory: "appearance" });
@@ -86,4 +110,39 @@ describe("SettingsNav", () => {
expect(screen.queryByRole("button", { name: "Images" })).not.toBeInTheDocument();
});
+
+ it("hides settings that require unavailable permissions", () => {
+ mocks.allowedPermissions = new Set();
+ renderSettingsNav({ activeCategory: "appearance" });
+
+ expect(screen.getByRole("button", { name: "Appearance" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Secrets" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Workspace access" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["global secret management", ["global_secrets.manage"]],
+ ["repository read and secret management", ["repositories.read", "repositories.secrets.manage"]],
+ ])("shows secrets with %s", (_description, permissions) => {
+ mocks.allowedPermissions = new Set(permissions);
+ renderSettingsNav({ activeCategory: "secrets" });
+
+ expect(screen.getByRole("button", { name: "Secrets" })).toBeInTheDocument();
+ });
+
+ it("hides secrets from repository secret managers without repository read access", () => {
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage"]);
+ renderSettingsNav({ activeCategory: "appearance" });
+
+ expect(screen.queryByRole("button", { name: "Secrets" })).not.toBeInTheDocument();
+ });
+
+ it("keeps read-level sandbox and environment panels visible", () => {
+ mocks.allowedPermissions = new Set(["environments.read", "integrations.read"]);
+ renderSettingsNav({ activeCategory: "environments" });
+
+ expect(screen.getByRole("button", { name: "Environments" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Sandbox" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
+ });
});
diff --git a/packages/web/src/components/settings/settings-nav.tsx b/packages/web/src/components/settings/settings-nav.tsx
index 38f734dc5..028f469eb 100644
--- a/packages/web/src/components/settings/settings-nav.tsx
+++ b/packages/web/src/components/settings/settings-nav.tsx
@@ -5,6 +5,7 @@ import { useState } from "react";
import { BackIcon, ChevronRightIcon, SearchIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import { useSettingsIsMobile } from "./settings-viewport-context";
import { getSettingsGroups, type SettingsCategory } from "./settings-registry";
@@ -38,10 +39,14 @@ function SettingsSearch({ value, onChange }: { value: string; onChange: (value:
);
}
+/**
+ * Renders searchable settings navigation containing only categories the current user may access.
+ */
export function SettingsNav({ activeCategory, onSelect }: SettingsNavProps) {
const isMobile = useSettingsIsMobile();
const [query, setQuery] = useState("");
- const groups = getSettingsGroups({ query });
+ const { hasPermission } = useCurrentUserAuthorization();
+ const groups = getSettingsGroups({ query, hasPermission });
const navigation = (
diff --git a/packages/web/src/components/settings/settings-registry.test.ts b/packages/web/src/components/settings/settings-registry.test.ts
new file mode 100644
index 000000000..7992439a2
--- /dev/null
+++ b/packages/web/src/components/settings/settings-registry.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import {
+ SETTINGS_GROUPS,
+ canUseSettingsCapability,
+ canViewSettingsCategory,
+ getSettingsPanel,
+ type SettingsCategory,
+} from "./settings-registry";
+
+describe("settings registry", () => {
+ it("registers every category exactly once with a panel and explicit visibility", () => {
+ const categories: string[] = [];
+
+ for (const group of SETTINGS_GROUPS) {
+ for (const item of group.items) {
+ categories.push(item.id);
+ expect(getSettingsPanel(item.id)).toBe(item.panel);
+ expect("public" in item.visibility || "anyOf" in item.visibility).toBe(true);
+ expect("public" in item.visibility && "anyOf" in item.visibility).toBe(false);
+ }
+ }
+ expect(new Set(categories).size).toBe(categories.length);
+ });
+
+ it.each([
+ [["global_secrets.manage"], true],
+ [["repositories.secrets.manage"], false],
+ [["repositories.read"], false],
+ [["repositories.secrets.manage", "repositories.read"], true],
+ [["integrations.read"], false],
+ ] as const)("resolves secrets visibility for %s", (permissions, expected) => {
+ expect(
+ canViewSettingsCategory("secrets", (candidate) => permissions.some((p) => p === candidate))
+ ).toBe(expected);
+ });
+
+ it("keeps public categories independent of permissions", () => {
+ const publicCategories: SettingsCategory[] = ["appearance", "keyboard-shortcuts"];
+
+ for (const category of publicCategories) {
+ expect(canViewSettingsCategory(category, () => false)).toBe(true);
+ }
+ });
+
+ it("requires repository visibility alongside image-build read access", () => {
+ expect(
+ canViewSettingsCategory("images", (permission) => permission === "image_builds.read")
+ ).toBe(false);
+ expect(
+ canViewSettingsCategory("images", (permission) =>
+ ["image_builds.read", "repositories.read"].includes(permission)
+ )
+ ).toBe(true);
+ });
+
+ it("models Data Controls viewing and unarchive capability separately", () => {
+ const readOnly = (permission: string) => permission === "sessions.read";
+ expect(canViewSettingsCategory("data-controls", readOnly)).toBe(true);
+ expect(canUseSettingsCapability("data-controls", "unarchiveSessions", readOnly)).toBe(false);
+ expect(
+ canUseSettingsCapability(
+ "data-controls",
+ "unarchiveSessions",
+ (permission) => permission === "sessions.lifecycle"
+ )
+ ).toBe(true);
+ });
+});
diff --git a/packages/web/src/components/settings/settings-registry.ts b/packages/web/src/components/settings/settings-registry.ts
index 2eb1e31e2..b9b9222f7 100644
--- a/packages/web/src/components/settings/settings-registry.ts
+++ b/packages/web/src/components/settings/settings-registry.ts
@@ -12,8 +12,46 @@ import {
TerminalIcon,
} from "@/components/ui/icons";
import { supportsRepoImages } from "@/lib/sandbox-provider";
+import type { PermissionId } from "@open-inspect/shared/rbac";
import { matchesSearchTerms } from "@/lib/search";
+import { lazy, type ComponentType, type LazyExoticComponent } from "react";
+type SettingsPermissionPredicate = PermissionId | { allOf: readonly PermissionId[] };
+type SettingsVisibility = { public: true } | { anyOf: readonly SettingsPermissionPredicate[] };
+export type SettingsCapability = "unarchiveSessions";
+
+interface SettingsItemDefinition {
+ id: string;
+ label: string;
+ description: string;
+ keywords: string;
+ icon: ComponentType<{ className?: string }>;
+ visibility: SettingsVisibility;
+ panel: LazyExoticComponent
;
+ capabilities?: Partial>;
+ requiresRepoImages?: boolean;
+}
+
+interface SettingsGroupDefinition {
+ label: string;
+ items: readonly SettingsItemDefinition[];
+}
+
+const publicSettings = { public: true } as const;
+
+function allOf(...permissions: PermissionId[]): SettingsPermissionPredicate {
+ return { allOf: permissions };
+}
+
+function anyOf(...predicates: SettingsPermissionPredicate[]): SettingsVisibility {
+ return { anyOf: predicates };
+}
+
+function lazyPanel(load: () => Promise): LazyExoticComponent {
+ return lazy(async () => ({ default: await load() }));
+}
+
+/** Settings categories and the permissions required for users to see them. */
export const SETTINGS_GROUPS = [
{
label: "Personal",
@@ -24,6 +62,10 @@ export const SETTINGS_GROUPS = [
description: "Theme and code highlighting",
keywords: "theme dark light syntax",
icon: AppearanceIcon,
+ visibility: publicSettings,
+ panel: lazyPanel(() =>
+ import("./appearance-settings").then(({ AppearanceSettings }) => AppearanceSettings)
+ ),
},
{
id: "keyboard-shortcuts",
@@ -31,6 +73,12 @@ export const SETTINGS_GROUPS = [
description: "Customize keyboard shortcuts",
keywords: "keys commands hotkeys",
icon: KeyboardIcon,
+ visibility: publicSettings,
+ panel: lazyPanel(() =>
+ import("./keyboard-shortcuts-settings").then(
+ ({ KeyboardShortcutsSettings }) => KeyboardShortcutsSettings
+ )
+ ),
},
],
},
@@ -43,6 +91,10 @@ export const SETTINGS_GROUPS = [
description: "Choose models available to agents",
keywords: "claude openai reasoning",
icon: ModelIcon,
+ visibility: anyOf("models.preferences.manage"),
+ panel: lazyPanel(() =>
+ import("./models-settings").then(({ ModelsSettings }) => ModelsSettings)
+ ),
},
{
id: "provider-accounts",
@@ -50,6 +102,12 @@ export const SETTINGS_GROUPS = [
description: "Connect model provider subscriptions",
keywords: "provider authentication credentials",
icon: KeyIcon,
+ visibility: anyOf("provider_accounts.read"),
+ panel: lazyPanel(() =>
+ import("./provider-accounts-settings").then(
+ ({ ProviderAccountsSettings }) => ProviderAccountsSettings
+ )
+ ),
},
{
id: "skills",
@@ -57,18 +115,37 @@ export const SETTINGS_GROUPS = [
description: "Manage shared skills and profiles",
keywords: "agent instructions profiles",
icon: SparkleIcon,
+ visibility: anyOf("skills.read"),
+ panel: lazyPanel(() =>
+ import("./skills-settings").then(({ SkillsSettings }) => SkillsSettings)
+ ),
},
],
},
{
label: "Workspace",
items: [
+ {
+ id: "workspace",
+ label: "Workspace access",
+ description: "Manage members and roles",
+ keywords: "rbac permissions users access",
+ icon: DataControlsIcon,
+ visibility: anyOf("workspace.members.read", "workspace.roles.read"),
+ panel: lazyPanel(() =>
+ import("./workspace-settings").then(({ WorkspaceSettings }) => WorkspaceSettings)
+ ),
+ },
{
id: "environments",
label: "Environments",
description: "Configure reusable repository setups",
keywords: "repositories branches prebuild",
icon: FolderIcon,
+ visibility: anyOf("environments.read"),
+ panel: lazyPanel(() =>
+ import("./environments-settings").then(({ EnvironmentsSettings }) => EnvironmentsSettings)
+ ),
},
{
id: "secrets",
@@ -76,6 +153,13 @@ export const SETTINGS_GROUPS = [
description: "Manage global and repository secrets",
keywords: "environment variables credentials",
icon: KeyIcon,
+ visibility: anyOf(
+ "global_secrets.manage",
+ allOf("repositories.secrets.manage", "repositories.read")
+ ),
+ panel: lazyPanel(() =>
+ import("./secrets-settings").then(({ SecretsSettings }) => SecretsSettings)
+ ),
},
{
id: "scm",
@@ -83,6 +167,10 @@ export const SETTINGS_GROUPS = [
description: "Configure pull request behavior",
keywords: "scm git pull request merge draft",
icon: GitPrIcon,
+ visibility: anyOf("integrations.read"),
+ panel: lazyPanel(() =>
+ import("./scm-settings").then(({ ScmSettingsPage }) => ScmSettingsPage)
+ ),
},
],
},
@@ -95,6 +183,10 @@ export const SETTINGS_GROUPS = [
description: "Set runtime resources and access",
keywords: "terminal ports cpu memory timeout",
icon: TerminalIcon,
+ visibility: anyOf("integrations.read"),
+ panel: lazyPanel(() =>
+ import("./sandbox-settings").then(({ SandboxSettingsPage }) => SandboxSettingsPage)
+ ),
},
{
id: "images",
@@ -103,6 +195,10 @@ export const SETTINGS_GROUPS = [
keywords: "prebuild containers",
icon: BoxIcon,
requiresRepoImages: true,
+ visibility: anyOf(allOf("image_builds.read", "repositories.read")),
+ panel: lazyPanel(() =>
+ import("./images-settings").then(({ ImagesSettings }) => ImagesSettings)
+ ),
},
{
id: "integrations",
@@ -110,6 +206,10 @@ export const SETTINGS_GROUPS = [
description: "Connect external tools and services",
keywords: "github slack linear vnc code server",
icon: IntegrationsIcon,
+ visibility: anyOf("integrations.read"),
+ panel: lazyPanel(() =>
+ import("./integrations-settings").then(({ IntegrationsSettings }) => IntegrationsSettings)
+ ),
},
{
id: "mcp-servers",
@@ -117,6 +217,10 @@ export const SETTINGS_GROUPS = [
description: "Configure local and remote MCP servers",
keywords: "tools protocol command url",
icon: TerminalIcon,
+ visibility: anyOf("mcp_servers.read"),
+ panel: lazyPanel(() =>
+ import("./mcp-servers-settings").then(({ McpServersSettings }) => McpServersSettings)
+ ),
},
{
id: "data-controls",
@@ -124,43 +228,115 @@ export const SETTINGS_GROUPS = [
description: "Review and restore archived sessions",
keywords: "archive restore retention",
icon: DataControlsIcon,
+ visibility: anyOf("sessions.read"),
+ capabilities: { unarchiveSessions: "sessions.lifecycle" },
+ panel: lazyPanel(() =>
+ import("./data-controls-settings").then(
+ ({ DataControlsSettings }) => DataControlsSettings
+ )
+ ),
},
],
},
-] as const;
+] as const satisfies readonly SettingsGroupDefinition[];
type SettingsItem = (typeof SETTINGS_GROUPS)[number]["items"][number];
+/** Identifier for a registered settings category. */
export type SettingsCategory = SettingsItem["id"];
export const DEFAULT_SETTINGS_CATEGORY: SettingsCategory = "secrets";
export const DEFAULT_SETTINGS_QUERY = "";
+/** Returns whether the user's effective permissions make a settings category visible. */
+export function canViewSettingsCategory(
+ category: SettingsCategory,
+ hasPermission: (permission: PermissionId) => boolean
+): boolean {
+ const visibility = getSettingsItem(category).visibility;
+ return (
+ "public" in visibility ||
+ visibility.anyOf.some((predicate) =>
+ typeof predicate === "string"
+ ? hasPermission(predicate)
+ : predicate.allOf.every(hasPermission)
+ )
+ );
+}
+
+/** Evaluates a named panel capability from the same descriptor used by settings navigation. */
+export function canUseSettingsCapability(
+ category: SettingsCategory,
+ capability: SettingsCapability,
+ hasPermission: (permission: PermissionId) => boolean
+): boolean {
+ const item: SettingsItemDefinition = getSettingsItem(category);
+ if (!("capabilities" in item)) return false;
+ const predicate = item.capabilities?.[capability];
+ if (!predicate) return false;
+ return typeof predicate === "string"
+ ? hasPermission(predicate)
+ : predicate.allOf.every(hasPermission);
+}
+
+/** Selects the requested visible category, or a category the user is allowed to view. */
+export function resolveSettingsCategory(
+ requested: string | null,
+ repoImagesEnabled: boolean,
+ hasPermission: (permission: PermissionId) => boolean
+): SettingsCategory {
+ if (
+ isSettingsCategory(requested, repoImagesEnabled) &&
+ canViewSettingsCategory(requested, hasPermission)
+ ) {
+ return requested;
+ }
+ if (canViewSettingsCategory(DEFAULT_SETTINGS_CATEGORY, hasPermission)) {
+ return DEFAULT_SETTINGS_CATEGORY;
+ }
+ for (const group of SETTINGS_GROUPS) {
+ for (const item of group.items) {
+ if (
+ isSettingsItemAvailable(item, repoImagesEnabled) &&
+ canViewSettingsCategory(item.id, hasPermission)
+ ) {
+ return item.id;
+ }
+ }
+ }
+ return "appearance";
+}
+
function isSettingsItemAvailable(item: SettingsItem, repoImagesEnabled: boolean): boolean {
return !("requiresRepoImages" in item) || repoImagesEnabled;
}
+/** Returns settings groups filtered to categories the user may view and the current search. */
export function getSettingsGroups({
query = DEFAULT_SETTINGS_QUERY,
repoImagesEnabled = supportsRepoImages(),
+ hasPermission,
}: {
query?: string;
repoImagesEnabled?: boolean;
-} = {}) {
+ hasPermission: (permission: PermissionId) => boolean;
+}) {
return SETTINGS_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
if (!isSettingsItemAvailable(item, repoImagesEnabled)) return false;
+ if (!canViewSettingsCategory(item.id, hasPermission)) return false;
return matchesSearchTerms(`${item.label} ${item.description} ${item.keywords}`, query);
}),
})).filter((group) => group.items.length > 0);
}
+/** Returns the user-facing label for a settings category. */
export function getSettingsCategoryLabel(category: SettingsCategory): string {
- for (const group of SETTINGS_GROUPS) {
- for (const item of group.items) {
- if (item.id === category) return item.label;
- }
- }
- return category;
+ return getSettingsItem(category).label;
+}
+
+/** Returns the panel shown for an authorized settings category. */
+export function getSettingsPanel(category: SettingsCategory): LazyExoticComponent {
+ return getSettingsItem(category).panel;
}
export function isSettingsCategory(
@@ -174,3 +350,12 @@ export function isSettingsCategory(
)
);
}
+
+function getSettingsItem(category: SettingsCategory): SettingsItem {
+ for (const group of SETTINGS_GROUPS) {
+ for (const item of group.items) {
+ if (item.id === category) return item;
+ }
+ }
+ throw new Error(`Unknown settings category: ${category}`);
+}
diff --git a/packages/web/src/components/settings/settings-shell.test.tsx b/packages/web/src/components/settings/settings-shell.test.tsx
index efb8cbb8d..76b67b62c 100644
--- a/packages/web/src/components/settings/settings-shell.test.tsx
+++ b/packages/web/src/components/settings/settings-shell.test.tsx
@@ -6,10 +6,17 @@ import * as matchers from "@testing-library/jest-dom/matchers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS_CATEGORY } from "./settings-nav";
import { SettingsShell } from "./settings-shell";
+import { PERMISSION_IDS } from "@open-inspect/shared/rbac";
expect.extend(matchers);
-const mocks = vi.hoisted(() => ({ isMobile: false, pathname: "", tab: "" }));
+const mocks = vi.hoisted(() => ({
+ isMobile: false,
+ pathname: "",
+ tab: "",
+ permissions: [] as string[],
+ replace: vi.fn(),
+}));
const SHELL_FIXTURE_DEFAULTS = {
isMobile: false,
pathname: "/settings",
@@ -18,15 +25,24 @@ const SHELL_FIXTURE_DEFAULTS = {
vi.mock("next/navigation", () => ({
usePathname: () => mocks.pathname,
- useRouter: () => ({ push: vi.fn() }),
+ useRouter: () => ({ push: vi.fn(), replace: mocks.replace }),
useSearchParams: () => new URLSearchParams(`tab=${mocks.tab}`),
}));
vi.mock("@/hooks/use-media-query", () => ({ useIsMobile: () => mocks.isMobile }));
vi.mock("@/lib/sandbox-provider", () => ({ supportsRepoImages: () => true }));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ authorization: { permissions: mocks.permissions },
+ loading: false,
+ hasPermission: (permission: string) => mocks.permissions.includes(permission),
+ }),
+}));
beforeEach(() => {
Object.assign(mocks, SHELL_FIXTURE_DEFAULTS);
+ mocks.permissions = [...PERMISSION_IDS];
+ mocks.replace.mockClear();
});
afterEach(() => {
@@ -57,4 +73,25 @@ describe("SettingsShell", () => {
expect(screen.queryByRole("navigation", { name: "Settings" })).not.toBeInTheDocument();
expect(screen.getByText("Mobile settings")).toBeInTheDocument();
});
+
+ it("redirects an unauthorized nested settings route", () => {
+ mocks.pathname = "/settings/integrations/github";
+ mocks.permissions = [];
+
+ render(Integration settings );
+
+ expect(mocks.replace).toHaveBeenCalledWith("/settings?tab=appearance");
+ expect(screen.queryByText("Integration settings")).not.toBeInTheDocument();
+ });
+
+ it("canonicalizes an unauthorized settings query to the rendered fallback", () => {
+ mocks.pathname = "/settings";
+ mocks.tab = "secrets";
+ mocks.permissions = [];
+
+ render(Secret settings );
+
+ expect(mocks.replace).toHaveBeenCalledWith("/settings?tab=appearance");
+ expect(screen.queryByText("Secret settings")).not.toBeInTheDocument();
+ });
});
diff --git a/packages/web/src/components/settings/settings-shell.tsx b/packages/web/src/components/settings/settings-shell.tsx
index d00654d75..8c0baee08 100644
--- a/packages/web/src/components/settings/settings-shell.tsx
+++ b/packages/web/src/components/settings/settings-shell.tsx
@@ -5,12 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useIsMobile } from "@/hooks/use-media-query";
import { supportsRepoImages } from "@/lib/sandbox-provider";
import { SettingsViewportProvider } from "@/components/settings/settings-viewport-context";
-import {
- DEFAULT_SETTINGS_CATEGORY,
- isSettingsCategory,
- SettingsNav,
-} from "@/components/settings/settings-nav";
+import { SettingsNav } from "@/components/settings/settings-nav";
+import { resolveSettingsCategory } from "@/components/settings/settings-registry";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+/**
+ * Hosts responsive settings content and redirects routes whose category is unavailable to the current user.
+ */
export function SettingsShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -18,15 +19,24 @@ export function SettingsShell({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
const [isHydrated, setIsHydrated] = useState(false);
const tab = searchParams.get("tab");
- const activeCategory = pathname.startsWith("/settings/integrations/")
- ? "integrations"
- : isSettingsCategory(tab, supportsRepoImages())
- ? tab
- : DEFAULT_SETTINGS_CATEGORY;
+ const { hasPermission, loading } = useCurrentUserAuthorization();
+ const requestedCategory = pathname.startsWith("/settings/integrations/") ? "integrations" : tab;
+ const activeCategory = resolveSettingsCategory(
+ requestedCategory,
+ supportsRepoImages(),
+ hasPermission
+ );
+ const categoryRedirectRequired =
+ requestedCategory !== null && activeCategory !== requestedCategory;
useEffect(() => setIsHydrated(true), []);
+ useEffect(() => {
+ if (isHydrated && !loading && categoryRedirectRequired) {
+ router.replace(`/settings?tab=${activeCategory}`);
+ }
+ }, [activeCategory, categoryRedirectRequired, isHydrated, loading, router]);
- if (!isHydrated) {
+ if (!isHydrated || loading || categoryRedirectRequired) {
return ;
}
diff --git a/packages/web/src/components/settings/skills-settings/index.test.tsx b/packages/web/src/components/settings/skills-settings/index.test.tsx
new file mode 100644
index 000000000..f678f9406
--- /dev/null
+++ b/packages/web/src/components/settings/skills-settings/index.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+///
+
+import { afterEach, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import { SkillsSettings } from "./index";
+
+expect.extend(matchers);
+
+const permissions = vi.hoisted(() => new Set());
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => permissions.has(permission),
+ }),
+}));
+vi.mock("./skills-catalog", () => ({
+ SkillsCatalog: ({ canManage }: { canManage: boolean }) => (
+ Shared skills are {canManage ? "manageable" : "read-only"}
+ ),
+}));
+vi.mock("./profiles", () => ({
+ Profiles: ({ canManage }: { canManage: boolean }) => (
+ Personal profiles are {canManage ? "manageable" : "read-only"}
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+ permissions.clear();
+});
+
+it("keeps Viewer skills read-only and hides personal profiles", () => {
+ permissions.add("skills.read");
+
+ render( );
+
+ expect(screen.getByText("Shared skills are read-only")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "My profiles" })).not.toBeInTheDocument();
+});
+
+it("keeps both mutation surfaces available to a managing role", async () => {
+ permissions.add("skills.manage");
+ permissions.add("skill_profiles.manage_own");
+ const user = userEvent.setup();
+
+ const { rerender } = render( );
+
+ expect(screen.getByText("Shared skills are manageable")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "My profiles" }));
+ expect(screen.getByText("Personal profiles are manageable")).toBeInTheDocument();
+
+ permissions.delete("skill_profiles.manage_own");
+ rerender( );
+ expect(screen.queryByText("Personal profiles are manageable")).not.toBeInTheDocument();
+ expect(screen.getByText("Shared skills are manageable")).toBeInTheDocument();
+});
diff --git a/packages/web/src/components/settings/skills-settings/index.tsx b/packages/web/src/components/settings/skills-settings/index.tsx
index 07bd48519..df71c7bd4 100644
--- a/packages/web/src/components/settings/skills-settings/index.tsx
+++ b/packages/web/src/components/settings/skills-settings/index.tsx
@@ -3,11 +3,17 @@
import { useState } from "react";
import { Profiles } from "./profiles";
import { SkillsCatalog } from "./skills-catalog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type View = "skills" | "profiles";
+/**
+ * Switches between shared skills and personal profiles, granting each view its applicable management capability.
+ */
export function SkillsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
const [view, setView] = useState("skills");
+ const canManageProfiles = hasPermission("skill_profiles.manage_own");
return (
@@ -16,19 +22,25 @@ export function SkillsSettings() {
Control the managed capabilities installed into new sessions.
-
- {(["skills", "profiles"] as const).map((item) => (
- setView(item)}
- className={`border-b-2 px-4 py-2 text-sm transition ${view === item ? "border-accent text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}
- >
- {item === "skills" ? "Shared skills" : "My profiles"}
-
- ))}
-
- {view === "skills" ?
:
}
+ {canManageProfiles && (
+
+ {(["skills", "profiles"] as const).map((item) => (
+ setView(item)}
+ className={`border-b-2 px-4 py-2 text-sm transition ${view === item ? "border-accent text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}
+ >
+ {item === "skills" ? "Shared skills" : "My profiles"}
+
+ ))}
+
+ )}
+ {view === "profiles" && canManageProfiles ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/packages/web/src/components/settings/skills-settings/profiles.test.tsx b/packages/web/src/components/settings/skills-settings/profiles.test.tsx
index 851d708db..ef994be42 100644
--- a/packages/web/src/components/settings/skills-settings/profiles.test.tsx
+++ b/packages/web/src/components/settings/skills-settings/profiles.test.tsx
@@ -79,7 +79,7 @@ describe("Profiles", () => {
});
useSkillsMock.mockReturnValue({ skills: [], loading, error, mutate: vi.fn() });
- render( );
+ render( );
expect(screen.getByRole("button", { name: /Frontend work/ })).toHaveTextContent(message);
expect(screen.queryByText(/Unavailable/)).not.toBeInTheDocument();
diff --git a/packages/web/src/components/settings/skills-settings/profiles.tsx b/packages/web/src/components/settings/skills-settings/profiles.tsx
index 7dbf41705..1221c5631 100644
--- a/packages/web/src/components/settings/skills-settings/profiles.tsx
+++ b/packages/web/src/components/settings/skills-settings/profiles.tsx
@@ -136,7 +136,10 @@ export function ProfileForm({
);
}
-export function Profiles() {
+/**
+ * Lists the user's skill profiles and exposes profile mutations only when `canManage` is true.
+ */
+export function Profiles({ canManage }: { canManage: boolean }) {
const { profiles, loading, error, mutate } = useSkillProfiles();
const { skills, loading: skillsLoading, error: skillsError } = useSkills();
const [editing, setEditing] = useState(null);
@@ -172,9 +175,11 @@ export function Profiles() {
Save personal skill sets for session creation.
- setEditing("new")}>
- New profile
-
+ {canManage && (
+ setEditing("new")}>
+ New profile
+
+ )}
{error ? (
Failed to load skill profiles.
@@ -190,7 +195,8 @@ export function Profiles() {
setEditing(profile)}
+ onClick={() => canManage && setEditing(profile)}
+ disabled={!canManage}
className="min-w-0 flex-1 text-left"
>
{profile.name}
@@ -207,9 +213,11 @@ export function Profiles() {
.join(", ") || "None"}
-
remove(profile)}>
- Delete
-
+ {canManage && (
+
remove(profile)}>
+ Delete
+
+ )}
))}
diff --git a/packages/web/src/components/settings/skills-settings/skill-details.tsx b/packages/web/src/components/settings/skills-settings/skill-details.tsx
new file mode 100644
index 000000000..b5882df9c
--- /dev/null
+++ b/packages/web/src/components/settings/skills-settings/skill-details.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import type { Skill, SkillAssignment } from "@open-inspect/shared/types/skills";
+import { Button } from "@/components/ui/button";
+
+function assignmentLabel(assignment: SkillAssignment): string {
+ if (assignment.type === "global") return "All sessions";
+ if (assignment.type === "repository") {
+ return `Repository: ${assignment.repoOwner}/${assignment.repoName}`;
+ }
+ return `Environment: ${assignment.environmentName ?? assignment.environmentId}`;
+}
+
+/** Read-only detail surface for users who may inspect, but not manage, shared skills. */
+export function SkillDetails({ skill, onClose }: { skill: Skill; onClose: () => void }) {
+ const supplementalFiles = skill.files.filter(({ path }) => path !== "SKILL.md");
+ const metadataEntries = Object.entries(skill.metadata);
+
+ return (
+
+
+
+
{skill.name}
+
{skill.description}
+
+
+ Close
+
+
+
+
+ Instructions
+
+ {skill.body}
+
+
+
+
+
+
License
+
{skill.license ?? "Not specified"}
+
+
+
Compatibility
+
{skill.compatibility ?? "Not specified"}
+
+
+
+
+ Assignments
+ {skill.assignments.length > 0 ? (
+
+ {skill.assignments.map((assignment) => (
+ {assignmentLabel(assignment)}
+ ))}
+
+ ) : (
+ No assignments
+ )}
+
+
+
+ Files
+ {supplementalFiles.length > 0 ? (
+
+ {supplementalFiles.map((file) => (
+
+
+ {file.path}
+ {file.executable ? " (executable)" : ""}
+
+
+ {file.content}
+
+
+ ))}
+
+ ) : (
+ No supplemental files
+ )}
+
+
+ {metadataEntries.length > 0 && (
+
+ Metadata
+
+ {metadataEntries.map(([key, value]) => (
+
+
{key}
+ {value}
+
+ ))}
+
+
+ )}
+
+ {skill.source && (
+
+ Import source
+
+ {skill.source.repoOwner}/{skill.source.repoName} at {skill.source.commitSha}
+ {skill.source.subdirectory ? ` / ${skill.source.subdirectory}` : ""}
+
+
+ )}
+
+
+ Revision {skill.revisionNumber} by{" "}
+ {skill.revisionAuthorDisplayName ?? skill.revisionCreatedBy}
+ {" · "}SHA-256 {skill.revisionSha256}
+
+
+ );
+}
diff --git a/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx b/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
index 4bc44b43c..1a037298a 100644
--- a/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
+++ b/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
@@ -10,15 +10,16 @@ import { SkillsCatalog } from "./skills-catalog";
expect.extend(matchers);
-const { useSkillCatalogPageMock } = vi.hoisted(() => ({
+const { useSkillCatalogPageMock, useSkillMock } = vi.hoisted(() => ({
useSkillCatalogPageMock: vi.fn(),
+ useSkillMock: vi.fn(),
}));
vi.mock("@/hooks/use-managed-skills", () => ({
deleteSkill: vi.fn(),
revalidateSkillCatalogPage: vi.fn(),
setSkillEnabled: vi.fn(),
- useSkill: () => ({ skill: undefined, loading: false, error: undefined, mutate: vi.fn() }),
+ useSkill: useSkillMock,
useSkillCatalogPage: useSkillCatalogPageMock,
}));
@@ -63,6 +64,12 @@ beforeEach(() => {
error: undefined,
}
);
+ useSkillMock.mockReturnValue({
+ skill: undefined,
+ loading: false,
+ error: undefined,
+ mutate: vi.fn(),
+ });
});
afterEach(cleanup);
@@ -70,7 +77,7 @@ afterEach(cleanup);
describe("SkillsCatalog", () => {
it("loads catalog pages on demand and navigates back with cursor history", async () => {
const user = userEvent.setup();
- render( );
+ render( );
expect(screen.getByText("first-skill")).toBeInTheDocument();
expect(screen.getByText("Page 1")).toBeInTheDocument();
@@ -105,12 +112,57 @@ describe("SkillsCatalog", () => {
error: undefined,
});
- render( );
+ render( );
expect(screen.getByText("· Created by User One")).toBeInTheDocument();
expect(screen.getByText("· Created by user-2")).toBeInTheDocument();
});
+ it("opens a complete read-only detail surface for users without manage permission", async () => {
+ const summary = skill("1", "first-skill");
+ useSkillMock.mockReturnValue({
+ skill: {
+ ...summary,
+ body: "## Workflow\nRun the checks.",
+ license: "MIT",
+ compatibility: "Open Inspect",
+ metadata: { owner: "platform" },
+ assignments: [
+ { id: "assignment-1", type: "repository", repoOwner: "acme", repoName: "api" },
+ ],
+ files: [
+ {
+ path: "SKILL.md",
+ content: "generated",
+ sha256: "b".repeat(64),
+ executable: false,
+ sizeBytes: 9,
+ },
+ {
+ path: "scripts/check.sh",
+ content: "npm test",
+ sha256: "c".repeat(64),
+ executable: true,
+ sizeBytes: 8,
+ },
+ ],
+ },
+ loading: false,
+ error: undefined,
+ mutate: vi.fn(),
+ });
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: /first-skill/i }));
+
+ expect(screen.getByText("Run the checks.", { exact: false })).toBeInTheDocument();
+ expect(screen.getByText("Repository: acme/api")).toBeInTheDocument();
+ expect(screen.getByText("scripts/check.sh (executable)")).toBeInTheDocument();
+ expect(screen.getByText(/Revision 1 by User One/)).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Save new revision/i })).not.toBeInTheDocument();
+ });
+
it.each([
[
"empty",
@@ -147,7 +199,7 @@ describe("SkillsCatalog", () => {
}
);
const user = userEvent.setup();
- render( );
+ render( );
await user.click(screen.getByRole("button", { name: "Next" }));
diff --git a/packages/web/src/components/settings/skills-settings/skills-catalog.tsx b/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
index 714cc9882..483c6aead 100644
--- a/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
+++ b/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
@@ -14,9 +14,13 @@ import { Switch } from "@/components/ui/switch";
import { PlusIcon, SparkleIcon } from "@/components/ui/icons";
import { SkillEditor } from "./skill-editor";
import { SkillImport } from "./skill-import";
+import { SkillDetails } from "./skill-details";
import { errorMessage } from "./utils";
-export function SkillsCatalog() {
+/**
+ * Displays the shared skill catalog and exposes catalog mutations only when `canManage` is true.
+ */
+export function SkillsCatalog({ canManage }: { canManage: boolean }) {
const [cursorHistory, setCursorHistory] = useState([]);
const cursor = cursorHistory.at(-1) ?? null;
const { skills, hasMore, nextCursor, loading, error } = useSkillCatalogPage(cursor);
@@ -91,7 +95,7 @@ export function SkillsCatalog() {
return Failed to load this managed skill.
;
if (loadingSkill || !skill)
return Loading skill...
;
- return (
+ return canManage ? (
+ ) : (
+ setSelectedId(null)} />
);
}
@@ -113,14 +119,16 @@ export function SkillsCatalog() {
Manage reusable instructions assigned to repositories and environments.
-
-
setImporting(true)}>
- Import from repository
-
-
setCreating(true)}>
- New skill
-
-
+ {canManage && (
+
+
setImporting(true)}>
+ Import from repository
+
+
setCreating(true)}>
+ New skill
+
+
+ )}
{error ? (
Failed to load managed skills.
@@ -171,14 +179,18 @@ export function SkillsCatalog() {
· Created by {item.creatorDisplayName || item.createdBy}
- toggleEnabled(item.id, value)}
- aria-label={`${item.enabled ? "Disable" : "Enable"} ${item.name}`}
- />
- remove(item.id, item.name)}>
- Delete
-
+ {canManage && (
+ toggleEnabled(item.id, value)}
+ aria-label={`${item.enabled ? "Disable" : "Enable"} ${item.name}`}
+ />
+ )}
+ {canManage && (
+ remove(item.id, item.name)}>
+ Delete
+
+ )}
))}
diff --git a/packages/web/src/components/settings/workspace-settings.test.tsx b/packages/web/src/components/settings/workspace-settings.test.tsx
new file mode 100644
index 000000000..06b77e010
--- /dev/null
+++ b/packages/web/src/components/settings/workspace-settings.test.tsx
@@ -0,0 +1,185 @@
+// @vitest-environment jsdom
+///
+
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { useWorkspaceAdministration } from "@/hooks/use-workspace-administration";
+import { WorkspaceSettings } from "./workspace-settings";
+
+expect.extend(matchers);
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: vi.fn(),
+}));
+vi.mock("@/hooks/use-workspace-administration", () => ({
+ useWorkspaceAdministration: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("WorkspaceSettings", () => {
+ it("shows assigned role names to members-only readers", () => {
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) => permission === "workspace.members.read",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [
+ {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ suspendedAt: null,
+ role: { id: "role_release", key: null, name: "Release Managers" },
+ },
+ ],
+ roles: [],
+ loading: false,
+ error: undefined,
+ updateMember: vi.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByText("Release Managers")).toBeInTheDocument();
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ });
+
+ it("does not offer destructive controls for the sole unsuspended Owner", () => {
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) =>
+ permission === "workspace.members.read" ||
+ permission === "workspace.roles.read" ||
+ permission === "workspace.members.manage" ||
+ permission === "workspace.transfer_ownership",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [
+ {
+ userId: "11111111111111111111111111111111",
+ displayName: "Owner",
+ email: "owner@example.com",
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner", name: "Owner" },
+ },
+ ],
+ roles: [
+ {
+ id: "role_builtin_owner",
+ key: "owner",
+ name: "Owner",
+ description: null,
+ permissions: [],
+ assignmentCount: 1,
+ },
+ ],
+ loading: false,
+ error: undefined,
+ updateMember: vi.fn(),
+ });
+
+ render( );
+
+ expect(screen.getAllByText("Owner").length).toBeGreaterThan(0);
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Suspend" })).toBeDisabled();
+ });
+
+ it("restores a suspended member through the boolean status contract", async () => {
+ const updateMember = vi.fn().mockResolvedValue(undefined);
+ const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ suspendedAt: 100,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ };
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) =>
+ permission === "workspace.members.read" || permission === "workspace.members.manage",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [member],
+ roles: [],
+ loading: false,
+ error: undefined,
+ updateMember,
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Restore" }));
+
+ await waitFor(() =>
+ expect(updateMember).toHaveBeenCalledWith(member, { kind: "status", suspended: false })
+ );
+ });
+
+ it("disables a member's role and status controls while their update is pending", async () => {
+ let finishUpdate!: () => void;
+ const updateMember = vi.fn(() => new Promise((resolve) => (finishUpdate = resolve)));
+ const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ };
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) =>
+ permission === "workspace.members.read" ||
+ permission === "workspace.roles.read" ||
+ permission === "workspace.members.manage",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [member],
+ roles: [
+ {
+ id: "role_builtin_member",
+ key: "member",
+ name: "Member",
+ description: null,
+ permissions: [],
+ assignmentCount: 1,
+ },
+ {
+ id: "role_builtin_administrator",
+ key: "administrator",
+ name: "Administrator",
+ description: null,
+ permissions: [],
+ assignmentCount: 0,
+ },
+ ],
+ loading: false,
+ error: undefined,
+ updateMember,
+ });
+
+ render( );
+ fireEvent.change(screen.getByRole("combobox"), {
+ target: { value: "role_builtin_administrator" },
+ });
+
+ await waitFor(() => expect(screen.getByRole("combobox")).toBeDisabled());
+ expect(screen.getByRole("button", { name: "Suspend" })).toBeDisabled();
+
+ finishUpdate();
+ await waitFor(() => expect(screen.getByRole("combobox")).toBeEnabled());
+ });
+});
diff --git a/packages/web/src/components/settings/workspace-settings.tsx b/packages/web/src/components/settings/workspace-settings.tsx
new file mode 100644
index 000000000..e5a012fbc
--- /dev/null
+++ b/packages/web/src/components/settings/workspace-settings.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useState } from "react";
+import { ErrorBanner } from "@/components/ui/error-banner";
+import { Button } from "@/components/ui/button";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { useWorkspaceAdministration } from "@/hooks/use-workspace-administration";
+
+/**
+ * Shows workspace members and roles, exposing member controls only when the user may manage them.
+ */
+export function WorkspaceSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canReadMembers = hasPermission("workspace.members.read");
+ const canReadRoles = hasPermission("workspace.roles.read");
+ const canManage = hasPermission("workspace.members.manage");
+ const canAssignRoles = canManage && canReadRoles;
+ const canTransfer = hasPermission("workspace.transfer_ownership");
+ const { members, roles, loading, error, updateMember } = useWorkspaceAdministration({
+ readMembers: canReadMembers,
+ readRoles: canReadRoles,
+ });
+ const [mutationError, setMutationError] = useState(null);
+ const [pendingMemberIds, setPendingMemberIds] = useState>(() => new Set());
+ const unsuspendedOwnerCount = members.filter(
+ (member) => member.role.key === "owner" && member.suspendedAt === null
+ ).length;
+
+ if (loading) return Loading workspace access...
;
+ if (error) return Failed to load workspace access. ;
+
+ async function mutate(memberId: string, action: () => Promise) {
+ setMutationError(null);
+ setPendingMemberIds((current) => new Set(current).add(memberId));
+ try {
+ await action();
+ } catch (cause) {
+ setMutationError(cause instanceof Error ? cause.message : "Workspace update failed");
+ } finally {
+ setPendingMemberIds((current) => {
+ const next = new Set(current);
+ next.delete(memberId);
+ return next;
+ });
+ }
+ }
+
+ return (
+
+
+
Workspace access
+
+ Assign one role to each canonical user. Backend authorization remains authoritative.
+
+
+
+ {mutationError &&
{mutationError} }
+
+ {canReadMembers && (
+
+
+ Members
+
+
+ {members.map((member) => (
+
+
+
+ {member.displayName ?? member.email ?? member.userId}
+
+
+ {member.email ?? member.userId}
+
+
+ {canAssignRoles &&
+ (member.role.key !== "owner" ||
+ (canTransfer &&
+ !(member.suspendedAt === null && unsuspendedOwnerCount === 1))) ? (
+
+ void mutate(member.userId, () =>
+ updateMember(member, { kind: "role", roleId: event.target.value })
+ )
+ }
+ className="rounded border border-border bg-background px-2 py-1.5 text-sm"
+ >
+ {roles
+ .filter(
+ (role) => role.key !== "owner" || canTransfer || role.id === member.role.id
+ )
+ .map((role) => (
+
+ {role.name}
+
+ ))}
+
+ ) : (
+
{member.role.name}
+ )}
+
+ void mutate(member.userId, () =>
+ updateMember(member, {
+ kind: "status",
+ suspended: member.suspendedAt === null,
+ })
+ )
+ }
+ >
+ {member.suspendedAt === null ? "Suspend" : "Restore"}
+
+
+ ))}
+
+
+ )}
+
+ {canReadRoles && (
+
+
+ Roles
+
+
+ {roles.map((role) => (
+
+
+
{role.name}
+
+ {role.assignmentCount} assigned
+
+
+
+ {role.description ?? `${role.permissions.length} permissions`}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/hooks/use-current-user-authorization.test.tsx b/packages/web/src/hooks/use-current-user-authorization.test.tsx
new file mode 100644
index 000000000..a8c087543
--- /dev/null
+++ b/packages/web/src/hooks/use-current-user-authorization.test.tsx
@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+
+import { renderHook, waitFor } from "@testing-library/react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "./use-current-user-authorization";
+
+vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() }));
+vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+
+const authorizations = {
+ owner: {
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner" as const, name: "Owner" },
+ permissions: ["workspace.transfer_ownership" as const],
+ },
+ member: {
+ userId: "22222222222222222222222222222222",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ permissions: ["repositories.read" as const],
+ },
+};
+
+describe("useCurrentUserAuthorization", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("does not reuse cached authorization after the authenticated user changes", async () => {
+ let currentUser: keyof typeof authorizations = "owner";
+ vi.mocked(useAuthSession).mockImplementation(
+ () =>
+ ({
+ status: "authenticated",
+ data: { user: { id: authorizations[currentUser].userId } },
+ }) as ReturnType
+ );
+ vi.mocked(browserApiFetch).mockImplementation(async () =>
+ Response.json(authorizations[currentUser])
+ );
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ new Map(), dedupingInterval: 0 }}>{children}
+ );
+ const { result, rerender } = renderHook(useCurrentUserAuthorization, { wrapper });
+ await waitFor(() => expect(result.current.authorization?.role.key).toBe("owner"));
+ const ownerHasPermission = result.current.hasPermission;
+ rerender();
+ expect(result.current.hasPermission).toBe(ownerHasPermission);
+
+ currentUser = "member";
+ rerender();
+
+ await waitFor(() => expect(result.current.authorization?.role.key).toBe("member"));
+ expect(result.current.hasPermission).not.toBe(ownerHasPermission);
+ expect(result.current.hasPermission("workspace.transfer_ownership")).toBe(false);
+ });
+});
diff --git a/packages/web/src/hooks/use-current-user-authorization.ts b/packages/web/src/hooks/use-current-user-authorization.ts
new file mode 100644
index 000000000..8eb4204fe
--- /dev/null
+++ b/packages/web/src/hooks/use-current-user-authorization.ts
@@ -0,0 +1,53 @@
+"use client";
+
+import useSWR from "swr";
+import {
+ effectiveAuthorizationSchema,
+ type EffectiveAuthorization,
+ type PermissionId,
+} from "@open-inspect/shared/rbac";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCallback } from "react";
+
+/** Endpoint key for the signed-in user's effective workspace authorization. */
+export const CURRENT_USER_AUTHORIZATION_KEY = "/api/me/authorization" as const;
+
+/** Returns the user-scoped cache key for effective workspace authorization. */
+export function currentUserAuthorizationKey(userId: string) {
+ return [CURRENT_USER_AUTHORIZATION_KEY, userId] as const;
+}
+
+async function fetchAuthorization(): Promise {
+ const response = await browserApiFetch(CURRENT_USER_AUTHORIZATION_KEY);
+ if (!response.ok) throw new Error(`Authorization request failed (${response.status})`);
+ return effectiveAuthorizationSchema.parse(await response.json());
+}
+
+/**
+ * Provides the signed-in user's effective permissions, denying permission checks until they load.
+ */
+export function useCurrentUserAuthorization(): {
+ authorization: EffectiveAuthorization | null;
+ loading: boolean;
+ error: unknown;
+ hasPermission: (permission: PermissionId) => boolean;
+} {
+ const { data: session, status } = useAuthSession();
+ const userId = session?.user?.id;
+ const { data, isLoading, error } = useSWR(
+ status === "authenticated" && userId ? currentUserAuthorizationKey(userId) : null,
+ fetchAuthorization
+ );
+ const hasPermission = useCallback(
+ (permission: PermissionId) => data?.permissions.includes(permission) ?? false,
+ [data?.permissions]
+ );
+
+ return {
+ authorization: data ?? null,
+ loading: status === "authenticated" && isLoading,
+ error,
+ hasPermission,
+ };
+}
diff --git a/packages/web/src/hooks/use-provider-accounts.test.tsx b/packages/web/src/hooks/use-provider-accounts.test.tsx
index 66e9b2c17..e31b3dbba 100644
--- a/packages/web/src/hooks/use-provider-accounts.test.tsx
+++ b/packages/web/src/hooks/use-provider-accounts.test.tsx
@@ -18,12 +18,21 @@ import {
useProviderAccounts,
} from "./use-provider-accounts";
-vi.mock("@/lib/auth-session", () => ({
- useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }),
+const permissions = vi.hoisted(() => new Set());
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => permissions.has(permission),
+ }),
}));
vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+beforeEach(() => {
+ permissions.clear();
+ permissions.add("provider_accounts.read");
+});
+
function wrapper({ children }: { children: ReactNode }) {
return (
new Map(), dedupingInterval: 0 }}>{children}
@@ -91,6 +100,34 @@ describe("useLegacyProviderCredentials", () => {
describe("useProviderAccounts", () => {
beforeEach(() => vi.clearAllMocks());
+ it("does not request provider resources without read permission", () => {
+ permissions.clear();
+
+ const { result } = renderHook(
+ () => ({ accounts: useProviderAccounts(), legacy: useLegacyProviderCredentials() }),
+ { wrapper }
+ );
+
+ expect(browserApiFetch).not.toHaveBeenCalled();
+ expect(result.current.accounts).toMatchObject({ accounts: [], defaults: [], loading: false });
+ expect(result.current.legacy).toMatchObject({ legacyKeys: [], loading: false });
+ });
+
+ it("clears provider resources when read permission is revoked", async () => {
+ vi.mocked(browserApiFetch)
+ .mockResolvedValueOnce(Response.json({ accounts: [account] }))
+ .mockResolvedValueOnce(Response.json({ defaults: [] }));
+
+ const { result, rerender } = renderHook(() => useProviderAccounts(), { wrapper });
+ await waitFor(() => expect(result.current.accounts).toEqual([account]));
+
+ permissions.clear();
+ rerender();
+
+ expect(result.current).toMatchObject({ accounts: [], defaults: [], loading: false });
+ expect(browserApiFetch).toHaveBeenCalledTimes(2);
+ });
+
it("uses the shared static provider catalog without fetching it", async () => {
vi.mocked(browserApiFetch)
.mockResolvedValueOnce(Response.json({ accounts: [] }))
diff --git a/packages/web/src/hooks/use-provider-accounts.ts b/packages/web/src/hooks/use-provider-accounts.ts
index 5c2a2348c..b281221f9 100644
--- a/packages/web/src/hooks/use-provider-accounts.ts
+++ b/packages/web/src/hooks/use-provider-accounts.ts
@@ -1,6 +1,6 @@
import useSWR from "swr";
import { z, type ZodType } from "zod";
-import { useAuthSession } from "@/lib/auth-session";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch";
import {
modelProviderAccountDefaultsResponseSchema,
@@ -89,11 +89,12 @@ async function requestProviderResourceWithoutContent(
}
export function useProviderAccounts() {
- const { data: session } = useAuthSession();
- const accounts = useSWR(session ? ACCOUNTS_KEY : null, async (path) => {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canRead = hasPermission("provider_accounts.read");
+ const accounts = useSWR(canRead ? ACCOUNTS_KEY : null, async (path) => {
return (await requestProviderResource(path, modelProviderAccountsResponseSchema)).accounts;
});
- const defaults = useSWR(session ? DEFAULTS_KEY : null, async (path) => {
+ const defaults = useSWR(canRead ? DEFAULTS_KEY : null, async (path) => {
return (await requestProviderResource(path, modelProviderAccountDefaultsResponseSchema))
.defaults;
});
@@ -112,9 +113,10 @@ export function useProviderAccounts() {
}
export function useLegacyProviderCredentials() {
- const { data: session } = useAuthSession();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canRead = hasPermission("provider_accounts.read");
const result = useSWR(
- session ? LEGACY_CREDENTIALS_KEY : null,
+ canRead ? LEGACY_CREDENTIALS_KEY : null,
async (path: BrowserApiPath) => {
return requestProviderResource(path, legacyProviderCredentialsResponseSchema);
}
diff --git a/packages/web/src/hooks/use-repos.test.tsx b/packages/web/src/hooks/use-repos.test.tsx
new file mode 100644
index 000000000..2aaeefa80
--- /dev/null
+++ b/packages/web/src/hooks/use-repos.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useRepos } from "./use-repos";
+
+const mocks = vi.hoisted(() => ({ useSWR: vi.fn() }));
+
+vi.mock("swr", () => ({ default: mocks.useSWR }));
+vi.mock("@/lib/auth-session", () => ({
+ useAuthSession: () => ({ data: { user: {} }, status: "authenticated" }),
+}));
+
+describe("useRepos", () => {
+ beforeEach(() => {
+ mocks.useSWR.mockReset();
+ mocks.useSWR.mockReturnValue({ data: undefined, isLoading: false, error: undefined });
+ });
+
+ it("does not request repositories when the caller is unauthorized", () => {
+ renderHook(() => useRepos(false));
+
+ expect(mocks.useSWR).toHaveBeenCalledWith(null);
+ });
+
+ it("requests repositories when enabled", () => {
+ renderHook(() => useRepos());
+
+ expect(mocks.useSWR).toHaveBeenCalledWith("/api/repos");
+ });
+});
diff --git a/packages/web/src/hooks/use-repos.ts b/packages/web/src/hooks/use-repos.ts
index f4f3799f6..56f3a3a7b 100644
--- a/packages/web/src/hooks/use-repos.ts
+++ b/packages/web/src/hooks/use-repos.ts
@@ -15,16 +15,21 @@ interface ReposResponse {
repos: Repo[];
}
-export function useRepos() {
+/**
+ * Loads repositories for an authenticated user when enabled, allowing callers to suppress unauthorized requests.
+ */
+export function useRepos(enabled = true) {
const { data: session, status } = useAuthSession();
- const { data, isLoading, error } = useSWR(session ? "/api/repos" : null);
+ const { data, isLoading, error } = useSWR(
+ enabled && session ? "/api/repos" : null
+ );
return {
repos: data?.repos ?? [],
// The fetch is gated on the auth session, so the list is still loading
// while the session itself resolves — don't report an authoritative [].
- loading: status === "loading" || isLoading,
+ loading: enabled && (status === "loading" || isLoading),
error,
};
}
diff --git a/packages/web/src/hooks/use-workspace-administration.test.tsx b/packages/web/src/hooks/use-workspace-administration.test.tsx
new file mode 100644
index 000000000..a07cb2641
--- /dev/null
+++ b/packages/web/src/hooks/use-workspace-administration.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { clearAuthSessionCache, useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useWorkspaceAdministration } from "./use-workspace-administration";
+
+vi.mock("@/lib/auth-session", () => ({
+ useAuthSession: vi.fn(),
+ clearAuthSessionCache: vi.fn(),
+}));
+vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ new Map(), dedupingInterval: 0 }}>{children}
+);
+
+const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ avatarUrl: null,
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ createdAt: 1,
+};
+
+describe("useWorkspaceAdministration", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAuthSession).mockReturnValue({ data: null, status: "unauthenticated" });
+ vi.mocked(browserApiFetch).mockResolvedValue(new Response(null, { status: 204 }));
+ });
+
+ it("sends the simplified role and suspension mutation contracts", async () => {
+ const { result } = renderHook(
+ () => useWorkspaceAdministration({ readMembers: false, readRoles: false }),
+ { wrapper }
+ );
+
+ await act(() => result.current.updateMember(member, { kind: "role", roleId: "role_release" }));
+ await act(() => result.current.updateMember(member, { kind: "status", suspended: true }));
+
+ expect(browserApiFetch).toHaveBeenNthCalledWith(
+ 1,
+ `/api/members/${member.userId}/role`,
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ roleId: "role_release" }) })
+ );
+ expect(browserApiFetch).toHaveBeenNthCalledWith(
+ 2,
+ `/api/members/${member.userId}/status`,
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ suspended: true }) })
+ );
+ });
+
+ it("treats self-suspension as successful and clears the authenticated session cache", async () => {
+ vi.mocked(useAuthSession).mockReturnValue({
+ data: { user: { id: member.userId, name: "Ada", email: "ada@example.com", image: null } },
+ status: "authenticated",
+ });
+ vi.mocked(clearAuthSessionCache).mockResolvedValue(undefined);
+ const { result } = renderHook(
+ () => useWorkspaceAdministration({ readMembers: false, readRoles: false }),
+ { wrapper }
+ );
+
+ await expect(
+ act(() => result.current.updateMember(member, { kind: "status", suspended: true }))
+ ).resolves.toBeUndefined();
+
+ expect(clearAuthSessionCache).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/web/src/hooks/use-workspace-administration.ts b/packages/web/src/hooks/use-workspace-administration.ts
new file mode 100644
index 000000000..e193bbc0a
--- /dev/null
+++ b/packages/web/src/hooks/use-workspace-administration.ts
@@ -0,0 +1,123 @@
+"use client";
+
+import useSWR, { useSWRConfig } from "swr";
+import {
+ roleListResponseSchema,
+ workspaceMemberListResponseSchema,
+ type EffectiveAuthorization,
+ type RoleSummary,
+ type WorkspaceMember,
+} from "@open-inspect/shared/rbac";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { clearAuthSessionCache, useAuthSession } from "@/lib/auth-session";
+import { currentUserAuthorizationKey } from "./use-current-user-authorization";
+
+async function fetchMembers(): Promise {
+ const response = await browserApiFetch("/api/members");
+ if (!response.ok) throw new Error(`Members request failed (${response.status})`);
+ return workspaceMemberListResponseSchema.parse(await response.json());
+}
+
+async function fetchRoles(): Promise {
+ const response = await browserApiFetch("/api/roles");
+ if (!response.ok) throw new Error(`Roles request failed (${response.status})`);
+ return roleListResponseSchema.parse(await response.json());
+}
+
+/**
+ * Provides the workspace members and roles the current user may read, plus authorized member updates.
+ */
+export function useWorkspaceAdministration(input: { readMembers: boolean; readRoles: boolean }) {
+ const { mutate } = useSWRConfig();
+ const { data: session } = useAuthSession();
+ const members = useSWR(input.readMembers ? "/api/members" : null, fetchMembers);
+ const roles = useSWR(input.readRoles ? "/api/roles" : null, fetchRoles);
+
+ async function updateMember(
+ user: WorkspaceMember,
+ action: { kind: "role"; roleId: string } | { kind: "status"; suspended: boolean }
+ ): Promise {
+ const path =
+ action.kind === "role"
+ ? (`/api/members/${encodeURIComponent(user.userId)}/role` as const)
+ : (`/api/members/${encodeURIComponent(user.userId)}/status` as const);
+ const body =
+ action.kind === "role" ? { roleId: action.roleId } : { suspended: action.suspended };
+ const response = await browserApiFetch(path, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error(`Member update failed (${response.status})`);
+
+ const nextRole =
+ action.kind === "role" ? roles.data?.find(({ id }) => id === action.roleId) : null;
+ try {
+ await members.mutate(
+ (current) =>
+ current?.map((member) => {
+ if (member.userId !== user.userId) return member;
+ if (action.kind === "status") {
+ return { ...member, suspendedAt: action.suspended ? Date.now() : null };
+ }
+ return nextRole ? { ...member, role: roleReference(nextRole) } : member;
+ }),
+ { revalidate: false }
+ );
+
+ if (action.kind === "role" && nextRole && nextRole.id !== user.role.id) {
+ await roles.mutate(
+ (current) =>
+ current?.map((role) => ({
+ ...role,
+ assignmentCount:
+ role.id === user.role.id
+ ? Math.max(0, role.assignmentCount - 1)
+ : role.id === nextRole.id
+ ? role.assignmentCount + 1
+ : role.assignmentCount,
+ })),
+ { revalidate: false }
+ );
+ }
+
+ if (session?.user?.id === user.userId) {
+ const authorizationKey = currentUserAuthorizationKey(user.userId);
+ if (action.kind === "status" && action.suspended) {
+ await Promise.all([
+ clearAuthSessionCache(),
+ mutate(authorizationKey, undefined, { revalidate: false }),
+ ]);
+ } else if (action.kind === "role" && nextRole) {
+ await mutate(
+ authorizationKey,
+ (current) =>
+ current
+ ? {
+ ...current,
+ role: roleReference(nextRole),
+ permissions: nextRole.permissions,
+ }
+ : current,
+ { revalidate: false }
+ );
+ }
+ }
+ } catch {
+ // The server mutation already committed. Cache maintenance must never
+ // turn that success into a retryable-looking mutation failure.
+ }
+ }
+
+ return {
+ members: members.data ?? [],
+ roles: roles.data ?? [],
+ loading: (input.readMembers && members.isLoading) || (input.readRoles && roles.isLoading),
+ error: members.error ?? roles.error,
+ updateMember,
+ };
+}
+
+function roleReference(role: RoleSummary): WorkspaceMember["role"] {
+ return { id: role.id, key: role.key, name: role.name };
+}
diff --git a/packages/web/src/lib/auth-session.tsx b/packages/web/src/lib/auth-session.tsx
index 75fc51962..8a774dca9 100644
--- a/packages/web/src/lib/auth-session.tsx
+++ b/packages/web/src/lib/auth-session.tsx
@@ -58,6 +58,11 @@ export async function signOut(): Promise {
if (!response.ok) {
throw new Error(`Sign-out failed with status ${response.status}`);
}
+ await clearAuthSessionCache();
+}
+
+/** Immediately reflect a server-side session revocation in the client cache. */
+export async function clearAuthSessionCache(): Promise {
await mutate(BROWSER_AUTH_SESSION_PATH, null, false);
}