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/api.js b/cda-gui/src/pages/user-lists/api.js new file mode 100644 index 0000000000..faddf66507 --- /dev/null +++ b/cda-gui/src/pages/user-lists/api.js @@ -0,0 +1,28 @@ +const apiRoot = import.meta.env.VITE_CDA_API_ROOT.replace(/\/$/, ""); + +export 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 { + // A proxy or gateway error may not contain CDA's JSON error envelope. + } + throw new Error(detail); + } + return response.status === 204 ? null : response.json(); +} + +export function userListsFrom(payload) { + return payload?.["user-lists"] ?? payload?.entries ?? payload ?? []; +} diff --git a/cda-gui/src/pages/user-lists/components/OfficeSelector.jsx b/cda-gui/src/pages/user-lists/components/OfficeSelector.jsx new file mode 100644 index 0000000000..a5945ae896 --- /dev/null +++ b/cda-gui/src/pages/user-lists/components/OfficeSelector.jsx @@ -0,0 +1,69 @@ +import { + Card, + Description, + Dropdown, + Field, + Label, + Skeleton, + Strong, + Text, +} from "@usace/groundwork"; +import PropTypes from "prop-types"; +import { HelpTip } from "../../../components/HelpTip"; + +export function OfficeSelector({ offices, office, canWrite, loading, onChange }) { + return ( + +
+ +
+ + + 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 ? ( + onChange(event.target.value)} + options={offices.map((item) => ( + + ))} + /> + ) : loading ? ( + + ) : ( + 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."} + +
+
+
+ ); +} + +OfficeSelector.propTypes = { + offices: PropTypes.arrayOf(PropTypes.string).isRequired, + office: PropTypes.string.isRequired, + canWrite: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/user-lists/components/StatusMessages.jsx b/cda-gui/src/pages/user-lists/components/StatusMessages.jsx new file mode 100644 index 0000000000..b02fdc3f72 --- /dev/null +++ b/cda-gui/src/pages/user-lists/components/StatusMessages.jsx @@ -0,0 +1,45 @@ +import { H3, Strong, Text } from "@usace/groundwork"; +import PropTypes from "prop-types"; + +export function Notice({ kind, children }) { + const isError = kind === "error"; + return ( +
+ {isError ? ( + {children} + ) : ( + {children} + )} +
+ ); +} + +export 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, +}; diff --git a/cda-gui/src/pages/user-lists/components/UserListBrowser.jsx b/cda-gui/src/pages/user-lists/components/UserListBrowser.jsx new file mode 100644 index 0000000000..d4dd11d054 --- /dev/null +++ b/cda-gui/src/pages/user-lists/components/UserListBrowser.jsx @@ -0,0 +1,114 @@ +import { Badge, Card, H2, Input, Skeleton, Strong, Text } from "@usace/groundwork"; +import PropTypes from "prop-types"; +import { FaListUl, FaSearch } from "react-icons/fa"; +import { EmptyState } from "./StatusMessages"; + +const userListShape = PropTypes.shape({ + "user-list-id": PropTypes.string.isRequired, + description: PropTypes.string, + "owned-by-user-id": PropTypes.string, +}); + +export function UserListBrowser({ + lists, + filteredLists, + office, + selected, + canWrite, + loading, + search, + onSearch, + onSelect, +}) { + return ( + +
+
+

Lists

+ Select a list to inspect its members. +
+ {lists.length} +
+ +
+ {lists.length > 0 && ( +
+
+ )} + {loading ? ( +
+ + +
+ ) : 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 ( + + ); + })} +
+ )} +
+
+ ); +} + +UserListBrowser.propTypes = { + lists: PropTypes.arrayOf(userListShape).isRequired, + filteredLists: PropTypes.arrayOf(userListShape).isRequired, + office: PropTypes.string.isRequired, + selected: PropTypes.string.isRequired, + canWrite: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + search: PropTypes.string.isRequired, + onSearch: PropTypes.func.isRequired, + onSelect: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/user-lists/components/UserListDialogs.jsx b/cda-gui/src/pages/user-lists/components/UserListDialogs.jsx new file mode 100644 index 0000000000..e1df7140b0 --- /dev/null +++ b/cda-gui/src/pages/user-lists/components/UserListDialogs.jsx @@ -0,0 +1,163 @@ +import { + Button, + Description, + Field, + Input, + Label, + Modal, + Textarea, +} from "@usace/groundwork"; +import PropTypes from "prop-types"; +import { FaPlus } from "react-icons/fa"; +import { HelpTip } from "../../../components/HelpTip"; + +export function UserListDialogs({ + office, + selected, + working, + createOpened, + newList, + description, + editOpened, + editDescription, + deleteOpened, + onCreateClose, + onCreate, + onNewListChange, + onDescriptionChange, + onEditClose, + onEdit, + onEditDescriptionChange, + onDeleteClose, + onDelete, +}) { + return ( + <> + +
+ +
+ + + 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. + + onNewListChange(event.target.value.toUpperCase())} + /> +
+ + + + Explain who belongs in the list and how it is used. + +