diff --git a/cda-gui/docs/screenshots/user-roles-custom.png b/cda-gui/docs/screenshots/user-roles-custom.png new file mode 100644 index 000000000..c2003ba66 Binary files /dev/null and b/cda-gui/docs/screenshots/user-roles-custom.png differ diff --git a/cda-gui/docs/screenshots/user-roles-presets.png b/cda-gui/docs/screenshots/user-roles-presets.png new file mode 100644 index 000000000..42feb9256 Binary files /dev/null and b/cda-gui/docs/screenshots/user-roles-presets.png differ diff --git a/cda-gui/docs/screenshots/users-landing.png b/cda-gui/docs/screenshots/users-landing.png new file mode 100644 index 000000000..44d9b2ca4 Binary files /dev/null and b/cda-gui/docs/screenshots/users-landing.png differ diff --git a/cda-gui/package.json b/cda-gui/package.json index 761e6bd1a..c0bf10074 100644 --- a/cda-gui/package.json +++ b/cda-gui/package.json @@ -8,7 +8,7 @@ "build": "vite build --mode production && node scripts/generate-sitemap.mjs", "build:development": "vite build --mode development && node scripts/generate-sitemap.mjs", "build:test": "vite build --mode test && node scripts/generate-sitemap.mjs", - "test": "node --test src/utils/auth-config.test.js", + "test": "node --test src/utils/auth-config.test.js src/pages/user-roles/role-state.test.js", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "prepare": "cd .. && husky cda-gui/.husky", diff --git a/cda-gui/src/components/AppAuthProvider.jsx b/cda-gui/src/components/AppAuthProvider.jsx index 990c073a2..e31fe4732 100644 --- a/cda-gui/src/components/AppAuthProvider.jsx +++ b/cda-gui/src/components/AppAuthProvider.jsx @@ -111,7 +111,9 @@ export default function AppAuthProvider({ children }) { return ( - {children} + + {children} + ); } diff --git a/cda-gui/src/links/header-links.js b/cda-gui/src/links/header-links.js index 9e6e03515..6dbbdee54 100644 --- a/cda-gui/src/links/header-links.js +++ b/cda-gui/src/links/header-links.js @@ -39,9 +39,26 @@ export default [ ], }, { - id: "user-lists", - text: "User Lists", - href: "/user-lists", + id: "users", + text: "Users", + href: "/users", + children: [ + { + id: "user-management", + text: "User Management", + href: "/users", + }, + { + id: "user-lists", + text: "User Lists", + href: "/user-lists", + }, + { + id: "user-roles", + text: "User Roles", + href: "/user-roles", + }, + ], }, { id: "help", diff --git a/cda-gui/src/main.jsx b/cda-gui/src/main.jsx index e5fad9b0e..e322c9bd9 100644 --- a/cda-gui/src/main.jsx +++ b/cda-gui/src/main.jsx @@ -17,12 +17,15 @@ import LocationSearch from "./pages/LocationSearch.jsx"; // Styles import "@usace/groundwork/dist/groundwork.css"; +import "@usace-watermanagement/groundwork-water/dist/style.css"; import "./css/index.css"; import ErrorFallback from "./pages/ErrorFallback"; import FilterExpressions from "./pages/rsql"; import Timestamps from "./pages/timestamps"; import LegacyFormat from "./pages/legacy-format/index.jsx"; import UserLists from "./pages/user-lists/index.jsx"; +import Users from "./pages/users/index.jsx"; +import UserRoles from "./pages/user-roles/index.jsx"; import { routePaths } from "./route-paths"; import AppAuthProvider from "./components/AppAuthProvider.jsx"; import GlobalErrorBoundary from "./components/GlobalErrorBoundary.jsx"; @@ -38,6 +41,8 @@ const routeComponents = { "legacy-format": LegacyFormat, "location-search": LocationSearch, "user-lists": UserLists, + users: Users, + "user-roles": UserRoles, }; const router = createBrowserRouter( diff --git a/cda-gui/src/pages/user-roles/index.jsx b/cda-gui/src/pages/user-roles/index.jsx new file mode 100644 index 000000000..bd86a2652 --- /dev/null +++ b/cda-gui/src/pages/user-roles/index.jsx @@ -0,0 +1,650 @@ +import { useEffect, useMemo, useState } from "react"; +import { + CWMS_USER_ROLE_DESCRIPTIONS, + CWMS_USER_ROLE_PRESETS, + OfficeDropdown, + matchCwmsUserRolePreset, + resolveCwmsUserRolePreset, + useAuth, + useCdaRoles, + useCdaUsers, + useUpdateCdaUserRoles, +} from "@usace-watermanagement/groundwork-water"; +import { + Badge, + Button, + Card, + H1, + H2, + Search, + Skeleton, + Strong, + Text, +} from "@usace/groundwork"; +import { + FaExternalLinkAlt, + FaSearch, + FaShieldAlt, + FaUserShield, + FaUsers, +} from "react-icons/fa"; + +import { HelpTip } from "../../components/HelpTip"; +import { EmptyState, Notice } from "../user-lists/components/StatusMessages"; +import { filterUsers, paginateUsers, rolesForOffice, sameRoles } from "./role-state"; + +const cdaUrl = import.meta.env.VITE_CDA_API_ROOT; +const cliRoleDocsUrl = + "https://cwms-cli.readthedocs.io/en/latest/cli/users.html#add-a-role-to-a-user"; +const usersPerPage = 10; +const fallbackRoleDescription = + "An office-scoped CWMS role returned by the CDA role catalog."; + +function userRoleSummary(roles) { + const presetId = matchCwmsUserRolePreset(roles); + const preset = CWMS_USER_ROLE_PRESETS.find(({ id }) => id === presetId); + return preset?.label ?? `Custom: ${roles.length ? roles.join(" · ") : "No roles"}`; +} + +function updateSummary(userName, additions, removals) { + const changes = []; + if (additions.length) changes.push(`added ${additions.length}`); + if (removals.length) changes.push(`removed ${removals.length}`); + return changes.length + ? `Updated ${userName}: ${changes.join(", ")} role${additions.length + removals.length === 1 ? "" : "s"}.` + : `No role changes were needed for ${userName}.`; +} + +export default function UserRoles() { + const auth = useAuth(); + const [office, setOffice] = useState(""); + const [selectedUserName, setSelectedUserName] = useState(""); + const [search, setSearch] = useState(""); + const [userPage, setUserPage] = useState(1); + const [draftRoles, setDraftRoles] = useState([]); + const [roleMode, setRoleMode] = useState("custom"); + const [message, setMessage] = useState(""); + const [mutationError, setMutationError] = useState(""); + + const adminOffices = useMemo( + () => + Object.entries(auth.profile?.roles ?? {}) + .filter(([, roles]) => roles.includes("CWMS User Admins")) + .map(([officeId]) => officeId) + .sort(), + [auth.profile], + ); + + useEffect(() => { + if (!adminOffices.includes(office)) setOffice(adminOffices[0] ?? ""); + }, [adminOffices, office]); + + const usersQuery = useCdaUsers({ + cdaUrl, + token: auth.token, + office, + queryOptions: { enabled: auth.isAuth && Boolean(office) }, + }); + const rolesQuery = useCdaRoles({ + cdaUrl, + token: auth.token, + queryOptions: { enabled: auth.isAuth && adminOffices.length > 0 }, + }); + const updateRoles = useUpdateCdaUserRoles({ cdaUrl, token: auth.token }); + + const users = useMemo(() => usersQuery.data?.users ?? [], [usersQuery.data]); + const userTotal = usersQuery.data?.total ?? users.length; + const selectedUser = users.find((user) => user["user-name"] === selectedUserName); + const currentRoles = useMemo( + () => rolesForOffice(selectedUser, office), + [selectedUser, office], + ); + const visibleUsers = useMemo( + () => filterUsers(users, search, office), + [users, search, office], + ); + const pagination = useMemo( + () => paginateUsers(visibleUsers, userPage, usersPerPage), + [userPage, visibleUsers], + ); + const roleCatalog = useMemo( + () => [...(rolesQuery.data ?? [])].sort((left, right) => left.localeCompare(right)), + [rolesQuery.data], + ); + const presetResolution = useMemo( + () => + roleMode === "custom" ? null : resolveCwmsUserRolePreset(roleMode, roleCatalog), + [roleCatalog, roleMode], + ); + + useEffect(() => { + if (!users.some((user) => user["user-name"] === selectedUserName)) { + setSelectedUserName(users[0]?.["user-name"] ?? ""); + } + }, [selectedUserName, users]); + + useEffect(() => { + if (pagination.currentPage !== userPage) setUserPage(pagination.currentPage); + }, [pagination.currentPage, userPage]); + + useEffect(() => { + setDraftRoles(currentRoles); + setRoleMode(matchCwmsUserRolePreset(currentRoles) ?? "custom"); + }, [currentRoles]); + + function changeOffice(nextOffice) { + setOffice(nextOffice); + setSelectedUserName(""); + setSearch(""); + setUserPage(1); + setMessage(""); + setMutationError(""); + } + + function changeSearch(event) { + const nextSearch = event.target.value; + const matches = filterUsers(users, nextSearch, office); + setSearch(nextSearch); + setUserPage(1); + setSelectedUserName(matches[0]?.["user-name"] ?? ""); + } + + function changeUserPage(nextPage) { + const next = paginateUsers(visibleUsers, nextPage, usersPerPage); + setUserPage(next.currentPage); + setMessage(""); + setMutationError(""); + } + + function toggleRole(role) { + if (role === "All Users") return; + setRoleMode("custom"); + setDraftRoles((current) => + current.includes(role) + ? current.filter((item) => item !== role) + : [...current, role].sort((left, right) => left.localeCompare(right)), + ); + setMessage(""); + setMutationError(""); + } + + function selectRoleMode(mode) { + setRoleMode(mode); + if (mode === "custom") return; + const { roles } = resolveCwmsUserRolePreset(mode, roleCatalog); + const protectedRoles = currentRoles.filter((role) => role === "All Users"); + setDraftRoles( + [...new Set([...protectedRoles, ...roles])].sort((left, right) => + left.localeCompare(right), + ), + ); + setMessage(""); + setMutationError(""); + } + + function resetRoles() { + setDraftRoles(currentRoles); + setRoleMode(matchCwmsUserRolePreset(currentRoles) ?? "custom"); + } + + async function saveRoles(event) { + event.preventDefault(); + if (!selectedUser) return; + setMessage(""); + setMutationError(""); + try { + const result = await updateRoles.mutateAsync({ + userName: selectedUser["user-name"], + office, + previousRoles: currentRoles, + roles: draftRoles, + }); + setMessage( + updateSummary(selectedUser["user-name"], result.additions, result.removals), + ); + } catch (error) { + setMutationError(error?.message ?? "Unable to update this user's roles."); + } + } + + if (!auth.isAuth) { + return ( + +
+
+

User Roles

+ + Sign in with a CWMS User Administrator account to review staff and manage + office-scoped role assignments. + + +
+ ); + } + + return ( +
+
+
+ CDA + User administrator +
+
+

User Roles

+ + The signed-in profile determines which offices appear. CDA and the CWMS + database enforce whether an administrator may grant or remove each role. + +
+ + Review every active user in an authorized office and maintain the roles that + control their CWMS access. + +
+ Prefer the command line? + + CWMS CLI user-role guide + +
+
+ + {mutationError && {mutationError}} + {usersQuery.error && {usersQuery.error.message}} + {rolesQuery.error && {rolesQuery.error.message}} + {message && {message}} + + +
+
+ Office + + Choose an office where you are a User Administrator. + +
+ {auth.isLoading ? ( + + ) : adminOffices.length ? ( + + ) : ( + + Your profile does not include CWMS User Administrator access for an + office. + + )} +
+
+ + {adminOffices.length > 0 && ( +
+ +
+
+

Office users

+ {office || "Select an office"} +
+ + {userTotal} {userTotal === 1 ? "user" : "users"} + +
+
+
+ +
+ + {usersQuery.isLoading ? ( +
+ + + +
+ ) : users.length === 0 ? ( + + CDA did not return any users with active privileges in this office. + + ) : visibleUsers.length === 0 ? ( + + Try a different name, email, user ID, or role. + + ) : ( + <> +
+ {pagination.users.map((user) => { + const userName = user["user-name"]; + const active = userName === selectedUserName; + const officeRoles = rolesForOffice(user, office); + return ( + + ); + })} +
+ + + )} +
+
+ + +
+
+
+

+ {selectedUser?.["user-name"] ?? "Role assignment"} +

+ + {selectedUser + ? selectedUser.email || selectedUser.principal + : "Choose an office user to manage."} + +
+ {selectedUser && {office}} +
+
+ + {!selectedUser ? ( +
+ + Select an office user to review and edit their roles. + +
+ ) : ( +
+
+ Assign roles for {office} + + Changes take effect only after you save. CDA records additions and + removals through its existing user-management endpoints. + +
+ +
+ + Role configuration + + + Choose a cwms-cli-compatible configuration, or select Custom to + assign specific roles. Applying a preset replaces other optional + roles when you save. + +
+ {CWMS_USER_ROLE_PRESETS.map((preset) => ( +
+ selectRoleMode(preset.id)} + /> + + + {preset.description} Selecting this configuration sets the + user's office roles to {preset.roles.join(", ")}. + +
+ ))} +
+ selectRoleMode("custom")} + /> + + + Use Custom when a staff member needs a combination that does not + exactly match the read-only, read/write, or administrator + presets. + +
+
+
+ + {presetResolution?.unavailableRoles.length > 0 && ( + + This CDA role catalog does not include:{" "} + {presetResolution.unavailableRoles.join(", ")}. The available + portions of the preset are selected; review them before saving. + + )} + + {roleMode === "custom" && + (rolesQuery.isLoading ? ( +
+ + + + +
+ ) : roleCatalog.length === 0 ? ( + + CDA did not return a role catalog for this administrator. + + ) : ( +
+
+
+ Specific roles + + Choose the exact roles to assign. + +
+ + These selections apply only to the chosen office. All Users is + required by CWMS and cannot be removed directly. + +
+ + Roles for {selectedUser["user-name"]} + +
+ {roleCatalog.map((role) => { + const checked = draftRoles.includes(role); + const protectedRole = role === "All Users"; + return ( +
+ toggleRole(role)} + /> + +
+ + {currentRoles.includes(role) + ? "Assigned" + : "Not assigned"} + + + {CWMS_USER_ROLE_DESCRIPTIONS[role] ?? + fallbackRoleDescription} + +
+
+ ); + })} +
+
+ ))} + +
+ + {draftRoles.length} role{draftRoles.length === 1 ? "" : "s"}{" "} + selected + +
+ + +
+
+
+ )} +
+
+ )} + +
+ Planned transition + + This page may be replaced at a later date pending completion of the new CWMS + authorization development. + +
+
+ ); +} diff --git a/cda-gui/src/pages/user-roles/role-state.js b/cda-gui/src/pages/user-roles/role-state.js new file mode 100644 index 000000000..533fd5b2b --- /dev/null +++ b/cda-gui/src/pages/user-roles/role-state.js @@ -0,0 +1,34 @@ +export function rolesForOffice(user, office) { + return [...(user?.roles?.[office] ?? [])].sort((left, right) => + left.localeCompare(right), + ); +} + +export function sameRoles(left, right) { + if (left.length !== right.length) return false; + const selected = new Set(right); + return left.every((role) => selected.has(role)); +} + +export function filterUsers(users, search, office) { + const term = search.trim().toLowerCase(); + if (!term) return users; + return users.filter((user) => + [user["user-name"], user.principal, user.email, ...rolesForOffice(user, office)] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(term)), + ); +} + +export function paginateUsers(users, page, pageSize) { + const pageCount = Math.max(1, Math.ceil(users.length / pageSize)); + const currentPage = Math.min(Math.max(page, 1), pageCount); + const start = (currentPage - 1) * pageSize; + return { + currentPage, + pageCount, + users: users.slice(start, start + pageSize), + start: users.length ? start + 1 : 0, + end: Math.min(start + pageSize, users.length), + }; +} diff --git a/cda-gui/src/pages/user-roles/role-state.test.js b/cda-gui/src/pages/user-roles/role-state.test.js new file mode 100644 index 000000000..da820512e --- /dev/null +++ b/cda-gui/src/pages/user-roles/role-state.test.js @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { filterUsers, paginateUsers, rolesForOffice, sameRoles } from "./role-state.js"; + +const users = [ + { + "user-name": "ALPHA", + principal: "alpha.one", + email: "alpha@example.com", + roles: { SWT: ["TS ID Creator", "CWMS Users"] }, + }, + { + "user-name": "BRAVO", + principal: "bravo.two", + email: "bravo@example.com", + roles: { SWT: ["CCP Mgr"] }, + }, +]; + +test("rolesForOffice returns a sorted office-scoped copy", () => { + assert.deepEqual(rolesForOffice(users[0], "SWT"), ["CWMS Users", "TS ID Creator"]); + assert.deepEqual(rolesForOffice(users[0], "SPK"), []); +}); + +test("sameRoles ignores selection order", () => { + assert.equal(sameRoles(["CWMS Users", "CCP Mgr"], ["CCP Mgr", "CWMS Users"]), true); + assert.equal(sameRoles(["CWMS Users"], ["CCP Mgr"]), false); +}); + +test("filterUsers searches identity fields and office roles", () => { + assert.deepEqual(filterUsers(users, "creator", "SWT"), [users[0]]); + assert.deepEqual(filterUsers(users, "bravo@", "SWT"), [users[1]]); +}); + +test("paginateUsers clamps pages and reports the visible range", () => { + const page = paginateUsers([1, 2, 3, 4, 5], 2, 2); + assert.deepEqual(page, { + currentPage: 2, + pageCount: 3, + users: [3, 4], + start: 3, + end: 4, + }); + assert.equal(paginateUsers([1, 2, 3], 99, 2).currentPage, 2); + assert.deepEqual(paginateUsers([], 1, 10), { + currentPage: 1, + pageCount: 1, + users: [], + start: 0, + end: 0, + }); +}); diff --git a/cda-gui/src/pages/users/index.jsx b/cda-gui/src/pages/users/index.jsx new file mode 100644 index 000000000..cce33f4a1 --- /dev/null +++ b/cda-gui/src/pages/users/index.jsx @@ -0,0 +1,56 @@ +import { Badge, Card, H1, H2, Text } from "@usace/groundwork"; +import { Link } from "react-router-dom"; +import { FaListUl, FaUserShield } from "react-icons/fa"; + +const pages = [ + { + title: "User Lists", + href: "/user-lists", + icon: FaListUl, + badge: "Recipients", + description: + "Create office-owned lists and manage their membership for notifications and other CDA applications.", + }, + { + title: "User Roles", + href: "/user-roles", + icon: FaUserShield, + badge: "Access", + description: + "Review staff in an authorized office and assign or remove their CWMS roles.", + }, +]; + +export default function Users() { + return ( +
+
+ CDA administration +

Users

+ + Manage reusable user groups and office-scoped access from one place. Your + signed-in CWMS permissions determine which information and actions are + available. + +
+ +
+ {pages.map(({ title, href, icon: Icon, badge, description }) => ( + + +
+
+
+ {badge} +
+

{title}

+ {description} + Open {title} → +
+ + ))} +
+
+ ); +} diff --git a/cda-gui/src/route-paths.js b/cda-gui/src/route-paths.js index 497180dd9..919cea9fd 100644 --- a/cda-gui/src/route-paths.js +++ b/cda-gui/src/route-paths.js @@ -30,11 +30,21 @@ export const routePaths = [ path: "timestamps", sitemapPath: "timestamps", }, + { + id: "users", + path: "users", + sitemapPath: "users", + }, { id: "user-lists", path: "user-lists", sitemapPath: "user-lists", }, + { + id: "user-roles", + path: "user-roles", + sitemapPath: "user-roles", + }, { id: "legacy-format", path: "legacy-format", diff --git a/cda-gui/vite.config.js b/cda-gui/vite.config.js index 77b09e58e..d3b15e5c6 100644 --- a/cda-gui/vite.config.js +++ b/cda-gui/vite.config.js @@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => { plugins: [react()], server: { proxy: { - "^/(auth|CWMSLogin|cwms-data/(?!$|swagger-ui(?:/|$)|data-query(?:/|$)|regexp(?:/|$)|filter-expressions(?:/|$)|timestamps(?:/|$)|user-lists(?:/|$)|legacy-format(?:/|$)|location-search(?:/|$)|assets/|src/|node_modules/|@).*)": + "^/(auth|CWMSLogin|cwms-data/(?!$|swagger-ui(?:/|$)|data-query(?:/|$)|regexp(?:/|$)|filter-expressions(?:/|$)|timestamps(?:/|$)|users(?:/|$)|user-lists(?:/|$)|user-roles(?:/|$)|legacy-format(?:/|$)|location-search(?:/|$)|assets/|src/|node_modules/|@).*)": { target: cdaApiRoot, changeOrigin: true,