You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/07/16 11:13:02 UTC

(airavata-custos) branch master updated: Wire role management tab to RBAC APIs (#524)

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git


The following commit(s) were added to refs/heads/master by this push:
     new 7eaab4ec8 Wire role management tab to RBAC APIs (#524)
7eaab4ec8 is described below

commit 7eaab4ec881e9bdea21d6bcc3ab60a9653afca8d
Author: Jayanth Vennamreddy <53...@users.noreply.github.com>
AuthorDate: Thu Jul 16 07:12:57 2026 -0400

    Wire role management tab to RBAC APIs (#524)
    
    * Wire role management to RBAC APIs
    
    * fix API proxy handling for no-content responses
    
    * Update role permission chip colors
    
    * Address role management review comments
    
    * Address role management review feedback
---
 .../admin/users/roles/PermissionMatrixEditor.tsx   |  63 +++++--
 .../app/(portal)/admin/users/roles/RoleCard.tsx    |  32 +---
 .../(portal)/admin/users/roles/RoleFormDialog.tsx  |  52 +++--
 .../app/(portal)/admin/users/roles/RolesGrid.tsx   |  50 ++++-
 .../app/api/v1/[...path]/__tests__/route.test.ts   |  59 ++++++
 web/src/app/api/v1/[...path]/route.ts              |   4 +-
 web/src/features/core/roles/api.ts                 | 157 ++++++++++++++++
 web/src/features/core/roles/queries.ts             |  98 ++++++++++
 web/src/features/core/roles/schemas.ts             |  58 ++++++
 web/src/mocks/handlers.ts                          |   2 +
 web/src/mocks/handlers/roles.ts                    | 209 +++++++++++++++++++++
 web/src/shared/users-admin/permissions.ts          |  87 ++++++---
 web/tests/role-management.e2e.ts                   |  48 +++++
 13 files changed, 827 insertions(+), 92 deletions(-)

diff --git a/web/src/app/(portal)/admin/users/roles/PermissionMatrixEditor.tsx b/web/src/app/(portal)/admin/users/roles/PermissionMatrixEditor.tsx
index 29a1bcf86..551dca3de 100644
--- a/web/src/app/(portal)/admin/users/roles/PermissionMatrixEditor.tsx
+++ b/web/src/app/(portal)/admin/users/roles/PermissionMatrixEditor.tsx
@@ -17,20 +17,31 @@
 
 "use client";
 
-import { PermissionRW } from "@/shared/users-admin/PermissionRW";
+import { cn } from "@/lib/utils";
 import type { PermissionKey } from "@/shared/users-admin/permissions";
-import { rwStateFor } from "@/shared/users-admin/permissions";
+import { permissionRowsFor } from "@/shared/users-admin/permissions";
+
+const ACTION_CHIP_CLASSES: Record<string, string> = {
+  read: "bg-[color:var(--tone-info-bg)] text-[color:var(--tone-info-fg)]",
+  write: "bg-[color:var(--tone-ok-bg)] text-[color:var(--tone-ok-fg)]",
+};
+const ACTION_CHIP_FALLBACK =
+  "bg-[color:var(--tone-accent-bg)] text-[color:var(--tone-accent-fg)]";
+const INACTIVE_CHIP_CLASS = "border border-border text-muted-foreground";
 
 export function PermissionMatrixEditor({
   permissions,
+  catalog,
   onTogglePermission,
   editable = true,
 }: {
   permissions: PermissionKey[];
-  onTogglePermission: (permission: PermissionKey) => void;
+  catalog?: readonly PermissionKey[];
+  onTogglePermission?: (permission: PermissionKey) => void;
   editable?: boolean;
 }) {
-  const rwPermissions = rwStateFor(permissions);
+  const rows = permissionRowsFor(permissions, catalog);
+  const canEdit = editable && onTogglePermission;
 
   return (
     <div>
@@ -38,17 +49,39 @@ export function PermissionMatrixEditor({
         Effective Privileges
       </h4>
       <ul className="space-y-2">
-        {rwPermissions.map((p) => (
-          <li key={p.section} className="flex items-center justify-between text-sm">
-            <span className="font-mono text-foreground">{p.section}</span>
-            <PermissionRW
-              read={p.read}
-              write={p.write}
-              onToggleRead={editable ? () => onTogglePermission(`${p.section}:read`) : undefined}
-              onToggleWrite={
-                editable ? () => onTogglePermission(`${p.section}:write`) : undefined
-              }
-            />
+        {rows.map((row) => (
+          <li key={row.section} className="flex items-center justify-between gap-3 text-sm">
+            <span className="font-mono text-foreground">{row.section}</span>
+            <div className="flex flex-wrap justify-end gap-1">
+              {row.actions.map((privilege) => {
+                const className = cn(
+                  "inline-flex h-6 items-center justify-center rounded px-2 text-xs font-medium",
+                  privilege.active
+                    ? ACTION_CHIP_CLASSES[privilege.action] ?? ACTION_CHIP_FALLBACK
+                    : INACTIVE_CHIP_CLASS,
+                  canEdit && "cursor-pointer transition-transform hover:scale-105",
+                );
+                if (!canEdit) {
+                  return (
+                    <span key={privilege.key} title={privilege.key} className={className}>
+                      {privilege.action}
+                    </span>
+                  );
+                }
+                return (
+                  <button
+                    key={privilege.key}
+                    type="button"
+                    title={privilege.key}
+                    aria-pressed={privilege.active}
+                    onClick={() => onTogglePermission(privilege.key)}
+                    className={className}
+                  >
+                    {privilege.action}
+                  </button>
+                );
+              })}
+            </div>
           </li>
         ))}
       </ul>
diff --git a/web/src/app/(portal)/admin/users/roles/RoleCard.tsx b/web/src/app/(portal)/admin/users/roles/RoleCard.tsx
index 199151824..4ce79caec 100644
--- a/web/src/app/(portal)/admin/users/roles/RoleCard.tsx
+++ b/web/src/app/(portal)/admin/users/roles/RoleCard.tsx
@@ -18,16 +18,13 @@
 "use client";
 
 import { Pencil, ShieldCheck } from "lucide-react";
+import type { RoleRow } from "@/features/core/roles/schemas";
 import { Badge } from "@/shared/ui/badge";
 import { Card, CardContent, CardHeader } from "@/shared/ui/card";
-import { PermissionRW } from "@/shared/users-admin/PermissionRW";
-import { rwStateFor } from "@/shared/users-admin/permissions";
-import type { RoleRow } from "@/shared/users-admin/types";
+import { PermissionMatrixEditor } from "./PermissionMatrixEditor";
 import { RoleFormDialog } from "./RoleFormDialog";
 
-export function RoleCard({ role, memberCount }: { role: RoleRow; memberCount: number }) {
-  const rwPermissions = rwStateFor(role.permissions).filter((p) => p.read || p.write);
-
+export function RoleCard({ role }: { role: RoleRow }) {
   return (
     <Card>
       <CardHeader>
@@ -45,30 +42,17 @@ export function RoleCard({ role, memberCount }: { role: RoleRow; memberCount: nu
             <p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
           </div>
           <Badge variant="secondary" className="shrink-0">
-            {memberCount} {memberCount === 1 ? "member" : "members"}
+            {role.memberCount} {role.memberCount === 1 ? "member" : "members"}
           </Badge>
         </div>
       </CardHeader>
       <CardContent className="space-y-4">
         <div className="border-t border-border" />
 
-        <div>
-          <h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
-            Effective Privileges
-          </h4>
-          {rwPermissions.length === 0 ? (
-            <p className="text-sm text-muted-foreground">No privileges granted.</p>
-          ) : (
-            <ul className="space-y-2">
-              {rwPermissions.map((p) => (
-                <li key={p.section} className="flex items-center justify-between text-sm">
-                  <span className="font-mono text-foreground">{p.section}</span>
-                  <PermissionRW read={p.read} write={p.write} />
-                </li>
-              ))}
-            </ul>
-          )}
-        </div>
+        <PermissionMatrixEditor
+          permissions={role.privileges}
+          editable={false}
+        />
 
         <div className="border-t border-border" />
 
diff --git a/web/src/app/(portal)/admin/users/roles/RoleFormDialog.tsx b/web/src/app/(portal)/admin/users/roles/RoleFormDialog.tsx
index f57f6406c..d97f6e4de 100644
--- a/web/src/app/(portal)/admin/users/roles/RoleFormDialog.tsx
+++ b/web/src/app/(portal)/admin/users/roles/RoleFormDialog.tsx
@@ -18,6 +18,9 @@
 "use client";
 
 import * as React from "react";
+import { toast } from "sonner";
+import { useCreateRole, usePrivilegeCatalog, useUpdateRole } from "@/features/core/roles/queries";
+import type { RoleRow } from "@/features/core/roles/schemas";
 import { Button } from "@/shared/ui/button";
 import {
   Dialog,
@@ -32,8 +35,8 @@ import { Input } from "@/shared/ui/input";
 import { Label } from "@/shared/ui/label";
 import { togglePermission } from "@/shared/users-admin/permissions";
 import type { PermissionKey } from "@/shared/users-admin/permissions";
-import type { RoleRow, UserRow } from "@/shared/users-admin/types";
 import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
+import type { UserRow } from "@/shared/users-admin/types";
 import { PermissionMatrixEditor } from "./PermissionMatrixEditor";
 
 function fullNameFor(user: UserRow): string {
@@ -51,8 +54,11 @@ export function RoleFormDialog({
   triggerRender: React.ReactElement;
   triggerContent: React.ReactNode;
 }) {
-  const { users, addRole, updateRole } = useUsersAdmin();
   const isEdit = Boolean(role);
+  const catalogQuery = usePrivilegeCatalog();
+  const createRole = useCreateRole();
+  const updateRole = useUpdateRole();
+  const { users } = useUsersAdmin();
   const [open, setOpen] = React.useState(false);
   const [name, setName] = React.useState("");
   const [description, setDescription] = React.useState("");
@@ -65,13 +71,9 @@ export function RoleFormDialog({
     if (next) {
       setName(role?.name ?? "");
       setDescription(role?.description ?? "");
-      setPermissions(role?.permissions ?? []);
+      setPermissions(role?.privileges ?? []);
       setUserSearch("");
-      setSelectedUserIds(
-        new Set(
-          role ? users.filter((u) => u.roles.some((r) => r.id === role.id)).map((u) => u.id ?? "") : [],
-        ),
-      );
+      setSelectedUserIds(new Set(role?.holderIds ?? []));
     }
   }
 
@@ -84,17 +86,26 @@ export function RoleFormDialog({
     });
   }
 
-  function handleSubmit() {
+  async function handleSubmit() {
     if (!name.trim()) return;
-    const input = { name: name.trim(), description: description.trim(), permissions };
-    if (isEdit && role?.id) {
-      updateRole(role.id, input, Array.from(selectedUserIds));
-    } else {
-      addRole(input, Array.from(selectedUserIds));
+    const input = { name: name.trim(), description: description.trim(), privileges: permissions };
+    const memberUserIds = Array.from(selectedUserIds);
+    try {
+      if (isEdit && role?.id) {
+        await updateRole.mutateAsync({ role, input, memberUserIds });
+        toast.success("Role updated");
+      } else {
+        await createRole.mutateAsync({ ...input, memberUserIds });
+        toast.success("Role created");
+      }
+      setOpen(false);
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : "Role update failed");
     }
-    setOpen(false);
   }
 
+  const saving = createRole.isPending || updateRole.isPending;
+  const catalog = catalogQuery.data ?? [];
   const needle = userSearch.trim().toLowerCase();
   const matchingUsers = needle
     ? users.filter((u) => `${fullNameFor(u)} ${u.email ?? ""}`.toLowerCase().includes(needle))
@@ -108,8 +119,8 @@ export function RoleFormDialog({
           <DialogTitle>{isEdit ? "Edit role" : "Create role"}</DialogTitle>
           <DialogDescription>
             {isEdit
-              ? `Update what ${role?.name} can see and do, and who holds it.`
-              : "Define a name, choose the permissions it grants, and optionally assign it to users right away."}
+              ? `Update what ${role?.name} can see and do.`
+              : "Define a name and choose the permissions it grants."}
           </DialogDescription>
         </DialogHeader>
 
@@ -140,6 +151,7 @@ export function RoleFormDialog({
 
           <PermissionMatrixEditor
             permissions={permissions}
+            catalog={catalog}
             onTogglePermission={(key) => setPermissions((prev) => togglePermission(prev, key))}
           />
 
@@ -186,11 +198,11 @@ export function RoleFormDialog({
         </div>
 
         <DialogFooter>
-          <Button variant="outline" onClick={() => setOpen(false)} type="button">
+          <Button variant="outline" onClick={() => setOpen(false)} type="button" disabled={saving}>
             Cancel
           </Button>
-          <Button onClick={handleSubmit} disabled={!name.trim()} type="button">
-            {isEdit ? "Save changes" : "Create role"}
+          <Button onClick={handleSubmit} disabled={!name.trim() || saving} type="button">
+            {saving ? "Saving..." : isEdit ? "Save changes" : "Create role"}
           </Button>
         </DialogFooter>
       </DialogContent>
diff --git a/web/src/app/(portal)/admin/users/roles/RolesGrid.tsx b/web/src/app/(portal)/admin/users/roles/RolesGrid.tsx
index 26c04a025..2e89665d2 100644
--- a/web/src/app/(portal)/admin/users/roles/RolesGrid.tsx
+++ b/web/src/app/(portal)/admin/users/roles/RolesGrid.tsx
@@ -17,20 +17,56 @@
 
 "use client";
 
-import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
+import { useRoleRows } from "@/features/core/roles/queries";
+import { Button } from "@/shared/ui/button";
 import { RoleCard } from "./RoleCard";
 
 export function RolesGrid() {
-  const { roles, users } = useUsersAdmin();
+  const rolesQuery = useRoleRows();
+
+  if (rolesQuery.isPending) {
+    return (
+      <div className="rounded-md border bg-card p-6 text-sm text-muted-foreground">
+        Loading roles...
+      </div>
+    );
+  }
+
+  if (rolesQuery.isError) {
+    const error = rolesQuery.error;
+    return (
+      <div className="space-y-3 rounded-md border bg-card p-6">
+        <p className="text-sm text-destructive">
+          {error instanceof Error ? error.message : "Could not load roles."}
+        </p>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={() => {
+            rolesQuery.refetch();
+          }}
+        >
+          Retry
+        </Button>
+      </div>
+    );
+  }
+
+  const roles = rolesQuery.data;
+
+  if (roles.length === 0) {
+    return (
+      <div className="rounded-md border bg-card p-6 text-sm text-muted-foreground">
+        No roles have been created yet.
+      </div>
+    );
+  }
 
   return (
     <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
       {roles.map((role) => (
-        <RoleCard
-          key={role.id}
-          role={role}
-          memberCount={users.filter((u) => u.roles.some((r) => r.id === role.id)).length}
-        />
+        <RoleCard key={role.id} role={role} />
       ))}
     </div>
   );
diff --git a/web/src/app/api/v1/[...path]/__tests__/route.test.ts b/web/src/app/api/v1/[...path]/__tests__/route.test.ts
new file mode 100644
index 000000000..1ad865e56
--- /dev/null
+++ b/web/src/app/api/v1/[...path]/__tests__/route.test.ts
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { NextRequest } from "next/server";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/env", () => ({
+  serverEnv: {
+    CUSTOS_CORE_API_BASE_URL: "https://core.example.org",
+  },
+}));
+
+vi.mock("@/shared/auth/session", () => ({
+  getPortalSession: vi.fn(async () => ({ user: { email: "admin@custos.local" } })),
+  pickBackendBearer: vi.fn(() => "access-token-abc"),
+}));
+
+import { POST } from "../route";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+const ctx = { params: Promise.resolve({ path: ["roles", "role-1", "privileges"] }) };
+
+afterEach(() => {
+  fetchMock.mockReset();
+});
+
+describe("api v1 proxy route", () => {
+  it("proxies no-content backend responses without constructing a response body", async () => {
+    fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
+
+    const response = await POST(
+      new NextRequest("http://localhost:3000/api/v1/roles/role-1/privileges", {
+        method: "POST",
+        body: JSON.stringify({ privilege: "core:roles:manage" }),
+        headers: { "content-type": "application/json" },
+      }),
+      ctx,
+    );
+
+    expect(response.status).toBe(204);
+    expect(await response.text()).toBe("");
+  });
+});
diff --git a/web/src/app/api/v1/[...path]/route.ts b/web/src/app/api/v1/[...path]/route.ts
index 61178486b..73a228cb3 100644
--- a/web/src/app/api/v1/[...path]/route.ts
+++ b/web/src/app/api/v1/[...path]/route.ts
@@ -57,7 +57,9 @@ async function proxy(request: NextRequest, ctx: Context) {
   const traceId = upstream.headers.get("x-trace-id");
   if (traceId) responseHeaders.set("x-trace-id", traceId);
 
-  return new NextResponse(await upstream.text(), {
+  const hasNoBody = upstream.status === 204 || upstream.status === 205 || upstream.status === 304;
+
+  return new NextResponse(hasNoBody ? null : await upstream.text(), {
     status: upstream.status,
     headers: responseHeaders,
   });
diff --git a/web/src/features/core/roles/api.ts b/web/src/features/core/roles/api.ts
new file mode 100644
index 000000000..a94257077
--- /dev/null
+++ b/web/src/features/core/roles/api.ts
@@ -0,0 +1,157 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { apiFetch } from "@/shared/api/client";
+import {
+  type PrivilegeKey,
+  type Role,
+  type RoleInput,
+  type RoleRow,
+  privilegeCatalogResponseSchema,
+  roleDetailResponseSchema,
+  roleHoldersResponseSchema,
+  roleInputSchema,
+  roleSchema,
+  rolesResponseSchema,
+} from "./schemas";
+
+export async function listRoles(): Promise<Role[]> {
+  return rolesResponseSchema.parse(await apiFetch("/roles"));
+}
+
+export async function getRolePrivileges(roleId: string): Promise<PrivilegeKey[]> {
+  const detail = roleDetailResponseSchema.parse(await apiFetch(`/roles/${roleId}`));
+  return detail.privileges;
+}
+
+export async function getRoleMemberCount(roleId: string): Promise<number> {
+  const holders = roleHoldersResponseSchema.parse(await apiFetch(`/roles/${roleId}/holders`));
+  return holders.length;
+}
+
+export async function getRoleHolderIds(roleId: string): Promise<string[]> {
+  const holders = roleHoldersResponseSchema.parse(await apiFetch(`/roles/${roleId}/holders`));
+  return holders.flatMap((holder) => (holder.user_id ? [holder.user_id] : []));
+}
+
+export async function listPrivilegeCatalog(): Promise<PrivilegeKey[]> {
+  return privilegeCatalogResponseSchema.parse(await apiFetch("/privileges/catalog"));
+}
+
+export async function listRoleRows(): Promise<RoleRow[]> {
+  const roles = await listRoles();
+  return Promise.all(
+    roles.map(async (role) => {
+      const roleId = role.id ?? "";
+      if (!roleId) return { ...role, privileges: [], holderIds: [], memberCount: 0 };
+      const [privileges, holderIds] = await Promise.all([
+        getRolePrivileges(roleId),
+        getRoleHolderIds(roleId),
+      ]);
+      return { ...role, privileges, holderIds, memberCount: holderIds.length };
+    }),
+  );
+}
+
+export async function createRole(input: RoleInput): Promise<Role> {
+  const payload = roleInputSchema.parse(input);
+  return roleSchema.parse(
+    await apiFetch("/roles", {
+      method: "POST",
+      body: { name: payload.name, description: payload.description },
+    }),
+  );
+}
+
+export async function updateRole(roleId: string, input: RoleInput): Promise<Role> {
+  const payload = roleInputSchema.parse(input);
+  return roleSchema.parse(
+    await apiFetch(`/roles/${roleId}`, {
+      method: "PUT",
+      body: { name: payload.name, description: payload.description },
+    }),
+  );
+}
+
+export async function addPrivilegeToRole(
+  roleId: string,
+  privilege: PrivilegeKey,
+): Promise<void> {
+  await apiFetch(`/roles/${roleId}/privileges`, {
+    method: "POST",
+    body: { privilege },
+  });
+}
+
+export async function removePrivilegeFromRole(
+  roleId: string,
+  privilege: PrivilegeKey,
+): Promise<void> {
+  await apiFetch(`/roles/${roleId}/privileges/${encodeURIComponent(privilege)}`, {
+    method: "DELETE",
+  });
+}
+
+export async function reconcileRolePrivileges(
+  roleId: string,
+  current: PrivilegeKey[],
+  next: PrivilegeKey[],
+): Promise<void> {
+  const currentSet = new Set(current);
+  const nextSet = new Set(next);
+  const toAdd = next.filter((key) => !currentSet.has(key));
+  const toRemove = current.filter((key) => !nextSet.has(key));
+
+  for (const privilege of toAdd) {
+    await addPrivilegeToRole(roleId, privilege);
+  }
+  for (const privilege of toRemove) {
+    await removePrivilegeFromRole(roleId, privilege);
+  }
+}
+
+export async function grantRoleToUser(userId: string, roleId: string): Promise<void> {
+  await apiFetch(`/users/${userId}/roles`, {
+    method: "POST",
+    body: { role_id: roleId },
+  });
+}
+
+export async function revokeRoleFromUser(userId: string, roleId: string): Promise<void> {
+  await apiFetch(`/users/${userId}/roles/${roleId}`, {
+    method: "DELETE",
+    body: {},
+  });
+}
+
+export async function reconcileRoleMembers(
+  roleId: string,
+  current: string[],
+  next: string[],
+): Promise<void> {
+  const currentSet = new Set(current);
+  const nextSet = new Set(next);
+  const toAdd = next.filter((userId) => !currentSet.has(userId));
+  const toRemove = current.filter((userId) => !nextSet.has(userId));
+
+  for (const userId of toAdd) {
+    await grantRoleToUser(userId, roleId);
+  }
+  for (const userId of toRemove) {
+    await revokeRoleFromUser(userId, roleId);
+  }
+}
diff --git a/web/src/features/core/roles/queries.ts b/web/src/features/core/roles/queries.ts
new file mode 100644
index 000000000..1fd96e473
--- /dev/null
+++ b/web/src/features/core/roles/queries.ts
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+  createRole,
+  listPrivilegeCatalog,
+  listRoleRows,
+  reconcileRoleMembers,
+  reconcileRolePrivileges,
+  updateRole,
+} from "./api";
+import type { RoleInput, RoleRow } from "./schemas";
+
+type RoleMutationInput = RoleInput & {
+  memberUserIds?: string[];
+};
+
+export const roleKeys = {
+  all: ["roles"] as const,
+  rows: () => [...roleKeys.all, "rows"] as const,
+  privileges: () => [...roleKeys.all, "privileges"] as const,
+};
+
+const DEFAULTS = {
+  staleTime: 30_000,
+  gcTime: 300_000,
+  refetchOnWindowFocus: false,
+} as const;
+
+export function useRoleRows() {
+  return useQuery({
+    queryKey: roleKeys.rows(),
+    queryFn: listRoleRows,
+    ...DEFAULTS,
+  });
+}
+
+export function usePrivilegeCatalog() {
+  return useQuery({
+    queryKey: roleKeys.privileges(),
+    queryFn: listPrivilegeCatalog,
+    ...DEFAULTS,
+  });
+}
+
+export function useCreateRole() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: async (input: RoleMutationInput) => {
+      const role = await createRole(input);
+      if (role.id) {
+        await reconcileRolePrivileges(role.id, [], input.privileges);
+        await reconcileRoleMembers(role.id, [], input.memberUserIds ?? []);
+      }
+      return role;
+    },
+    onSettled: () => client.invalidateQueries({ queryKey: roleKeys.all }),
+  });
+}
+
+export function useUpdateRole() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: async ({
+      role,
+      input,
+      memberUserIds,
+    }: {
+      role: RoleRow;
+      input: RoleInput;
+      memberUserIds?: string[];
+    }) => {
+      if (!role.id) throw new Error("Role id is required");
+      const updated = await updateRole(role.id, input);
+      await reconcileRolePrivileges(role.id, role.privileges, input.privileges);
+      await reconcileRoleMembers(role.id, role.holderIds, memberUserIds ?? role.holderIds);
+      return updated;
+    },
+    onSettled: () => client.invalidateQueries({ queryKey: roleKeys.all }),
+  });
+}
diff --git a/web/src/features/core/roles/schemas.ts b/web/src/features/core/roles/schemas.ts
new file mode 100644
index 000000000..d4fedd8c8
--- /dev/null
+++ b/web/src/features/core/roles/schemas.ts
@@ -0,0 +1,58 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { z } from "zod";
+import {
+  privilegeKeySchema,
+  roleDetailResponseSchema,
+  roleSchema,
+  userRoleSchema,
+} from "@/features/core/identity/schemas";
+
+export { privilegeKeySchema, roleDetailResponseSchema, roleSchema };
+
+export const rolesResponseSchema = z
+  .array(roleSchema)
+  .nullish()
+  .transform((value) => value ?? []);
+
+export const roleHoldersResponseSchema = z
+  .array(userRoleSchema)
+  .nullish()
+  .transform((value) => value ?? []);
+
+export const privilegeCatalogResponseSchema = z
+  .array(privilegeKeySchema)
+  .nullish()
+  .transform((value) => value ?? []);
+
+export const roleInputSchema = z.object({
+  name: z.string().trim().min(1),
+  description: z.string().trim(),
+  privileges: z.array(privilegeKeySchema),
+});
+
+export type PrivilegeKey = z.infer<typeof privilegeKeySchema>;
+export type Role = z.infer<typeof roleSchema>;
+export type RoleInput = z.infer<typeof roleInputSchema>;
+export type UserRole = z.infer<typeof roleHoldersResponseSchema>[number];
+
+export type RoleRow = Role & {
+  privileges: PrivilegeKey[];
+  holderIds: string[];
+  memberCount: number;
+};
diff --git a/web/src/mocks/handlers.ts b/web/src/mocks/handlers.ts
index b5f3df526..9a594574c 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/mocks/handlers.ts
@@ -25,11 +25,13 @@ import { organizationsHandlers } from "./handlers/organizations";
 import { privilegesHandlers } from "./handlers/privileges";
 import { projectsHandlers } from "./handlers/projects";
 import { resourcesHandlers } from "./handlers/resources";
+import { rolesHandlers } from "./handlers/roles";
 import { tracesHandlers } from "./handlers/traces";
 
 export const handlers: RequestHandler[] = [
   ...healthzHandlers,
   ...privilegesHandlers,
+  ...rolesHandlers,
   ...identityHandlers,
   ...projectsHandlers,
   ...organizationsHandlers,
diff --git a/web/src/mocks/handlers/roles.ts b/web/src/mocks/handlers/roles.ts
new file mode 100644
index 000000000..769bc0edd
--- /dev/null
+++ b/web/src/mocks/handlers/roles.ts
@@ -0,0 +1,209 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { http, HttpResponse } from "msw";
+import type { PrivilegeKey, Role, UserRole } from "@/generated/core/types.gen";
+
+const PRIVILEGES: PrivilegeKey[] = [
+  "core:allocations:read",
+  "core:allocations:write",
+  "core:clusters:read",
+  "core:clusters:write",
+  "core:organizations:read",
+  "core:organizations:write",
+  "core:privileges:grant",
+  "core:projects:read",
+  "core:projects:write",
+  "core:roles:manage",
+  "core:traces:read",
+  "core:users:read",
+  "core:users:write",
+];
+
+type MockRole = Role & { privileges: PrivilegeKey[]; holders: UserRole[] };
+
+let nextRoleId = 4;
+
+const initialRoles: MockRole[] = [
+    {
+      id: "role-super-admin",
+      name: "Super Admin",
+      description: "Full administrative access across the portal.",
+      is_system: true,
+      created_at: "2026-01-15T09:00:00Z",
+      privileges: PRIVILEGES,
+      holders: [
+        { user_id: "u1", role_id: "role-super-admin", granted_at: "2026-01-15T09:00:00Z" },
+      ],
+    },
+    {
+      id: "role-operator",
+      name: "Operator",
+      description: "Day-to-day allocation and project operations.",
+      is_system: false,
+      created_at: "2026-01-15T09:00:00Z",
+      privileges: [
+        "core:allocations:read",
+        "core:allocations:write",
+        "core:projects:read",
+        "core:projects:write",
+        "core:clusters:read",
+      ],
+      holders: [
+        { user_id: "u2", role_id: "role-operator", granted_at: "2026-01-15T09:00:00Z" },
+        { user_id: "u4", role_id: "role-operator", granted_at: "2026-01-15T09:00:00Z" },
+      ],
+    },
+    {
+      id: "role-auditor",
+      name: "Auditor",
+      description: "Read-only access across allocations, projects, and tracing.",
+      is_system: false,
+      created_at: "2026-01-15T09:00:00Z",
+      privileges: ["core:allocations:read", "core:projects:read", "core:traces:read"],
+      holders: [
+        { user_id: "u3", role_id: "role-auditor", granted_at: "2026-01-15T09:00:00Z" },
+      ],
+    },
+  ];
+
+const roles = new Map<string, MockRole>(initialRoles.map((role) => [role.id ?? "", role]));
+
+function publicRole(role: MockRole): Role {
+  const { privileges: _privileges, holders: _holders, ...rest } = role;
+  return rest;
+}
+
+function roleById(id: string): MockRole | undefined {
+  return roles.get(id);
+}
+
+export const rolesHandlers = [
+  http.get("*/api/v1/privileges/catalog", () => HttpResponse.json(PRIVILEGES)),
+
+  http.get("*/api/v1/roles", () => HttpResponse.json(Array.from(roles.values()).map(publicRole))),
+
+  http.post("*/api/v1/roles", async ({ request }) => {
+    const body = (await request.json()) as { name?: string; description?: string };
+    const name = body.name?.trim();
+    if (!name) return HttpResponse.json({ error: "role name is required" }, { status: 400 });
+    const duplicate = Array.from(roles.values()).some(
+      (role) => role.name?.toLowerCase() === name.toLowerCase(),
+    );
+    if (duplicate) return HttpResponse.json({ error: "role name already exists" }, { status: 409 });
+
+    const id = `role-custom-${nextRoleId++}`;
+    const role: MockRole = {
+      id,
+      name,
+      description: body.description?.trim() ?? "",
+      is_system: false,
+      created_at: new Date().toISOString(),
+      privileges: [],
+      holders: [],
+    };
+    roles.set(id, role);
+    return HttpResponse.json(publicRole(role), { status: 201 });
+  }),
+
+  http.get("*/api/v1/roles/:roleId", ({ params }) => {
+    const role = roleById(String(params.roleId));
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    return HttpResponse.json({ role: publicRole(role), privileges: role.privileges });
+  }),
+
+  http.put("*/api/v1/roles/:roleId", async ({ params, request }) => {
+    const role = roleById(String(params.roleId));
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    if (role.is_system) {
+      return HttpResponse.json({ error: "system roles cannot be renamed" }, { status: 400 });
+    }
+
+    const body = (await request.json()) as { name?: string; description?: string };
+    const name = body.name?.trim();
+    if (!name) return HttpResponse.json({ error: "role name is required" }, { status: 400 });
+    const duplicate = Array.from(roles.values()).some(
+      (candidate) =>
+        candidate.id !== role.id && candidate.name?.toLowerCase() === name.toLowerCase(),
+    );
+    if (duplicate) return HttpResponse.json({ error: "role name already exists" }, { status: 409 });
+
+    role.name = name;
+    role.description = body.description?.trim() ?? "";
+    return HttpResponse.json(publicRole(role));
+  }),
+
+  http.get("*/api/v1/roles/:roleId/holders", ({ params }) => {
+    const role = roleById(String(params.roleId));
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    return HttpResponse.json(role.holders);
+  }),
+
+  http.post("*/api/v1/users/:userId/roles", async ({ params, request }) => {
+    const body = (await request.json()) as { role_id?: string };
+    const role = body.role_id ? roleById(body.role_id) : undefined;
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    const userId = String(params.userId);
+    if (role.holders.some((holder) => holder.user_id === userId)) {
+      return HttpResponse.json({ error: "user already holds that role" }, { status: 409 });
+    }
+    const holder = {
+      user_id: userId,
+      role_id: role.id,
+      granted_at: new Date().toISOString(),
+    };
+    role.holders = [...role.holders, holder];
+    return HttpResponse.json(holder, { status: 201 });
+  }),
+
+  http.delete("*/api/v1/users/:userId/roles/:roleId", ({ params }) => {
+    const role = roleById(String(params.roleId));
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    const userId = String(params.userId);
+    if (!role.holders.some((holder) => holder.user_id === userId)) {
+      return HttpResponse.json({ error: "user does not hold that role" }, { status: 404 });
+    }
+    role.holders = role.holders.filter((holder) => holder.user_id !== userId);
+    return new HttpResponse(null, { status: 204 });
+  }),
+
+  http.post("*/api/v1/roles/:roleId/privileges", async ({ params, request }) => {
+    const role = roleById(String(params.roleId));
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    const body = (await request.json()) as { privilege?: PrivilegeKey };
+    const privilege = body.privilege;
+    if (!privilege || !PRIVILEGES.includes(privilege)) {
+      return HttpResponse.json({ error: "unknown privilege" }, { status: 400 });
+    }
+    if (role.privileges.includes(privilege)) {
+      return HttpResponse.json({ error: "role already carries that privilege" }, { status: 409 });
+    }
+    role.privileges = [...role.privileges, privilege].sort();
+    return new HttpResponse(null, { status: 204 });
+  }),
+
+  http.delete("*/api/v1/roles/:roleId/privileges/:key", ({ params }) => {
+    const role = roleById(String(params.roleId));
+    if (!role) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    const key = String(params.key) as PrivilegeKey;
+    if (!role.privileges.includes(key)) {
+      return HttpResponse.json({ error: "role does not carry that privilege" }, { status: 404 });
+    }
+    role.privileges = role.privileges.filter((privilege) => privilege !== key);
+    return new HttpResponse(null, { status: 204 });
+  }),
+];
diff --git a/web/src/shared/users-admin/permissions.ts b/web/src/shared/users-admin/permissions.ts
index 12a6b4fdd..1258035f2 100644
--- a/web/src/shared/users-admin/permissions.ts
+++ b/web/src/shared/users-admin/permissions.ts
@@ -15,30 +15,63 @@
 // specific language governing permissions and limitations
 // under the License.
 
-// Permission sections mirror the backend's privilege scopes (domain:resource,
-// e.g. "core:allocations"), so each section can be granted read/write
-// independently. Displayed verbatim as the effective-privilege key.
-export const PERMISSION_SECTIONS = [
-  "amie:packets",
-  "amie:replies",
-  "amie:unmapped",
-  "core:allocations",
-  "core:clusters",
-  "core:organizations",
-  "core:projects",
-  "core:traces",
-  "core:users",
-  "temp-account:accounts",
-] as const;
-export type PermissionSection = (typeof PERMISSION_SECTIONS)[number];
-export type PermissionKey = `${PermissionSection}:read` | `${PermissionSection}:write`;
+export type PermissionKey = string;
 
-export function rwStateFor(permissions: PermissionKey[]) {
+type PermissionParts = {
+  section: string;
+  action: string;
+};
+
+const ACTION_ORDER = ["read", "write", "grant", "manage"];
+
+function splitPermission(key: PermissionKey): PermissionParts {
+  const parts = key.split(":");
+  const action = parts.pop() ?? key;
+  return { section: parts.join(":") || key, action };
+}
+
+export function permissionRowsFor(
+  permissions: PermissionKey[],
+  catalog: readonly PermissionKey[] = permissions,
+) {
   const held = new Set(permissions);
-  return PERMISSION_SECTIONS.map((section) => ({
-    section,
-    read: held.has(`${section}:read` as PermissionKey),
-    write: held.has(`${section}:write` as PermissionKey),
+  const keys = Array.from(new Set([...catalog, ...permissions])).sort();
+  const rows = new Map<
+    string,
+    {
+      section: string;
+      actions: Array<{ action: string; key: PermissionKey; active: boolean }>;
+    }
+  >();
+
+  for (const key of keys) {
+    const { section, action } = splitPermission(key);
+    const row = rows.get(section) ?? { section, actions: [] };
+    row.actions.push({ action, key, active: held.has(key) });
+    rows.set(section, row);
+  }
+
+  return Array.from(rows.values()).map((row) => ({
+    ...row,
+    actions: row.actions.sort((a, b) => {
+      const ai = ACTION_ORDER.indexOf(a.action);
+      const bi = ACTION_ORDER.indexOf(b.action);
+      if (ai !== bi) {
+        return (
+          (ai === -1 ? ACTION_ORDER.length : ai) -
+          (bi === -1 ? ACTION_ORDER.length : bi)
+        );
+      }
+      return a.action.localeCompare(b.action);
+    }),
+  }));
+}
+
+export function rwStateFor(permissions: PermissionKey[], catalog?: readonly PermissionKey[]) {
+  return permissionRowsFor(permissions, catalog).map((row) => ({
+    section: row.section,
+    read: row.actions.some((a) => a.action === "read" && a.active),
+    write: row.actions.some((a) => a.action === "write" && a.active),
   }));
 }
 
@@ -46,7 +79,9 @@ export function rwStateFor(permissions: PermissionKey[]) {
 // write also grants read, and revoking read also revokes write.
 export function togglePermission(permissions: PermissionKey[], key: PermissionKey): PermissionKey[] {
   const held = new Set(permissions);
-  const isRead = key.endsWith(":read");
+  const { action } = splitPermission(key);
+  const isRead = action === "read";
+  const isWrite = action === "write";
   const pairedKey = (
     isRead ? key.replace(/:read$/, ":write") : key.replace(/:write$/, ":read")
   ) as PermissionKey;
@@ -56,13 +91,15 @@ export function togglePermission(permissions: PermissionKey[], key: PermissionKe
     if (isRead) held.delete(pairedKey);
   } else {
     held.add(key);
-    if (!isRead) held.add(pairedKey);
+    if (isWrite) held.add(pairedKey);
   }
   return Array.from(held);
 }
 
 // A user's effective permissions are the union of everything granted by
 // each role they hold.
-export function permissionsFromRoles(roles: Array<{ permissions: PermissionKey[] }>): PermissionKey[] {
+export function permissionsFromRoles(
+  roles: Array<{ permissions: PermissionKey[] }>,
+): PermissionKey[] {
   return Array.from(new Set(roles.flatMap((r) => r.permissions)));
 }
diff --git a/web/tests/role-management.e2e.ts b/web/tests/role-management.e2e.ts
new file mode 100644
index 000000000..cbe1db5f0
--- /dev/null
+++ b/web/tests/role-management.e2e.ts
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { expect, test } from "@playwright/test";
+import { signInAs } from "./fixtures/auth";
+
+test.describe("role management", () => {
+  test("lists roles, creates a role, and updates its privileges", async ({ page }) => {
+    await signInAs(page, "admin");
+    await page.goto("/admin/users/roles");
+
+    await expect(page.getByRole("heading", { name: "Users & Permissions" })).toBeVisible();
+    await expect(page.getByText("Super Admin")).toBeVisible();
+    await expect(page.getByText("Operator")).toBeVisible();
+    await expect(page.getByText("Auditor")).toBeVisible();
+
+    const roleName = `Billing Reviewer ${Date.now()}`;
+    await page.getByRole("button", { name: "Create role" }).click();
+    await page.getByLabel("Name").fill(roleName);
+    await page.getByLabel("Description").fill("Reviews allocation billing reports.");
+    await page.getByRole("button", { name: "read", exact: true }).first().click();
+    await page.getByRole("button", { name: "Create role" }).click();
+
+    await expect(page.getByText("Role created")).toBeVisible();
+    await expect(page.getByText(roleName)).toBeVisible();
+
+    await page.getByRole("button", { name: "Edit" }).last().click();
+    await expect(page.getByRole("dialog", { name: "Edit role" })).toBeVisible();
+    await page.getByRole("button", { name: "write", exact: true }).first().click();
+    await page.getByRole("button", { name: "Save changes" }).click();
+
+    await expect(page.getByText("Role updated")).toBeVisible();
+  });
+});