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();
+ });
+});