From 67cb6acd01f5832b46115feb34c72e7a532eca94 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 6 Nov 2026 00:16:57 -0500 Subject: [PATCH] feat(roles): manage roles, permissions, mappings and organization roles Add a Roles page for the permission catalogue, the organization role catalogue, and what each instance and organization role grants. Writes ask for step-up, refusals name what stood in the way, and an API older than v0.23 gets an upgrade message rather than retries. Organization member roles now come from the catalogue, keeping a legacy role a member already holds. Bump @seamless-auth/types to 0.38 and categorize the new audit events. Closes #297 --- .changeset/roles-and-permissions.md | 7 + AGENTS.md | 5 +- README.md | 11 + e2e/factories.ts | 30 + e2e/personas.ts | 18 + e2e/tests/organizations.spec.ts | 4 +- e2e/tests/roles.spec.ts | 369 +++++++++ package-lock.json | 8 +- package.json | 2 +- src/App.tsx | 2 + src/components/Sidebar.tsx | 2 + src/components/Table.test.tsx | 26 + src/components/Table.tsx | 4 + src/hooks/useRolePermissions.test.tsx | 197 +++++ src/hooks/useRolePermissions.ts | 178 +++++ src/hooks/useRoles.ts | 3 +- src/hooks/useUpdateSystemConfig.test.tsx | 4 + src/hooks/useUpdateSystemConfig.ts | 3 + src/lib/api.test.ts | 21 + src/lib/api.ts | 21 +- src/lib/eventCategories.test.ts | 7 + src/lib/eventCategories.ts | 22 +- src/lib/eventGroups.test.ts | 1 + src/lib/eventLabels.ts | 8 + src/lib/oidcApplications.test.ts | 1 + src/lib/roleRefusals.test.ts | 87 +++ src/lib/roleRefusals.ts | 73 ++ src/pages/Applications.test.tsx | 1 + src/pages/Organizations.test.tsx | 259 ++++++- src/pages/Organizations.tsx | 87 ++- src/pages/Roles.test.tsx | 474 ++++++++++++ src/pages/Roles.tsx | 929 +++++++++++++++++++++++ src/pages/SystemConfig.tsx | 2 +- 33 files changed, 2811 insertions(+), 55 deletions(-) create mode 100644 .changeset/roles-and-permissions.md create mode 100644 e2e/tests/roles.spec.ts create mode 100644 src/hooks/useRolePermissions.test.tsx create mode 100644 src/hooks/useRolePermissions.ts create mode 100644 src/lib/roleRefusals.test.ts create mode 100644 src/lib/roleRefusals.ts create mode 100644 src/pages/Roles.test.tsx create mode 100644 src/pages/Roles.tsx diff --git a/.changeset/roles-and-permissions.md b/.changeset/roles-and-permissions.md new file mode 100644 index 0000000..375c66c --- /dev/null +++ b/.changeset/roles-and-permissions.md @@ -0,0 +1,7 @@ +--- +"seamless-auth-admin-dashboard": minor +--- + +Add a Roles page to manage the permission catalogue, the organization role catalogue, and what each instance and organization role grants. Every write asks for step-up, read-only admins see the model without the controls, and a refused delete or mapping names what stood in the way. The page states that a change reaches a user with their next access token, and that an instance `admin` and an organization `admin` never share a grant. Against an API older than v0.23 it says the API needs upgrading instead of retrying an error. + +Organization member roles are now picked from the organization role catalogue. A legacy role a member already holds stays visible and is kept, and a refused grant names the roles. The audit events for permissions, roles, members and session exchange have categories and labels in the Events view. `@seamless-auth/types` moves to 0.38. diff --git a/AGENTS.md b/AGENTS.md index ab45347..b961d5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ Current route model: - `/users` - `/users/:id` - `/organizations` +- `/roles` - `/applications` - `/resources` - `/enrollment` @@ -199,7 +200,8 @@ Every admin write the API guards with step-up calls `ensureStepUp()` from `useSt the mutation. `apiFetch` also handles `403 { error: "step_up_required" }` centrally: it asks `StepUpRequiredHandler` for a step-up and retries the request once, so a window that lapses mid-task prompts instead of failing. `ApiError.code` carries the API's `error` code for branching -(for example `policy_version_conflict`). +(for example `policy_version_conflict`). `ApiError.details` carries the API's `details` unparsed; +`describeRefusal` in `src/lib/roleRefusals.ts` turns the role and permission refusals into text. For a route that answers with a file rather than JSON (the coverage CSV, the audit export), use `apiDownload(path, fallbackName)` and `saveBlob` from `src/lib/csvExport.ts`. @@ -217,6 +219,7 @@ Current page intent: - `Users.tsx`: searchable user directory with create, edit, and delete actions - `UserDetail.tsx`: single-user overview, sessions, credentials, events, and anomalies - `Organizations.tsx`: organization directory, details, and membership management +- `Roles.tsx`: the permission catalogue, organization role catalogue, and what each role grants. Instance roles are listed for their mappings but stay defined in `SystemConfig.tsx`. An API older than v0.23 answers 404 on these routes, which is a state of its own rather than an error - `Applications.tsx`: OpenID Provider applications (clients): create and edit, public and confidential clients, redirect URI checks (https, loopback, private-use), secret rotation shown once, key registration, and the resources and scopes each application may use. Shared-secret clients are marked as weaker - `Resources.tsx`: OpenID Provider resources (APIs), their audience identifier, scopes, and token TTL - `Enrollment.tsx`: passkey enrollment status by user, and enrollment invites diff --git a/README.md b/README.md index 91e44b5..8d5b461 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,17 @@ When the server adapter is mounted at `/auth`, the dashboard sends this as `DELE - list and inspect organizations - create and update organizations - add, update, and remove organization members +- assign members roles from the organization role catalogue, keeping a legacy role a member already holds + +### Roles and Permissions + +- manage the permission catalogue and the organization role catalogue +- edit which permissions each instance and organization role grants +- instance roles stay defined in System configuration, and the page links there +- a change reaches a user when their next access token is issued, within the access token TTL +- an instance `admin` and an organization `admin` are different roles and never share a grant +- needs Seamless Auth API v0.23, and a server adapter that forwards these routes. An older API answers 404, and the page says so +- every write asks for step-up first ### Applications and Resources diff --git a/e2e/factories.ts b/e2e/factories.ts index 2e8695f..71c71db 100644 --- a/e2e/factories.ts +++ b/e2e/factories.ts @@ -14,9 +14,11 @@ import type { Organization, OrganizationMembership, PartialAuthEvent, + Permission, PolicyDocument, PolicyDryRunResponse, PolicyVersion, + RoleDefinition, Session, StepUpStatus, SystemConfig, @@ -205,6 +207,7 @@ export function makeOidcApplication( postLogoutRedirectUris: [], grantTypes: ["authorization_code", "refresh_token"], deviceFlowEnabled: false, + sessionExchangeEnabled: false, noticesEnabled: false, pkceRequired: true, nonceRequired: true, @@ -222,6 +225,32 @@ export function makeOidcApplication( }; } +export function makePermission( + overrides: Partial = {}, +): Permission { + return { + id: nextId("permission"), + name: "invoices:read", + description: null, + createdAt: isoOffset(-30 * DAY), + updatedAt: isoOffset(-1 * DAY), + ...overrides, + }; +} + +export function makeRoleDefinition( + overrides: Partial = {}, +): RoleDefinition { + return { + name: "member", + kind: "organization", + description: null, + builtIn: false, + permissions: [], + ...overrides, + }; +} + export function makeOidcResource( overrides: Partial = {}, ): OidcResource { @@ -363,6 +392,7 @@ export function makeSystemConfig( origins: ["http://localhost:4287"], oidc_provider_enabled: false, oidc_issuer: null, + oidc_cli_client_id: null, allow_client_secrets: false, third_party_clients_enabled: false, hosted_ui: null, diff --git a/e2e/personas.ts b/e2e/personas.ts index a0f8e66..b60b997 100644 --- a/e2e/personas.ts +++ b/e2e/personas.ts @@ -20,7 +20,9 @@ import { makeMeUser, makeOrganization, makePolicyDryRun, + makePermission, makePolicyVersion, + makeRoleDefinition, makeSession, makeStepUpStatus, makeSystemConfig, @@ -173,6 +175,22 @@ export function seedDeployment(api: MockApi) { api.get("/system-config/roles", { json: { roles: ["user", "admin:read", "admin:write"] }, }); + api.get("/admin/permissions", () => { + const permissions = [makePermission({ name: "invoices:read" })]; + return { json: { permissions, total: permissions.length } }; + }); + api.get("/admin/roles", { + json: { + roles: [ + ...["user", "admin:read", "admin:write"].map((name) => + makeRoleDefinition({ name, kind: "instance" }), + ), + ...["admin", "member", "owner"].map((name) => + makeRoleDefinition({ name, builtIn: true }), + ), + ], + }, + }); api.post("/system-config/oauth-providers", ({ payload }) => ({ provider: payload, })); diff --git a/e2e/tests/organizations.spec.ts b/e2e/tests/organizations.spec.ts index 2609b81..446d50d 100644 --- a/e2e/tests/organizations.spec.ts +++ b/e2e/tests/organizations.spec.ts @@ -220,13 +220,13 @@ test.describe("Organizations", () => { .click(); await page.getByLabel("Member email").fill("hopper@example.com"); - await page.getByLabel("Member roles").selectOption("user"); + await page.getByLabel("Member roles").selectOption("admin"); await page.getByRole("button", { name: "Add", exact: true }).click(); await expectToast(page, "Member added"); expect( api.lastCall("POST", "/admin/organizations/org_1/members")?.payload, - ).toMatchObject({ email: "hopper@example.com", roles: ["user"] }); + ).toMatchObject({ email: "hopper@example.com", roles: ["admin"] }); }); test("removes a member behind a confirmation", async ({ diff --git a/e2e/tests/roles.spec.ts b/e2e/tests/roles.spec.ts new file mode 100644 index 0000000..d38d4df --- /dev/null +++ b/e2e/tests/roles.spec.ts @@ -0,0 +1,369 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { expect, test } from "../fixtures"; +import type { MockApi } from "../mockApi"; +import { confirmDialog, expectInlineAlert, expectToast } from "../helpers"; +import { + makeMembership, + makeMeUser, + makeOrganization, + makePermission, + makeRoleDefinition, + makeStepUpStatus, +} from "../factories"; + +function seedModel(api: MockApi) { + const permissions = [ + makePermission({ name: "invoices:read", description: "Read invoices" }), + makePermission({ name: "reports:read" }), + ]; + const roles = [ + makeRoleDefinition({ name: "admin", kind: "instance" }), + makeRoleDefinition({ + name: "admin", + builtIn: true, + permissions: ["invoices:read"], + }), + makeRoleDefinition({ name: "Legacy Role" }), + ]; + + api.get("/admin/permissions", () => ({ + json: { permissions, total: permissions.length }, + })); + api.get("/admin/roles", () => ({ json: { roles } })); + + api.post("/admin/permissions", ({ payload }) => { + const permission = makePermission(payload as object); + permissions.push(permission); + return { status: 201, json: { permission } }; + }); + + api.delete("/admin/permissions/:permissionName", ({ params }) => ({ + status: 409, + json: { + error: "Permission is still granted by a role", + details: { + roles: roles + .filter((role) => role.permissions.includes(params.permissionName)) + .map(({ kind, name }) => ({ kind, name })), + }, + }, + })); + + api.put("/admin/roles/:kind/:roleName/permissions", ({ params, payload }) => { + const role = roles.find( + (candidate) => + candidate.kind === params.kind && candidate.name === params.roleName, + )!; + role.permissions = (payload as { permissions: string[] }).permissions; + return { json: { role } }; + }); + + api.post("/admin/organization-roles", ({ payload }) => { + const role = makeRoleDefinition(payload as object); + roles.push(role); + return { status: 201, json: { role } }; + }); + + api.delete("/admin/organization-roles/:roleName", () => ({ + status: 409, + json: { + error: "Organization role is still held", + details: { holders: 2 }, + }, + })); + + return { permissions, roles }; +} + +test.describe("Roles", () => { + test("lists the model and says how it behaves", async ({ + page, + api, + signInAs, + }) => { + seedModel(api); + + await signInAs("writeAdmin", "/roles"); + + await expect( + page.getByRole("heading", { name: "Roles and permissions", level: 1 }), + ).toBeVisible(); + await expect( + page + .getByRole("table", { name: "Permissions" }) + .getByText("Read invoices"), + ).toBeVisible(); + await expect( + page + .getByRole("table", { name: "Organization roles" }) + .getByText("Built in"), + ).toBeVisible(); + await expect(page.getByText(/next access token is issued/)).toBeVisible(); + await expect(page.getByText(/never share a grant/)).toBeVisible(); + await expect( + page.getByRole("link", { name: "Add or remove in System" }), + ).toHaveAttribute("href", "/system"); + }); + + test("creates a permission", async ({ page, api, signInAs }) => { + seedModel(api); + + await signInAs("writeAdmin", "/roles"); + await page.getByRole("button", { name: "New permission" }).click(); + + const form = page.getByRole("dialog"); + await form.getByLabel("Name").fill("projects:write"); + await form.getByRole("button", { name: "Create permission" }).click(); + + await expectToast(page, "Permission created"); + expect(api.lastCall("POST", "/admin/permissions")?.payload).toEqual({ + name: "projects:write", + }); + await expect( + page + .getByRole("table", { name: "Permissions" }) + .getByText("projects:write"), + ).toBeVisible(); + }); + + test("replaces a mapping on a role with a space in its name", async ({ + page, + api, + signInAs, + }) => { + seedModel(api); + + await signInAs("writeAdmin", "/roles"); + await page + .getByRole("button", { name: "Edit permissions Legacy Role" }) + .click(); + + const form = page.getByRole("dialog"); + await form.getByRole("button", { name: "reports:read" }).click(); + await form.getByRole("button", { name: "Save" }).click(); + + await expectToast(page, "Role permissions saved"); + expect( + api.lastCall("PUT", "/admin/roles/organization/Legacy%20Role/permissions") + ?.payload, + ).toEqual({ permissions: ["reports:read"] }); + }); + + test("shows the roles behind a refused permission delete", async ({ + page, + api, + signInAs, + }) => { + seedModel(api); + + await signInAs("writeAdmin", "/roles"); + await page.getByRole("button", { name: "Delete invoices:read" }).click(); + await confirmDialog(page, "Delete"); + + await expectInlineAlert( + page, + /still granted by a role: organization role admin/, + ); + }); + + test("shows the holder count on a refused role delete", async ({ + page, + api, + signInAs, + }) => { + seedModel(api); + + await signInAs("writeAdmin", "/roles"); + await page.getByRole("button", { name: "Delete Legacy Role" }).click(); + await confirmDialog(page, "Delete"); + + await expectInlineAlert(page, /2 memberships hold it/); + }); + + test("shows a mapping refused by the API in the dialog", async ({ + page, + api, + signInAs, + }) => { + seedModel(api); + api.put("/admin/roles/:kind/:roleName/permissions", { + status: 400, + json: { + error: "Unknown permissions", + details: { permissions: ["reports:read"] }, + }, + }); + + await signInAs("writeAdmin", "/roles"); + await page + .getByRole("button", { name: "Edit permissions Legacy Role" }) + .click(); + + const form = page.getByRole("dialog"); + await form.getByRole("button", { name: "reports:read" }).click(); + await form.getByRole("button", { name: "Save" }).click(); + + await expect( + form.getByText(/Unknown permissions: reports:read/), + ).toBeVisible(); + }); + + test("writes nothing when step-up fails", async ({ page, api }) => { + seedModel(api); + api.get("/users/me", { + json: { + user: makeMeUser({ roles: ["admin:write"] }), + credentials: [], + organizations: [], + activeOrganization: null, + }, + }); + api.get("/step-up/status", { json: makeStepUpStatus({ fresh: false }) }); + + await page.goto("/roles"); + await page.getByRole("button", { name: "New permission" }).click(); + + const form = page.getByRole("dialog"); + await form.getByLabel("Name").fill("projects:write"); + await form.getByRole("button", { name: "Create permission" }).click(); + + await expectToast(page, /security key or authenticator is required/i); + expect(api.recordedCalls("POST", "/admin/permissions")).toHaveLength(0); + await expect(form).toBeVisible(); + }); + + test("hides writes from a read-only admin", async ({ + page, + api, + signInAs, + }) => { + seedModel(api); + + await signInAs("readAdmin", "/roles"); + + await expect(page.getByText("Read-only access")).toBeVisible(); + await expect( + page.getByRole("table", { name: "Permissions" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: /^New / })).toHaveCount(0); + await expect(page.getByRole("button", { name: /^Delete / })).toHaveCount(0); + }); + + test("says the API needs upgrading when the routes are missing, once", async ({ + page, + api, + signInAs, + }) => { + api.get("/admin/permissions", { + status: 404, + json: { error: "Not found" }, + }); + api.get("/admin/roles", { status: 404, json: { error: "Not found" } }); + + await signInAs("writeAdmin", "/roles"); + + await expect(page.getByText("Needs Seamless Auth API v0.23")).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry" })).toHaveCount(0); + await page.clock.runFor(30_000); + expect(api.recordedCalls("GET", "/admin/roles").length).toBeLessThanOrEqual( + 1, + ); + }); +}); + +test.describe("Organization member roles", () => { + function seedMember(api: MockApi, roles: string[]) { + api.get("/admin/organizations", { + json: { + organizations: [makeOrganization({ id: "org_1", name: "Acme Corp" })], + total: 1, + }, + }); + api.get("/admin/organizations/:organizationId/members", { + json: { + members: [ + makeMembership({ userId: "user_1", organizationId: "org_1", roles }), + ], + total: 1, + }, + }); + api.get("/admin/roles", { + json: { + roles: [ + makeRoleDefinition({ name: "reports", kind: "instance" }), + makeRoleDefinition({ name: "member", builtIn: true }), + makeRoleDefinition({ name: "owner", builtIn: true }), + ], + }, + }); + } + + test("offers the catalogue and keeps a legacy role", async ({ + page, + api, + signInAs, + }) => { + seedMember(api, ["member", "Legacy Role"]); + api.patch( + "/admin/organizations/:organizationId/members/:userId", + ({ payload }) => ({ + json: { membership: makeMembership(payload as object) }, + }), + ); + + await signInAs("writeAdmin", "/organizations"); + + await expect(page.getByLabel("Member roles").locator("option")).toHaveText([ + "member", + "owner", + ]); + + await page + .getByRole("button", { name: "Edit roles ada@example.com" }) + .click(); + const form = page.getByRole("dialog"); + await expect( + form.getByRole("button", { name: "Legacy Role" }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + form.getByText(/Not in the catalogue: Legacy Role/), + ).toBeVisible(); + + await form.getByRole("button", { name: "Save" }).click(); + + await expectToast(page, "Membership updated"); + expect( + api.lastCall("PATCH", "/admin/organizations/org_1/members/user_1") + ?.payload, + ).toMatchObject({ roles: ["member", "Legacy Role"] }); + }); + + test("names the roles in a 403 refusal", async ({ page, api, signInAs }) => { + seedMember(api, ["member"]); + api.patch("/admin/organizations/:organizationId/members/:userId", { + status: 403, + json: { + error: "Not allowed to change these roles", + details: { roles: ["owner"] }, + }, + }); + + await signInAs("writeAdmin", "/organizations"); + await page + .getByRole("button", { name: "Edit roles ada@example.com" }) + .click(); + + const form = page.getByRole("dialog"); + await form.getByRole("button", { name: "owner" }).click(); + await form.getByRole("button", { name: "Save" }).click(); + + await expect( + form.getByText(/You may not change these roles: owner/), + ).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index a3cb28d..d1d8688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.12.0", "dependencies": { "@seamless-auth/react": "^0.19.0", - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/types": "^0.38.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.105.0", "clsx": "^2.1.1", @@ -1587,9 +1587,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.34.0.tgz", - "integrity": "sha512-bTgZkEdpXwAejxwnA1J8vTGAq+FW9L6J4n97q2MI6SqWWIOqXyaF6zMTkzeKqNTvfgF0NIn2DHh9qXL+92FN/A==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 65e1d0c..c287dd4 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "@seamless-auth/react": "^0.19.0", - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/types": "^0.38.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.105.0", "clsx": "^2.1.1", diff --git a/src/App.tsx b/src/App.tsx index 22b2e61..83ce548 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import Security from "./pages/Security"; import Organizations from "./pages/Organizations"; import Applications from "./pages/Applications"; import Resources from "./pages/Resources"; +import Roles from "./pages/Roles"; import Enrollment from "./pages/Enrollment"; import Coverage from "./pages/Coverage"; import UserDetail from "./pages/UserDetail"; @@ -107,6 +108,7 @@ const router = createBrowserRouter( { path: "/organizations", element: }, { path: "/applications", element: }, { path: "/resources", element: }, + { path: "/roles", element: }, { path: "/enrollment", element: }, { path: "/coverage", element: }, { path: "/sessions", element: }, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b8b4cb1..2b1983d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -20,6 +20,7 @@ import { ScrollText, AppWindow, Boxes, + ListChecks, } from "lucide-react"; import packageJson from "../../package.json"; import { trapTabKey } from "../lib/focusTrap"; @@ -28,6 +29,7 @@ const navItems = [ { name: "Overview", path: "/", icon: LayoutDashboard }, { name: "Users", path: "/users", icon: Users }, { name: "Organizations", path: "/organizations", icon: Building2 }, + { name: "Roles", path: "/roles", icon: ListChecks }, { name: "Applications", path: "/applications", icon: AppWindow }, { name: "Resources", path: "/resources", icon: Boxes }, { name: "Enrollment", path: "/enrollment", icon: Fingerprint }, diff --git a/src/components/Table.test.tsx b/src/components/Table.test.tsx index 71018fc..87355a6 100644 --- a/src/components/Table.test.tsx +++ b/src/components/Table.test.tsx @@ -413,4 +413,30 @@ describe("Table", () => { expect(screen.getByText("Showing 1-2 of 40")).toBeInTheDocument(); }); + + it("leaves a row action off the rows it does not apply to", () => { + render( + row.name} + columns={[{ key: "name", label: "Name" }]} + actions={[ + { + icon: () => null, + label: "Delete", + hidden: (row) => row.name === "built-in", + onClick: () => undefined, + }, + ]} + />, + ); + + expect(screen.getByRole("button", { name: "Delete kept" })).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Delete built-in" }), + ).toBeNull(); + }); }); diff --git a/src/components/Table.tsx b/src/components/Table.tsx index d22b87b..0264367 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -71,6 +71,8 @@ type RowAction = { onClick: (row: T) => void; label?: string; variant?: "default" | "danger"; + /** Leaves the action off a row it can never apply to. */ + hidden?: (row: T) => boolean; }; type BulkAction = { @@ -565,6 +567,8 @@ export default function Table>({ {actions.map((action, idx) => { const Icon = action.icon; + if (action.hidden?.(row)) return null; + return ( + ) : undefined + } + > + + label="Permissions" + data={permissions} + rowLabel={(permission) => permission.name} + total={permissions.length} + emptyTitle="No permissions" + emptyDescription={ + canWrite + ? "Create a permission, then map it to a role to grant it." + : "No permissions are defined." + } + columns={[ + { + key: "name", + label: "Permission", + sortable: true, + width: "wide", + wrap: true, + render: (value, row) => ( + + ), + }, + { + key: "grantedBy", + label: "Granted by", + width: "large", + wrap: true, + render: (value) => , + }, + ]} + actions={ + canWrite + ? [ + { + icon: Pencil, + label: "Edit", + onClick: (row: PermissionRow) => openPermissionDialog(row), + }, + { + icon: Trash2, + label: "Delete", + variant: "danger" as const, + onClick: (row: PermissionRow) => + void handlePermissionDelete(row), + }, + ] + : [] + } + /> + + +
openRoleDialog("new")} + > + + New organization role + + ) : undefined + } + > + + label="Organization roles" + data={organizationRoles} + rowLabel={(role) => role.name} + total={organizationRoles.length} + emptyTitle="No organization roles" + emptyDescription="No organization roles are defined." + columns={[ + { + key: "name", + label: "Role", + sortable: true, + width: "wide", + wrap: true, + render: (value, row) => ( + + ), + }, + { + key: "permissions", + label: "Grants", + width: "large", + wrap: true, + render: (value) => , + }, + ]} + actions={ + canWrite + ? [ + mappingAction, + { + icon: Pencil, + label: "Edit", + onClick: (row: RoleRow) => openRoleDialog(row), + }, + { + icon: Trash2, + label: "Delete", + variant: "danger" as const, + hidden: (row: RoleRow) => row.builtIn, + onClick: (row: RoleRow) => void handleRoleDelete(row), + }, + ] + : [] + } + /> +
+ +
+ Add or remove in System + + } + > + + label="Instance roles" + data={instanceRoles} + rowLabel={(role) => role.name} + total={instanceRoles.length} + emptyTitle="No instance roles" + emptyDescription="Add one to the available roles in system configuration." + columns={[ + { + key: "name", + label: "Role", + sortable: true, + width: "wide", + wrap: true, + render: (value) => , + }, + { + key: "permissions", + label: "Grants", + width: "large", + wrap: true, + render: (value) => , + }, + ]} + actions={canWrite ? [mappingAction] : []} + /> +
+ + {permissionDialog && ( + void handlePermissionSubmit(form)} + onClose={() => setPermissionDialog(null)} + /> + )} + + {roleDialog && ( + void handleRoleSubmit(form)} + onClose={() => setRoleDialog(null)} + /> + )} + + {mappingRole && ( + void handleMappingSubmit(selected)} + onClose={() => setMappingRole(null)} + /> + )} + + ); +} + +function PermissionDialog({ + permission, + error, + isPending, + onSubmit, + onClose, +}: { + permission: Permission | null; + error: string | null; + isPending: boolean; + onSubmit: (form: { name: string; description: string }) => void; + onClose: () => void; +}) { + const [name, setName] = useState(permission?.name ?? ""); + const [description, setDescription] = useState(permission?.description ?? ""); + const [submitted, setSubmitted] = useState(false); + const nameValid = + permission !== null || + CreatePermissionRequestSchema.shape.name.safeParse(name).success; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + setSubmitted(true); + if (!nameValid) return; + onSubmit({ name, description }); + }; + + return ( + +
+ + setName(event.target.value)} + disabled={permission !== null} + placeholder="invoices:read" + className={`${inputClass} font-mono`} + /> + + + {permission + ? "The name is fixed because tokens and backends refer to it." + : NAME_HINT} + + + setDescription(event.target.value)} + maxLength={500} + className={inputClass} + /> + + + + +
+ ); +} + +function OrganizationRoleDialog({ + role, + permissionNames, + error, + isPending, + onSubmit, + onClose, +}: { + role: RoleDefinition | null; + permissionNames: string[]; + error: string | null; + isPending: boolean; + onSubmit: (form: { + name: string; + description: string; + permissions: string[]; + }) => void; + onClose: () => void; +}) { + const [name, setName] = useState(role?.name ?? ""); + const [description, setDescription] = useState(role?.description ?? ""); + const [permissions, setPermissions] = useState([]); + const [submitted, setSubmitted] = useState(false); + const nameValid = + role !== null || + CreateOrganizationRoleRequestSchema.shape.name.safeParse(name).success; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + setSubmitted(true); + if (!nameValid) return; + onSubmit({ name, description, permissions }); + }; + + return ( + +
+ + setName(event.target.value)} + disabled={role !== null} + placeholder="billing" + className={`${inputClass} font-mono`} + /> + + + {role ? "The name is fixed because memberships hold it." : NAME_HINT} + + + setDescription(event.target.value)} + maxLength={500} + className={inputClass} + /> + + {role === null && ( +
+ + Grants + + +
+ )} + + + +
+ ); +} + +function MappingDialog({ + role, + permissionNames, + error, + isPending, + onSubmit, + onClose, +}: { + role: RoleDefinition; + permissionNames: string[]; + error: string | null; + isPending: boolean; + onSubmit: (selected: string[]) => void; + onClose: () => void; +}) { + const [selected, setSelected] = useState(role.permissions); + const dirty = + selected.length !== role.permissions.length || + selected.some((name) => !role.permissions.includes(name)); + const otherKind = role.kind === "instance" ? "organization" : "instance"; + + return ( + +
{ + event.preventDefault(); + onSubmit(selected); + }} + > + + + Each holder gets this change when their next access token is issued. + + + + +
+ ); +} + +function PermissionPicker({ + permissionNames, + selected, + onChange, +}: { + permissionNames: string[]; + selected: string[]; + onChange: (selected: string[]) => void; +}) { + if (permissionNames.length === 0) { + return ( +

+ The permission catalogue is empty. Create a permission first. +

+ ); + } + + return ( + + ); +} + +function DialogFooter({ + problem, + isPending, + submitDisabled, + submitLabel, + onClose, +}: { + problem: string | null; + isPending: boolean; + submitDisabled?: boolean; + submitLabel: string; + onClose: () => void; +}) { + return ( + <> + {problem && ( + + )} +
+ + +
+ + ); +} + +function NameCell({ + name, + description, + badge, +}: { + name: string; + description?: string | null; + badge?: string; +}) { + return ( +
+ + + {name} + + {badge && ( + + {badge} + + )} + + {description && {description}} +
+ ); +} + +function ChipList({ values, empty }: { values: string[]; empty: string }) { + if (values.length === 0) { + return {empty}; + } + + return ( +
+ {values.map((value) => ( + + {value} + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function FieldNote({ children }: { children: ReactNode }) { + return

{children}

; +} diff --git a/src/pages/SystemConfig.tsx b/src/pages/SystemConfig.tsx index 989b2a8..7038bbf 100644 --- a/src/pages/SystemConfig.tsx +++ b/src/pages/SystemConfig.tsx @@ -250,7 +250,7 @@ export default function SystemConfigPage() { if ( !(await confirm({ title: "Remove role", - description: `Remove "${role}" from the available roles? It will no longer be assignable, and it will be removed from the default roles.`, + description: `Remove "${role}" from the available roles? It will no longer be assignable, and it will be removed from the default roles. Any permissions it grants are removed too, so adding the name back later grants nothing.`, confirmLabel: "Remove", tone: "danger", }))