diff --git a/.gitignore b/.gitignore index b9f9a61062..84a9c61734 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ etl.env cwms-data-api/features.properties cda-etl/logs cda-etl/cache -**/.venv \ No newline at end of file +**/.venv +cda-gui/.env.dev-cda-compose.local diff --git a/cda-gui/.env.dev-cda-compose b/cda-gui/.env.dev-cda-compose new file mode 100644 index 0000000000..f50751ba9b --- /dev/null +++ b/cda-gui/.env.dev-cda-compose @@ -0,0 +1,4 @@ +VITE_CDA_API_ROOT=/cwms-data +CDA_DEV_PROXY_ROOT=http://localhost:8081 +VITE_AUTH_HOST=http://localhost:8081/auth +VITE_AUTH_REALM=cwms diff --git a/cda-gui/.env.development b/cda-gui/.env.development index c86560b277..2835760128 100644 --- a/cda-gui/.env.development +++ b/cda-gui/.env.development @@ -1 +1,3 @@ -VITE_CDA_API_ROOT=https://water.dev.cwbi.us/cwms-data \ No newline at end of file +VITE_CDA_API_ROOT=https://water.dev.cwbi.us/cwms-data +VITE_AUTH_HOST=https://identity-test.cwbi.us/auth +VITE_AUTH_REALM=cwbi diff --git a/cda-gui/.env.test b/cda-gui/.env.test index 6f12fa2e4d..8d72983cb8 100644 --- a/cda-gui/.env.test +++ b/cda-gui/.env.test @@ -1 +1,3 @@ -VITE_CDA_API_ROOT=https://cwms-data-test.cwbi.us/cwms-data \ No newline at end of file +VITE_CDA_API_ROOT=https://cwms-data-test.cwbi.us/cwms-data +VITE_AUTH_HOST=https://identity-test.cwbi.us/auth +VITE_AUTH_REALM=cwbi diff --git a/cda-gui/src/components/AuthButton.jsx b/cda-gui/src/components/AuthButton.jsx new file mode 100644 index 0000000000..d8379a1ced --- /dev/null +++ b/cda-gui/src/components/AuthButton.jsx @@ -0,0 +1,14 @@ +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import { LoginButton } from "@usace/groundwork"; + +export default function AuthButton() { + const auth = useAuth(); + + return auth.isAuth ? ( + + ) : ( + + ); +} diff --git a/cda-gui/src/components/HelpTip.jsx b/cda-gui/src/components/HelpTip.jsx new file mode 100644 index 0000000000..bdfb4f8701 --- /dev/null +++ b/cda-gui/src/components/HelpTip.jsx @@ -0,0 +1,68 @@ +import { useId, useState } from "react"; +import PropTypes from "prop-types"; +import { FaCircleQuestion, FaXmark } from "react-icons/fa6"; + +export function HelpTip({ title, children, className = "" }) { + const [open, setOpen] = useState(false); + const titleId = useId(); + + return ( +
+ + {open && ( +
{ + if (event.key === "Escape") setOpen(false); + }} + > + +
+ {children} +
+ + )} + + ); +} + +HelpTip.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + className: PropTypes.string, +}; diff --git a/cda-gui/src/components/Layout.jsx b/cda-gui/src/components/Layout.jsx index f81536bbb2..e33b0423de 100644 --- a/cda-gui/src/components/Layout.jsx +++ b/cda-gui/src/components/Layout.jsx @@ -5,6 +5,7 @@ import footerLinks from "../links/footer-links"; import externalLinks from "../links/external-links"; import Breadcrumbs from "./Breadcrumbs"; import { FaGithub } from "react-icons/fa"; +import AuthButton from "./AuthButton"; export default function Layout() { return ( @@ -15,16 +16,19 @@ export default function Layout() { subtitle="CWMS Restful API for Data Retrieval" aboutText="Deliver vital engineering solutions, in collaboration with our partners, to secure our Nation, energize our economy, and reduce disaster risk. The official public website of the U.S. Army Corps of Engineers Hydrologic Engineering Center (HEC)." navRight={ - +
+ + +
} usaceLinks={footerLinks} externalLinks={externalLinks} diff --git a/cda-gui/src/links/header-links.js b/cda-gui/src/links/header-links.js index c66f468c0c..9e6e03515b 100644 --- a/cda-gui/src/links/header-links.js +++ b/cda-gui/src/links/header-links.js @@ -38,6 +38,11 @@ export default [ }, ], }, + { + id: "user-lists", + text: "User Lists", + href: "/user-lists", + }, { id: "help", text: "Help", diff --git a/cda-gui/src/main.jsx b/cda-gui/src/main.jsx index 95e0386c15..a4c10bcd73 100644 --- a/cda-gui/src/main.jsx +++ b/cda-gui/src/main.jsx @@ -5,6 +5,10 @@ import { Link, createBrowserRouter, RouterProvider } from "react-router-dom"; import { LinkProvider } from "@usace/groundwork"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + AuthProvider, + createKeycloakAuthMethod, +} from "@usace-watermanagement/groundwork-water"; // Pages import Home from "./pages/Home"; @@ -22,9 +26,56 @@ 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 { routePaths } from "./route-paths"; const queryClient = new QueryClient(); + +function createLocalAuthMethod() { + let token; + return { + async login() { + const response = await fetch( + `${import.meta.env.VITE_AUTH_HOST}/realms/${import.meta.env.VITE_AUTH_REALM}/protocol/openid-connect/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "password", + client_id: "cwms", + username: import.meta.env.VITE_AUTH_USER, + password: import.meta.env.VITE_AUTH_PASSWORD, + }), + }, + ); + if (!response.ok) { + throw new Error(`Local Keycloak login failed (${response.status})`); + } + token = (await response.json()).access_token; + }, + async logout() { + token = undefined; + }, + async isAuth() { + return !!token; + }, + get token() { + return token; + }, + }; +} + +const authMethod = + import.meta.env.MODE === "dev-cda-compose" + ? createLocalAuthMethod() + : createKeycloakAuthMethod({ + host: import.meta.env.VITE_AUTH_HOST, + realm: import.meta.env.VITE_AUTH_REALM, + client: "cwms", + flow: "authorization-code-pkce", + redirectUri: window.location.href, + providerHint: "federation-eams", + }); const routeComponents = { home: Home, "swagger-ui": SwaggerUI, @@ -34,6 +85,7 @@ const routeComponents = { timestamps: Timestamps, "legacy-format": LegacyFormat, "location-search": LocationSearch, + "user-lists": UserLists, }; const router = createBrowserRouter( @@ -59,9 +111,11 @@ const router = createBrowserRouter( ReactDOM.createRoot(document.getElementById("root")).render( - - - + + + + + , ); diff --git a/cda-gui/src/pages/user-lists/index.jsx b/cda-gui/src/pages/user-lists/index.jsx new file mode 100644 index 0000000000..c533ad4aa0 --- /dev/null +++ b/cda-gui/src/pages/user-lists/index.jsx @@ -0,0 +1,902 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Badge, + Button, + Card, + Description, + Dropdown, + Field, + H1, + H2, + H3, + Input, + Label, + Modal, + Skeleton, + Strong, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, + Textarea, +} from "@usace/groundwork"; +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import PropTypes from "prop-types"; +import { FaListUl, FaPen, FaPlus, FaSearch, FaUserPlus, FaUsers } from "react-icons/fa"; +import { HelpTip } from "../../components/HelpTip"; + +const apiRoot = import.meta.env.VITE_CDA_API_ROOT.replace(/\/$/, ""); + +async function request(path, token, options = {}) { + const response = await fetch(`${apiRoot}${path}`, { + ...options, + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...options.headers, + }, + }); + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const payload = await response.json(); + detail = payload.message ?? payload.detail ?? detail; + } catch { + // Keep the HTTP status when CDA does not return a JSON error body. + } + throw new Error(detail); + } + return response.status === 204 ? null : response.json(); +} + +function values(payload) { + return payload?.["user-lists"] ?? payload?.entries ?? payload ?? []; +} + +function Notice({ kind, children }) { + const isError = kind === "error"; + return ( +
+ {isError ? ( + {children} + ) : ( + {children} + )} +
+ ); +} + +function EmptyState({ icon: Icon, title, children }) { + return ( +
+
+
+

{title}

+ {children} +
+ ); +} + +Notice.propTypes = { + kind: PropTypes.oneOf(["error", "success"]).isRequired, + children: PropTypes.node.isRequired, +}; + +EmptyState.propTypes = { + icon: PropTypes.elementType.isRequired, + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +export default function UserLists() { + const auth = useAuth(); + const [profile, setProfile] = useState(null); + const [profileLoading, setProfileLoading] = useState(true); + const [office, setOffice] = useState(""); + const [lists, setLists] = useState([]); + const [selected, setSelected] = useState(""); + const [members, setMembers] = useState([]); + const [newList, setNewList] = useState(""); + const [description, setDescription] = useState(""); + const [newMember, setNewMember] = useState(""); + const [candidateSearch, setCandidateSearch] = useState(""); + const [candidates, setCandidates] = useState([]); + const [candidateLoading, setCandidateLoading] = useState(false); + const [listSearch, setListSearch] = useState(""); + const [memberSearch, setMemberSearch] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [listsLoading, setListsLoading] = useState(false); + const [membersLoading, setMembersLoading] = useState(false); + const [working, setWorking] = useState(false); + const [createOpened, setCreateOpened] = useState(false); + const [editOpened, setEditOpened] = useState(false); + const [editDescription, setEditDescription] = useState(""); + const [deleteOpened, setDeleteOpened] = useState(false); + + const offices = useMemo( + () => Object.keys(profile?.roles ?? profile?.["office-roles"] ?? {}).sort(), + [profile], + ); + const roles = profile?.roles?.[office] ?? profile?.["office-roles"]?.[office] ?? []; + const canWrite = roles.includes("CWMS User Admins"); + const selectedList = lists.find((item) => item["user-list-id"] === selected); + const filteredLists = useMemo(() => { + const term = listSearch.trim().toLowerCase(); + if (!term) return lists; + return lists.filter( + (item) => + item["user-list-id"]?.toLowerCase().includes(term) || + item.description?.toLowerCase().includes(term), + ); + }, [listSearch, lists]); + const filteredMembers = useMemo(() => { + const term = memberSearch.trim().toLowerCase(); + if (!term) return members; + return members.filter((member) => + [member["user-id"], member["full-name"], member.email] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(term)), + ); + }, [memberSearch, members]); + + const loadLists = useCallback( + async (targetOffice = office) => { + if (!targetOffice) return []; + setListsLoading(true); + try { + const payload = await request( + `/user/list?office=${encodeURIComponent(targetOffice)}`, + auth.token, + ); + const nextLists = values(payload); + setLists(nextLists); + setSelected((current) => + nextLists.some((item) => item["user-list-id"] === current) + ? current + : (nextLists[0]?.["user-list-id"] ?? ""), + ); + return nextLists; + } finally { + setListsLoading(false); + } + }, + [auth.token, office], + ); + + const loadMembers = useCallback( + async (userListId = selected) => { + if (!userListId || !office || !auth.token) { + setMembers([]); + return []; + } + setMembersLoading(true); + try { + const payload = await request( + `/user/list/${encodeURIComponent(userListId)}/members?office=${encodeURIComponent(office)}`, + auth.token, + ); + const nextMembers = payload?.members ?? payload ?? []; + setMembers(nextMembers); + return nextMembers; + } finally { + setMembersLoading(false); + } + }, + [auth.token, office, selected], + ); + + useEffect(() => { + if (!auth.isAuth || !auth.token) return; + request("/user/profile", auth.token) + .then((nextProfile) => { + setProfile(nextProfile); + const nextOffice = Object.keys( + nextProfile?.roles ?? nextProfile?.["office-roles"] ?? {}, + ).sort()[0]; + setOffice((current) => current || nextOffice || ""); + }) + .catch((cause) => setError(cause.message)) + .finally(() => setProfileLoading(false)); + }, [auth.isAuth, auth.token]); + + useEffect(() => { + setError(""); + loadLists().catch((cause) => setError(cause.message)); + }, [loadLists]); + + useEffect(() => { + setError(""); + loadMembers().catch((cause) => setError(cause.message)); + }, [loadMembers]); + + useEffect(() => { + const search = candidateSearch.trim(); + if (!auth.token || !office || search.length < 2) { + setCandidates([]); + setCandidateLoading(false); + return undefined; + } + const controller = new AbortController(); + setError(""); + setCandidates([]); + const timeout = window.setTimeout(async () => { + setCandidateLoading(true); + try { + const parameters = new URLSearchParams({ + office, + search, + "page-size": "20", + }); + const payload = await request( + `/user/list-member-candidates?${parameters}`, + auth.token, + { signal: controller.signal }, + ); + setCandidates(payload?.candidates ?? payload ?? []); + } catch (cause) { + if (cause.name !== "AbortError") setError(cause.message); + } finally { + if (!controller.signal.aborted) setCandidateLoading(false); + } + }, 250); + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [auth.token, candidateSearch, office]); + + async function mutate(action, success, refresh = {}) { + setError(""); + setMessage(""); + setWorking(true); + try { + const result = await action(); + if (refresh.lists !== false) await loadLists(); + if (refresh.members) await loadMembers(refresh.members); + setMessage(success); + return result ?? true; + } catch (cause) { + setError(cause.message); + return null; + } finally { + setWorking(false); + } + } + + async function createList(event) { + event.preventDefault(); + const listId = newList.trim().toUpperCase(); + const created = await mutate( + () => + request("/user/list", auth.token, { + method: "POST", + body: JSON.stringify({ + "office-id": office, + "user-list-id": listId, + description: description.trim() || null, + }), + }), + `Created ${listId}.`, + ); + if (created) { + setSelected(listId); + setNewList(""); + setDescription(""); + setCreateOpened(false); + } + } + + if (!auth.isAuth) { + return ( + +
+
+

User Lists

+ + Sign in to view reusable recipient lists and manage membership for your + authorized CWMS offices. + + +
+ ); + } + + return ( +
+
+
+
+ CDA + + {canWrite ? "User list administrator" : "Read only"} + +
+
+

User Lists

+ + CDA records the authenticated creator as the list owner for audit history. + The owner does not change when the description or membership changes; + office User Administrators control edits. + +
+ + Build reusable, office-owned lists for notifications and other CDA-aware + applications. Membership stays in CDA so every consumer uses the same list. + +
+ {canWrite && ( + + )} +
+ + {error && {error}} + {message && {message}} + + +
+ +
+ + + Each office owns a separate collection of lists. The same list ID may + exist in two offices without sharing members. Your office role + determines whether you can view or edit a list. + +
+ + User lists are isolated by their owning CWMS office. + + {offices.length > 0 ? ( + { + setOffice(event.target.value); + setSelected(""); + setMembers([]); + setMessage(""); + setError(""); + }} + options={offices.map((item) => ( + + ))} + /> + ) : profileLoading ? ( + + ) : ( + No authorized CWMS offices are available. + )} +
+
+ + {office || "No office selected"} + {canWrite + ? " administrators can create lists and update membership." + : " lists are available for viewing with your current role."} + +
+
+
+ +
+ +
+
+

Lists

+ Select a list to inspect its members. +
+ {lists.length} +
+ +
+ {lists.length > 0 && ( +
+
+ )} + {listsLoading ? ( +
+ + +
+ ) : lists.length === 0 ? ( + + {canWrite + ? "Create the first reusable list for this office." + : "Ask a CWMS User Administrator to create a list for this office."} + + ) : filteredLists.length === 0 ? ( + + Try a different list ID or description. + + ) : ( +
+ {filteredLists.map((item) => { + const listId = item["user-list-id"]; + const active = listId === selected; + return ( + + ); + })} +
+ )} +
+
+ + +
+
+
+

{selected || "Members"}

+ {selected && {members.length} members} +
+ + {selectedList?.description || + "Select a user list to view its current membership."} + +
+ {canWrite && selected && ( +
+ + +
+ )} +
+ + {!selected ? ( +
+ + Select a user list from the left to see names, user IDs, and email + addresses. + +
+ ) : ( + <> + {canWrite && ( +
{ + event.preventDefault(); + const userId = newMember.trim().toUpperCase(); + const added = await mutate( + () => + request( + `/user/list/${encodeURIComponent(selected)}/members?office=${encodeURIComponent(office)}`, + auth.token, + { + method: "POST", + body: JSON.stringify({ "user-id": userId }), + }, + ), + `Added ${userId} to ${selected}.`, + { lists: false, members: selected }, + ); + if (added) { + setNewMember(""); + setCandidateSearch(""); + setCandidates([]); + } + }} + > + +
+ + + Members must already have a CDA user profile. CDA stores the + user ID in the list and resolves the member's current name + and email for consumers such as notifications. + +
+ + Search by user ID, name, or email, then choose an existing CWMS + user. + +
+
+ {candidateLoading && ( + + Searching… + + )} + {!candidateLoading && candidateSearch.trim().length >= 2 && ( +
+ {candidates.length === 0 ? ( + No matching users found. + ) : ( + candidates.map((candidate) => { + const candidateId = candidate["user-id"]; + const chosen = candidateId === newMember; + return ( + + ); + }) + )} +
+ )} +
+ + {newMember + ? `Selected: ${newMember}` + : "Select a user from the search results."} + + +
+
+
+ )} + +
+ {membersLoading ? ( +
+ + +
+ ) : members.length === 0 ? ( + + {canWrite + ? "Add a CWMS user to make this list available to notification consumers." + : "A list administrator has not added any members yet."} + + ) : ( + <> +
+
+ {filteredMembers.length === 0 ? ( + + Try a different name, user ID, or email. + + ) : ( +
+ + + + User + Email + {canWrite && ( + Action + )} + + + + {filteredMembers.map((member) => { + const userId = member["user-id"] ?? member; + return ( + + + {member["full-name"] || userId} + {member["full-name"] && ( + {userId} + )} + + + {member.email ? ( + + {member.email} + + ) : ( + No email in CDA profile + )} + + {canWrite && ( + + + + )} + + ); + })} + +
+
+ )} + + )} +
+ + )} +
+
+ + setCreateOpened(false)} + dialogTitle="Create a user list" + dialogDescription={`Create an office-owned list for ${office}.`} + size="lg" + > +
+ +
+ + + The ID is unique within the selected office and cannot be renamed after + creation. Use up to 128 uppercase letters, numbers, periods, hyphens, or + underscores. + +
+ + Use a short, recognizable name such as ON-CALL-HYDROLOGISTS. + + setNewList(event.target.value.toUpperCase())} + /> +
+ + + + Explain who belongs in the list and how it is used. + +