Add Users & Permissions admin section with role management (#520)
* Add Users & Permissions admin section with role management
Adds User Management and Role Management tabs under /admin/users, backed
by a shared client-side context: browsable/filterable users table with
roles and external identities, a role-permission matrix mapped to the
portal's left-nav sections, and a create/edit role dialog that can assign
roles to users directly.
* Replace role-assign dropdown with a Manage Roles modal
Roles are now assigned/unassigned from a centered "Manage roles" modal
opened via an "Edit roles" button in the user drawer, instead of an
inline dropdown in the users table. Each role renders as its own card
showing only the privileges it actually grants, changes are buffered
locally and only applied on Save, and the modal's backdrop now renders
correctly when nested inside the non-modal side drawer.
Also moves the users-admin context, types, and permission helpers out
of the admin/users route into @/shared/users-admin so the account
dropdown's new "My Permissions" dialog can reuse them, and lifts
UsersAdminProvider up to the portal layout.
* Add Apache license headers to the users-admin source files
These files were added without the ASF header used elsewhere in the repo.
* Remove My Permissions item from the account menu
Drops the dropdown entry and its now-unused dialog; permissions are
already reviewable from the Users & Permissions admin drawer.
* Gate the Users & Permissions nav item behind read:User
Every other admin nav entry already checks an ability; this one had
none, so it rendered for any signed-in user regardless of privileges.
* Scope UsersAdminProvider to the admin/users layout
Addresses review feedback: the mock users-admin state was wrapping the
whole portal layout, but only pages under admin/users ever read it.
diff --git "a/web/src/app/\050portal\051/admin/users/UsersNav.tsx" "b/web/src/app/\050portal\051/admin/users/UsersNav.tsx"
new file mode 100644
index 0000000..4ae8d4c
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/UsersNav.tsx"
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+const TABS = [
+ { href: "/admin/users/management", label: "User Management" },
+ { href: "/admin/users/roles", label: "Role Management" },
+] as const;
+
+export function UsersNav({ rightSlot }: { rightSlot?: React.ReactNode }) {
+ const pathname = usePathname();
+ 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) => {
+ const active = pathname === tab.href || pathname?.startsWith(`${tab.href}/`);
+ return (
+ <li key={tab.href}>
+ <Link
+ href={tab.href}
+ className={cn(
+ "inline-flex items-center px-4 py-2 text-sm font-medium transition-colors",
+ active
+ ? "border-b-2 border-brand text-foreground"
+ : "border-b-2 border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {tab.label}
+ </Link>
+ </li>
+ );
+ })}
+ </ul>
+ {rightSlot ?? null}
+ </nav>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/layout.tsx" "b/web/src/app/\050portal\051/admin/users/layout.tsx"
new file mode 100644
index 0000000..0e7e087
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/layout.tsx"
@@ -0,0 +1,37 @@
+// 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 { ReactNode } from "react";
+import { UsersAdminProvider } from "@/shared/users-admin/UsersAdminContext";
+
+export default function AdminUsersLayout({ children }: { children: ReactNode }) {
+ return (
+ <UsersAdminProvider>
+ <div className="space-y-4">
+ <header className="space-y-1 pb-4">
+ <h1 className="font-display text-[28px] font-bold leading-tight">
+ Users & Permissions
+ </h1>
+ <p className="text-sm text-muted-foreground">
+ Manage user accounts, roles, and the permissions each role grants.
+ </p>
+ </header>
+ {children}
+ </div>
+ </UsersAdminProvider>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/IdentitiesCell.tsx" "b/web/src/app/\050portal\051/admin/users/management/IdentitiesCell.tsx"
new file mode 100644
index 0000000..0f5633e
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/IdentitiesCell.tsx"
@@ -0,0 +1,72 @@
+// 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 type { UserIdentity } from "@/generated/core/types.gen";
+import { Badge } from "@/shared/ui/badge";
+import { identitySourceIcon, identitySourceLabel } from "./identities";
+
+const VISIBLE_IDENTITY_COUNT = 2;
+
+export function IdentitiesCell({
+ identities,
+ expanded,
+ onToggleExpand,
+}: {
+ identities: UserIdentity[];
+ expanded: boolean;
+ onToggleExpand: () => void;
+}) {
+ const visible = expanded ? identities : identities.slice(0, VISIBLE_IDENTITY_COUNT);
+ const hiddenCount = identities.length - visible.length;
+
+ return (
+ <div className="flex w-[220px] flex-wrap items-center gap-1.5">
+ {identities.length === 0 ? (
+ <span className="text-sm text-muted-foreground">No identities</span>
+ ) : (
+ visible.map((identity) => {
+ const Icon = identitySourceIcon(identity.source);
+ return (
+ <Badge key={identity.id} variant="outline">
+ <Icon data-icon="inline-start" />
+ {identitySourceLabel(identity.source)}
+ </Badge>
+ );
+ })
+ )}
+ {hiddenCount > 0 ? (
+ <button
+ type="button"
+ onClick={onToggleExpand}
+ className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
+ >
+ +{hiddenCount}
+ </button>
+ ) : expanded && identities.length > VISIBLE_IDENTITY_COUNT ? (
+ <button
+ type="button"
+ onClick={onToggleExpand}
+ className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
+ >
+ Show less
+ </button>
+ ) : null}
+ </div>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/PermissionsDrawer.tsx" "b/web/src/app/\050portal\051/admin/users/management/PermissionsDrawer.tsx"
new file mode 100644
index 0000000..7b57328
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/PermissionsDrawer.tsx"
@@ -0,0 +1,117 @@
+// 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 { 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 { RoleAssignMenu } from "./RoleAssignMenu";
+
+export function PermissionsDrawer({
+ user,
+ onClose,
+}: {
+ user: UserRow | null;
+ onClose: () => void;
+}) {
+ const { roles, toggleUserRole } = useUsersAdmin();
+ const rwPermissions = rwStateFor(user ? permissionsFromRoles(user.roles) : []);
+ const heldRoleIds = new Set(user?.roles.map((r) => r.id ?? "") ?? []);
+
+ return (
+ <SideDrawer
+ open={user !== null}
+ onOpenChange={(open) => {
+ if (!open) onClose();
+ }}
+ title={user ? [user.first_name, user.last_name].filter(Boolean).join(" ") : undefined}
+ description={user?.email}
+ width="sm"
+ modal={false}
+ disablePointerDismissal
+ >
+ {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" />
+
+ <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 ? (
+ <span className="text-sm text-muted-foreground">No identities</span>
+ ) : (
+ user.identities.map((identity) => {
+ const Icon = identitySourceIcon(identity.source);
+ return (
+ <Badge key={identity.id} variant="outline">
+ <Icon data-icon="inline-start" />
+ {identitySourceLabel(identity.source)}
+ </Badge>
+ );
+ })
+ )}
+ </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>
+ <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>
+ </div>
+ )}
+ </SideDrawer>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/RoleAssignMenu.tsx" "b/web/src/app/\050portal\051/admin/users/management/RoleAssignMenu.tsx"
new file mode 100644
index 0000000..cb27a86
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/RoleAssignMenu.tsx"
@@ -0,0 +1,164 @@
+// 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 { Pencil } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ 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";
+
+function setsEqual(a: Set<string>, b: Set<string>) {
+ if (a.size !== b.size) return false;
+ for (const value of a) {
+ if (!b.has(value)) return false;
+ }
+ return true;
+}
+
+export function RoleAssignMenu({
+ roles,
+ heldRoleIds,
+ onToggleRole,
+ triggerLabel,
+}: {
+ roles: RoleRow[];
+ heldRoleIds: Set<string>;
+ onToggleRole: (roleId: string) => void;
+ triggerLabel: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [draftIds, setDraftIds] = useState<Set<string>>(new Set());
+
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+ if (next) setDraftIds(new Set(heldRoleIds));
+ }
+
+ function toggleDraft(roleId: string) {
+ setDraftIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(roleId)) next.delete(roleId);
+ else next.add(roleId);
+ return next;
+ });
+ }
+
+ function handleSave() {
+ for (const role of roles) {
+ if (role.id && draftIds.has(role.id) !== heldRoleIds.has(role.id)) {
+ onToggleRole(role.id);
+ }
+ }
+ setOpen(false);
+ }
+
+ const hasChanges = !setsEqual(draftIds, heldRoleIds);
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger
+ render={
+ <button
+ type="button"
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
+ />
+ }
+ >
+ <Pencil className="size-3.5" />
+ Edit roles
+ </DialogTrigger>
+ <DialogContent className="gap-5 sm:max-w-2xl">
+ <DialogHeader className="-mx-4 -mb-5 gap-1 border-b border-border px-4 pt-2 pb-4">
+ <DialogTitle className="text-lg font-semibold">Manage roles</DialogTitle>
+ <DialogDescription>{triggerLabel}</DialogDescription>
+ </DialogHeader>
+
+ <div className="-mx-4 max-h-[28rem] overflow-y-auto">
+ <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);
+
+ return (
+ <li key={role.id} className="overflow-hidden rounded-lg border border-border">
+ <div className="flex items-start justify-between gap-4 bg-muted/60 px-4 py-3">
+ <div>
+ <p className="font-heading text-base font-semibold text-foreground">
+ {role.name}
+ </p>
+ {role.description && (
+ <p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
+ )}
+ </div>
+ <Button
+ type="button"
+ variant={assigned ? "secondary" : "default"}
+ size="sm"
+ className="shrink-0"
+ onClick={() => role.id && toggleDraft(role.id)}
+ >
+ {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>
+ ) : (
+ <p className="text-sm text-muted-foreground">No privileges granted.</p>
+ )}
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+
+ <DialogFooter className="-mt-5">
+ <Button variant="outline" onClick={() => setOpen(false)} type="button">
+ Cancel
+ </Button>
+ <Button onClick={handleSave} type="button" disabled={!hasChanges}>
+ Save
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/RolesCell.tsx" "b/web/src/app/\050portal\051/admin/users/management/RolesCell.tsx"
new file mode 100644
index 0000000..7b221d8
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/RolesCell.tsx"
@@ -0,0 +1,68 @@
+// 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 { Badge } from "@/shared/ui/badge";
+import type { UserRow } from "@/shared/users-admin/types";
+
+const VISIBLE_ROLE_COUNT = 2;
+
+export function RolesCell({
+ user,
+ expanded,
+ onToggleExpand,
+}: {
+ user: UserRow;
+ expanded: boolean;
+ onToggleExpand: () => void;
+}) {
+ const userRoles = user.roles;
+ const visibleRoles = expanded ? userRoles : userRoles.slice(0, VISIBLE_ROLE_COUNT);
+ const hiddenCount = userRoles.length - visibleRoles.length;
+
+ return (
+ <div className="flex w-[260px] flex-wrap items-center gap-1.5">
+ {userRoles.length === 0 ? (
+ <span className="text-sm text-muted-foreground">No roles</span>
+ ) : (
+ visibleRoles.map((role) => (
+ <Badge key={role.id} variant="outline">
+ {role.name}
+ </Badge>
+ ))
+ )}
+ {hiddenCount > 0 ? (
+ <button
+ type="button"
+ onClick={onToggleExpand}
+ className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
+ >
+ +{hiddenCount}
+ </button>
+ ) : expanded && userRoles.length > VISIBLE_ROLE_COUNT ? (
+ <button
+ type="button"
+ onClick={onToggleExpand}
+ className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
+ >
+ Show less
+ </button>
+ ) : null}
+ </div>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/UsersTable.tsx" "b/web/src/app/\050portal\051/admin/users/management/UsersTable.tsx"
new file mode 100644
index 0000000..402e0da
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/UsersTable.tsx"
@@ -0,0 +1,208 @@
+// 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 { ChevronRight } from "lucide-react";
+import * as React from "react";
+import { useCurrentUser } from "@/features/core/identity/queries";
+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 { IdentitiesCell } from "./IdentitiesCell";
+import { PermissionsDrawer } from "./PermissionsDrawer";
+import { RolesCell } from "./RolesCell";
+
+function fullNameFor(user: UserRow): string {
+ const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
+ return name || (user.email ?? "Unknown user");
+}
+
+// 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 };
+}
+
+export function UsersTable() {
+ const { user: currentUser } = useCurrentUser();
+ const { users, roles } = useUsersAdmin();
+ 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";
+
+ 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);
+ }
+
+ function isCurrentUser(row: UserRow): boolean {
+ return Boolean(currentUser?.email) && row.email === currentUser?.email;
+ }
+
+ 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;
+ }
+ if (roleFilter !== "all" && !user.roles.some((r) => r.id === roleFilter)) return false;
+ if (identityFilter !== "all" && !user.identities.some((i) => i.source === identityFilter)) {
+ return false;
+ }
+ return true;
+ });
+ }, [users, search, roleFilter, identityFilter]);
+
+ const columns: Array<DataTableColumn<UserRow>> = [
+ {
+ key: "username",
+ header: "Username",
+ cell: (row) => (
+ <span className="font-medium text-foreground">
+ {fullNameFor(row)}
+ {isCurrentUser(row) ? (
+ <span className="ml-1.5 font-normal text-muted-foreground">(You)</span>
+ ) : null}
+ </span>
+ ),
+ },
+ {
+ key: "email",
+ header: "Email",
+ cell: (row) => <span className="text-muted-foreground">{row.email}</span>,
+ },
+ {
+ 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)}
+ />
+ ),
+ },
+ {
+ key: "identities",
+ header: "External Identities",
+ width: "220px",
+ interactive: true,
+ cell: (row) => (
+ <IdentitiesCell
+ identities={row.identities}
+ expanded={row.id !== undefined && row.id === expandedRow.expandedId}
+ onToggleExpand={() => row.id && expandedRow.toggle(row.id)}
+ />
+ ),
+ },
+ {
+ key: "actions",
+ header: "",
+ align: "right",
+ cell: () => <ChevronRight className="ml-auto text-muted-foreground" strokeWidth={1.5} />,
+ },
+ ];
+
+ return (
+ <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"
+ value={search}
+ onChange={(e) => updateFilterParam("q", e.target.value)}
+ aria-label="Search users"
+ 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>
+ <select
+ value={identityFilter}
+ onChange={(e) => updateFilterParam("identity", e.target.value)}
+ aria-label="Filter by external identity"
+ className="h-9 rounded-md border bg-background px-3 text-sm"
+ >
+ <option value="all">All external identities</option>
+ {Object.entries(IDENTITY_SOURCE_LABELS).map(([source, label]) => (
+ <option key={source} value={source}>
+ {label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ <DataTable
+ columns={columns}
+ rows={filteredUsers}
+ rowKey={(row) => row.id ?? row.email ?? ""}
+ onRowClick={(row) => {
+ if (row.id) expandedRow.collapseUnless(row.id);
+ setSelectedId((prev) => (prev === row.id ? null : (row.id ?? null)));
+ }}
+ rowClassName={(row) =>
+ 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.
+ </span>
+ }
+ />
+ <PermissionsDrawer user={selectedUser} onClose={() => setSelectedId(null)} />
+ </div>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/identities.ts" "b/web/src/app/\050portal\051/admin/users/management/identities.ts"
new file mode 100644
index 0000000..59f296f
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/identities.ts"
@@ -0,0 +1,44 @@
+// 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 { BookUser, Fingerprint, Globe, KeyRound, type LucideIcon } from "lucide-react";
+
+// Display metadata for the `source` field on UserIdentity (GET
+// /users/{id}/user-identities) — "the source's native identifier" per the
+// API doc, e.g. "access", "cilogon", "orcid", "nairr".
+export const IDENTITY_SOURCE_LABELS: Record<string, string> = {
+ access: "ACCESS",
+ cilogon: "CILogon",
+ orcid: "ORCID",
+ nairr: "NAIRR",
+};
+
+const IDENTITY_SOURCE_ICONS: Record<string, LucideIcon> = {
+ access: Globe,
+ cilogon: KeyRound,
+ orcid: BookUser,
+ nairr: Fingerprint,
+};
+
+export function identitySourceLabel(source: string | undefined): string {
+ if (!source) return "Unknown";
+ return IDENTITY_SOURCE_LABELS[source] ?? source;
+}
+
+export function identitySourceIcon(source: string | undefined): LucideIcon {
+ return (source ? IDENTITY_SOURCE_ICONS[source] : undefined) ?? Fingerprint;
+}
diff --git "a/web/src/app/\050portal\051/admin/users/management/page.tsx" "b/web/src/app/\050portal\051/admin/users/management/page.tsx"
new file mode 100644
index 0000000..321e17e
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/management/page.tsx"
@@ -0,0 +1,28 @@
+// 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 { UsersNav } from "../UsersNav";
+import { UsersTable } from "./UsersTable";
+
+export default function UserManagementPage() {
+ return (
+ <div className="space-y-4">
+ <UsersNav />
+ <UsersTable />
+ </div>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/page.tsx" "b/web/src/app/\050portal\051/admin/users/page.tsx"
new file mode 100644
index 0000000..1b71f63
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/page.tsx"
@@ -0,0 +1,23 @@
+// 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 { redirect } from "next/navigation";
+
+// /admin/users has no content of its own; bounce to the canonical tab.
+export default function AdminUsersIndex() {
+ redirect("/admin/users/management");
+}
diff --git "a/web/src/app/\050portal\051/admin/users/roles/PermissionMatrixEditor.tsx" "b/web/src/app/\050portal\051/admin/users/roles/PermissionMatrixEditor.tsx"
new file mode 100644
index 0000000..29a1bcf
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/roles/PermissionMatrixEditor.tsx"
@@ -0,0 +1,57 @@
+// 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 { PermissionRW } from "@/shared/users-admin/PermissionRW";
+import type { PermissionKey } from "@/shared/users-admin/permissions";
+import { rwStateFor } from "@/shared/users-admin/permissions";
+
+export function PermissionMatrixEditor({
+ permissions,
+ onTogglePermission,
+ editable = true,
+}: {
+ permissions: PermissionKey[];
+ onTogglePermission: (permission: PermissionKey) => void;
+ editable?: boolean;
+}) {
+ const rwPermissions = rwStateFor(permissions);
+
+ return (
+ <div>
+ <h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+ Effective Privileges
+ </h4>
+ <ul className="space-y-2">
+ {rwPermissions.map((p) => (
+ <li key={p.section} className="flex items-center justify-between text-sm">
+ <span className="font-mono text-foreground">{p.section}</span>
+ <PermissionRW
+ read={p.read}
+ write={p.write}
+ onToggleRead={editable ? () => onTogglePermission(`${p.section}:read`) : undefined}
+ onToggleWrite={
+ editable ? () => onTogglePermission(`${p.section}:write`) : undefined
+ }
+ />
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/roles/RoleCard.tsx" "b/web/src/app/\050portal\051/admin/users/roles/RoleCard.tsx"
new file mode 100644
index 0000000..0b0b78d
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/roles/RoleCard.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.
+
+"use client";
+
+import { Pencil, ShieldCheck } from "lucide-react";
+import { Badge } from "@/shared/ui/badge";
+import { Card, CardContent, CardHeader } from "@/shared/ui/card";
+import { PermissionRW } from "@/shared/users-admin/PermissionRW";
+import { rwStateFor } from "@/shared/users-admin/permissions";
+import type { RoleRow } from "@/shared/users-admin/types";
+import { RoleFormDialog } from "./RoleFormDialog";
+
+export function RoleCard({ role, memberCount }: { role: RoleRow; memberCount: number }) {
+ const rwPermissions = rwStateFor(role.permissions);
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-start justify-between gap-2">
+ <div>
+ <div className="flex items-center gap-1.5 font-heading text-base font-semibold text-foreground">
+ {role.name}
+ {role.is_system ? (
+ <ShieldCheck
+ className="size-3.5 text-muted-foreground"
+ aria-label="System role"
+ />
+ ) : null}
+ </div>
+ <p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
+ </div>
+ <Badge variant="secondary" className="shrink-0">
+ {memberCount} {memberCount === 1 ? "member" : "members"}
+ </Badge>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-t border-border" />
+
+ <div>
+ <h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+ Effective Privileges
+ </h4>
+ <ul className="space-y-2">
+ {rwPermissions.map((p) => (
+ <li key={p.section} className="flex items-center justify-between text-sm">
+ <span className="font-mono text-foreground">{p.section}</span>
+ <PermissionRW read={p.read} write={p.write} />
+ </li>
+ ))}
+ </ul>
+ </div>
+
+ <div className="border-t border-border" />
+
+ <RoleFormDialog
+ role={role}
+ triggerRender={
+ <button
+ type="button"
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
+ />
+ }
+ triggerContent={
+ <>
+ <Pencil className="size-3.5" />
+ Edit
+ </>
+ }
+ />
+ </CardContent>
+ </Card>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/roles/RoleFormDialog.tsx" "b/web/src/app/\050portal\051/admin/users/roles/RoleFormDialog.tsx"
new file mode 100644
index 0000000..f57f640
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/roles/RoleFormDialog.tsx"
@@ -0,0 +1,199 @@
+// 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 { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/shared/ui/dialog";
+import { Input } from "@/shared/ui/input";
+import { Label } from "@/shared/ui/label";
+import { togglePermission } from "@/shared/users-admin/permissions";
+import type { PermissionKey } from "@/shared/users-admin/permissions";
+import type { RoleRow, UserRow } from "@/shared/users-admin/types";
+import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
+import { PermissionMatrixEditor } from "./PermissionMatrixEditor";
+
+function fullNameFor(user: UserRow): string {
+ const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
+ return name || (user.email ?? "Unknown user");
+}
+
+export function RoleFormDialog({
+ role,
+ triggerRender,
+ triggerContent,
+}: {
+ // Omit for "create a new role"; pass an existing role to edit it in place.
+ role?: RoleRow;
+ triggerRender: React.ReactElement;
+ triggerContent: React.ReactNode;
+}) {
+ const { users, addRole, updateRole } = useUsersAdmin();
+ const isEdit = Boolean(role);
+ const [open, setOpen] = React.useState(false);
+ const [name, setName] = React.useState("");
+ const [description, setDescription] = React.useState("");
+ const [permissions, setPermissions] = React.useState<PermissionKey[]>([]);
+ const [userSearch, setUserSearch] = React.useState("");
+ const [selectedUserIds, setSelectedUserIds] = React.useState<Set<string>>(new Set());
+
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+ if (next) {
+ setName(role?.name ?? "");
+ setDescription(role?.description ?? "");
+ setPermissions(role?.permissions ?? []);
+ setUserSearch("");
+ setSelectedUserIds(
+ new Set(
+ role ? users.filter((u) => u.roles.some((r) => r.id === role.id)).map((u) => u.id ?? "") : [],
+ ),
+ );
+ }
+ }
+
+ function toggleUserSelected(userId: string) {
+ setSelectedUserIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(userId)) next.delete(userId);
+ else next.add(userId);
+ return next;
+ });
+ }
+
+ function handleSubmit() {
+ if (!name.trim()) return;
+ const input = { name: name.trim(), description: description.trim(), permissions };
+ if (isEdit && role?.id) {
+ updateRole(role.id, input, Array.from(selectedUserIds));
+ } else {
+ addRole(input, Array.from(selectedUserIds));
+ }
+ setOpen(false);
+ }
+
+ const needle = userSearch.trim().toLowerCase();
+ const matchingUsers = needle
+ ? users.filter((u) => `${fullNameFor(u)} ${u.email ?? ""}`.toLowerCase().includes(needle))
+ : users;
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger render={triggerRender}>{triggerContent}</DialogTrigger>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>{isEdit ? "Edit role" : "Create role"}</DialogTitle>
+ <DialogDescription>
+ {isEdit
+ ? `Update what ${role?.name} can see and do, and who holds it.`
+ : "Define a name, choose the permissions it grants, and optionally assign it to users right away."}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="max-h-[60vh] space-y-5 overflow-y-auto">
+ <div className="space-y-3">
+ <div className="space-y-1.5">
+ <Label htmlFor="role-name">Name</Label>
+ <Input
+ id="role-name"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="e.g. Billing Reviewer"
+ autoFocus
+ />
+ </div>
+ <div className="space-y-1.5">
+ <Label htmlFor="role-description">Description</Label>
+ <Input
+ id="role-description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ placeholder="What this role is for"
+ />
+ </div>
+ </div>
+
+ <div className="border-t border-border" />
+
+ <PermissionMatrixEditor
+ permissions={permissions}
+ onTogglePermission={(key) => setPermissions((prev) => togglePermission(prev, key))}
+ />
+
+ <div className="border-t border-border" />
+
+ <div className="space-y-2">
+ <Label htmlFor="role-user-search">Assign to users (optional)</Label>
+ <Input
+ id="role-user-search"
+ type="search"
+ value={userSearch}
+ onChange={(e) => setUserSearch(e.target.value)}
+ placeholder="Search by username or email"
+ />
+ <ul className="max-h-40 space-y-1 overflow-y-auto rounded-md border p-2">
+ {matchingUsers.length === 0 ? (
+ <li className="px-1 py-1 text-sm text-muted-foreground">No users match.</li>
+ ) : (
+ matchingUsers.map((u) => {
+ const id = u.id ?? u.email ?? "";
+ return (
+ <li key={id}>
+ <label className="flex cursor-pointer items-center gap-2 rounded-sm px-1 py-1 text-sm hover:bg-muted">
+ <input
+ type="checkbox"
+ checked={selectedUserIds.has(id)}
+ onChange={() => toggleUserSelected(id)}
+ className="size-4 rounded border-input"
+ />
+ <span className="font-medium text-foreground">{fullNameFor(u)}</span>
+ <span className="text-xs text-muted-foreground">{u.email}</span>
+ </label>
+ </li>
+ );
+ })
+ )}
+ </ul>
+ {selectedUserIds.size > 0 ? (
+ <p className="text-xs text-muted-foreground">
+ {selectedUserIds.size} user{selectedUserIds.size === 1 ? "" : "s"} selected
+ </p>
+ ) : null}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)} type="button">
+ Cancel
+ </Button>
+ <Button onClick={handleSubmit} disabled={!name.trim()} type="button">
+ {isEdit ? "Save changes" : "Create role"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/roles/RolesGrid.tsx" "b/web/src/app/\050portal\051/admin/users/roles/RolesGrid.tsx"
new file mode 100644
index 0000000..26c04a0
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/roles/RolesGrid.tsx"
@@ -0,0 +1,37 @@
+// 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 { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
+import { RoleCard } from "./RoleCard";
+
+export function RolesGrid() {
+ const { roles, users } = useUsersAdmin();
+
+ return (
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
+ {roles.map((role) => (
+ <RoleCard
+ key={role.id}
+ role={role}
+ memberCount={users.filter((u) => u.roles.some((r) => r.id === role.id)).length}
+ />
+ ))}
+ </div>
+ );
+}
diff --git "a/web/src/app/\050portal\051/admin/users/roles/page.tsx" "b/web/src/app/\050portal\051/admin/users/roles/page.tsx"
new file mode 100644
index 0000000..680e3c1
--- /dev/null
+++ "b/web/src/app/\050portal\051/admin/users/roles/page.tsx"
@@ -0,0 +1,32 @@
+// 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 { Button } from "@/shared/ui/button";
+import { UsersNav } from "../UsersNav";
+import { RoleFormDialog } from "./RoleFormDialog";
+import { RolesGrid } from "./RolesGrid";
+
+export default function RoleManagementPage() {
+ return (
+ <div className="space-y-6">
+ <UsersNav
+ rightSlot={<RoleFormDialog triggerRender={<Button />} triggerContent="Create role" />}
+ />
+ <RolesGrid />
+ </div>
+ );
+}
diff --git a/web/src/features/core/allocations/components/AllocationsList.tsx b/web/src/features/core/allocations/components/AllocationsList.tsx
index 7bac8ac..35df38d 100644
--- a/web/src/features/core/allocations/components/AllocationsList.tsx
+++ b/web/src/features/core/allocations/components/AllocationsList.tsx
@@ -121,7 +121,6 @@
{
key: "initial",
header: "Initial SUs",
- align: "right",
sortable: true,
sortValue: (row) => row.initial_su_amount,
cell: (row) => <span className="tabular-nums">{formatSU(row.initial_su_amount)}</span>,
diff --git a/web/src/shared/layout/UserPill.tsx b/web/src/shared/layout/UserPill.tsx
index 74dce1e..3811c4a 100644
--- a/web/src/shared/layout/UserPill.tsx
+++ b/web/src/shared/layout/UserPill.tsx
@@ -17,6 +17,10 @@
"use client";
+import { LogOut, Settings } from "lucide-react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { useSignOut } from "@/shared/auth/useSignOut";
import { Avatar, AvatarFallback } from "@/shared/ui/avatar";
import {
DropdownMenu,
@@ -25,10 +29,6 @@
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
-import { LogOut, Settings } from "lucide-react";
-import Link from "next/link";
-import { useSession } from "next-auth/react";
-import { useSignOut } from "@/shared/auth/useSignOut";
export function UserPill() {
const { data: session, status } = useSession();
diff --git a/web/src/shared/layout/nav.ts b/web/src/shared/layout/nav.ts
index 9934686..99d0c69 100644
--- a/web/src/shared/layout/nav.ts
+++ b/web/src/shared/layout/nav.ts
@@ -21,8 +21,9 @@
ClipboardList,
FolderKanban,
HardDrive,
- Server,
type LucideIcon,
+ Server,
+ UserCog,
} from "lucide-react";
export type AbilityCheck = { action: string; subject: string };
@@ -58,6 +59,13 @@
ability: { action: "read", subject: "Project" },
},
{
+ href: "/admin/users",
+ label: "Users & Permissions",
+ icon: UserCog,
+ group: "admin",
+ ability: { action: "read", subject: "User" },
+ },
+ {
href: "/admin/traces",
label: "Tracing",
icon: Activity,
diff --git a/web/src/shared/ui/DataTable.tsx b/web/src/shared/ui/DataTable.tsx
index cbd5e70..4750742 100644
--- a/web/src/shared/ui/DataTable.tsx
+++ b/web/src/shared/ui/DataTable.tsx
@@ -59,6 +59,7 @@
rows: T[];
rowKey: (row: T, index: number) => string;
onRowClick?: (row: T) => void;
+ rowClassName?: (row: T) => string | undefined;
caption?: string;
empty?: React.ReactNode;
pagination?: DataTablePagination;
@@ -91,6 +92,7 @@
rows,
rowKey,
onRowClick,
+ rowClassName,
caption,
empty,
pagination,
@@ -201,16 +203,23 @@
className={cn(
"border-t border-border/60 bg-card",
onRowClick && "cursor-pointer",
+ rowClassName?.(row),
)}
onClick={
onRowClick
? (e) => {
// Ignore clicks on interactive children (buttons,
// checkboxes, links) — they have their own handlers.
+ // Also covers portal-rendered content (dropdown
+ // menus, popovers): the clicked DOM node's
+ // ancestors won't include the <tr>, but React's
+ // synthetic events still bubble through the
+ // *component* tree, so without this the row click
+ // fires alongside the menu item's own click.
const target = e.target as HTMLElement;
if (
target.closest(
- "button, a, input, select, textarea, label",
+ "button, a, input, select, textarea, label, [role='menu'], [role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'], [role='dialog'], [role='listbox'], [role='option']",
)
)
return;
@@ -242,7 +251,7 @@
<td
key={col.key}
className={cn(
- "px-4 py-3 align-middle text-sm text-foreground",
+ "px-4 py-3 align-top text-sm text-foreground",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
diff --git a/web/src/shared/ui/SideDrawer.tsx b/web/src/shared/ui/SideDrawer.tsx
index acca245..24d60d1 100644
--- a/web/src/shared/ui/SideDrawer.tsx
+++ b/web/src/shared/ui/SideDrawer.tsx
@@ -39,6 +39,16 @@
description?: React.ReactNode;
children: React.ReactNode;
width?: SideDrawerWidth;
+ // Non-modal drawers skip the dimming backdrop and leave the rest of the
+ // page interactive — e.g. clicking another table row while the drawer is
+ // open should swap its contents rather than being blocked by an overlay.
+ modal?: boolean;
+ // Base UI closes on any outside pointer press by default, which races with
+ // a caller's own click handling (e.g. a row toggling the same drawer) —
+ // the outside-press listener fires before React's onClick, so the caller's
+ // "close if already open" check always sees a fresh `null` state. Set this
+ // when the caller owns open/close entirely (row clicks, toggle buttons).
+ disablePointerDismissal?: boolean;
};
export function SideDrawer({
@@ -48,11 +58,20 @@
description,
children,
width = "md",
+ modal = true,
+ disablePointerDismissal = false,
}: SideDrawerProps) {
return (
- <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
+ <DialogPrimitive.Root
+ open={open}
+ onOpenChange={onOpenChange}
+ modal={modal}
+ disablePointerDismissal={disablePointerDismissal}
+ >
<DialogPrimitive.Portal>
- <DialogPrimitive.Backdrop className="fixed inset-0 z-50 bg-black/30 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
+ {modal ? (
+ <DialogPrimitive.Backdrop className="fixed inset-0 z-50 bg-black/30 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
+ ) : null}
<DialogPrimitive.Popup
className={cn(
"fixed inset-y-0 right-0 z-50 flex w-full flex-col bg-popover text-popover-foreground shadow-xl outline-none",
diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx
index 6a76d93..a6a6014 100644
--- a/web/src/shared/ui/dialog.tsx
+++ b/web/src/shared/ui/dialog.tsx
@@ -44,8 +44,12 @@
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
+ // Base UI skips rendering the backdrop for a dialog it considers "nested"
+ // (e.g. opened from within the SideDrawer's own Dialog root) — force it so
+ // the page behind is always dimmed and inert, regardless of ancestry.
+ forceRender
className={cn(
- "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+ "fixed inset-0 isolate z-50 bg-black/40 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
diff --git a/web/src/shared/users-admin/PermissionRW.tsx b/web/src/shared/users-admin/PermissionRW.tsx
new file mode 100644
index 0000000..74a80cd
--- /dev/null
+++ b/web/src/shared/users-admin/PermissionRW.tsx
@@ -0,0 +1,85 @@
+// 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 { cn } from "@/lib/utils";
+
+function Cell({
+ label,
+ active,
+ activeClass,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ activeClass: string;
+ onClick?: () => void;
+}) {
+ const title = label === "read" ? "Read" : "Write";
+ const className = cn(
+ "inline-flex h-6 items-center justify-center rounded px-2 text-xs font-medium",
+ active ? activeClass : "bg-[color:var(--custos-gray-100)] text-[color:var(--custos-gray-400)]",
+ onClick && "cursor-pointer transition-transform hover:scale-105",
+ );
+ if (!onClick) {
+ return (
+ <span title={title} className={className}>
+ {label}
+ </span>
+ );
+ }
+ return (
+ <button
+ type="button"
+ title={title}
+ aria-pressed={active}
+ aria-label={`${title}${active ? ", granted" : ", not granted"}`}
+ onClick={onClick}
+ className={className}
+ >
+ {label}
+ </button>
+ );
+}
+
+export function PermissionRW({
+ read,
+ write,
+ onToggleRead,
+ onToggleWrite,
+}: {
+ read: boolean;
+ write: boolean;
+ onToggleRead?: () => void;
+ onToggleWrite?: () => void;
+}) {
+ return (
+ <div className="flex items-center gap-1">
+ <Cell
+ label="read"
+ active={read}
+ activeClass="bg-[color:var(--custos-blue-50)] text-[color:var(--custos-blue-700)]"
+ onClick={onToggleRead}
+ />
+ <Cell
+ label="write"
+ active={write}
+ activeClass="bg-[color:var(--custos-green-50)] text-[color:var(--custos-green-700)]"
+ onClick={onToggleWrite}
+ />
+ </div>
+ );
+}
diff --git a/web/src/shared/users-admin/UsersAdminContext.tsx b/web/src/shared/users-admin/UsersAdminContext.tsx
new file mode 100644
index 0000000..59d91e4
--- /dev/null
+++ b/web/src/shared/users-admin/UsersAdminContext.tsx
@@ -0,0 +1,127 @@
+// 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 type { PermissionKey } from "./permissions";
+import { INITIAL_ROLES } from "./roles-catalog";
+import { INITIAL_USERS } from "./seed-users";
+import type { RoleRow, UserRow } from "./types";
+
+export type RoleInput = {
+ name: string;
+ description: string;
+ permissions: PermissionKey[];
+};
+
+type UsersAdminContextValue = {
+ users: UserRow[];
+ roles: RoleRow[];
+ toggleUserRole: (userId: string, roleId: string) => void;
+ addRole: (input: RoleInput, memberUserIds: string[]) => void;
+ updateRole: (roleId: string, input: RoleInput, memberUserIds: string[]) => void;
+};
+
+const UsersAdminContext = React.createContext<UsersAdminContextValue | null>(null);
+
+let nextRoleSuffix = 1;
+
+export function UsersAdminProvider({ children }: { children: React.ReactNode }) {
+ const [users, setUsers] = React.useState<UserRow[]>(INITIAL_USERS);
+ const [roles, setRoles] = React.useState<RoleRow[]>(INITIAL_ROLES);
+
+ function toggleUserRole(userId: string, roleId: string) {
+ const role = roles.find((r) => r.id === roleId);
+ if (!role) return;
+ setUsers((prev) =>
+ prev.map((user) => {
+ if (user.id !== userId) return user;
+ const hasRole = user.roles.some((r) => r.id === roleId);
+ const nextRoles = hasRole
+ ? user.roles.filter((r) => r.id !== roleId)
+ : [...user.roles, role];
+ return { ...user, roles: nextRoles };
+ }),
+ );
+ }
+
+ function addRole(input: RoleInput, memberUserIds: string[]) {
+ const role: RoleRow = {
+ id: `role-custom-${nextRoleSuffix++}`,
+ name: input.name,
+ description: input.description,
+ is_system: false,
+ permissions: input.permissions,
+ };
+ setRoles((prev) => [...prev, role]);
+ if (memberUserIds.length > 0) {
+ const memberSet = new Set(memberUserIds);
+ setUsers((prev) =>
+ prev.map((user) =>
+ user.id && memberSet.has(user.id) ? { ...user, roles: [...user.roles, role] } : user,
+ ),
+ );
+ }
+ }
+
+ // Role objects are embedded (by value) into each user's `roles` array
+ // rather than referenced by id, mirroring how the table/drawer render
+ // them — so updating a role here must also refresh the copy held by every
+ // member, add it to newly-assigned members, and drop it from anyone
+ // deselected.
+ function updateRole(roleId: string, input: RoleInput, memberUserIds: string[]) {
+ const existing = roles.find((r) => r.id === roleId);
+ const updated: RoleRow = {
+ id: roleId,
+ name: input.name,
+ description: input.description,
+ is_system: existing?.is_system ?? false,
+ permissions: input.permissions,
+ };
+ setRoles((prev) => prev.map((r) => (r.id === roleId ? updated : r)));
+ const memberSet = new Set(memberUserIds);
+ setUsers((prev) =>
+ prev.map((user) => {
+ const hasRole = user.roles.some((r) => r.id === roleId);
+ const shouldHaveRole = Boolean(user.id) && memberSet.has(user.id as string);
+ if (shouldHaveRole) {
+ const nextRoles = hasRole
+ ? user.roles.map((r) => (r.id === roleId ? updated : r))
+ : [...user.roles, updated];
+ return { ...user, roles: nextRoles };
+ }
+ if (hasRole) {
+ return { ...user, roles: user.roles.filter((r) => r.id !== roleId) };
+ }
+ return user;
+ }),
+ );
+ }
+
+ return (
+ <UsersAdminContext.Provider value={{ users, roles, toggleUserRole, addRole, updateRole }}>
+ {children}
+ </UsersAdminContext.Provider>
+ );
+}
+
+export function useUsersAdmin(): UsersAdminContextValue {
+ const ctx = React.useContext(UsersAdminContext);
+ if (!ctx) throw new Error("useUsersAdmin must be used within UsersAdminProvider");
+ return ctx;
+}
diff --git a/web/src/shared/users-admin/permissions.ts b/web/src/shared/users-admin/permissions.ts
new file mode 100644
index 0000000..12a6b4f
--- /dev/null
+++ b/web/src/shared/users-admin/permissions.ts
@@ -0,0 +1,68 @@
+// 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.
+
+// Permission sections mirror the backend's privilege scopes (domain:resource,
+// e.g. "core:allocations"), so each section can be granted read/write
+// independently. Displayed verbatim as the effective-privilege key.
+export const PERMISSION_SECTIONS = [
+ "amie:packets",
+ "amie:replies",
+ "amie:unmapped",
+ "core:allocations",
+ "core:clusters",
+ "core:organizations",
+ "core:projects",
+ "core:traces",
+ "core:users",
+ "temp-account:accounts",
+] as const;
+export type PermissionSection = (typeof PERMISSION_SECTIONS)[number];
+export type PermissionKey = `${PermissionSection}:read` | `${PermissionSection}:write`;
+
+export function rwStateFor(permissions: PermissionKey[]) {
+ const held = new Set(permissions);
+ return PERMISSION_SECTIONS.map((section) => ({
+ section,
+ read: held.has(`${section}:read` as PermissionKey),
+ write: held.has(`${section}:write` as PermissionKey),
+ }));
+}
+
+// Write implies read (you can't write what you can't read), so granting
+// write also grants read, and revoking read also revokes write.
+export function togglePermission(permissions: PermissionKey[], key: PermissionKey): PermissionKey[] {
+ const held = new Set(permissions);
+ const isRead = key.endsWith(":read");
+ const pairedKey = (
+ isRead ? key.replace(/:read$/, ":write") : key.replace(/:write$/, ":read")
+ ) as PermissionKey;
+
+ if (held.has(key)) {
+ held.delete(key);
+ if (isRead) held.delete(pairedKey);
+ } else {
+ held.add(key);
+ if (!isRead) held.add(pairedKey);
+ }
+ return Array.from(held);
+}
+
+// A user's effective permissions are the union of everything granted by
+// each role they hold.
+export function permissionsFromRoles(roles: Array<{ permissions: PermissionKey[] }>): PermissionKey[] {
+ return Array.from(new Set(roles.flatMap((r) => r.permissions)));
+}
diff --git a/web/src/shared/users-admin/roles-catalog.ts b/web/src/shared/users-admin/roles-catalog.ts
new file mode 100644
index 0000000..ff09246
--- /dev/null
+++ b/web/src/shared/users-admin/roles-catalog.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 type { RoleRow } from "./types";
+
+// Seed roles for the mock portal. Permissions are granted per left-nav
+// section (see permissions.ts) — source once a real "list roles" call backs
+// this page: GET /roles + GET /roles/{id}.
+export const ROLE_SUPER_ADMIN: RoleRow = {
+ id: "role-super-admin",
+ name: "Super Admin",
+ description: "Full administrative access across the portal.",
+ is_system: true,
+ permissions: [
+ "amie:packets:read",
+ "amie:packets:write",
+ "amie:replies:read",
+ "amie:replies:write",
+ "amie:unmapped:read",
+ "amie:unmapped:write",
+ "core:allocations:read",
+ "core:allocations:write",
+ "core:clusters:read",
+ "core:clusters:write",
+ "core:organizations:read",
+ "core:organizations:write",
+ "core:projects:read",
+ "core:projects:write",
+ "core:traces:read",
+ "core:users:read",
+ "core:users:write",
+ "temp-account:accounts:read",
+ "temp-account:accounts:write",
+ ],
+};
+export const ROLE_OPERATOR: RoleRow = {
+ id: "role-operator",
+ name: "Operator",
+ description: "Day-to-day allocation, project, and AMIE operations (read + write).",
+ is_system: false,
+ permissions: [
+ "amie:packets:read",
+ "amie:packets:write",
+ "amie:replies:read",
+ "amie:replies:write",
+ "amie:unmapped:read",
+ "amie:unmapped:write",
+ "core:allocations:read",
+ "core:allocations:write",
+ "core:projects:read",
+ "core:projects:write",
+ ],
+};
+export const ROLE_AUDITOR: RoleRow = {
+ id: "role-auditor",
+ name: "Auditor",
+ description: "Read-only access across allocations, projects, tracing, and AMIE.",
+ is_system: false,
+ permissions: [
+ "core:allocations:read",
+ "core:projects:read",
+ "core:traces:read",
+ "amie:packets:read",
+ "amie:replies:read",
+ "amie:unmapped:read",
+ ],
+};
+
+export const INITIAL_ROLES: RoleRow[] = [ROLE_SUPER_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR];
diff --git a/web/src/shared/users-admin/seed-users.ts b/web/src/shared/users-admin/seed-users.ts
new file mode 100644
index 0000000..e3d5533
--- /dev/null
+++ b/web/src/shared/users-admin/seed-users.ts
@@ -0,0 +1,96 @@
+// 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 { UserIdentity } from "@/generated/core/types.gen";
+import { ROLE_AUDITOR, ROLE_OPERATOR, ROLE_SUPER_ADMIN } from "./roles-catalog";
+import type { UserRow } from "./types";
+
+function identity(
+ user_id: string,
+ source: string,
+ external_id: string,
+ email?: string,
+): UserIdentity {
+ return {
+ id: `${user_id}-${source}`,
+ user_id,
+ source,
+ external_id,
+ email,
+ created_at: "2026-01-15T09:00:00Z",
+ };
+}
+
+export const INITIAL_USERS: UserRow[] = [
+ {
+ id: "u1",
+ first_name: "Dev",
+ last_name: "Admin",
+ email: "admin@custos.local",
+ status: "ACTIVE",
+ roles: [ROLE_SUPER_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR],
+ identities: [
+ identity("u1", "access", "dev-admin-access-001", "admin@access-ci.org"),
+ identity("u1", "cilogon", "cilogon-uid-1001", "admin@cilogon.org"),
+ identity("u1", "orcid", "0000-0001-1111-0001"),
+ identity("u1", "nairr", "nairr-uid-1001"),
+ ],
+ },
+ {
+ id: "u2",
+ first_name: "Rachel",
+ last_name: "Gao",
+ email: "rgao@access-ci.org",
+ status: "ACTIVE",
+ roles: [ROLE_OPERATOR],
+ identities: [
+ identity("u2", "access", "rgao-access-001", "rgao@access-ci.org"),
+ identity("u2", "orcid", "0000-0002-1825-0097"),
+ ],
+ },
+ {
+ id: "u3",
+ first_name: "James",
+ last_name: "Okonkwo",
+ email: "jokonkwo@university.edu",
+ status: "ACTIVE",
+ roles: [ROLE_AUDITOR],
+ identities: [identity("u3", "cilogon", "cilogon-uid-3003", "jokonkwo@cilogon.org")],
+ },
+ {
+ id: "u4",
+ first_name: "Priya",
+ last_name: "Sharma",
+ email: "psharma@hpc-lab.org",
+ status: "SUSPENDED",
+ roles: [ROLE_OPERATOR, ROLE_AUDITOR],
+ identities: [
+ identity("u4", "access", "psharma-access-004", "psharma@hpc-lab.org"),
+ identity("u4", "cilogon", "cilogon-uid-4004"),
+ identity("u4", "nairr", "nairr-uid-4004"),
+ ],
+ },
+ {
+ id: "u5",
+ first_name: "Daniel",
+ last_name: "Wu",
+ email: "dwu@custos-hpc.io",
+ status: "INACTIVE",
+ identities: [],
+ roles: [],
+ },
+];
diff --git a/web/src/shared/users-admin/types.ts b/web/src/shared/users-admin/types.ts
new file mode 100644
index 0000000..64b484c
--- /dev/null
+++ b/web/src/shared/users-admin/types.ts
@@ -0,0 +1,31 @@
+// 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 { Role, User, UserIdentity } from "@/generated/core/types.gen";
+import type { PermissionKey } from "./permissions";
+
+export type RoleRow = Role & { permissions: PermissionKey[] };
+
+// Shaped to match GET /users/{id} (User), joined with the roles resolved
+// from GET /users/{id}/roles + GET /roles, and the linked accounts from
+// GET /users/{id}/user-identities — a stand-in for a future "list users"
+// endpoint that returns this view directly. Effective permissions are
+// derived from the union of the user's roles rather than tracked per-user.
+export type UserRow = User & {
+ roles: RoleRow[];
+ identities: UserIdentity[];
+};