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/19 20:07:00 UTC
(airavata-custos) branch master updated: Add live user management role assignment (#528)
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 ec7069d1c Add live user management role assignment (#528)
ec7069d1c is described below
commit ec7069d1c2b4e4d89e15189e0dbdf69522ec5be9
Author: Ruihuan Gao <ga...@gmail.com>
AuthorDate: Mon Jul 20 04:06:54 2026 +0800
Add live user management role assignment (#528)
* Add live user management role assignment
Add core users API schemas and query hooks for users, roles, identities, and privileges
Load paged user rows with role and identity details in the admin users table
Support role assignment updates with rollback handling and privilege display
Handle empty proxy responses for 204/205/304 statuses
Cover users management and proxy behavior with tests
Co-authored-by: Cursor <cu...@cursor.com>
* Restore account menu layout files to master
Drop the stale My Permissions UserPill changes that broke typecheck
against current master after the clean rebase.
Co-authored-by: Cursor <cu...@cursor.com>
* Add licenses
Co-authored-by: Ruihuan Gao <ga...@gmail.com>
* Fix RoleAssignMenu reason test after RBAC rebase
Save stays disabled until roles change, so unassign before asserting
that the typed reason is forwarded with the role set.
Co-authored-by: Ruihuan Gao <ga...@gmail.com>
* Drop unused single-value pageSizeOptions from UsersTable
Pagination has a fixed page size and no onPageSizeChange handler, so a
one-item pageSizeOptions list is dead UI config. Address review feedback.
Co-authored-by: Ruihuan Gao <ga...@gmail.com>
* Hide Role Management when the user lacks roles:manage.
Gate the tab and page behind CASL so users without permission never hit a 403 roles API.
---------
Co-authored-by: Cursor <cu...@cursor.com>
---
web/src/app/(portal)/admin/users/UsersNav.tsx | 14 +-
.../admin/users/__tests__/UsersNav.test.tsx | 52 ++++
.../admin/users/management/IdentitiesCell.tsx | 12 +-
.../admin/users/management/PermissionsDrawer.tsx | 191 ++++++++++-----
.../admin/users/management/PrivilegeList.tsx | 69 ++++++
.../admin/users/management/RoleAssignMenu.tsx | 107 ++++++---
.../(portal)/admin/users/management/RolesCell.tsx | 23 +-
.../(portal)/admin/users/management/UsersTable.tsx | 216 +++++++++++------
.../admin/users/management/UsersTableContainer.tsx | 83 +++++++
.../__tests__/PermissionsDrawer.test.tsx | 89 +++++++
.../management/__tests__/PrivilegeList.test.tsx | 40 ++++
.../management/__tests__/RoleAssignMenu.test.tsx | 173 ++++++++++++++
.../users/management/__tests__/UsersTable.test.tsx | 119 ++++++++++
.../app/(portal)/admin/users/management/page.tsx | 4 +-
.../roles/{page.tsx => RoleManagementView.tsx} | 21 +-
.../roles/__tests__/RoleManagementView.test.tsx | 65 +++++
web/src/app/(portal)/admin/users/roles/page.tsx | 14 +-
.../app/api/v1/[...path]/__tests__/route.test.ts | 12 +
.../v1/[...path]/proxy-response.ts} | 15 +-
web/src/app/api/v1/[...path]/route.ts | 11 +-
web/src/features/core/users/__tests__/api.test.ts | 81 +++++++
.../features/core/users/__tests__/queries.test.ts | 221 +++++++++++++++++
.../features/core/users/__tests__/schemas.test.ts | 55 +++++
web/src/features/core/users/api.ts | 83 +++++++
web/src/features/core/users/queries.ts | 261 +++++++++++++++++++++
web/src/features/core/users/schemas.ts | 79 +++++++
.../page.tsx => features/core/users/types.ts} | 39 +--
web/src/mocks/handlers.ts | 2 +
web/src/mocks/handlers/identity.ts | 25 +-
web/src/mocks/handlers/users.ts | 47 ++++
30 files changed, 1985 insertions(+), 238 deletions(-)
diff --git a/web/src/app/(portal)/admin/users/UsersNav.tsx b/web/src/app/(portal)/admin/users/UsersNav.tsx
index 4ae8d4c5a..ca8e233c9 100644
--- a/web/src/app/(portal)/admin/users/UsersNav.tsx
+++ b/web/src/app/(portal)/admin/users/UsersNav.tsx
@@ -20,21 +20,31 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
+import { useAbility } from "@/shared/casl/AbilityProvider";
const TABS = [
{ href: "/admin/users/management", label: "User Management" },
- { href: "/admin/users/roles", label: "Role Management" },
+ {
+ href: "/admin/users/roles",
+ label: "Role Management",
+ ability: { action: "manage", subject: "Role" } as const,
+ },
] as const;
export function UsersNav({ rightSlot }: { rightSlot?: React.ReactNode }) {
const pathname = usePathname();
+ const ability = useAbility();
+ const tabs = TABS.filter(
+ (tab) => !("ability" in tab) || ability.can(tab.ability.action, tab.ability.subject),
+ );
+
return (
<nav
aria-label="Users & permissions sections"
className="flex min-h-9 items-end justify-between border-b border-border/60"
>
<ul className="-mb-px flex flex-wrap gap-1">
- {TABS.map((tab) => {
+ {tabs.map((tab) => {
const active = pathname === tab.href || pathname?.startsWith(`${tab.href}/`);
return (
<li key={tab.href}>
diff --git a/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx b/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx
new file mode 100644
index 000000000..57dcc41ef
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx
@@ -0,0 +1,52 @@
+// 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 { defineAbilitiesFor } from "@/shared/casl/abilities";
+import type { Privilege } from "@/features/core/identity/types";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { UsersNav } from "../UsersNav";
+
+let currentPrivileges: Privilege[] = [];
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/admin/users/management",
+}));
+
+vi.mock("@/shared/casl/AbilityProvider", () => ({
+ useAbility: () => defineAbilitiesFor(currentPrivileges),
+}));
+
+describe("UsersNav", () => {
+ beforeEach(() => {
+ currentPrivileges = [];
+ });
+
+ it("hides Role Management without roles:manage", () => {
+ currentPrivileges = ["core:users:read"];
+ render(<UsersNav />);
+ expect(screen.getByRole("link", { name: "User Management" })).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: "Role Management" })).toBeNull();
+ });
+
+ it("shows Role Management with roles:manage", () => {
+ currentPrivileges = ["core:users:read", "core:roles:manage"];
+ render(<UsersNav />);
+ expect(screen.getByRole("link", { name: "User Management" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Role Management" })).toBeInTheDocument();
+ });
+});
diff --git a/web/src/app/(portal)/admin/users/management/IdentitiesCell.tsx b/web/src/app/(portal)/admin/users/management/IdentitiesCell.tsx
index 0f5633ead..19256a7f1 100644
--- a/web/src/app/(portal)/admin/users/management/IdentitiesCell.tsx
+++ b/web/src/app/(portal)/admin/users/management/IdentitiesCell.tsx
@@ -17,7 +17,7 @@
"use client";
-import type { UserIdentity } from "@/generated/core/types.gen";
+import type { UserIdentity } from "@/features/core/users/schemas";
import { Badge } from "@/shared/ui/badge";
import { identitySourceIcon, identitySourceLabel } from "./identities";
@@ -25,10 +25,14 @@ const VISIBLE_IDENTITY_COUNT = 2;
export function IdentitiesCell({
identities,
+ isLoading,
+ hasError,
expanded,
onToggleExpand,
}: {
identities: UserIdentity[];
+ isLoading: boolean;
+ hasError: boolean;
expanded: boolean;
onToggleExpand: () => void;
}) {
@@ -37,7 +41,11 @@ export function IdentitiesCell({
return (
<div className="flex w-[220px] flex-wrap items-center gap-1.5">
- {identities.length === 0 ? (
+ {isLoading ? (
+ <span className="text-sm text-muted-foreground">Loading identities…</span>
+ ) : hasError ? (
+ <span className="text-sm text-muted-foreground">Identities unavailable</span>
+ ) : identities.length === 0 ? (
<span className="text-sm text-muted-foreground">No identities</span>
) : (
visible.map((identity) => {
diff --git a/web/src/app/(portal)/admin/users/management/PermissionsDrawer.tsx b/web/src/app/(portal)/admin/users/management/PermissionsDrawer.tsx
index 28c47cf21..08fa446b7 100644
--- a/web/src/app/(portal)/admin/users/management/PermissionsDrawer.tsx
+++ b/web/src/app/(portal)/admin/users/management/PermissionsDrawer.tsx
@@ -15,33 +15,93 @@
// specific language governing permissions and limitations
// under the License.
-import { Badge } from "@/shared/ui/badge";
+"use client";
+
+import {
+ useDirectPrivileges,
+ useRoleDetails,
+ useUpdateUserRoles,
+} from "@/features/core/users/queries";
+import type { Role } from "@/features/core/users/schemas";
+import type { UserManagementRow } from "@/features/core/users/types";
import { SideDrawer } from "@/shared/ui/SideDrawer";
-import { PermissionRW } from "@/shared/users-admin/PermissionRW";
-import { permissionsFromRoles, rwStateFor } from "@/shared/users-admin/permissions";
-import type { UserRow } from "@/shared/users-admin/types";
-import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
-import { identitySourceIcon, identitySourceLabel } from "./identities";
+import { Badge } from "@/shared/ui/badge";
+import * as React from "react";
+import { toast } from "sonner";
+import { PrivilegeList } from "./PrivilegeList";
import { RoleAssignMenu } from "./RoleAssignMenu";
+import { identitySourceIcon, identitySourceLabel } from "./identities";
export function PermissionsDrawer({
user,
+ rolesCatalog,
+ canManageRoles,
+ canReadDirectPrivileges,
+ currentUserEmail,
onClose,
}: {
- user: UserRow | null;
+ user: UserManagementRow | null;
+ rolesCatalog: Role[];
+ canManageRoles: boolean;
+ canReadDirectPrivileges: boolean;
+ currentUserEmail: string | undefined;
onClose: () => void;
}) {
- const { roles, toggleUserRole } = useUsersAdmin();
- const rwPermissions = rwStateFor(user ? permissionsFromRoles(user.roles) : []).filter(
- (p) => p.read || p.write,
+ const roleIds = user?.roles.flatMap((role) => (role.id ? [role.id] : [])) ?? [];
+ const roleDetails = useRoleDetails(roleIds, Boolean(user) && canManageRoles);
+ const directPrivileges = useDirectPrivileges(
+ user?.id,
+ Boolean(user) && canManageRoles && canReadDirectPrivileges,
);
- const heldRoleIds = new Set(user?.roles.map((r) => r.id ?? "") ?? []);
+ const updateRoles = useUpdateUserRoles();
+ const [assignmentError, setAssignmentError] = React.useState<{
+ userId: string;
+ message: string;
+ } | null>(null);
+ const currentAssignmentError =
+ assignmentError && assignmentError.userId === user?.id ? assignmentError.message : null;
+
+ const roleDerivedPrivileges = Array.from(
+ new Set(roleDetails.roles.flatMap((role) => role.privileges)),
+ );
+ const displayedPrivileges = canReadDirectPrivileges
+ ? Array.from(
+ new Set([
+ ...roleDerivedPrivileges,
+ ...(directPrivileges.data ?? []).map((grant) => grant.privilege),
+ ]),
+ )
+ : roleDerivedPrivileges;
+
+ async function handleSaveRoles(desiredRoleIds: string[], reason?: string): Promise<boolean> {
+ if (!user?.id) return false;
+ setAssignmentError(null);
+ try {
+ await updateRoles.mutateAsync({
+ userId: user.id,
+ currentRoleIds: roleIds,
+ desiredRoleIds,
+ reason,
+ });
+ toast.success("User roles updated");
+ return true;
+ } catch (error) {
+ setAssignmentError({
+ userId: user.id,
+ message: error instanceof Error ? error.message : "Failed to update user roles",
+ });
+ return false;
+ }
+ }
return (
<SideDrawer
open={user !== null}
onOpenChange={(open) => {
- if (!open) onClose();
+ if (!open) {
+ setAssignmentError(null);
+ onClose();
+ }
}}
title={user ? [user.first_name, user.last_name].filter(Boolean).join(" ") : undefined}
description={user?.email}
@@ -49,39 +109,55 @@ export function PermissionsDrawer({
modal={false}
disablePointerDismissal
>
- {user && (
+ {user ? (
<div className="space-y-5">
- <section>
- <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
- Roles
- </h3>
- <div className="flex flex-wrap items-center gap-1.5">
- {user.roles.length === 0 ? (
- <span className="text-sm text-muted-foreground">No roles</span>
- ) : (
- user.roles.map((role) => (
- <Badge key={role.id} variant="outline">
- {role.name}
- </Badge>
- ))
- )}
- <RoleAssignMenu
- roles={roles}
- heldRoleIds={heldRoleIds}
- onToggleRole={(roleId) => user.id && toggleUserRole(user.id, roleId)}
- triggerLabel={`Assign a role to ${user.email ?? "this user"}`}
- />
- </div>
- </section>
-
- <div className="border-t border-border" />
+ {canManageRoles ? (
+ <>
+ <section>
+ <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+ Roles
+ </h3>
+ <div className="flex flex-wrap items-center gap-1.5">
+ {user.rolesLoading ? (
+ <span className="text-sm text-muted-foreground">Loading roles…</span>
+ ) : user.rolesError ? (
+ <span className="text-sm text-muted-foreground">Roles unavailable</span>
+ ) : user.roles.length === 0 ? (
+ <span className="text-sm text-muted-foreground">No roles</span>
+ ) : (
+ user.roles.map((role) => (
+ <Badge key={role.id} variant="outline">
+ {role.name}
+ </Badge>
+ ))
+ )}
+ {!user.rolesLoading && !user.rolesError ? (
+ <RoleAssignMenu
+ roles={rolesCatalog}
+ heldRoleIds={new Set(roleIds)}
+ onSave={handleSaveRoles}
+ triggerLabel={`Assign a role to ${user.email ?? "this user"}`}
+ isCurrentUser={Boolean(currentUserEmail) && user.email === currentUserEmail}
+ isPending={updateRoles.isPending}
+ error={currentAssignmentError}
+ />
+ ) : null}
+ </div>
+ </section>
+ <div className="border-t border-border" />
+ </>
+ ) : null}
<section>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
External Identities
</h3>
<div className="flex flex-wrap items-center gap-1.5">
- {user.identities.length === 0 ? (
+ {user.identitiesLoading ? (
+ <span className="text-sm text-muted-foreground">Loading identities…</span>
+ ) : user.identitiesError ? (
+ <span className="text-sm text-muted-foreground">Identities unavailable</span>
+ ) : user.identities.length === 0 ? (
<span className="text-sm text-muted-foreground">No identities</span>
) : (
user.identities.map((identity) => {
@@ -97,27 +173,26 @@ export function PermissionsDrawer({
</div>
</section>
- <div className="border-t border-border" />
-
- <section>
- <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
- Effective Privileges
- </h3>
- {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>
- )}
- </section>
+ {canManageRoles ? (
+ <>
+ <div className="border-t border-border" />
+ <section>
+ <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+ {canReadDirectPrivileges ? "Effective Privileges" : "Role-derived Privileges"}
+ </h3>
+ {roleDetails.isLoading ||
+ (canReadDirectPrivileges && directPrivileges.isLoading) ? (
+ <p className="text-sm text-muted-foreground">Loading privileges…</p>
+ ) : roleDetails.isError || (canReadDirectPrivileges && directPrivileges.isError) ? (
+ <p className="text-sm text-muted-foreground">Privileges unavailable.</p>
+ ) : (
+ <PrivilegeList privileges={displayedPrivileges} />
+ )}
+ </section>
+ </>
+ ) : null}
</div>
- )}
+ ) : null}
</SideDrawer>
);
}
diff --git a/web/src/app/(portal)/admin/users/management/PrivilegeList.tsx b/web/src/app/(portal)/admin/users/management/PrivilegeList.tsx
new file mode 100644
index 000000000..842f53764
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/PrivilegeList.tsx
@@ -0,0 +1,69 @@
+// 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 { Badge } from "@/shared/ui/badge";
+import { PermissionRW } from "@/shared/users-admin/PermissionRW";
+
+export type GroupedPrivilege =
+ | { kind: "rw"; key: string; read: boolean; write: boolean }
+ | { kind: "standalone"; key: string };
+
+export function groupPrivileges(privileges: string[]): GroupedPrivilege[] {
+ const unique = Array.from(new Set(privileges.filter(Boolean))).sort();
+ const rw = new Map<string, { read: boolean; write: boolean }>();
+ const standalone: string[] = [];
+
+ for (const key of unique) {
+ const match = key.match(/^(.*):(read|write)$/);
+ if (!match) {
+ standalone.push(key);
+ continue;
+ }
+ const [, section, action] = match;
+ if (!section || !action) continue;
+ const current = rw.get(section) ?? { read: false, write: false };
+ current[action as "read" | "write"] = true;
+ rw.set(section, current);
+ }
+
+ return [
+ ...Array.from(rw, ([key, value]) => ({ kind: "rw" as const, key, ...value })),
+ ...standalone.map((key) => ({ kind: "standalone" as const, key })),
+ ];
+}
+
+export function PrivilegeList({ privileges }: { privileges: string[] }) {
+ const grouped = groupPrivileges(privileges);
+ if (grouped.length === 0) {
+ return <p className="text-sm text-muted-foreground">No privileges granted.</p>;
+ }
+
+ return (
+ <ul className="space-y-2">
+ {grouped.map((privilege) => (
+ <li key={privilege.key} className="flex items-center justify-between gap-3 text-sm">
+ <span className="break-all font-mono text-foreground">{privilege.key}</span>
+ {privilege.kind === "rw" ? (
+ <PermissionRW read={privilege.read} write={privilege.write} />
+ ) : (
+ <Badge variant="outline">granted</Badge>
+ )}
+ </li>
+ ))}
+ </ul>
+ );
+}
diff --git a/web/src/app/(portal)/admin/users/management/RoleAssignMenu.tsx b/web/src/app/(portal)/admin/users/management/RoleAssignMenu.tsx
index cb27a86a0..3dac5518c 100644
--- a/web/src/app/(portal)/admin/users/management/RoleAssignMenu.tsx
+++ b/web/src/app/(portal)/admin/users/management/RoleAssignMenu.tsx
@@ -17,8 +17,8 @@
"use client";
-import { Pencil } from "lucide-react";
-import { useState } from "react";
+import { useRoleDetails } from "@/features/core/users/queries";
+import type { Role } from "@/features/core/users/schemas";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -29,9 +29,11 @@ import {
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
-import { PermissionRW } from "@/shared/users-admin/PermissionRW";
-import { rwStateFor } from "@/shared/users-admin/permissions";
-import type { RoleRow } from "@/shared/users-admin/types";
+import { Input } from "@/shared/ui/input";
+import { Label } from "@/shared/ui/label";
+import { Pencil } from "lucide-react";
+import { useState } from "react";
+import { PrivilegeList } from "./PrivilegeList";
function setsEqual(a: Set<string>, b: Set<string>) {
if (a.size !== b.size) return false;
@@ -44,20 +46,35 @@ function setsEqual(a: Set<string>, b: Set<string>) {
export function RoleAssignMenu({
roles,
heldRoleIds,
- onToggleRole,
+ onSave,
triggerLabel,
+ isCurrentUser,
+ isPending,
+ error,
}: {
- roles: RoleRow[];
+ roles: Role[];
heldRoleIds: Set<string>;
- onToggleRole: (roleId: string) => void;
+ onSave: (roleIds: string[], reason?: string) => Promise<boolean>;
triggerLabel: string;
+ isCurrentUser: boolean;
+ isPending: boolean;
+ error: string | null;
}) {
const [open, setOpen] = useState(false);
const [draftIds, setDraftIds] = useState<Set<string>>(new Set());
+ const [reason, setReason] = useState("");
+ const details = useRoleDetails(
+ roles.flatMap((role) => (role.id ? [role.id] : [])),
+ open,
+ );
+ const detailById = new Map(details.roles.map((role) => [role.id, role]));
function handleOpenChange(next: boolean) {
setOpen(next);
- if (next) setDraftIds(new Set(heldRoleIds));
+ if (next) {
+ setDraftIds(new Set(heldRoleIds));
+ setReason("");
+ }
}
function toggleDraft(roleId: string) {
@@ -69,13 +86,20 @@ export function RoleAssignMenu({
});
}
- function handleSave() {
- for (const role of roles) {
- if (role.id && draftIds.has(role.id) !== heldRoleIds.has(role.id)) {
- onToggleRole(role.id);
- }
+ async function handleSave() {
+ const removedIds = [...heldRoleIds].filter((roleId) => !draftIds.has(roleId));
+ const removesOwnRoleManager =
+ isCurrentUser &&
+ removedIds.some((roleId) => detailById.get(roleId)?.privileges.includes("core:roles:manage"));
+ const confirmationMessage = removesOwnRoleManager
+ ? "This may remove your own ability to manage roles. Continue with these changes?"
+ : isCurrentUser && removedIds.length > 0 && details.isError
+ ? "Some role privileges are unavailable, so these changes may remove your own access. Continue?"
+ : null;
+ if (confirmationMessage && !window.confirm(confirmationMessage)) {
+ return;
}
- setOpen(false);
+ if (await onSave([...draftIds], reason.trim() || undefined)) setOpen(false);
}
const hasChanges = !setsEqual(draftIds, heldRoleIds);
@@ -103,7 +127,7 @@ export function RoleAssignMenu({
<ul className="space-y-3 px-6 pt-3 pb-4">
{roles.map((role) => {
const assigned = role.id ? draftIds.has(role.id) : false;
- const rwPermissions = rwStateFor(role.permissions).filter((p) => p.read || p.write);
+ const privileges = role.id ? (detailById.get(role.id)?.privileges ?? []) : [];
return (
<li key={role.id} className="overflow-hidden rounded-lg border border-border">
@@ -122,26 +146,19 @@ export function RoleAssignMenu({
size="sm"
className="shrink-0"
onClick={() => role.id && toggleDraft(role.id)}
+ disabled={isPending}
>
{assigned ? "Unassign" : "Assign"}
</Button>
</div>
<div className="p-4">
- {rwPermissions.length > 0 ? (
- <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>
+ {details.isLoading && privileges.length === 0 ? (
+ <p className="text-sm text-muted-foreground">Loading privileges…</p>
+ ) : details.isError && privileges.length === 0 ? (
+ <p className="text-sm text-muted-foreground">Privileges unavailable.</p>
) : (
- <p className="text-sm text-muted-foreground">No privileges granted.</p>
+ <PrivilegeList privileges={privileges} />
)}
</div>
</li>
@@ -150,12 +167,38 @@ export function RoleAssignMenu({
</ul>
</div>
+ <div className="space-y-1.5">
+ <Label htmlFor="role-assign-reason">Reason (optional)</Label>
+ <Input
+ id="role-assign-reason"
+ value={reason}
+ onChange={(e) => setReason(e.target.value)}
+ placeholder="Record why these roles are being granted"
+ maxLength={500}
+ disabled={isPending}
+ />
+ </div>
+
+ {error ? (
+ <p role="alert" className="text-sm text-[color:var(--custos-red-600)]">
+ {error}
+ </p>
+ ) : null}
<DialogFooter className="-mt-5">
- <Button variant="outline" onClick={() => setOpen(false)} type="button">
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ type="button"
+ disabled={isPending}
+ >
Cancel
</Button>
- <Button onClick={handleSave} type="button" disabled={!hasChanges}>
- Save
+ <Button
+ onClick={() => void handleSave()}
+ type="button"
+ disabled={!hasChanges || isPending || details.isLoading}
+ >
+ {isPending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
diff --git a/web/src/app/(portal)/admin/users/management/RolesCell.tsx b/web/src/app/(portal)/admin/users/management/RolesCell.tsx
index 7b221d892..1450e8e8f 100644
--- a/web/src/app/(portal)/admin/users/management/RolesCell.tsx
+++ b/web/src/app/(portal)/admin/users/management/RolesCell.tsx
@@ -18,26 +18,33 @@
"use client";
import { Badge } from "@/shared/ui/badge";
-import type { UserRow } from "@/shared/users-admin/types";
+import type { Role } from "@/features/core/users/schemas";
const VISIBLE_ROLE_COUNT = 2;
export function RolesCell({
- user,
+ roles,
+ isLoading,
+ hasError,
expanded,
onToggleExpand,
}: {
- user: UserRow;
+ roles: Role[];
+ isLoading: boolean;
+ hasError: boolean;
expanded: boolean;
onToggleExpand: () => void;
}) {
- const userRoles = user.roles;
- const visibleRoles = expanded ? userRoles : userRoles.slice(0, VISIBLE_ROLE_COUNT);
- const hiddenCount = userRoles.length - visibleRoles.length;
+ const visibleRoles = expanded ? roles : roles.slice(0, VISIBLE_ROLE_COUNT);
+ const hiddenCount = roles.length - visibleRoles.length;
return (
<div className="flex w-[260px] flex-wrap items-center gap-1.5">
- {userRoles.length === 0 ? (
+ {isLoading ? (
+ <span className="text-sm text-muted-foreground">Loading roles…</span>
+ ) : hasError ? (
+ <span className="text-sm text-muted-foreground">Roles unavailable</span>
+ ) : roles.length === 0 ? (
<span className="text-sm text-muted-foreground">No roles</span>
) : (
visibleRoles.map((role) => (
@@ -54,7 +61,7 @@ export function RolesCell({
>
+{hiddenCount}
</button>
- ) : expanded && userRoles.length > VISIBLE_ROLE_COUNT ? (
+ ) : expanded && roles.length > VISIBLE_ROLE_COUNT ? (
<button
type="button"
onClick={onToggleExpand}
diff --git a/web/src/app/(portal)/admin/users/management/UsersTable.tsx b/web/src/app/(portal)/admin/users/management/UsersTable.tsx
index 49cce9da9..4b29648b6 100644
--- a/web/src/app/(portal)/admin/users/management/UsersTable.tsx
+++ b/web/src/app/(portal)/admin/users/management/UsersTable.tsx
@@ -17,83 +17,118 @@
"use client";
-import { ChevronRight } from "lucide-react";
-import * as React from "react";
-import { useCurrentUser } from "@/features/core/identity/queries";
-import { cn } from "@/lib/utils";
+import type { Role } from "@/features/core/users/schemas";
+import type { UserManagementRow } from "@/features/core/users/types";
import {
replaceShallowSearchParams,
useShallowSearchParams,
} from "@/shared/hooks/useShallowSearchParams";
import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
import { Input } from "@/shared/ui/input";
-import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
-import type { UserRow } from "@/shared/users-admin/types";
-import { IDENTITY_SOURCE_LABELS } from "./identities";
+import { ChevronRight } from "lucide-react";
+import * as React from "react";
import { IdentitiesCell } from "./IdentitiesCell";
import { PermissionsDrawer } from "./PermissionsDrawer";
import { RolesCell } from "./RolesCell";
+import { IDENTITY_SOURCE_LABELS } from "./identities";
-function fullNameFor(user: UserRow): string {
+function fullNameFor(user: UserManagementRow): string {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
- return name || (user.email ?? "Unknown user");
+ return name || user.email;
}
-// Roles and identities collapse to a couple of chips with a "+N" toggle.
-// Both columns share one row id so expanding either from a row expands both
-// — the row is expanded either way — and only one row is expanded at a
-// time: switching rows (expanding a different one, or opening its drawer)
-// collapses whichever was open before.
function useExpandableRow() {
const [expandedId, setExpandedId] = React.useState<string | null>(null);
- function toggle(id: string) {
- setExpandedId((prev) => (prev === id ? null : id));
- }
- function collapseUnless(id: string) {
- setExpandedId((prev) => (prev === id ? prev : null));
- }
- return { expandedId, toggle, collapseUnless };
+ return {
+ expandedId,
+ toggle: (id: string) => setExpandedId((previous) => (previous === id ? null : id)),
+ collapseUnless: (id: string) =>
+ setExpandedId((previous) => (previous === id ? previous : null)),
+ clear: () => setExpandedId(null),
+ };
}
-export function UsersTable() {
- const { user: currentUser } = useCurrentUser();
- const { users, roles } = useUsersAdmin();
+export function UsersTable({
+ users,
+ rolesCatalog,
+ currentUserEmail,
+ canManageRoles,
+ canReadDirectPrivileges,
+ page,
+ pageSize,
+ total,
+ onPageChange,
+}: {
+ users: UserManagementRow[];
+ rolesCatalog: Role[];
+ currentUserEmail: string | undefined;
+ canManageRoles: boolean;
+ canReadDirectPrivileges: boolean;
+ page: number;
+ pageSize: number;
+ total: number;
+ onPageChange: (page: number) => void;
+}) {
const [selectedId, setSelectedId] = React.useState<string | null>(null);
- const selectedUser = users.find((u) => u.id === selectedId) ?? null;
const expandedRow = useExpandableRow();
-
const searchParams = useShallowSearchParams();
const search = searchParams.get("q") ?? "";
const roleFilter = searchParams.get("role") ?? "all";
const identityFilter = searchParams.get("identity") ?? "all";
+ const filtersActive =
+ Boolean(search.trim()) || (canManageRoles && roleFilter !== "all") || identityFilter !== "all";
+ const selectedUser = users.find((user) => user.id === selectedId) ?? null;
+
+ function resetSelection() {
+ setSelectedId(null);
+ expandedRow.clear();
+ }
function updateFilterParam(key: string, value: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (!value || value === "all") params.delete(key);
else params.set(key, value);
replaceShallowSearchParams(params);
+ if (key !== "q") {
+ resetSelection();
+ onPageChange(1);
+ }
}
- function isCurrentUser(row: UserRow): boolean {
- return Boolean(currentUser?.email) && row.email === currentUser?.email;
+ function isCurrentUser(row: UserManagementRow): boolean {
+ return Boolean(currentUserEmail) && row.email === currentUserEmail;
}
const filteredUsers = React.useMemo(() => {
const needle = search.trim().toLowerCase();
return users.filter((user) => {
if (needle) {
- const hay = `${fullNameFor(user)} ${user.email ?? ""}`.toLowerCase();
- if (!hay.includes(needle)) return false;
+ const haystack = `${fullNameFor(user)} ${user.email}`.toLowerCase();
+ if (!haystack.includes(needle)) return false;
+ }
+ if (canManageRoles && roleFilter !== "all") {
+ if (
+ !user.rolesLoading &&
+ !user.rolesError &&
+ !user.roles.some((role) => role.id === roleFilter)
+ ) {
+ return false;
+ }
}
- if (roleFilter !== "all" && !user.roles.some((r) => r.id === roleFilter)) return false;
- if (identityFilter !== "all" && !user.identities.some((i) => i.source === identityFilter)) {
- return false;
+ if (identityFilter !== "all") {
+ if (
+ !user.identitiesLoading &&
+ !user.identitiesError &&
+ !user.identities.some((identity) => identity.source === identityFilter)
+ ) {
+ return false;
+ }
}
return true;
});
- }, [users, search, roleFilter, identityFilter]);
+ }, [users, search, roleFilter, identityFilter, canManageRoles]);
- const columns: Array<DataTableColumn<UserRow>> = [
+ const columns: Array<DataTableColumn<UserManagementRow>> = [
{
key: "username",
header: "Username",
@@ -111,19 +146,27 @@ export function UsersTable() {
header: "Email",
cell: (row) => <span className="text-muted-foreground">{row.email}</span>,
},
- {
+ ];
+
+ if (canManageRoles) {
+ columns.push({
key: "roles",
header: "Roles",
width: "260px",
interactive: true,
cell: (row) => (
<RolesCell
- user={row}
- expanded={row.id !== undefined && row.id === expandedRow.expandedId}
- onToggleExpand={() => row.id && expandedRow.toggle(row.id)}
+ roles={row.roles}
+ isLoading={row.rolesLoading}
+ hasError={row.rolesError}
+ expanded={row.id === expandedRow.expandedId}
+ onToggleExpand={() => expandedRow.toggle(row.id)}
/>
),
- },
+ });
+ }
+
+ columns.push(
{
key: "identities",
header: "External Identities",
@@ -132,8 +175,10 @@ export function UsersTable() {
cell: (row) => (
<IdentitiesCell
identities={row.identities}
- expanded={row.id !== undefined && row.id === expandedRow.expandedId}
- onToggleExpand={() => row.id && expandedRow.toggle(row.id)}
+ isLoading={row.identitiesLoading}
+ hasError={row.identitiesError}
+ expanded={row.id === expandedRow.expandedId}
+ onToggleExpand={() => expandedRow.toggle(row.id)}
/>
),
},
@@ -143,43 +188,38 @@ export function UsersTable() {
align: "right",
cell: () => <ChevronRight className="ml-auto text-muted-foreground" strokeWidth={1.5} />,
},
- ];
+ );
return (
- // The details panel is non-modal; push the content aside while it is
- // open so no table columns hide behind it (24rem = the panel's max-w-sm).
- <div
- className={cn(
- "space-y-4 transition-[margin-right] duration-200",
- selectedUser && "lg:mr-[24rem]",
- )}
- >
+ <div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card p-4 sm:flex-row sm:items-center">
<Input
type="search"
- placeholder="Search by username or email"
+ placeholder="Search this page by username or email"
value={search}
- onChange={(e) => updateFilterParam("q", e.target.value)}
- aria-label="Search users"
+ onChange={(event) => updateFilterParam("q", event.target.value)}
+ aria-label="Search users on this page"
className="sm:w-72"
/>
- <select
- value={roleFilter}
- onChange={(e) => updateFilterParam("role", e.target.value)}
- aria-label="Filter by role"
- className="h-9 rounded-md border bg-background px-3 text-sm"
- >
- <option value="all">All roles</option>
- {roles.map((role) => (
- <option key={role.id} value={role.id}>
- {role.name}
- </option>
- ))}
- </select>
+ {canManageRoles ? (
+ <select
+ value={roleFilter}
+ onChange={(event) => updateFilterParam("role", event.target.value)}
+ aria-label="Filter this page by role"
+ className="h-9 rounded-md border bg-background px-3 text-sm"
+ >
+ <option value="all">All roles</option>
+ {rolesCatalog.map((role) => (
+ <option key={role.id} value={role.id}>
+ {role.name}
+ </option>
+ ))}
+ </select>
+ ) : null}
<select
value={identityFilter}
- onChange={(e) => updateFilterParam("identity", e.target.value)}
- aria-label="Filter by external identity"
+ onChange={(event) => updateFilterParam("identity", event.target.value)}
+ aria-label="Filter this page by external identity"
className="h-9 rounded-md border bg-background px-3 text-sm"
>
<option value="all">All external identities</option>
@@ -191,26 +231,50 @@ export function UsersTable() {
</select>
</div>
+ {filtersActive ? (
+ <p className="text-sm text-muted-foreground">
+ Showing {filteredUsers.length} match{filteredUsers.length === 1 ? "" : "es"} on this page;{" "}
+ {total} users total.
+ </p>
+ ) : null}
+
<DataTable
columns={columns}
rows={filteredUsers}
- rowKey={(row) => row.id ?? row.email ?? ""}
+ rowKey={(row) => row.id}
onRowClick={(row) => {
- if (row.id) expandedRow.collapseUnless(row.id);
- setSelectedId((prev) => (prev === row.id ? null : (row.id ?? null)));
+ expandedRow.collapseUnless(row.id);
+ setSelectedId((previous) => (previous === row.id ? null : row.id));
}}
rowClassName={(row) =>
- row.id && row.id === selectedId
- ? "bg-[color:var(--brand-tint)] hover:bg-[color:var(--brand-tint)]"
+ isCurrentUser(row)
+ ? "bg-[color:var(--custos-blue-50)]/40 hover:bg-[color:var(--custos-blue-50)]/60"
: undefined
}
empty={
<span className="text-sm text-muted-foreground">
- No users match the current filters.
+ {filtersActive ? "No users match on this page." : "No users found."}
</span>
}
+ pagination={{
+ page,
+ pageSize,
+ total,
+ onPageChange: (nextPage) => {
+ resetSelection();
+ onPageChange(nextPage);
+ },
+ }}
+ />
+ <PermissionsDrawer
+ key={selectedUser?.id ?? "closed"}
+ user={selectedUser}
+ rolesCatalog={rolesCatalog}
+ canManageRoles={canManageRoles}
+ canReadDirectPrivileges={canReadDirectPrivileges}
+ currentUserEmail={currentUserEmail}
+ onClose={() => setSelectedId(null)}
/>
- <PermissionsDrawer user={selectedUser} onClose={() => setSelectedId(null)} />
</div>
);
}
diff --git a/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx b/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
new file mode 100644
index 000000000..16bc4ebf7
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
@@ -0,0 +1,83 @@
+// 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 * as React from "react";
+import { useCurrentUser } from "@/features/core/identity/queries";
+import { useRolesCatalog, useUserPageDetails, useUsers } from "@/features/core/users/queries";
+import { useAbility } from "@/shared/casl/AbilityProvider";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { TableSkeleton } from "@/shared/ui/Loading";
+import { UsersTable } from "./UsersTable";
+
+const PAGE_SIZE = 25;
+
+export function UsersTableContainer() {
+ const ability = useAbility();
+ const canReadUsers = ability.can("read", "User");
+ const canManageRoles = ability.can("manage", "Role");
+ const canReadDirectPrivileges = ability.can("manage", "PrivilegeGrant");
+ const { user: currentUser } = useCurrentUser();
+ const [page, setPage] = React.useState(1);
+ const usersQuery = useUsers(
+ { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
+ { enabled: canReadUsers },
+ );
+ const rolesQuery = useRolesCatalog(canManageRoles);
+ const roleReadsEnabled = canManageRoles && rolesQuery.isSuccess;
+ const hydratedRows = useUserPageDetails(
+ usersQuery.data?.items ?? [],
+ rolesQuery.data ?? [],
+ roleReadsEnabled,
+ ).map((row) => ({
+ ...row,
+ rolesLoading: canManageRoles && (rolesQuery.isLoading || row.rolesLoading),
+ rolesError: canManageRoles && (rolesQuery.isError || row.rolesError),
+ }));
+
+ if (!canReadUsers) {
+ return <ErrorState heading="Not permitted" message="You cannot view users." />;
+ }
+ if (usersQuery.isLoading) {
+ return <TableSkeleton rows={8} columns={canManageRoles ? 5 : 4} />;
+ }
+ if (usersQuery.isError) {
+ return (
+ <ErrorState
+ message={
+ usersQuery.error instanceof Error ? usersQuery.error.message : "Could not load users."
+ }
+ onRetry={() => void usersQuery.refetch()}
+ />
+ );
+ }
+
+ return (
+ <UsersTable
+ users={hydratedRows}
+ rolesCatalog={rolesQuery.data ?? []}
+ currentUserEmail={currentUser?.email}
+ canManageRoles={canManageRoles}
+ canReadDirectPrivileges={canReadDirectPrivileges}
+ page={page}
+ pageSize={PAGE_SIZE}
+ total={usersQuery.data?.total ?? 0}
+ onPageChange={setPage}
+ />
+ );
+}
diff --git a/web/src/app/(portal)/admin/users/management/__tests__/PermissionsDrawer.test.tsx b/web/src/app/(portal)/admin/users/management/__tests__/PermissionsDrawer.test.tsx
new file mode 100644
index 000000000..5ef64e874
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/__tests__/PermissionsDrawer.test.tsx
@@ -0,0 +1,89 @@
+// 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 type { UserManagementRow } from "@/features/core/users/types";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { PermissionsDrawer } from "../PermissionsDrawer";
+
+const queryMocks = vi.hoisted(() => ({
+ mutateAsync: vi.fn(),
+}));
+
+vi.mock("@/features/core/users/queries", () => ({
+ useDirectPrivileges: () => ({ data: [], isLoading: false, isError: false }),
+ useRoleDetails: () => ({ roles: [], isLoading: false, isError: false }),
+ useUpdateUserRoles: () => ({
+ mutateAsync: queryMocks.mutateAsync,
+ isPending: false,
+ }),
+}));
+
+vi.mock("../RoleAssignMenu", () => ({
+ RoleAssignMenu: ({
+ onSave,
+ error,
+ }: {
+ onSave: (roleIds: string[], reason?: string) => Promise<boolean>;
+ error: string | null;
+ }) => (
+ <div>
+ <button type="button" onClick={() => void onSave([], "Onboarding")}>
+ Save mocked roles
+ </button>
+ {error ? <span role="alert">{error}</span> : null}
+ </div>
+ ),
+}));
+
+const user: UserManagementRow = {
+ id: "user-1",
+ email: "user@example.org",
+ first_name: "Example",
+ last_name: "User",
+ roles: [{ id: "role-1", name: "Administrator" }],
+ identities: [],
+ rolesLoading: false,
+ identitiesLoading: false,
+ rolesError: false,
+ identitiesError: false,
+};
+
+describe("PermissionsDrawer", () => {
+ it("clears a role assignment error when the drawer closes", async () => {
+ queryMocks.mutateAsync.mockRejectedValueOnce(new Error("role update failed"));
+ const onClose = vi.fn();
+ const props = {
+ rolesCatalog: [{ id: "role-1", name: "Administrator" }],
+ canManageRoles: true,
+ canReadDirectPrivileges: false,
+ currentUserEmail: "another@example.org",
+ onClose,
+ };
+ const view = render(<PermissionsDrawer {...props} user={user} />);
+
+ fireEvent.click(screen.getByRole("button", { name: "Save mocked roles" }));
+ await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("role update failed"));
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(onClose).toHaveBeenCalled();
+
+ view.rerender(<PermissionsDrawer {...props} user={null} />);
+ view.rerender(<PermissionsDrawer {...props} user={user} />);
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+});
diff --git a/web/src/app/(portal)/admin/users/management/__tests__/PrivilegeList.test.tsx b/web/src/app/(portal)/admin/users/management/__tests__/PrivilegeList.test.tsx
new file mode 100644
index 000000000..993a4e75b
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/__tests__/PrivilegeList.test.tsx
@@ -0,0 +1,40 @@
+// 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 { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { groupPrivileges, PrivilegeList } from "../PrivilegeList";
+
+describe("PrivilegeList", () => {
+ it("groups real read/write keys without inventing write", () => {
+ expect(groupPrivileges(["core:traces:read", "core:users:read", "core:users:write"])).toEqual([
+ { kind: "rw", key: "core:traces", read: true, write: false },
+ { kind: "rw", key: "core:users", read: true, write: true },
+ ]);
+ });
+
+ it("preserves meta and unknown privileges verbatim", () => {
+ render(<PrivilegeList privileges={["core:roles:manage", "connector:custom:inspect"]} />);
+ expect(screen.getByText("core:roles:manage")).toBeInTheDocument();
+ expect(screen.getByText("connector:custom:inspect")).toBeInTheDocument();
+ });
+
+ it("deduplicates repeated keys", () => {
+ render(<PrivilegeList privileges={["core:roles:manage", "core:roles:manage"]} />);
+ expect(screen.getAllByText("core:roles:manage")).toHaveLength(1);
+ });
+});
diff --git a/web/src/app/(portal)/admin/users/management/__tests__/RoleAssignMenu.test.tsx b/web/src/app/(portal)/admin/users/management/__tests__/RoleAssignMenu.test.tsx
new file mode 100644
index 000000000..7efec04a1
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/__tests__/RoleAssignMenu.test.tsx
@@ -0,0 +1,173 @@
+// 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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { RoleAssignMenu } from "../RoleAssignMenu";
+
+const roleDetailsMock = vi.hoisted(() => ({
+ roles: [
+ {
+ id: "role-1",
+ name: "Administrator",
+ privileges: ["core:roles:manage"],
+ },
+ ],
+ isLoading: false,
+ isError: false,
+}));
+
+vi.mock("@/features/core/users/queries", () => ({
+ useRoleDetails: () => roleDetailsMock,
+}));
+
+describe("RoleAssignMenu", () => {
+ beforeEach(() => {
+ roleDetailsMock.roles = [
+ {
+ id: "role-1",
+ name: "Administrator",
+ privileges: ["core:roles:manage"],
+ },
+ ];
+ roleDetailsMock.isLoading = false;
+ roleDetailsMock.isError = false;
+ });
+
+ it("submits the desired role set once", async () => {
+ const onSave = vi.fn().mockResolvedValue(true);
+ render(
+ <RoleAssignMenu
+ roles={[{ id: "role-1", name: "Administrator" }]}
+ heldRoleIds={new Set(["role-1"])}
+ onSave={onSave}
+ triggerLabel="Manage user roles"
+ isCurrentUser={false}
+ isPending={false}
+ error={null}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /edit roles/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Unassign" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith([], undefined));
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+
+ it("submits the typed reason with the role set", async () => {
+ const onSave = vi.fn().mockResolvedValue(true);
+ render(
+ <RoleAssignMenu
+ roles={[{ id: "role-1", name: "Administrator" }]}
+ heldRoleIds={new Set(["role-1"])}
+ onSave={onSave}
+ triggerLabel="Manage user roles"
+ isCurrentUser={false}
+ isPending={false}
+ error={null}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /edit roles/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Unassign" }));
+ fireEvent.change(screen.getByLabelText(/reason/i), {
+ target: { value: "Onboarding new team member" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() =>
+ expect(onSave).toHaveBeenCalledWith([], "Onboarding new team member"),
+ );
+ });
+
+ it("confirms before self-removing a role-manager role", async () => {
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
+ const onSave = vi.fn().mockResolvedValue(true);
+ render(
+ <RoleAssignMenu
+ roles={[{ id: "role-1", name: "Administrator" }]}
+ heldRoleIds={new Set(["role-1"])}
+ onSave={onSave}
+ triggerLabel="Manage user roles"
+ isCurrentUser
+ isPending={false}
+ error={null}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /edit roles/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Unassign" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(confirm).toHaveBeenCalled();
+ expect(onSave).not.toHaveBeenCalled();
+ confirm.mockRestore();
+ });
+
+ it("allows saving when role privilege previews are unavailable", async () => {
+ roleDetailsMock.roles = [];
+ roleDetailsMock.isError = true;
+ const onSave = vi.fn().mockResolvedValue(true);
+ render(
+ <RoleAssignMenu
+ roles={[{ id: "role-1", name: "Administrator" }]}
+ heldRoleIds={new Set(["role-1"])}
+ onSave={onSave}
+ triggerLabel="Manage user roles"
+ isCurrentUser={false}
+ isPending={false}
+ error={null}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /edit roles/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Unassign" }));
+ const save = screen.getByRole("button", { name: "Save" });
+ expect(save).toBeEnabled();
+ fireEvent.click(save);
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith([], undefined));
+ });
+
+ it("uses a conservative confirmation when current-user privileges are unavailable", () => {
+ roleDetailsMock.roles = [];
+ roleDetailsMock.isError = true;
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
+ const onSave = vi.fn().mockResolvedValue(true);
+ render(
+ <RoleAssignMenu
+ roles={[{ id: "role-1", name: "Administrator" }]}
+ heldRoleIds={new Set(["role-1"])}
+ onSave={onSave}
+ triggerLabel="Manage user roles"
+ isCurrentUser
+ isPending={false}
+ error={null}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /edit roles/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Unassign" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(confirm).toHaveBeenCalledWith(expect.stringContaining("privileges are unavailable"));
+ expect(onSave).not.toHaveBeenCalled();
+ confirm.mockRestore();
+ });
+});
diff --git a/web/src/app/(portal)/admin/users/management/__tests__/UsersTable.test.tsx b/web/src/app/(portal)/admin/users/management/__tests__/UsersTable.test.tsx
new file mode 100644
index 000000000..f09c578ae
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/management/__tests__/UsersTable.test.tsx
@@ -0,0 +1,119 @@
+// 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 type { UserManagementRow } from "@/features/core/users/types";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { UsersTable } from "../UsersTable";
+
+const searchParamMocks = vi.hoisted(() => ({
+ params: new URLSearchParams(),
+ replace: vi.fn(),
+}));
+
+vi.mock("@/shared/hooks/useShallowSearchParams", () => ({
+ useShallowSearchParams: () => searchParamMocks.params,
+ replaceShallowSearchParams: searchParamMocks.replace,
+}));
+
+const row: UserManagementRow = {
+ id: "user-1",
+ email: "user@example.org",
+ first_name: "Example",
+ last_name: "User",
+ roles: [{ id: "role-1", name: "Administrator" }],
+ identities: [{ id: "identity-1", user_id: "user-1", source: "cilogon" }],
+ rolesLoading: false,
+ identitiesLoading: false,
+ rolesError: false,
+ identitiesError: false,
+};
+
+function renderTable(canManageRoles: boolean, users: UserManagementRow[] = [row]) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const onPageChange = vi.fn();
+ const view = render(
+ <QueryClientProvider client={client}>
+ <UsersTable
+ users={users}
+ rolesCatalog={[{ id: "role-1", name: "Administrator" }]}
+ currentUserEmail="user@example.org"
+ canManageRoles={canManageRoles}
+ canReadDirectPrivileges={false}
+ page={1}
+ pageSize={25}
+ total={users.length}
+ onPageChange={onPageChange}
+ />
+ </QueryClientProvider>,
+ );
+ return { ...view, onPageChange };
+}
+
+describe("UsersTable", () => {
+ beforeEach(() => {
+ searchParamMocks.params = new URLSearchParams();
+ searchParamMocks.replace.mockReset();
+ });
+
+ it("renders a hydrated user and marks the current user", () => {
+ renderTable(true);
+ expect(screen.getByText("Example User")).toBeInTheDocument();
+ expect(screen.getByText("(You)")).toBeInTheDocument();
+ expect(screen.getAllByText("Administrator")).toHaveLength(2);
+ expect(screen.getAllByText("CILogon")).toHaveLength(2);
+ });
+
+ it("hides all role UI without roles:manage", () => {
+ renderTable(false);
+ expect(screen.queryByText("Roles")).toBeNull();
+ expect(screen.queryByText("Administrator")).toBeNull();
+ expect(screen.queryByRole("combobox", { name: /role/i })).toBeNull();
+ });
+
+ it("keeps rows visible while filtered details are loading or unavailable", () => {
+ searchParamMocks.params = new URLSearchParams([
+ ["role", "role-1"],
+ ["identity", "cilogon"],
+ ]);
+ renderTable(true, [
+ {
+ ...row,
+ roles: [],
+ identities: [],
+ rolesLoading: true,
+ identitiesError: true,
+ },
+ ]);
+
+ expect(screen.getByText("Example User")).toBeInTheDocument();
+ expect(screen.getByText("Loading roles…")).toBeInTheDocument();
+ expect(screen.getByText("Identities unavailable")).toBeInTheDocument();
+ });
+
+ it("keeps the permissions drawer open while typing in search", () => {
+ const { onPageChange } = renderTable(true);
+ fireEvent.click(screen.getByText("Example User"));
+ expect(screen.getByText("Edit roles")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "exam" } });
+
+ expect(screen.getByText("Edit roles")).toBeInTheDocument();
+ expect(onPageChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/app/(portal)/admin/users/management/page.tsx b/web/src/app/(portal)/admin/users/management/page.tsx
index 321e17ee0..a2edce6f7 100644
--- a/web/src/app/(portal)/admin/users/management/page.tsx
+++ b/web/src/app/(portal)/admin/users/management/page.tsx
@@ -16,13 +16,13 @@
// under the License.
import { UsersNav } from "../UsersNav";
-import { UsersTable } from "./UsersTable";
+import { UsersTableContainer } from "./UsersTableContainer";
export default function UserManagementPage() {
return (
<div className="space-y-4">
<UsersNav />
- <UsersTable />
+ <UsersTableContainer />
</div>
);
}
diff --git a/web/src/app/(portal)/admin/users/roles/page.tsx b/web/src/app/(portal)/admin/users/roles/RoleManagementView.tsx
similarity index 70%
copy from web/src/app/(portal)/admin/users/roles/page.tsx
copy to web/src/app/(portal)/admin/users/roles/RoleManagementView.tsx
index 680e3c165..697d70091 100644
--- a/web/src/app/(portal)/admin/users/roles/page.tsx
+++ b/web/src/app/(portal)/admin/users/roles/RoleManagementView.tsx
@@ -15,12 +15,31 @@
// specific language governing permissions and limitations
// under the License.
+"use client";
+
+import { useAbility } from "@/shared/casl/AbilityProvider";
import { Button } from "@/shared/ui/button";
+import { ErrorState } from "@/shared/ui/ErrorState";
import { UsersNav } from "../UsersNav";
import { RoleFormDialog } from "./RoleFormDialog";
import { RolesGrid } from "./RolesGrid";
-export default function RoleManagementPage() {
+export function RoleManagementView() {
+ const ability = useAbility();
+ const canManageRoles = ability.can("manage", "Role");
+
+ if (!canManageRoles) {
+ return (
+ <div className="space-y-6">
+ <UsersNav />
+ <ErrorState
+ heading="Not permitted"
+ message="You do not have permission to manage roles."
+ />
+ </div>
+ );
+ }
+
return (
<div className="space-y-6">
<UsersNav
diff --git a/web/src/app/(portal)/admin/users/roles/__tests__/RoleManagementView.test.tsx b/web/src/app/(portal)/admin/users/roles/__tests__/RoleManagementView.test.tsx
new file mode 100644
index 000000000..84e4d6fbe
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/roles/__tests__/RoleManagementView.test.tsx
@@ -0,0 +1,65 @@
+// 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 { defineAbilitiesFor } from "@/shared/casl/abilities";
+import type { Privilege } from "@/features/core/identity/types";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { RoleManagementView } from "../RoleManagementView";
+
+let currentPrivileges: Privilege[] = [];
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/admin/users/roles",
+}));
+
+vi.mock("@/shared/casl/AbilityProvider", () => ({
+ useAbility: () => defineAbilitiesFor(currentPrivileges),
+}));
+
+vi.mock("../RolesGrid", () => ({
+ RolesGrid: () => <div>Roles grid</div>,
+}));
+
+vi.mock("../RoleFormDialog", () => ({
+ RoleFormDialog: ({ triggerContent }: { triggerContent: string }) => (
+ <button type="button">{triggerContent}</button>
+ ),
+}));
+
+describe("RoleManagementView", () => {
+ beforeEach(() => {
+ currentPrivileges = [];
+ });
+
+ it("blocks the roles page without roles:manage and does not mount the grid", () => {
+ currentPrivileges = ["core:users:read"];
+ render(<RoleManagementView />);
+ expect(screen.getByRole("alert")).toHaveTextContent(/do not have permission to manage roles/i);
+ expect(screen.queryByText("Roles grid")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Create role" })).toBeNull();
+ expect(screen.queryByRole("link", { name: "Role Management" })).toBeNull();
+ });
+
+ it("renders role management UI with roles:manage", () => {
+ currentPrivileges = ["core:roles:manage"];
+ render(<RoleManagementView />);
+ expect(screen.getByText("Roles grid")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Create role" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Role Management" })).toBeInTheDocument();
+ });
+});
diff --git a/web/src/app/(portal)/admin/users/roles/page.tsx b/web/src/app/(portal)/admin/users/roles/page.tsx
index 680e3c165..f2f672db9 100644
--- a/web/src/app/(portal)/admin/users/roles/page.tsx
+++ b/web/src/app/(portal)/admin/users/roles/page.tsx
@@ -15,18 +15,8 @@
// specific language governing permissions and limitations
// under the License.
-import { Button } from "@/shared/ui/button";
-import { UsersNav } from "../UsersNav";
-import { RoleFormDialog } from "./RoleFormDialog";
-import { RolesGrid } from "./RolesGrid";
+import { RoleManagementView } from "./RoleManagementView";
export default function RoleManagementPage() {
- return (
- <div className="space-y-6">
- <UsersNav
- rightSlot={<RoleFormDialog triggerRender={<Button />} triggerContent="Create role" />}
- />
- <RolesGrid />
- </div>
- );
+ return <RoleManagementView />;
}
diff --git a/web/src/app/api/v1/[...path]/__tests__/route.test.ts b/web/src/app/api/v1/[...path]/__tests__/route.test.ts
index 1ad865e56..1b8bcd7cb 100644
--- a/web/src/app/api/v1/[...path]/__tests__/route.test.ts
+++ b/web/src/app/api/v1/[...path]/__tests__/route.test.ts
@@ -18,6 +18,8 @@
import { NextRequest } from "next/server";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { responseBodyForStatus } from "../proxy-response";
+
vi.mock("@/lib/env", () => ({
serverEnv: {
CUSTOS_CORE_API_BASE_URL: "https://core.example.org",
@@ -40,6 +42,16 @@ afterEach(() => {
fetchMock.mockReset();
});
+describe("responseBodyForStatus", () => {
+ it.each([204, 205, 304])("returns null for bodyless status %s", (status) => {
+ expect(responseBodyForStatus(status, "")).toBeNull();
+ });
+
+ it("preserves an ordinary response body", () => {
+ expect(responseBodyForStatus(200, '{"ok":true}')).toBe('{"ok":true}');
+ });
+});
+
describe("api v1 proxy route", () => {
it("proxies no-content backend responses without constructing a response body", async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
diff --git a/web/src/app/(portal)/admin/users/management/page.tsx b/web/src/app/api/v1/[...path]/proxy-response.ts
similarity index 68%
copy from web/src/app/(portal)/admin/users/management/page.tsx
copy to web/src/app/api/v1/[...path]/proxy-response.ts
index 321e17ee0..a18a6e7c0 100644
--- a/web/src/app/(portal)/admin/users/management/page.tsx
+++ b/web/src/app/api/v1/[...path]/proxy-response.ts
@@ -15,14 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-import { UsersNav } from "../UsersNav";
-import { UsersTable } from "./UsersTable";
-
-export default function UserManagementPage() {
- return (
- <div className="space-y-4">
- <UsersNav />
- <UsersTable />
- </div>
- );
+export function responseBodyForStatus(status: number, body: string): string | null {
+ // The Fetch standard forbids a body for these statuses. Passing even an
+ // empty string makes the Response constructor throw, turning a successful
+ // upstream DELETE (204) into a portal-side 500.
+ return status === 204 || status === 205 || status === 304 ? null : body;
}
diff --git a/web/src/app/api/v1/[...path]/route.ts b/web/src/app/api/v1/[...path]/route.ts
index 73a228cb3..c267ea600 100644
--- a/web/src/app/api/v1/[...path]/route.ts
+++ b/web/src/app/api/v1/[...path]/route.ts
@@ -18,6 +18,7 @@
import { serverEnv } from "@/lib/env";
import { getPortalSession, pickBackendBearer } from "@/shared/auth/session";
import { type NextRequest, NextResponse } from "next/server";
+import { responseBodyForStatus } from "./proxy-response";
export const runtime = "nodejs";
@@ -31,7 +32,10 @@ async function proxy(request: NextRequest, ctx: Context) {
const session = await getPortalSession();
const bearer = pickBackendBearer(session);
if (!bearer) {
- return NextResponse.json({ code: "missing_bearer", message: "Not authenticated" }, { status: 401 });
+ return NextResponse.json(
+ { code: "missing_bearer", message: "Not authenticated" },
+ { status: 401 },
+ );
}
const headers = new Headers();
@@ -57,9 +61,8 @@ async function proxy(request: NextRequest, ctx: Context) {
const traceId = upstream.headers.get("x-trace-id");
if (traceId) responseHeaders.set("x-trace-id", traceId);
- const hasNoBody = upstream.status === 204 || upstream.status === 205 || upstream.status === 304;
-
- return new NextResponse(hasNoBody ? null : await upstream.text(), {
+ const upstreamBody = await upstream.text();
+ return new NextResponse(responseBodyForStatus(upstream.status, upstreamBody), {
status: upstream.status,
headers: responseHeaders,
});
diff --git a/web/src/features/core/users/__tests__/api.test.ts b/web/src/features/core/users/__tests__/api.test.ts
new file mode 100644
index 000000000..0b85aae93
--- /dev/null
+++ b/web/src/features/core/users/__tests__/api.test.ts
@@ -0,0 +1,81 @@
+// 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 { afterEach, describe, expect, it, vi } from "vitest";
+import { assignUserRole, listUsers, removeUserRole } from "../api";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+function mockResponse(status: number, body?: unknown): Response {
+ return new Response(body === undefined ? null : JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+afterEach(() => fetchMock.mockReset());
+
+describe("users API", () => {
+ it("sends limit and offset when listing users", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockResponse(200, {
+ items: [{ id: "user-1", email: "user@example.org" }],
+ total: 1,
+ }),
+ );
+ const result = await listUsers({ limit: 25, offset: 50 });
+ expect(result.total).toBe(1);
+ const url = String(fetchMock.mock.calls[0]?.[0]);
+ expect(url).toContain("limit=25");
+ expect(url).toContain("offset=50");
+ });
+
+ it("posts the role id when assigning a role", async () => {
+ fetchMock.mockResolvedValueOnce(mockResponse(201, { user_id: "user-1", role_id: "role-1" }));
+ await assignUserRole("user-1", "role-1");
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ method: "POST",
+ body: JSON.stringify({ role_id: "role-1" }),
+ });
+ });
+
+ it("includes the reason in the assignment body when provided", async () => {
+ fetchMock.mockResolvedValueOnce(mockResponse(201, { user_id: "user-1", role_id: "role-1" }));
+ await assignUserRole("user-1", "role-1", "Onboarding");
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ method: "POST",
+ body: JSON.stringify({ role_id: "role-1", reason: "Onboarding" }),
+ });
+ });
+
+ it("omits the reason when the value is blank", async () => {
+ fetchMock.mockResolvedValueOnce(mockResponse(201, { user_id: "user-1", role_id: "role-1" }));
+ await assignUserRole("user-1", "role-1", " ");
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ method: "POST",
+ body: JSON.stringify({ role_id: "role-1" }),
+ });
+ });
+
+ it("uses the assignment resource when removing a role", async () => {
+ fetchMock.mockResolvedValueOnce(mockResponse(204));
+ await removeUserRole("user-1", "role-1");
+ expect(fetchMock.mock.calls[0]?.[0]).toContain("/users/user-1/roles/role-1");
+ expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE");
+ });
+});
diff --git a/web/src/features/core/users/__tests__/queries.test.ts b/web/src/features/core/users/__tests__/queries.test.ts
new file mode 100644
index 000000000..7e0e4875c
--- /dev/null
+++ b/web/src/features/core/users/__tests__/queries.test.ts
@@ -0,0 +1,221 @@
+// 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook } from "@testing-library/react";
+import { type ReactNode, createElement } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { identityKeys } from "@/features/core/identity/queries";
+import { applyUserRoleChanges, useUpdateUserRoles, userKeys } from "../queries";
+import type { UserRole } from "../schemas";
+
+const apiMocks = vi.hoisted(() => ({
+ assignUserRole: vi.fn(),
+ removeUserRole: vi.fn(),
+}));
+
+vi.mock("../api", async (importOriginal) => ({
+ ...(await importOriginal<typeof import("../api")>()),
+ assignUserRole: apiMocks.assignUserRole,
+ removeUserRole: apiMocks.removeUserRole,
+}));
+
+afterEach(() => {
+ apiMocks.assignUserRole.mockReset();
+ apiMocks.removeUserRole.mockReset();
+});
+
+describe("userKeys", () => {
+ it("uses one shared role-grant key per user", () => {
+ expect(userKeys.roles("user-1")).toEqual(["users", "roles", "user-1"]);
+ });
+
+ it("keeps role detail separate from user grants", () => {
+ expect(userKeys.roleDetail("role-1")).toEqual(["users", "role-detail", "role-1"]);
+ });
+
+ it("carries pagination parameters in list keys", () => {
+ const params = { limit: 25, offset: 25 };
+ expect(userKeys.list(params)).toEqual(["users", "list", params]);
+ });
+});
+
+describe("applyUserRoleChanges", () => {
+ it("applies additions before removals in a deterministic order", async () => {
+ const calls: string[] = [];
+ const operations = {
+ assign: vi.fn(async (_userId: string, roleId: string) => {
+ calls.push(`assign:${roleId}`);
+ }),
+ remove: vi.fn(async (_userId: string, roleId: string) => {
+ calls.push(`remove:${roleId}`);
+ }),
+ };
+
+ await applyUserRoleChanges(
+ {
+ userId: "user-1",
+ currentRoleIds: ["old-role"],
+ desiredRoleIds: ["new-role"],
+ },
+ operations,
+ );
+
+ expect(calls).toEqual(["assign:new-role", "remove:old-role"]);
+ });
+
+ it("forwards the reason to assign operations", async () => {
+ const assignArgs: { roleId: string; reason?: string }[] = [];
+ const operations = {
+ assign: vi.fn(async (_userId: string, roleId: string, reason?: string) => {
+ assignArgs.push({ roleId, reason });
+ }),
+ remove: vi.fn(async () => undefined),
+ };
+
+ await applyUserRoleChanges(
+ {
+ userId: "user-1",
+ currentRoleIds: [],
+ desiredRoleIds: ["new-role"],
+ reason: "Onboarding",
+ },
+ operations,
+ );
+
+ expect(assignArgs).toEqual([{ roleId: "new-role", reason: "Onboarding" }]);
+ });
+
+ it("rolls back completed changes in reverse order when a later change fails", async () => {
+ const calls: string[] = [];
+ let oldRoleRemovalAttempts = 0;
+ const operations = {
+ assign: vi.fn(async (_userId: string, roleId: string) => {
+ calls.push(`assign:${roleId}`);
+ }),
+ remove: vi.fn(async (_userId: string, roleId: string) => {
+ calls.push(`remove:${roleId}`);
+ if (roleId === "old-role-2" && oldRoleRemovalAttempts++ === 0) {
+ throw new Error("backend rejected removal");
+ }
+ }),
+ };
+
+ await expect(
+ applyUserRoleChanges(
+ {
+ userId: "user-1",
+ currentRoleIds: ["old-role-1", "old-role-2"],
+ desiredRoleIds: ["new-role"],
+ },
+ operations,
+ ),
+ ).rejects.toThrow("completed changes were rolled back");
+
+ expect(calls).toEqual([
+ "assign:new-role",
+ "remove:old-role-1",
+ "remove:old-role-2",
+ "assign:old-role-1",
+ "remove:new-role",
+ ]);
+ });
+
+ it("warns when rollback cannot fully restore the previous roles", async () => {
+ let newRoleRemovalAttempts = 0;
+ const operations = {
+ assign: vi.fn(async () => undefined),
+ remove: vi.fn(async (_userId: string, roleId: string) => {
+ if (roleId === "old-role") throw new Error("update failed");
+ if (roleId === "new-role" && newRoleRemovalAttempts++ === 0) {
+ throw new Error("rollback failed");
+ }
+ }),
+ };
+
+ await expect(
+ applyUserRoleChanges(
+ {
+ userId: "user-1",
+ currentRoleIds: ["old-role"],
+ desiredRoleIds: ["new-role"],
+ },
+ operations,
+ ),
+ ).rejects.toThrow("rollback could not fully restore");
+ });
+});
+
+describe("useUpdateUserRoles", () => {
+ it("updates the cached role baseline before the mutation resolves", async () => {
+ apiMocks.assignUserRole.mockResolvedValue({
+ user_id: "user-1",
+ role_id: "new-role",
+ });
+ apiMocks.removeUserRole.mockResolvedValue(undefined);
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
+ client.setQueryData<UserRole[]>(userKeys.roles("user-1"), [
+ { user_id: "user-1", role_id: "old-role" },
+ ]);
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client }, children);
+ const { result } = renderHook(() => useUpdateUserRoles(), { wrapper });
+
+ await act(() =>
+ result.current.mutateAsync({
+ userId: "user-1",
+ currentRoleIds: ["old-role"],
+ desiredRoleIds: ["new-role"],
+ }),
+ );
+
+ expect(client.getQueryData<UserRole[]>(userKeys.roles("user-1"))).toEqual([
+ { user_id: "user-1", role_id: "new-role" },
+ ]);
+ });
+
+ it("invalidates current access after role changes", async () => {
+ apiMocks.assignUserRole.mockResolvedValue({
+ user_id: "user-1",
+ role_id: "new-role",
+ });
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
+ client.setQueryData(identityKeys.privileges(), ["core:roles:manage"]);
+ client.setQueryData([...identityKeys.access("user-1"), true], {
+ roles: [],
+ direct: [],
+ privileges: ["core:roles:manage"],
+ provenance: true,
+ });
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client }, children);
+ const { result } = renderHook(() => useUpdateUserRoles(), { wrapper });
+
+ await act(() =>
+ result.current.mutateAsync({
+ userId: "user-1",
+ currentRoleIds: [],
+ desiredRoleIds: ["new-role"],
+ }),
+ );
+
+ expect(client.getQueryState(identityKeys.privileges())?.isInvalidated).toBe(true);
+ expect(client.getQueryState([...identityKeys.access("user-1"), true])?.isInvalidated).toBe(
+ true,
+ );
+ });
+});
diff --git a/web/src/features/core/users/__tests__/schemas.test.ts b/web/src/features/core/users/__tests__/schemas.test.ts
new file mode 100644
index 000000000..86e88cf4c
--- /dev/null
+++ b/web/src/features/core/users/__tests__/schemas.test.ts
@@ -0,0 +1,55 @@
+// 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 { describe, expect, it } from "vitest";
+import {
+ roleDetailResponseSchema,
+ userListResponseSchema,
+ userRolesResponseSchema,
+} from "../schemas";
+
+const user = {
+ id: "user-1",
+ email: "user@example.org",
+ first_name: "Example",
+ status: "ACTIVE",
+};
+
+describe("user schemas", () => {
+ it("parses a complete paginated user response", () => {
+ expect(userListResponseSchema.parse({ items: [user], total: 1 })).toEqual({
+ items: [user],
+ total: 1,
+ });
+ });
+
+ it("rejects missing pagination totals", () => {
+ expect(() => userListResponseSchema.parse({ items: [user] })).toThrow();
+ });
+
+ it("normalizes null role lists to an empty array", () => {
+ expect(userRolesResponseSchema.parse(null)).toEqual([]);
+ });
+
+ it("preserves arbitrary privilege keys returned by role detail", () => {
+ const result = roleDetailResponseSchema.parse({
+ role: { id: "role-1", name: "Admin" },
+ privileges: ["core:roles:manage", "connector:custom:inspect"],
+ });
+ expect(result.privileges).toEqual(["core:roles:manage", "connector:custom:inspect"]);
+ });
+});
diff --git a/web/src/features/core/users/api.ts b/web/src/features/core/users/api.ts
new file mode 100644
index 000000000..28d64d0ed
--- /dev/null
+++ b/web/src/features/core/users/api.ts
@@ -0,0 +1,83 @@
+// 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 {
+ grantRoleResponseSchema,
+ roleDetailResponseSchema,
+ rolesResponseSchema,
+ userIdentitiesResponseSchema,
+ userListResponseSchema,
+ userPrivilegesResponseSchema,
+ userRolesResponseSchema,
+} from "./schemas";
+import type {
+ Role,
+ RoleDetail,
+ UserIdentity,
+ UserListResponse,
+ UserPrivilege,
+ UserRole,
+} from "./schemas";
+import type { UserListParams } from "./types";
+
+export async function listUsers(params: UserListParams = {}): Promise<UserListResponse> {
+ const search = new URLSearchParams();
+ if (typeof params.limit === "number") search.set("limit", String(params.limit));
+ if (typeof params.offset === "number") search.set("offset", String(params.offset));
+ const query = search.toString();
+ return userListResponseSchema.parse(await apiFetch(`/users${query ? `?${query}` : ""}`));
+}
+
+export async function listRolesCatalog(): Promise<Role[]> {
+ return rolesResponseSchema.parse(await apiFetch("/roles"));
+}
+
+export async function listRolesForUser(userId: string): Promise<UserRole[]> {
+ return userRolesResponseSchema.parse(await apiFetch(`/users/${userId}/roles`));
+}
+
+export async function listUserIdentities(userId: string): Promise<UserIdentity[]> {
+ return userIdentitiesResponseSchema.parse(await apiFetch(`/users/${userId}/user-identities`));
+}
+
+export async function listDirectPrivileges(userId: string): Promise<UserPrivilege[]> {
+ return userPrivilegesResponseSchema.parse(await apiFetch(`/users/${userId}/privileges`));
+}
+
+export async function getRoleDetail(roleId: string): Promise<RoleDetail> {
+ return roleDetailResponseSchema.parse(await apiFetch(`/roles/${roleId}`));
+}
+
+export async function assignUserRole(
+ userId: string,
+ roleId: string,
+ reason?: string,
+): Promise<UserRole> {
+ const body: Record<string, string> = { role_id: roleId };
+ if (reason?.trim()) body.reason = reason.trim();
+ return grantRoleResponseSchema.parse(
+ await apiFetch(`/users/${userId}/roles`, {
+ method: "POST",
+ body,
+ }),
+ );
+}
+
+export async function removeUserRole(userId: string, roleId: string): Promise<void> {
+ await apiFetch(`/users/${userId}/roles/${roleId}`, { method: "DELETE" });
+}
diff --git a/web/src/features/core/users/queries.ts b/web/src/features/core/users/queries.ts
new file mode 100644
index 000000000..7b4022963
--- /dev/null
+++ b/web/src/features/core/users/queries.ts
@@ -0,0 +1,261 @@
+// 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, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
+import { identityKeys } from "@/features/core/identity/queries";
+import {
+ assignUserRole,
+ getRoleDetail,
+ listDirectPrivileges,
+ listRolesCatalog,
+ listRolesForUser,
+ listUserIdentities,
+ listUsers,
+ removeUserRole,
+} from "./api";
+import type { Role, User, UserRole } from "./schemas";
+import type {
+ RoleWithPrivileges,
+ UpdateUserRolesInput,
+ UserListParams,
+ UserManagementRow,
+} from "./types";
+
+export const userKeys = {
+ all: ["users"] as const,
+ list: (params: UserListParams = {}) => [...userKeys.all, "list", params] as const,
+ rolesCatalog: () => [...userKeys.all, "roles-catalog"] as const,
+ roles: (userId: string) => [...userKeys.all, "roles", userId] as const,
+ identities: (userId: string) => [...userKeys.all, "identities", userId] as const,
+ directPrivileges: (userId: string) => [...userKeys.all, "direct-privileges", userId] as const,
+ roleDetail: (roleId: string) => [...userKeys.all, "role-detail", roleId] as const,
+};
+
+const DEFAULTS = {
+ staleTime: 30_000,
+ gcTime: 300_000,
+ refetchOnWindowFocus: false,
+} as const;
+
+type RoleUpdateOperations = {
+ assign: (userId: string, roleId: string, reason?: string) => Promise<unknown>;
+ remove: (userId: string, roleId: string) => Promise<unknown>;
+};
+
+const defaultRoleUpdateOperations: RoleUpdateOperations = {
+ assign: assignUserRole,
+ remove: removeUserRole,
+};
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+export async function applyUserRoleChanges(
+ { userId, currentRoleIds, desiredRoleIds, reason }: UpdateUserRolesInput,
+ operations: RoleUpdateOperations = defaultRoleUpdateOperations,
+): Promise<void> {
+ const current = new Set(currentRoleIds);
+ const desired = new Set(desiredRoleIds);
+ const changes = [
+ ...[...desired]
+ .filter((roleId) => !current.has(roleId))
+ .map((roleId) => ({
+ apply: () => operations.assign(userId, roleId, reason),
+ rollback: () => operations.remove(userId, roleId),
+ })),
+ ...[...current]
+ .filter((roleId) => !desired.has(roleId))
+ .map((roleId) => ({
+ apply: () => operations.remove(userId, roleId),
+ rollback: () => operations.assign(userId, roleId),
+ })),
+ ];
+ const applied: typeof changes = [];
+
+ try {
+ for (const change of changes) {
+ await change.apply();
+ applied.push(change);
+ }
+ } catch (error) {
+ const rollbackFailures: unknown[] = [];
+ for (const change of [...applied].reverse()) {
+ try {
+ await change.rollback();
+ } catch (rollbackError) {
+ rollbackFailures.push(rollbackError);
+ }
+ }
+
+ if (rollbackFailures.length > 0) {
+ throw new Error(
+ `Role update partially failed, and rollback could not fully restore the previous roles. Refresh to see the current assignments. Original error: ${errorMessage(error)}`,
+ );
+ }
+ if (applied.length > 0) {
+ throw new Error(
+ `Role update failed; completed changes were rolled back. ${errorMessage(error)}`,
+ );
+ }
+ throw error;
+ }
+}
+
+export function useUsers(params: UserListParams = {}, options: { enabled?: boolean } = {}) {
+ return useQuery({
+ queryKey: userKeys.list(params),
+ queryFn: () => listUsers(params),
+ enabled: options.enabled ?? true,
+ ...DEFAULTS,
+ });
+}
+
+export function useRolesCatalog(enabled: boolean) {
+ return useQuery({
+ queryKey: userKeys.rolesCatalog(),
+ queryFn: listRolesCatalog,
+ enabled,
+ ...DEFAULTS,
+ });
+}
+
+export function useUserRoles(userId: string | undefined, enabled: boolean) {
+ return useQuery({
+ queryKey: userId ? userKeys.roles(userId) : [...userKeys.all, "roles", "none"],
+ queryFn: () => listRolesForUser(userId as string),
+ enabled: Boolean(userId) && enabled,
+ ...DEFAULTS,
+ });
+}
+
+export function useUserIdentities(userId: string | undefined, enabled = true) {
+ return useQuery({
+ queryKey: userId ? userKeys.identities(userId) : [...userKeys.all, "identities", "none"],
+ queryFn: () => listUserIdentities(userId as string),
+ enabled: Boolean(userId) && enabled,
+ ...DEFAULTS,
+ });
+}
+
+export function useDirectPrivileges(userId: string | undefined, enabled: boolean) {
+ return useQuery({
+ queryKey: userId
+ ? userKeys.directPrivileges(userId)
+ : [...userKeys.all, "direct-privileges", "none"],
+ queryFn: () => listDirectPrivileges(userId as string),
+ enabled: Boolean(userId) && enabled,
+ ...DEFAULTS,
+ });
+}
+
+export function useRoleDetails(
+ roleIds: string[],
+ enabled: boolean,
+): { roles: RoleWithPrivileges[]; isLoading: boolean; isError: boolean } {
+ const stableIds = Array.from(new Set(roleIds.filter(Boolean))).sort();
+ const results = useQueries({
+ queries: stableIds.map((roleId) => ({
+ queryKey: userKeys.roleDetail(roleId),
+ queryFn: () => getRoleDetail(roleId),
+ enabled,
+ ...DEFAULTS,
+ })),
+ });
+
+ const roles = results.flatMap((result) => {
+ if (!result.data?.role) return [];
+ return [{ ...result.data.role, privileges: result.data.privileges }];
+ });
+ return {
+ roles,
+ isLoading: enabled && results.some((result) => result.isLoading),
+ isError: enabled && results.some((result) => result.isError),
+ };
+}
+
+export function useUserPageDetails(
+ users: User[],
+ rolesCatalog: Role[],
+ canManageRoles: boolean,
+): UserManagementRow[] {
+ const usersWithIds = users.filter((user): user is User & { id: string } => Boolean(user.id));
+ const identityResults = useQueries({
+ queries: usersWithIds.map((user) => ({
+ queryKey: userKeys.identities(user.id),
+ queryFn: () => listUserIdentities(user.id),
+ ...DEFAULTS,
+ })),
+ });
+ const roleResults = useQueries({
+ queries: usersWithIds.map((user) => ({
+ queryKey: userKeys.roles(user.id),
+ queryFn: () => listRolesForUser(user.id),
+ enabled: canManageRoles,
+ ...DEFAULTS,
+ })),
+ });
+ const roleById = new Map(rolesCatalog.map((role) => [role.id, role]));
+ const indexById = new Map(usersWithIds.map((user, index) => [user.id, index]));
+
+ return users.map((user) => {
+ const index = indexById.get(user.id);
+ const identitiesQuery = index === undefined ? undefined : identityResults[index];
+ const rolesQuery = index === undefined ? undefined : roleResults[index];
+ const roles = canManageRoles
+ ? (rolesQuery?.data ?? []).flatMap((grant) => {
+ const role = roleById.get(grant.role_id);
+ return role ? [role] : [];
+ })
+ : [];
+ return {
+ ...user,
+ roles,
+ identities: identitiesQuery?.data ?? [],
+ rolesLoading: canManageRoles && Boolean(rolesQuery?.isLoading),
+ identitiesLoading: Boolean(identitiesQuery?.isLoading),
+ rolesError: canManageRoles && Boolean(rolesQuery?.isError),
+ identitiesError: Boolean(identitiesQuery?.isError),
+ };
+ });
+}
+
+export function useUpdateUserRoles() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (input: UpdateUserRolesInput) => applyUserRoleChanges(input),
+ onSuccess: (_data, { userId, desiredRoleIds }) => {
+ client.setQueryData<UserRole[]>(userKeys.roles(userId), (currentGrants = []) =>
+ desiredRoleIds.map(
+ (roleId) =>
+ currentGrants.find((grant) => grant.role_id === roleId) ?? {
+ user_id: userId,
+ role_id: roleId,
+ },
+ ),
+ );
+ },
+ onSettled: (_data, _error, variables) =>
+ Promise.all([
+ client.invalidateQueries({ queryKey: userKeys.roles(variables.userId) }),
+ client.invalidateQueries({ queryKey: identityKeys.privileges() }),
+ client.invalidateQueries({ queryKey: identityKeys.access(variables.userId) }),
+ ]).then(() => undefined),
+ });
+}
diff --git a/web/src/features/core/users/schemas.ts b/web/src/features/core/users/schemas.ts
new file mode 100644
index 000000000..7fdc821ad
--- /dev/null
+++ b/web/src/features/core/users/schemas.ts
@@ -0,0 +1,79 @@
+// 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 { zRole, zUser, zUserIdentity } from "@/generated/core/zod.gen";
+import {
+ privilegeKeySchema,
+ userPrivilegeSchema as baseUserPrivilegeSchema,
+ userRoleSchema as baseUserRoleSchema,
+} from "@/features/core/identity/schemas";
+
+export { privilegeKeySchema };
+
+export const userSchema = zUser.required({ id: true, email: true });
+export const roleSchema = zRole.required({ id: true, name: true });
+export const userIdentitySchema = zUserIdentity.required({
+ id: true,
+ source: true,
+ user_id: true,
+});
+export const userRoleSchema = baseUserRoleSchema.required({
+ role_id: true,
+ user_id: true,
+});
+export const userPrivilegeSchema = baseUserPrivilegeSchema
+ .extend({ privilege: privilegeKeySchema })
+ .required({ user_id: true });
+
+export const userListResponseSchema = z.object({
+ items: z.array(userSchema),
+ total: z.number().int().nonnegative(),
+});
+export const rolesResponseSchema = z
+ .array(roleSchema)
+ .nullish()
+ .transform((value) => value ?? []);
+export const userRolesResponseSchema = z
+ .array(userRoleSchema)
+ .nullish()
+ .transform((value) => value ?? []);
+export const userIdentitiesResponseSchema = z
+ .array(userIdentitySchema)
+ .nullish()
+ .transform((value) => value ?? []);
+export const userPrivilegesResponseSchema = z
+ .array(userPrivilegeSchema)
+ .nullish()
+ .transform((value) => value ?? []);
+export const roleDetailResponseSchema = z.object({
+ role: roleSchema.optional(),
+ privileges: z
+ .array(privilegeKeySchema)
+ .nullish()
+ .transform((value) => value ?? []),
+});
+
+export const grantRoleResponseSchema = userRoleSchema;
+
+export type User = z.infer<typeof userSchema>;
+export type Role = z.infer<typeof roleSchema>;
+export type UserIdentity = z.infer<typeof userIdentitySchema>;
+export type UserRole = z.infer<typeof userRoleSchema>;
+export type UserPrivilege = z.infer<typeof userPrivilegeSchema>;
+export type UserListResponse = z.infer<typeof userListResponseSchema>;
+export type RoleDetail = z.infer<typeof roleDetailResponseSchema>;
diff --git a/web/src/app/(portal)/admin/users/roles/page.tsx b/web/src/features/core/users/types.ts
similarity index 60%
copy from web/src/app/(portal)/admin/users/roles/page.tsx
copy to web/src/features/core/users/types.ts
index 680e3c165..b98f9b217 100644
--- a/web/src/app/(portal)/admin/users/roles/page.tsx
+++ b/web/src/features/core/users/types.ts
@@ -15,18 +15,29 @@
// specific language governing permissions and limitations
// under the License.
-import { Button } from "@/shared/ui/button";
-import { UsersNav } from "../UsersNav";
-import { RoleFormDialog } from "./RoleFormDialog";
-import { RolesGrid } from "./RolesGrid";
+import type { Role, User, UserIdentity } from "./schemas";
-export default function RoleManagementPage() {
- return (
- <div className="space-y-6">
- <UsersNav
- rightSlot={<RoleFormDialog triggerRender={<Button />} triggerContent="Create role" />}
- />
- <RolesGrid />
- </div>
- );
-}
+export type UserListParams = {
+ limit?: number;
+ offset?: number;
+};
+
+export type RoleWithPrivileges = Role & {
+ privileges: string[];
+};
+
+export type UserManagementRow = User & {
+ roles: Role[];
+ identities: UserIdentity[];
+ rolesLoading: boolean;
+ identitiesLoading: boolean;
+ rolesError: boolean;
+ identitiesError: boolean;
+};
+
+export type UpdateUserRolesInput = {
+ userId: string;
+ currentRoleIds: string[];
+ desiredRoleIds: string[];
+ reason?: string;
+};
diff --git a/web/src/mocks/handlers.ts b/web/src/mocks/handlers.ts
index 9a594574c..c05d37c1d 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/mocks/handlers.ts
@@ -27,12 +27,14 @@ import { projectsHandlers } from "./handlers/projects";
import { resourcesHandlers } from "./handlers/resources";
import { rolesHandlers } from "./handlers/roles";
import { tracesHandlers } from "./handlers/traces";
+import { usersHandlers } from "./handlers/users";
export const handlers: RequestHandler[] = [
...healthzHandlers,
...privilegesHandlers,
...rolesHandlers,
...identityHandlers,
+ ...usersHandlers,
...projectsHandlers,
...organizationsHandlers,
...allocationsHandlers,
diff --git a/web/src/mocks/handlers/identity.ts b/web/src/mocks/handlers/identity.ts
index 9ad87ec55..42f1500ee 100644
--- a/web/src/mocks/handlers/identity.ts
+++ b/web/src/mocks/handlers/identity.ts
@@ -26,24 +26,13 @@ type RoleDetail = (typeof fixture.roleDetails)[keyof typeof fixture.roleDetails]
let user = { ...fixture.user };
export const identityHandlers = [
- http.get("*/api/v1/me", () =>
- HttpResponse.json({
- user,
- privileges: effectivePrivileges(),
- roles: fixture.roles.flatMap((grant) => {
- const detail = (fixture.roleDetails as Record<string, RoleDetail | undefined>)[
- grant.role_id
- ];
- return detail?.role
- ? [{ role: detail.role, privileges: detail.privileges, granted_at: grant.granted_at }]
- : [];
- }),
- }),
+ http.get("*/api/v1/me", () => HttpResponse.json({ user, privileges: effectivePrivileges() })),
+ http.get("*/api/v1/users/:id/user-identities", ({ params }) =>
+ HttpResponse.json(String(params.id) === fixture.user.id ? fixture.identities : []),
),
- http.get("*/api/v1/users/:id/user-identities", () =>
- HttpResponse.json(fixture.identities),
+ http.get("*/api/v1/users/:id/roles", ({ params }) =>
+ HttpResponse.json(String(params.id) === fixture.user.id ? fixture.roles : []),
),
- http.get("*/api/v1/users/:id/roles", () => HttpResponse.json(fixture.roles)),
http.get("*/api/v1/roles/:roleId", ({ params }) => {
const detail = (fixture.roleDetails as Record<string, RoleDetail | undefined>)[
String(params.roleId)
@@ -51,7 +40,9 @@ export const identityHandlers = [
if (!detail) return HttpResponse.json({ error: "not found" }, { status: 404 });
return HttpResponse.json(detail);
}),
- http.get("*/api/v1/users/:id/privileges", () => HttpResponse.json(fixture.direct)),
+ http.get("*/api/v1/users/:id/privileges", ({ params }) =>
+ HttpResponse.json(String(params.id) === fixture.user.id ? fixture.direct : []),
+ ),
http.put("*/api/v1/users/:id", async ({ request }) => {
const body = (await request.json()) as {
first_name?: string;
diff --git a/web/src/mocks/handlers/users.ts b/web/src/mocks/handlers/users.ts
new file mode 100644
index 000000000..b34608fbb
--- /dev/null
+++ b/web/src/mocks/handlers/users.ts
@@ -0,0 +1,47 @@
+// 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 fixture from "@/features/core/identity/__fixtures__/settings.json";
+
+const roles = Object.values(fixture.roleDetails).map((detail) => detail.role);
+
+export const usersHandlers = [
+ http.get("*/api/v1/users", ({ request }) => {
+ const url = new URL(request.url);
+ const limit = Number(url.searchParams.get("limit") ?? 25);
+ const offset = Number(url.searchParams.get("offset") ?? 0);
+ const users = [fixture.user];
+ return HttpResponse.json({
+ items: users.slice(offset, offset + limit),
+ total: users.length,
+ });
+ }),
+ http.get("*/api/v1/roles", () => HttpResponse.json(roles)),
+ http.post("*/api/v1/users/:id/roles", async ({ params, request }) => {
+ const body = (await request.json()) as { role_id?: string };
+ return HttpResponse.json(
+ {
+ user_id: String(params.id),
+ role_id: body.role_id,
+ granted_at: new Date().toISOString(),
+ },
+ { status: 201 },
+ );
+ }),
+ http.delete("*/api/v1/users/:id/roles/:roleId", () => new HttpResponse(null, { status: 204 })),
+];