diff --git a/.github/workflows/web-gui-checks.yml b/.github/workflows/web-gui-checks.yml index 7342d2cfb..cea0d817a 100644 --- a/.github/workflows/web-gui-checks.yml +++ b/.github/workflows/web-gui-checks.yml @@ -47,3 +47,9 @@ jobs: - name: run Storybook UI tests working-directory: ./cda-gui run: npm run test-storybook -- --run + + - name: run API key unit and browser tests + working-directory: ./cda-gui + run: | + npm run test:unit + npm run test:api-keys diff --git a/cda-gui/.gitignore b/cda-gui/.gitignore index 6850026b9..cac2330bb 100644 --- a/cda-gui/.gitignore +++ b/cda-gui/.gitignore @@ -11,8 +11,8 @@ node_modules dist dist-ssr storybook-static -test-results/ -playwright-report/ +test-results +playwright-report *.local # Editor directories and files diff --git a/cda-gui/package.json b/cda-gui/package.json index 0b4be13d8..c50b43f57 100644 --- a/cda-gui/package.json +++ b/cda-gui/package.json @@ -9,7 +9,8 @@ "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:unit": "node --test src/utils/auth-config.test.js src/pages/api-keys/api.test.js", + "test:api-keys": "playwright test --config playwright.api-keys.config.js", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "storybook": "storybook dev -p 6006", diff --git a/cda-gui/playwright.api-keys.config.js b/cda-gui/playwright.api-keys.config.js new file mode 100644 index 000000000..175cc8cb8 --- /dev/null +++ b/cda-gui/playwright.api-keys.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/api-keys", + workers: 1, + use: { baseURL: "http://127.0.0.1:5178", browserName: "chromium" }, + webServer: { + command: + "npx vite --mode dev-cda-compose --host 127.0.0.1 --port 5178 --strictPort", + url: "http://127.0.0.1:5178/cwms-data/", + timeout: 120000, + env: { + VITE_CDA_API_ROOT: "/cwms-data", + VITE_AUTH_HOST: "/auth", + VITE_AUTH_REALM: "cwms", + VITE_AUTH_USER: "test-user", + VITE_AUTH_PASSWORD: "test-only", + }, + }, +}); 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/components/Layout.jsx b/cda-gui/src/components/Layout.jsx index e33b0423d..07fb8563c 100644 --- a/cda-gui/src/components/Layout.jsx +++ b/cda-gui/src/components/Layout.jsx @@ -6,11 +6,13 @@ import externalLinks from "../links/external-links"; import Breadcrumbs from "./Breadcrumbs"; import { FaGithub } from "react-icons/fa"; import AuthButton from "./AuthButton"; +import { useAuth } from "@usace-watermanagement/groundwork-water"; export default function Layout() { + const { isAuth } = useAuth(); return ( !link.requiresAuth || isAuth)} usaBanner army250Logo subtitle="CWMS Restful API for Data Retrieval" diff --git a/cda-gui/src/links/header-links.js b/cda-gui/src/links/header-links.js index 9e6e03515..469cfb291 100644 --- a/cda-gui/src/links/header-links.js +++ b/cda-gui/src/links/header-links.js @@ -43,6 +43,7 @@ export default [ text: "User Lists", href: "/user-lists", }, + { id: "api-keys", text: "API Keys", href: "/api-keys", requiresAuth: true }, { id: "help", text: "Help", diff --git a/cda-gui/src/main.jsx b/cda-gui/src/main.jsx index e5fad9b0e..292954d05 100644 --- a/cda-gui/src/main.jsx +++ b/cda-gui/src/main.jsx @@ -23,6 +23,8 @@ 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 ApiKeys from "./pages/api-keys/index.jsx"; +import ApiKeyHelp from "./pages/api-keys/help.jsx"; import { routePaths } from "./route-paths"; import AppAuthProvider from "./components/AppAuthProvider.jsx"; import GlobalErrorBoundary from "./components/GlobalErrorBoundary.jsx"; @@ -38,6 +40,8 @@ const routeComponents = { "legacy-format": LegacyFormat, "location-search": LocationSearch, "user-lists": UserLists, + "api-keys": ApiKeys, + "api-key-help": ApiKeyHelp, }; const router = createBrowserRouter( diff --git a/cda-gui/src/pages/api-keys/README.md b/cda-gui/src/pages/api-keys/README.md new file mode 100644 index 000000000..d6609b612 --- /dev/null +++ b/cda-gui/src/pages/api-keys/README.md @@ -0,0 +1,87 @@ +# API Keys + +Signed-in users can open `/cwms-data/api-keys` from the API Keys navigation link +or from any of the four Authorization key operations in Swagger. Signed-out +visitors are redirected to Home, and the navigation link is hidden. + +`/cwms-data/api-keys/help` is a separate signed-in guide with four numbered +steps: create, save, send a request, and replace or revoke. It preserves office +context when returning to key management and has a separate troubleshooting +section. Both routes are excluded from the public sitemap. + +Page entrypoints compose the files in `components/`: authentication guard, +manager, header, office context, key list/details, individual dialogs, and guide +sections. API requests and date handling remain in `api.js`. + +The page uses Groundwork controls and dialogs, Groundwork Water's authentication +and profile provider, and the existing `cwmsjs` Authorization API. No unpublished +shared-library changes are required. Its card layout follows the User Roles page +in CDA PR #1903 while remaining independently mergeable. + +Keys belong to the current user. The existing key endpoints have no office +parameter: the office selector shows the user's roles and sets the office in the +help example, without filtering keys or limiting their authority. Creation uses +the CWMS profile's user name rather than accepting another user's ID. + +The secret is retained only in component memory until the user acknowledges +saving it. It is excluded from list/detail state and never put in browser +storage or query caches. Session changes and navigation unmount that state and +abort pending requests. List/detail calls return metadata only. Revocation has +an explicit confirmation step. + +Rotation creates a uniquely named replacement first, displays its secret, then +offers to revoke the old key after the user has saved it and updated their +application. Cancelling the final confirmation keeps both keys. Creation failure +does not revoke the old key; revocation failure leaves the replacement available +and allows retry. Existing endpoints do not offer atomic same-name rotation. + +Expired keys have red list styling, a warning icon/badge, and a detail notice +explaining they cannot authenticate. Status refreshes while the page is open. +Request failures, including generic server errors, display plain-language messages +without raw response bodies or status codes. Uncertain server failures direct the +user to refresh before retrying a change. + +Dismissible toasts report creation, copying, refresh, revocation, rotation, +expired keys, and failures. Toasts remain inside the active dialog for keyboard +access and screen-reader announcements. Success messages close after eight seconds +(paused on hover or focus); errors and warnings remain until dismissed or the +user moves to another action. Form errors also remain beside their fields. + +The adapter recognizes the validation responses introduced in #1935, including +invalid dates, missing names, malformed JSON, and overlong names. Duplicate names +return actionable conflict feedback. Creation rejects invalid local dates before +sending a request, and replacement names stay within the 64-character limit. +Refreshing clears details for keys that no longer exist. + +A 403 from any key operation replaces management controls with a warning page +explaining the required `CWMS Users` permission and signed-in `cac_auth` session. +It directs users to their CWMS Admin and offers an access retry. Recognized missing +roles are highlighted without displaying arbitrary server error details. The +endpoint decides access; office profile roles are not used to infer authorization. + +The adapter uses `cwmsjs` raw responses because CDA's bracketed timezone dates +are not parsed by the generated model and DELETE returns an empty 204 body. +The client still handles request serialization and URL encoding. + +## Validation + +- `npm run test:unit`: request/auth/encoding contracts, creation and expiration, empty + 204 revocation, CDA dates, and safe error messages. +- `npm run lint` and `npm run build`. +- `gradlew :cwms-data-api:test --tests '*SpaErrorStatusFilterTest'` verifies the + server's direct page route, including the trailing slash. +- `npm run test:api-keys`: Chromium checks for create/copy/refresh/revoke feedback, + conflict/date-error recovery, rotation failure/cancellation/retry, expired and + vanished keys, keyboard dismissal, secret storage checks, and 390px layout. + These tests use synthetic responses and run in the web GUI CI workflow. + +## Real local verification + +The updated frontend was tested against a local CDA/Oracle/Keycloak stack running +the #1935 endpoint fixes. A uniquely named key created after the page loaded +produced a real 409 conflict and an actionable toast. Removing that fixture allowed +retry in the same dialog, followed by creation, expiration metadata refresh, +closing the one-time secret, and successful empty-body 204 revocation feedback. +The temporary key was removed. Secrets stayed in test-process/component memory +and were excluded from logs and screenshots. Simulated failures were tested +separately in Chromium, including both halves of rotation. diff --git a/cda-gui/src/pages/api-keys/api-keys.css b/cda-gui/src/pages/api-keys/api-keys.css new file mode 100644 index 000000000..8634fdab1 --- /dev/null +++ b/cda-gui/src/pages/api-keys/api-keys.css @@ -0,0 +1,50 @@ +.api-key-toast { + position: fixed; + z-index: 100; + right: 1rem; + bottom: 1rem; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + width: min(28rem, calc(100vw - 2rem)); + padding: 1rem; + border: 1px solid; + border-left-width: 4px; + border-radius: 0.5rem; + box-shadow: 0 4px 18px rgb(0 0 0 / 18%); +} +.api-key-toast-success { + background: #f0fdf4; + color: #14532d; + border-color: #15803d; +} +.api-key-toast-error { + background: #fef2f2; + color: #7f1d1d; + border-color: #b91c1c; +} +.api-key-toast-warning { + background: #fffbeb; + color: #78350f; + border-color: #b45309; +} +.api-key-toast-info { + background: #eff6ff; + color: #1e3a8a; + border-color: #1d4ed8; +} + +/* Keep the shared key-management dialogs within narrow screens. */ +.api-key-dialog [id^="headlessui-dialog-panel"] { + width: 100%; + min-width: 0; + max-height: calc(100dvh - 2rem); + overflow-y: auto; +} + +@media (max-width: 639px) { + .api-key-dialog [id^="headlessui-dialog-panel"] { + padding: 1.5rem; + } +} diff --git a/cda-gui/src/pages/api-keys/api.js b/cda-gui/src/pages/api-keys/api.js new file mode 100644 index 000000000..3418d475d --- /dev/null +++ b/cda-gui/src/pages/api-keys/api.js @@ -0,0 +1,136 @@ +import { AuthorizationApi, Configuration } from "cwmsjs"; + +class KeyInputError extends Error {} + +// Read raw responses: CDA dates include [UTC], which the generated model's +// Date constructor cannot parse, and DELETE returns an empty 204 response. +export function createApiKeyClient(basePath, token, fetchApi = fetch) { + const api = new AuthorizationApi( + new Configuration({ + basePath: basePath.replace(/\/$/, ""), + fetchApi, + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }), + ); + const options = { cache: "no-store" }; + return { + async list(signal) { + const response = await api.getAuthKeysRaw({ ...options, signal }); + return response.raw.json(); + }, + async get(keyName, signal) { + const response = await api.getAuthKeysWithKeyNameRaw( + { keyName }, + { ...options, signal }, + ); + return response.raw.json(); + }, + async create(userId, keyName, expires, signal) { + if (!userId?.trim() || !keyName?.trim()) { + throw new KeyInputError( + "A signed-in user and a nonblank key name are required.", + ); + } + const expiration = expires ? keyDate(expires) : null; + if (expires && !expiration) { + throw new KeyInputError( + "Choose a valid expiration date, or clear it for no expiration.", + ); + } + const response = await api.postAuthKeysRaw( + { + apiKey: { + userId, + keyName, + expires: expiration ?? undefined, + }, + }, + { ...options, signal }, + ); + return response.raw.json(); + }, + async revoke(keyName, signal) { + await api.deleteAuthKeysWithKeyNameRaw({ keyName }, { ...options, signal }); + }, + }; +} + +export async function keyAccessDenied(error) { + if (error?.response?.status !== 403) return null; + const missingRoles = []; + try { + const { message } = await error.response.clone().json(); + if (typeof message === "string" && message.startsWith("Missing roles {")) { + for (const role of ["CWMS Users", "cac_auth"]) { + if (message.includes(`Role{name='${role}'}`)) missingRoles.push(role); + } + } + } catch { + // Proxies may omit CDA's role details. Still explain the route requirements. + } + return { missingRoles }; +} + +export async function keyError(error) { + if (error instanceof KeyInputError) return error.message; + const status = error?.response?.status; + if (status === 401) + return "Your sign-in could not be verified. Sign out and sign in again."; + if (status === 403) + return "CDA denied access. Sign in with your user account and check your CWMS access with your office administrator. API keys cannot manage keys."; + if (status === 404) return "This key no longer exists. Refresh your keys."; + if (status === 409) + return "A key with this name already exists. Choose another name."; + if (status >= 500) + return "CDA could not complete the request. Refresh your keys to check whether the change was saved before trying again. If this continues, contact your office administrator."; + if (status === 400 || status === 422) { + // Only translate recognized CDA validation messages. Never echo arbitrary + // response text, submitted values, SQL details, or stack traces into the UI. + try { + const body = await error.response.clone().json(); + const message = body?.message; + if ( + message === + "expires must be a valid date/time string, for example 2030-01-01T00:00:00Z." + ) + return "Choose a valid expiration date, or clear it for no expiration."; + if (message === "user-id and key-name are required and must not be blank.") + return "Enter a key name. If your profile is missing, sign in again."; + if (message === "Request body must be a valid API key JSON object.") + return "CDA could not read the key details. Refresh the page and try again."; + if ( + typeof message === "string" && + /^One or more provided values exceeds the maximum length for the parameter\. The field KEY_NAME with provided length of \d+ has a maximum length of 64 characters\.$/.test( + message, + ) + ) + return "Key names must be 64 characters or fewer."; + } catch { + // Older servers and proxies may return non-JSON errors. + } + return "CDA could not accept these key details. Check the name and expiration date and try again."; + } + if (status === 429) + return "Too many requests were sent. Wait a moment, then try again."; + // Do not render server response bodies, which may contain submitted credentials. + return status + ? "The request could not be completed. Refresh your keys and try again." + : "Unable to reach CDA. Check your connection and try again."; +} + +export function keyDate(value) { + if (!value) return null; + if (typeof value !== "string") return null; + const date = new Date(value.replace(/\[[^\]]+\]$/, "")); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function keyStatus(key, now = Date.now()) { + if (!key.expires) return "No expiration"; + const expires = keyDate(key.expires); + return !expires + ? "Unknown expiration" + : expires.getTime() <= now + ? "Expired" + : "Active"; +} diff --git a/cda-gui/src/pages/api-keys/api.test.js b/cda-gui/src/pages/api-keys/api.test.js new file mode 100644 index 000000000..802e97607 --- /dev/null +++ b/cda-gui/src/pages/api-keys/api.test.js @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createApiKeyClient, + keyAccessDenied, + keyDate, + keyError, + keyStatus, +} from "./api.js"; + +test("access denial only exposes recognized missing roles and tolerates proxy errors", async () => { + for (const roles of [["CWMS Users"], ["cac_auth"], ["CWMS Users", "cac_auth"]]) { + const response = new Response( + JSON.stringify({ + message: `Missing roles {${roles.map((role) => `Role{name='${role}'}`).join(",")}}`, + details: "secret", + }), + { status: 403 }, + ); + assert.deepEqual(await keyAccessDenied({ response }), { missingRoles: roles }); + assert.equal(response.bodyUsed, false); + } + assert.deepEqual( + await keyAccessDenied({ + response: new Response("proxy denial secret", { status: 403 }), + }), + { missingRoles: [] }, + ); + assert.equal( + await keyAccessDenied({ response: new Response(null, { status: 401 }) }), + null, + ); +}); + +test("cwmsjs sends bearer auth, encodes names, and preserves CDA dates", async () => { + const calls = []; + const key = { + "user-id": "TEST", + "key-name": "report / #1", + created: "2026-09-01T00:00:00+0000[UTC]", + }; + const client = createApiKeyClient( + "https://example.test/cwms-data/", + "test-token", + async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify(url.endsWith("/keys") ? [key] : key), { + status: 200, + }); + }, + ); + assert.deepEqual(await client.list(), [key]); + assert.deepEqual(await client.get(key["key-name"]), key); + assert.equal( + calls[1].url, + "https://example.test/cwms-data/auth/keys/report%20%2F%20%231", + ); + for (const { init } of calls) { + assert.equal(init.headers.Authorization, "Bearer test-token"); + assert.equal(init.cache, "no-store"); + } +}); + +test("create serializes the current user and expiration without an office or supplied secret", async () => { + let request; + const client = createApiKeyClient( + "https://example.test", + "test-token", + async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ "api-key": "test-only-secret" }), { + status: 201, + }); + }, + ); + assert.equal( + (await client.create("TEST", "report", "2026-12-01T00:00:00Z"))["api-key"], + "test-only-secret", + ); + assert.equal(request.init.method, "POST"); + assert.deepEqual(JSON.parse(request.init.body), { + "user-id": "TEST", + "key-name": "report", + expires: "2026-12-01T00:00:00.000Z", + }); + await client.create("TEST", "report", null); + assert.deepEqual(JSON.parse(request.init.body), { + "user-id": "TEST", + "key-name": "report", + }); +}); + +test("revoke accepts CDA's empty 204 and forwards cancellation", async () => { + const controller = new AbortController(); + const client = createApiKeyClient( + "https://example.test", + "test-token", + async (url, init) => { + assert.equal(init.method, "DELETE"); + assert.equal(init.signal, controller.signal); + return new Response(null, { status: 204 }); + }, + ); + await client.revoke("report", controller.signal); +}); + +test("CDA timezone dates distinguish expired, active, missing and malformed expiration", () => { + assert.equal( + keyDate("2026-09-01T00:00:00+0000[UTC]").toISOString(), + "2026-09-01T00:00:00.000Z", + ); + assert.equal( + keyStatus({ expires: "2026-09-01T00:00:00+0000[UTC]" }, Date.parse("2026-09-02")), + "Expired", + ); + assert.equal( + keyStatus({ expires: "2026-12-01T00:00:00Z" }, Date.parse("2026-09-02")), + "Active", + ); + assert.equal(keyStatus({}), "No expiration"); + assert.equal(keyStatus({ expires: "bad-date" }), "Unknown expiration"); +}); + +test("error messages explain auth failures without displaying response bodies", async () => { + for (const status of [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 418]) { + const message = await keyError({ + response: new Response("secret-must-not-be-shown", { status }), + }); + assert.ok(!message.includes("secret-must-not-be-shown")); + assert.ok(message.length > 20); + assert.ok(!/\b[45]\d\d\b|HTTP|interactively/.test(message)); + } +}); + +test("all key operations catch generic server failures with a useful message", async () => { + const client = createApiKeyClient( + "https://example.test", + "test-token", + async () => new Response("Internal server error", { status: 500 }), + ); + for (const request of [ + () => client.list(), + () => client.get("test"), + () => client.create("TEST", "test", null), + () => client.revoke("test"), + ]) { + await assert.rejects(request, (asyncError) => + Boolean(asyncError.response?.status === 500), + ); + } + assert.match( + await keyError({ response: { status: 500 } }), + /Refresh your keys to check whether the change was saved/, + ); +}); + +test("new CDA validation responses produce specific, safe guidance", async () => { + const cases = [ + [ + "expires must be a valid date/time string, for example 2030-01-01T00:00:00Z.", + /valid expiration date/, + ], + ["user-id and key-name are required and must not be blank.", /Enter a key name/], + [ + "Request body must be a valid API key JSON object.", + /could not read the key details/, + ], + [ + "One or more provided values exceeds the maximum length for the parameter. The field KEY_NAME with provided length of 65 has a maximum length of 64 characters.", + /64 characters/, + ], + ]; + for (const [message, expected] of cases) { + const response = new Response( + JSON.stringify({ + message, + details: { stackTraceLines: ["secret-must-not-be-shown"] }, + }), + { status: 400 }, + ); + assert.match(await keyError({ response }), expected); + assert.equal(response.bodyUsed, false); + } + assert.match( + await keyError({ + response: new Response( + JSON.stringify({ + message: "secret-must-not-be-shown", + details: { message: "secret-must-not-be-shown" }, + }), + { status: 400 }, + ), + }), + /Check the name and expiration/, + ); +}); + +test("invalid local dates are reported before sending a request", async () => { + const client = createApiKeyClient("https://example.test", "test-token", async () => + assert.fail("No request should be sent"), + ); + let failure; + await assert.rejects( + () => client.create("TEST", "report", "invalid-date"), + (error) => { + failure = error; + return true; + }, + ); + assert.match(await keyError(failure), /valid expiration date/); +}); + +test("timestamps with UTC and fixed-offset suffixes preserve their instant", () => { + for (const value of [ + "2030-01-01T17:30:45+0000[Z]", + "2030-01-01T12:30:45-0500[-05:00]", + ]) { + assert.equal(keyDate(value).toISOString(), "2030-01-01T17:30:45.000Z"); + } + assert.equal(keyDate({}), null); +}); diff --git a/cda-gui/src/pages/api-keys/components/CreateKeyDialog.jsx b/cda-gui/src/pages/api-keys/components/CreateKeyDialog.jsx new file mode 100644 index 000000000..3cfce74d2 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/CreateKeyDialog.jsx @@ -0,0 +1,129 @@ +import PropTypes from "prop-types"; +import { + Modal, + Text, + Field, + Label, + Input, + Description, + Button, +} from "@usace/groundwork"; +import ExpirationShortcuts from "./ExpirationShortcuts"; +export default function CreateKeyDialog({ + createOpen, + working, + setCreateOpen, + create, + error, + profile, + name, + setName, + expires, + setExpires, + keys, + rotationSource, + feedback, +}) { + return ( + { + if (!working) setCreateOpen(false); + }} + dialogTitle={rotationSource ? "Rotate API key" : "Create API key"} + size="lg" + > + {feedback} +
+ {error &&

{error}

} + {rotationSource && ( + + Create a replacement for {rotationSource["key-name"]} with + a new name. The old key will not be revoked until you save the new secret + and confirm revocation. If creation fails, the old key is unchanged. + + )} + + This key belongs to {profile?.userName} and uses your permissions across + offices.{" "} + + Save the key when it is created. You will not be able to see it again after + closing the save dialog. + + + + + setName(event.target.value)} + /> + + Use a unique, case-sensitive name for this application, such as + daily-report. + + + + + setExpires(event.target.value)} + min={new Date(Date.now() + 86400000).toISOString().slice(0, 10)} + /> + + Expires at the start of this date in UTC. Clear the date for no expiration. + + + +
+ + +
+ {keys.some((key) => key["key-name"] === name.trim()) && ( + A key with this name already exists. Choose another name. + )} +
+
+ ); +} +CreateKeyDialog.propTypes = { + rotationSource: PropTypes.object, + feedback: PropTypes.node, + createOpen: PropTypes.bool.isRequired, + working: PropTypes.bool.isRequired, + setCreateOpen: PropTypes.func.isRequired, + create: PropTypes.func.isRequired, + error: PropTypes.string.isRequired, + profile: PropTypes.object, + name: PropTypes.string.isRequired, + setName: PropTypes.func.isRequired, + expires: PropTypes.string.isRequired, + setExpires: PropTypes.func.isRequired, + keys: PropTypes.array.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/ExpirationShortcuts.jsx b/cda-gui/src/pages/api-keys/components/ExpirationShortcuts.jsx new file mode 100644 index 000000000..e265f3388 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/ExpirationShortcuts.jsx @@ -0,0 +1,48 @@ +import PropTypes from "prop-types"; +import dayjs from "dayjs"; +import { Button } from "@usace/groundwork"; + +export default function ExpirationShortcuts({ setExpires, working }) { + function setExpiration(amount, unit) { + const today = dayjs(new Date().toISOString().slice(0, 10)); + setExpires(today.add(amount, unit).format("YYYY-MM-DD")); + } + + return ( +
+ {[ + ["30d", 30, "day"], + ["90d", 90, "day"], + ["1y", 1, "year"], + ].map(([label, amount, unit]) => ( + + ))} + +
+ ); +} + +ExpirationShortcuts.propTypes = { + setExpires: PropTypes.func.isRequired, + working: PropTypes.bool.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyAccessWarning.jsx b/cda-gui/src/pages/api-keys/components/KeyAccessWarning.jsx new file mode 100644 index 000000000..5d4319685 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyAccessWarning.jsx @@ -0,0 +1,68 @@ +import PropTypes from "prop-types"; +import { Button, UsaceBox } from "@usace/groundwork"; +import { FaExclamationTriangle } from "react-icons/fa"; + +export default function KeyAccessWarning({ missingRoles, loading, retry, feedback }) { + return ( +
+ +
+
+
+
+ + +
+
+
+ {feedback} +
+ ); +} + +KeyAccessWarning.propTypes = { + missingRoles: PropTypes.arrayOf(PropTypes.string).isRequired, + loading: PropTypes.bool.isRequired, + retry: PropTypes.func.isRequired, + feedback: PropTypes.node, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyDetails.jsx b/cda-gui/src/pages/api-keys/components/KeyDetails.jsx new file mode 100644 index 000000000..f7fdc3e86 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyDetails.jsx @@ -0,0 +1,78 @@ +import PropTypes from "prop-types"; +import { Card, H2, Text, Button } from "@usace/groundwork"; +import { FaKey } from "react-icons/fa"; +import { EmptyState, Notice } from "../../user-lists/components/StatusMessages"; +import KeyStatusBadge from "./KeyStatusBadge"; +import { keyStatus, keyDate } from "../api"; +const formatDate = (value) => + keyDate(value)?.toLocaleString() ?? (value ? "Unknown" : "None"); +export default function KeyDetails({ + selected, + working, + setError, + setRevokeOpen, + rotate, + now, +}) { + return ( + +

+ {selected?.["key-name"] ?? "Key details"} +

+ {selected ? ( + <> + {keyStatus(selected, now) === "Expired" && ( + + This key has expired and can no longer authenticate requests. Rotate it to + get a replacement, or revoke it if it is no longer needed. + + )} +
+
Owner
+
{selected["user-id"]}
+
Status
+
+ +
+
Created (local)
+
{formatDate(selected.created)}
+
Expires (local)
+
{formatDate(selected.expires)}
+
+ + The secret is shown only when the key is created. If you lose it, create a + replacement and revoke the old key. + +
+ + +
+ + ) : ( + + Select a key to view its owner, creation date, and expiration or to revoke it. + + )} +
+ ); +} +KeyDetails.propTypes = { + rotate: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, + selected: PropTypes.object, + working: PropTypes.bool.isRequired, + setError: PropTypes.func.isRequired, + setRevokeOpen: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyFeedback.jsx b/cda-gui/src/pages/api-keys/components/KeyFeedback.jsx new file mode 100644 index 000000000..edbab2a3e --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyFeedback.jsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { + FaCheckCircle, + FaExclamationTriangle, + FaInfoCircle, + FaTimes, +} from "react-icons/fa"; + +const icons = { + success: FaCheckCircle, + error: FaExclamationTriangle, + warning: FaExclamationTriangle, + info: FaInfoCircle, +}; + +// Render within the active dialog so its focus trap and inert background do not +// hide the notification or prevent keyboard users from dismissing it. +export default function KeyFeedback({ notification, onDismiss }) { + const [paused, setPaused] = useState(false); + useEffect(() => { + if (!notification || paused || ["error", "warning"].includes(notification.kind)) + return; + const timer = window.setTimeout(onDismiss, 8000); + return () => window.clearTimeout(timer); + }, [notification, onDismiss, paused]); + if (!notification) return null; + const Icon = icons[notification.kind]; + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={() => setPaused(false)} + > +
+
+ +
+ ); +} + +KeyFeedback.propTypes = { + notification: PropTypes.shape({ + id: PropTypes.number.isRequired, + kind: PropTypes.oneOf(["success", "error", "warning", "info"]).isRequired, + message: PropTypes.string.isRequired, + }), + onDismiss: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyHeader.jsx b/cda-gui/src/pages/api-keys/components/KeyHeader.jsx new file mode 100644 index 000000000..df15e0f47 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyHeader.jsx @@ -0,0 +1,54 @@ +import PropTypes from "prop-types"; +import { Button, Text, UsaceBox } from "@usace/groundwork"; +import { FaKey } from "react-icons/fa"; +export default function KeyHeader({ + profile, + working, + loading, + office, + setError, + setCreateOpen, +}) { + return ( + +
+
+ + +
+
+ + Create, view, and revoke your API keys for scripts and applications that access + CWMS data. + + + Keys belong to your user account and use your existing office permissions. They + are not shared office credentials or restricted to the office selected below. + You can manage only your own keys. + +
+ ); +} +KeyHeader.propTypes = { + profile: PropTypes.object, + working: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + office: PropTypes.string.isRequired, + setError: PropTypes.func.isRequired, + setCreateOpen: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyHelpGuide.jsx b/cda-gui/src/pages/api-keys/components/KeyHelpGuide.jsx new file mode 100644 index 000000000..002fec7e3 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyHelpGuide.jsx @@ -0,0 +1,88 @@ +import { useSearchParams } from "react-router-dom"; +import { useEffect } from "react"; +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import { Button, Text, UsaceBox } from "@usace/groundwork"; +import KeyHelpStep from "./KeyHelpStep"; +import KeyUsageExample from "./KeyUsageExample"; +import KeyHelpTroubleshooting from "./KeyHelpTroubleshooting"; +import KeyReplacementSteps from "./KeyReplacementSteps"; +import KeySecurityNotice from "./KeySecurityNotice"; + +export default function KeyHelpGuide() { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + const [params] = useSearchParams(); + const { profile } = useAuth(); + const offices = Object.keys(profile?.roles ?? {}).sort(); + const requested = params.get("office"); + const office = offices.includes(requested) ? requested : (offices[0] ?? ""); + const returnOffice = requested ?? office; + const backLink = `/api-keys${returnOffice ? `?office=${encodeURIComponent(returnOffice)}` : ""}`; + + return ( +
+ + + Connect a script or application in four steps. + + +
+ + Your key acts as you. It belongs to your user account and + uses your existing office permissions. Choosing an office on the API Keys page + does not restrict the key to that office. + +
+
    + + + Open API Keys → Create key. Give each application its own + unique name, such as daily-report. + + + Choose an expiration date. The key expires at the start of that date in UTC; + a blank date means no expiration. + + + + + Copy the generated key into your application's secure secret store + before closing the dialog.{" "} + + The key is shown only when it is created. CDA cannot show it again. + + +
      +
    • + Keep the key private.{" "} + + Do not share it + + . +
    • +
    • + Keep it out of URLs, source code, .env files, and public + browser apps. +
    • +
    • If it is compromised, create a replacement and revoke the old key.
    • +
    +
    + + + + + + +
+
+ +
+
+ +
+
+ ); +} diff --git a/cda-gui/src/pages/api-keys/components/KeyHelpStep.jsx b/cda-gui/src/pages/api-keys/components/KeyHelpStep.jsx new file mode 100644 index 000000000..b8bef3095 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyHelpStep.jsx @@ -0,0 +1,30 @@ +import PropTypes from "prop-types"; +import { Card, H2 } from "@usace/groundwork"; + +export default function KeyHelpStep({ number, title, children }) { + return ( +
  • + +
    + +

    + Step {number}: + {title} +

    +
    +
    {children}
    +
    +
  • + ); +} + +KeyHelpStep.propTypes = { + number: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyHelpTroubleshooting.jsx b/cda-gui/src/pages/api-keys/components/KeyHelpTroubleshooting.jsx new file mode 100644 index 000000000..3569635e7 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyHelpTroubleshooting.jsx @@ -0,0 +1,48 @@ +import { Card, H2, Strong, Text } from "@usace/groundwork"; + +export default function KeyHelpTroubleshooting() { + return ( + +

    If a request fails

    +
    +
    +
    + Your sign-in could not be verified +
    +
    + + Confirm the header prefix and key value. The key may be invalid, expired, + or revoked. + +
    +
    +
    +
    + Access was denied +
    +
    + + Check the requested office and your user permissions. Contact your office + administrator if access is missing. + +
    +
    +
    +
    + Cannot manage keys? +
    +
    + Sign in. An API key cannot create, list, or revoke keys. +
    +
    +
    +
    + CDA could not complete the request + + Refresh your keys to check whether the change was saved before trying again. + Contact your office administrator if the problem continues. + +
    +
    + ); +} diff --git a/cda-gui/src/pages/api-keys/components/KeyList.jsx b/cda-gui/src/pages/api-keys/components/KeyList.jsx new file mode 100644 index 000000000..27ca90062 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyList.jsx @@ -0,0 +1,90 @@ +import PropTypes from "prop-types"; +import { Card, H2, Badge, Button, Input, Skeleton } from "@usace/groundwork"; +import { FaFilter, FaKey } from "react-icons/fa"; +import { EmptyState } from "../../user-lists/components/StatusMessages"; +import { keyStatus } from "../api"; +import KeyStatusBadge from "./KeyStatusBadge"; +export default function KeyList({ + keys, + loading, + working, + refresh, + search, + setSearch, + visibleKeys, + selected, + view, + now, +}) { + return ( + +
    +

    + Your keys {keys.length} +

    + +
    +
    +
    + {loading ? ( + + ) : visibleKeys.length === 0 ? ( + + Create a key to connect a script or application. + + ) : ( +
      + {visibleKeys.map((key) => ( +
    • + +
    • + ))} +
    + )} +
    + ); +} +KeyList.propTypes = { + now: PropTypes.number.isRequired, + keys: PropTypes.array.isRequired, + loading: PropTypes.bool.isRequired, + working: PropTypes.bool.isRequired, + refresh: PropTypes.func.isRequired, + search: PropTypes.string.isRequired, + setSearch: PropTypes.func.isRequired, + visibleKeys: PropTypes.array.isRequired, + selected: PropTypes.object, + view: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyManager.jsx b/cda-gui/src/pages/api-keys/components/KeyManager.jsx new file mode 100644 index 000000000..2c4764732 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyManager.jsx @@ -0,0 +1,378 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import { useSearchParams } from "react-router-dom"; +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@usace/groundwork"; +import { Notice } from "../../user-lists/components/StatusMessages"; +import { + createApiKeyClient, + keyAccessDenied, + keyDate, + keyError, + keyStatus, +} from "../api"; +import "../api-keys.css"; +import KeyHeader from "./KeyHeader"; +import OfficeContext from "./OfficeContext"; +import KeyList from "./KeyList"; +import KeyDetails from "./KeyDetails"; +import CreateKeyDialog from "./CreateKeyDialog"; +import SaveKeyDialog from "./SaveKeyDialog"; +import RevokeKeyDialog from "./RevokeKeyDialog"; +import KeyFeedback from "./KeyFeedback"; +import KeyAccessWarning from "./KeyAccessWarning"; +const cdaUrl = import.meta.env.VITE_CDA_API_ROOT; +export default function KeyManager({ token }) { + const [params] = useSearchParams(); + const { profile } = useAuth(); + const queryClient = useQueryClient(); + const api = useMemo(() => createApiKeyClient(cdaUrl, token), [token]); + const controller = useRef(null); + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [working, setWorking] = useState(false); + const [error, setError] = useState(""); + const [accessDenied, setAccessDenied] = useState(null); + const [notification, setNotification] = useState(null); + const notificationId = useRef(0); + const notify = useCallback((kind, message) => { + setNotification({ id: ++notificationId.current, kind, message }); + }, []); + const dismiss = useCallback(() => setNotification(null), []); + const [officeChoice, setOfficeChoice] = useState(() => params.get("office") ?? ""); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [revokeOpen, setRevokeOpen] = useState(false); + const [created, setCreated] = useState(null); + const [rotationSource, setRotationSource] = useState(null); + const [now, setNow] = useState(Date.now); + const [name, setName] = useState(""); + const [expires, setExpires] = useState(() => + new Date(Date.now() + 90 * 86400000).toISOString().slice(0, 10), + ); + const offices = Object.keys(profile?.roles ?? {}).sort(); + const office = offices.includes(officeChoice) ? officeChoice : (offices[0] ?? ""); + const visibleKeys = keys.filter((key) => + key["key-name"].toLowerCase().includes(search.toLowerCase()), + ); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 30000); + return () => window.clearInterval(timer); + }, []); + + function changeCreateOpen(open) { + setError(""); + dismiss(); + setRotationSource(null); + setCreateOpen(open); + } + + function rotate() { + if (!selected || working) return; + let candidate; + let suffix = 1; + do { + const ending = `-replacement${suffix > 1 ? `-${suffix}` : ""}`; + candidate = `${selected["key-name"].slice(0, 64 - ending.length)}${ending}`; + suffix++; + } while (keys.some((key) => key["key-name"] === candidate)); + setName(candidate); + setExpires(new Date(Date.now() + 90 * 86400000).toISOString().slice(0, 10)); + setRotationSource(selected); + setError(""); + dismiss(); + setCreateOpen(true); + } + + function saved() { + const canFinish = rotationSource && created?.["api-key"]; + setCreated(null); + dismiss(); + if (canFinish) setRevokeOpen(true); + else setRotationSource(null); + } + + function changeRevokeOpen(open) { + setRevokeOpen(open); + if (!open && rotationSource) { + notify( + "warning", + `Replacement created. The old key ${rotationSource["key-name"]} has not been revoked. Revoke it after updating your application.`, + ); + setRotationSource(null); + } + } + + const showError = useCallback( + async (cause, signal) => { + const denied = await keyAccessDenied(cause); + const message = denied ? null : await keyError(cause); + if (signal.aborted) return; + if (denied) { + setAccessDenied(denied); + setCreateOpen(false); + setRevokeOpen(false); + setCreated(null); + setRotationSource(null); + setSelected(null); + setKeys([]); + setError(""); + dismiss(); + } else { + setError(message); + notify("error", message); + } + }, + [dismiss, notify], + ); + + useEffect(() => { + const current = new AbortController(); + controller.current = current; + api + .list(current.signal) + .then(setKeys) + .catch((cause) => showError(cause, current.signal)) + .finally(() => { + if (!current.signal.aborted) setLoading(false); + }); + return () => current.abort(); + }, [api, showError]); + + async function refresh() { + const signal = controller.current.signal; + setLoading(true); + setError(""); + try { + const current = await api.list(signal); + if (signal.aborted) return; + setKeys(current); + setAccessDenied(null); + setSelected( + (previous) => + current.find((key) => key["key-name"] === previous?.["key-name"]) ?? null, + ); + notify("success", "Your keys are up to date."); + } catch (cause) { + await showError(cause, signal); + } finally { + setLoading(false); + } + } + + async function view(keyName) { + const signal = controller.current.signal; + setWorking(true); + setError(""); + try { + const key = await api.get(keyName, signal); + if (signal.aborted) return; + setSelected(key); + dismiss(); + if (keyStatus(key) === "Expired") + notify( + "warning", + "This key has expired. Rotate it to get a replacement, or revoke it if it is no longer needed.", + ); + if (keyStatus(key) === "Unknown expiration") + notify( + "warning", + "This key's expiration could not be read. Refresh your keys before using it.", + ); + } catch (cause) { + if (!signal.aborted && cause?.response?.status === 404) { + setKeys((current) => current.filter((key) => key["key-name"] !== keyName)); + setSelected(null); + } + await showError(cause, signal); + } finally { + setWorking(false); + } + } + + async function create(event) { + event.preventDefault(); + if (!name.trim() || !profile?.userName || working) return; + const expiration = expires ? keyDate(`${expires}T00:00:00Z`) : null; + if (expires && (!expiration || expiration.getTime() <= Date.now())) { + const message = + "Choose a valid expiration date after today, or clear it for no expiration."; + setError(message); + notify("error", message); + return; + } + const signal = controller.current.signal; + setWorking(true); + setError(""); + dismiss(); + try { + const result = await api.create( + profile.userName, + name.trim(), + expires ? `${expires}T00:00:00Z` : null, + signal, + ); + if (signal.aborted) return; + setCreated(result); + // Keep only metadata in the list and detail view, never the secret. + const metadata = { ...result, "api-key": undefined }; + setKeys((current) => [metadata, ...current]); + setSelected(metadata); + setSearch(""); + setCreateOpen(false); + setName(""); + notify( + result["api-key"] ? "success" : "warning", + result["api-key"] + ? rotationSource + ? "Replacement created. Save its secret before revoking the old key." + : "API key created. Save its secret now; it will only be shown once." + : "The key was created without a returned secret. Revoke it and create a replacement.", + ); + } catch (cause) { + await showError(cause, signal); + } finally { + setWorking(false); + } + } + + async function revoke() { + const target = rotationSource ?? selected; + if (!target || working) return; + const signal = controller.current.signal; + setWorking(true); + setError(""); + dismiss(); + try { + await api.revoke(target["key-name"], signal); + if (signal.aborted) return; + setKeys((current) => + current.filter((key) => key["key-name"] !== target["key-name"]), + ); + notify( + "success", + rotationSource + ? `Rotation complete. Revoked ${target["key-name"]}; use the replacement key in your application.` + : `Revoked ${target["key-name"]}. Applications using it must switch to another key.`, + ); + if (!rotationSource) setSelected(null); + setRotationSource(null); + setRevokeOpen(false); + } catch (cause) { + await showError(cause, signal); + } finally { + setWorking(false); + } + } + + async function copySecret() { + try { + await navigator.clipboard.writeText(created["api-key"]); + notify( + "success", + "Key copied. Save it in a secure secret store, then clear your clipboard.", + ); + } catch { + notify( + "error", + "Clipboard access is unavailable. Select and copy the key manually.", + ); + } + } + + if (accessDenied) { + return ( + } + /> + ); + } + + return ( +
    + + {!createOpen && !revokeOpen && !created && ( + + )} + {!profile && ( + + Waiting for your CWMS profile. If it does not load, retry or sign in again.{" "} + + + )} + + +
    + + +
    + + } + {...{ + createOpen, + working, + create, + error, + profile, + name, + setName, + expires, + setExpires, + keys, + rotationSource, + }} + setCreateOpen={changeCreateOpen} + /> + } + {...{ created, copySecret, rotationSource }} + onSaved={saved} + /> + + } + {...{ revokeOpen, working, error, revoke }} + selected={rotationSource ?? selected} + rotation={Boolean(rotationSource)} + setRevokeOpen={changeRevokeOpen} + /> +
    + ); +} +KeyManager.propTypes = { token: PropTypes.string.isRequired }; diff --git a/cda-gui/src/pages/api-keys/components/KeyReplacementSteps.jsx b/cda-gui/src/pages/api-keys/components/KeyReplacementSteps.jsx new file mode 100644 index 000000000..a58d2d97f --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyReplacementSteps.jsx @@ -0,0 +1,49 @@ +import { H3, Text } from "@usace/groundwork"; + +export default function KeyReplacementSteps() { + return ( +
    +
    +

    Replace a key

    +
      +
    1. + Select the old key and choose Rotate key. +
    2. +
    3. + Choose a new name and expiration date, then select{" "} + Generate replacement. +
    4. +
    5. + Copy the new key and save it securely.{" "} + You can only see it once. CDA cannot show it again. +
    6. +
    7. + Update your application to use the new key and check that its requests work. +
    8. +
    9. + Select Close, then confirm revocation of the old key. If + you are not ready, cancel the confirmation to keep both keys. +
    10. +
    +
    +
    +

    Revoke a key without replacing it

    +
      +
    1. Select the key you want to remove.
    2. +
    3. + Choose Revoke key. +
    4. +
    5. Check the key name in the confirmation, then confirm revocation.
    6. +
    +
    + + Revocation is permanent. Any application still using the old + key will lose access. + + + Expired keys are marked in red and cannot authenticate requests. Follow the + replacement steps to get a usable key, or revoke the expired key to remove it. + +
    + ); +} diff --git a/cda-gui/src/pages/api-keys/components/KeySecurityNotice.jsx b/cda-gui/src/pages/api-keys/components/KeySecurityNotice.jsx new file mode 100644 index 000000000..f3a97ee4c --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeySecurityNotice.jsx @@ -0,0 +1,26 @@ +import { FaExclamationTriangle } from "react-icons/fa"; + +export default function KeySecurityNotice() { + return ( + + ); +} diff --git a/cda-gui/src/pages/api-keys/components/KeyStatusBadge.jsx b/cda-gui/src/pages/api-keys/components/KeyStatusBadge.jsx new file mode 100644 index 000000000..0beb404a9 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyStatusBadge.jsx @@ -0,0 +1,30 @@ +import PropTypes from "prop-types"; +import { Badge } from "@usace/groundwork"; +import { FaExclamationTriangle } from "react-icons/fa"; +import { keyStatus } from "../api"; + +export default function KeyStatusBadge({ apiKey, now }) { + const status = keyStatus(apiKey, now); + return ( + + {status === "Expired" && ( + + ); +} + +KeyStatusBadge.propTypes = { + apiKey: PropTypes.object.isRequired, + now: PropTypes.number.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/KeyUsageExample.jsx b/cda-gui/src/pages/api-keys/components/KeyUsageExample.jsx new file mode 100644 index 000000000..7191f766e --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/KeyUsageExample.jsx @@ -0,0 +1,46 @@ +import PropTypes from "prop-types"; +import { Strong, Text } from "@usace/groundwork"; + +export default function KeyUsageExample({ office }) { + const endpoint = new URL( + `${import.meta.env.BASE_URL.replace(/\/$/, "")}/timeseries`, + window.location.origin, + ).href; + const example = [ + "curl --get \\", + ' --header "Authorization: apikey $CWMS_API_KEY" \\', + ' --header "Accept: application/json;version=2" \\', + ` --data-urlencode "office=${office || "YOUR_OFFICE"}" \\`, + ' --data-urlencode "name=KEYS.elev.inst.1hour.0.ccp-rev" \\', + ` "${endpoint}"`, + ].join("\n"); + + return ( +
    + + Send the key in this header. Include the space after apikey. + +
    +        Authorization: apikey YOUR_KEY
    +      
    +
    + Try a request (Bash / curl) +
    + + Load your saved key into the CWMS_API_KEY environment variable. + +
    +        {example}
    +      
    + + The example uses {office || "YOUR_OFFICE"}. Use the office required by the + endpoint you are calling. + +
    + ); +} + +KeyUsageExample.propTypes = { office: PropTypes.string.isRequired }; diff --git a/cda-gui/src/pages/api-keys/components/OfficeContext.jsx b/cda-gui/src/pages/api-keys/components/OfficeContext.jsx new file mode 100644 index 000000000..d67dee2da --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/OfficeContext.jsx @@ -0,0 +1,53 @@ +import PropTypes from "prop-types"; +import { Card, Strong, Text, Field, Label, Description } from "@usace/groundwork"; +export default function OfficeContext({ profile, office, offices, setOfficeChoice }) { + return ( + +
    +
    + Signed in as + {profile?.userName ?? "Loading profile…"} +
    + + + + + Your keys use your permissions for this office, shown below. Keys belong to + your user account and can also access other offices where you have + permission. + + +
    + {office && ( + + Your roles in {office}: {(profile.roles[office] ?? []).join(", ") || "None"}. + + )} +
    + ); +} +OfficeContext.propTypes = { + profile: PropTypes.object, + office: PropTypes.string.isRequired, + offices: PropTypes.arrayOf(PropTypes.string).isRequired, + setOfficeChoice: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/RequireSignIn.jsx b/cda-gui/src/pages/api-keys/components/RequireSignIn.jsx new file mode 100644 index 000000000..db6ae33d5 --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/RequireSignIn.jsx @@ -0,0 +1,13 @@ +import PropTypes from "prop-types"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import { Skeleton } from "@usace/groundwork"; + +export default function RequireSignIn({ children }) { + const auth = useAuth(); + if (auth.isLoading) return ; + if (!auth.isAuth) return ; + return children; +} + +RequireSignIn.propTypes = { children: PropTypes.node.isRequired }; diff --git a/cda-gui/src/pages/api-keys/components/RevokeKeyDialog.jsx b/cda-gui/src/pages/api-keys/components/RevokeKeyDialog.jsx new file mode 100644 index 000000000..c53b2e07c --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/RevokeKeyDialog.jsx @@ -0,0 +1,62 @@ +import PropTypes from "prop-types"; +import { Modal, Text, Button } from "@usace/groundwork"; +export default function RevokeKeyDialog({ + revokeOpen, + working, + setRevokeOpen, + error, + selected, + revoke, + rotation = false, + feedback, +}) { + return ( + { + if (!working) setRevokeOpen(false); + }} + dialogTitle={ + rotation ? "Finish rotation: revoke the old key?" : "Revoke API key?" + } + size="md" + > + {feedback} + {error &&

    {error}

    } + {rotation && ( + + Your replacement has been created. Confirm only after you have saved its + secret and updated your application. Cancel to keep both keys for now. + + )} + + Revoke {selected?.["key-name"]}? Applications using this key will lose access. + This cannot be undone. + +
    + + +
    +
    + ); +} +RevokeKeyDialog.propTypes = { + rotation: PropTypes.bool, + feedback: PropTypes.node, + revokeOpen: PropTypes.bool.isRequired, + working: PropTypes.bool.isRequired, + setRevokeOpen: PropTypes.func.isRequired, + error: PropTypes.string.isRequired, + selected: PropTypes.object, + revoke: PropTypes.func.isRequired, +}; diff --git a/cda-gui/src/pages/api-keys/components/SaveKeyDialog.jsx b/cda-gui/src/pages/api-keys/components/SaveKeyDialog.jsx new file mode 100644 index 000000000..a97b72bbc --- /dev/null +++ b/cda-gui/src/pages/api-keys/components/SaveKeyDialog.jsx @@ -0,0 +1,76 @@ +import PropTypes from "prop-types"; +import { Modal, Text, Input, Button } from "@usace/groundwork"; +import { FaExclamationTriangle } from "react-icons/fa"; +import { Notice } from "../../user-lists/components/StatusMessages"; +export default function SaveKeyDialog({ + created, + copySecret, + feedback, + onSaved, + rotationSource, +}) { + return ( + {}} + dialogTitle="Save your new API key" + size="lg" + > + {feedback} +
    +
    +
    + {created?.["api-key"] ? ( + <> + + + + ) : ( + + CDA did not return a secret. Revoke this key and create a replacement. + + )} + {rotationSource && ( + + The old key {rotationSource["key-name"]} has not been + revoked. Save this replacement and update your application before revoking + the old key. + + )} + +
    +
    + ); +} +SaveKeyDialog.propTypes = { + created: PropTypes.object, + copySecret: PropTypes.func.isRequired, + feedback: PropTypes.node, + onSaved: PropTypes.func.isRequired, + rotationSource: PropTypes.object, +}; diff --git a/cda-gui/src/pages/api-keys/help.jsx b/cda-gui/src/pages/api-keys/help.jsx new file mode 100644 index 000000000..0570ddbcf --- /dev/null +++ b/cda-gui/src/pages/api-keys/help.jsx @@ -0,0 +1,10 @@ +import RequireSignIn from "./components/RequireSignIn"; +import KeyHelpGuide from "./components/KeyHelpGuide"; + +export default function ApiKeyHelp() { + return ( + + + + ); +} diff --git a/cda-gui/src/pages/api-keys/index.jsx b/cda-gui/src/pages/api-keys/index.jsx new file mode 100644 index 000000000..9c0d83089 --- /dev/null +++ b/cda-gui/src/pages/api-keys/index.jsx @@ -0,0 +1,12 @@ +import { useAuth } from "@usace-watermanagement/groundwork-water"; +import RequireSignIn from "./components/RequireSignIn"; +import KeyManager from "./components/KeyManager"; +export default function ApiKeys() { + const auth = useAuth(); + // Session changes unmount the manager and clear any one-time key secret. + return ( + + + + ); +} diff --git a/cda-gui/src/route-paths.js b/cda-gui/src/route-paths.js index 497180dd9..496b81ec5 100644 --- a/cda-gui/src/route-paths.js +++ b/cda-gui/src/route-paths.js @@ -1,5 +1,7 @@ // Routes are defined here to allow building a sitemap dynamically export const routePaths = [ + { id: "api-keys", path: "api-keys" }, + { id: "api-key-help", path: "api-keys/help" }, { id: "home", index: true, @@ -47,4 +49,6 @@ export const routePaths = [ }, ]; -export const sitemapPaths = routePaths.map(({ sitemapPath }) => sitemapPath); +export const sitemapPaths = routePaths + .filter(({ sitemapPath }) => sitemapPath !== undefined) + .map(({ sitemapPath }) => sitemapPath); diff --git a/cda-gui/tests/api-keys/feedback.spec.js b/cda-gui/tests/api-keys/feedback.spec.js new file mode 100644 index 000000000..fd9c443c3 --- /dev/null +++ b/cda-gui/tests/api-keys/feedback.spec.js @@ -0,0 +1,303 @@ +import { expect, test } from "@playwright/test"; +import process from "node:process"; + +const original = { + "user-id": "TEST", + "key-name": "daily-report", + created: "2026-01-01T00:00:00+0000[Z]", + expires: null, +}; + +async function setup(page, failure = null) { + const state = { keys: [{ ...original }], failure, requests: [] }; + await page.route("**/protocol/openid-connect/token", (route) => + route.fulfill({ json: { access_token: "test-token" } }), + ); + await page.route("**/cwms-data/user/profile", (route) => + route.fulfill({ json: { "user-name": "TEST", roles: { SWT: ["CWMS Users"] } } }), + ); + await page.route(/\/cwms-data\/auth\/keys(?:\/.*)?$/, async (route) => { + const request = route.request(); + const method = request.method(); + const name = decodeURIComponent( + new URL(request.url()).pathname.split("/")[4] ?? "", + ); + state.requests.push(method); + if (state.failure?.method === method) { + return route.fulfill({ + status: state.failure.status, + json: { message: state.failure.message ?? "System Error" }, + }); + } + if (method === "POST") { + const input = request.postDataJSON(); + if (state.keys.some((key) => key["key-name"] === input["key-name"])) + return route.fulfill({ status: 409, json: { message: "Already exists" } }); + const key = { ...original, ...input }; + state.keys.unshift(key); + return route.fulfill({ + status: 201, + json: { ...key, "api-key": "synthetic-one-time-secret" }, + }); + } + if (method === "DELETE") { + state.keys = state.keys.filter((key) => key["key-name"] !== name); + return route.fulfill({ status: 204 }); + } + const key = state.keys.find((item) => item["key-name"] === name); + return route.fulfill({ + status: name && !key ? 404 : 200, + json: name ? (key ?? { message: "Not found" }) : state.keys, + }); + }); + await page.goto("/cwms-data/"); + await page.getByRole("button", { name: /login|sign in/i }).click(); + await page.getByRole("link", { name: "API Keys", exact: true }).click(); + if (failure?.status === 403) return state; + await expect( + page.getByRole("button", { name: "Create key", exact: true }), + ).toBeEnabled(); + return state; +} + +const toast = (page) => page.locator(".api-key-toast:visible"); + +for (const roles of [["CWMS Users"], ["cac_auth"], ["CWMS Users", "cac_auth"]]) { + test(`missing ${roles.join(" and ")} shows the permission page and recovers`, async ({ + page, + }) => { + const state = await setup(page, { + method: "GET", + status: 403, + message: `Missing roles {${roles.map((role) => `Role{name='${role}'}`).join(",")}}`, + }); + await expect( + page.getByRole("heading", { name: "API key access required" }), + ).toBeVisible(); + await expect(page.getByRole("alert")).toContainText("Reach out to your CWMS Admin"); + for (const role of roles) + await expect(page.getByRole("listitem").filter({ hasText: role })).toContainText( + "missing from your current", + ); + await expect( + page.getByRole("button", { name: "Create key", exact: true }), + ).toHaveCount(0); + expect(state.requests).toEqual(["GET"]); + if (process.env.API_KEYS_SCREENSHOT_DIR && roles.length === 2) { + await page.screenshot({ + path: `${process.env.API_KEYS_SCREENSHOT_DIR}/api-keys-permissions.png`, + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("alert")).toBeVisible(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(390); + await page.screenshot({ + path: `${process.env.API_KEYS_SCREENSHOT_DIR}/api-keys-permissions-mobile.png`, + fullPage: true, + }); + } + state.failure = { method: "GET", status: 503 }; + await page.getByRole("button", { name: "Check access again" }).click(); + await expect(toast(page)).toContainText("CDA could not complete"); + await expect( + page.getByRole("heading", { name: "API key access required" }), + ).toBeVisible(); + state.failure = null; + await page.getByRole("button", { name: "Check access again" }).click(); + await expect( + page.getByRole("button", { name: "Create key", exact: true }), + ).toBeEnabled(); + }); +} + +test("permission loss during creation replaces the dialog with the warning page", async ({ + page, +}) => { + const state = await setup(page); + state.failure = { method: "POST", status: 403 }; + await create(page); + await expect( + page.getByRole("heading", { name: "API key access required" }), + ).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Create key", exact: true }), + ).toHaveCount(0); + expect(state.keys).toHaveLength(1); +}); + +const dialog = (page) => page.getByRole("dialog"); +async function create(page, name = "new-report") { + await page.getByRole("button", { name: "Create key", exact: true }).click(); + await page.getByRole("textbox", { name: "Key name", exact: true }).fill(name); + await page.getByRole("button", { name: "Generate key", exact: true }).click(); +} + +test("create, copy, refresh and empty-body revoke give toast feedback", async ({ + page, + context, +}) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await setup(page); + await create(page); + await expect(toast(page)).toContainText("API key created"); + await expect( + dialog(page).getByRole("textbox", { name: "Generated API key" }), + ).toHaveValue("synthetic-one-time-secret"); + await page.getByRole("button", { name: "Copy key", exact: true }).click(); + await expect(toast(page)).toContainText("Key copied"); + await page.getByRole("button", { name: "Dismiss notification" }).focus(); + await page.keyboard.press("Enter"); + await expect(toast(page)).toHaveCount(0); + await dialog(page).getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Generated API key" })).toHaveCount(0); + expect( + await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })), + ).not.toContain("synthetic-one-time-secret"); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(toast(page)).toContainText("up to date"); + await page.getByRole("button", { name: "Revoke key", exact: true }).click(); + await page.getByRole("button", { name: "Confirm revoke", exact: true }).click(); + await expect(toast(page)).toContainText("Revoked new-report"); +}); + +test("server conflicts and date validation stay in the creation dialog and allow retry", async ({ + page, +}) => { + const state = await setup(page); + state.keys.push({ ...original, "key-name": "new-report" }); // another session created it after list loading + await create(page); + await expect(toast(page)).toContainText("already exists"); + await expect( + dialog(page).getByRole("textbox", { name: "Key name", exact: true }), + ).toHaveValue("new-report"); + state.failure = { + method: "POST", + status: 400, + message: + "expires must be a valid date/time string, for example 2030-01-01T00:00:00Z.", + }; + await page + .getByRole("textbox", { name: "Key name", exact: true }) + .fill("retry-report"); + await page.getByRole("button", { name: "Generate key", exact: true }).click(); + await expect(toast(page)).toContainText("valid expiration date"); + await expect(page.getByLabel("Key name", { exact: true })).toHaveValue( + "retry-report", + ); + state.failure = null; + await page.getByRole("button", { name: "Generate key", exact: true }).click(); + await expect(toast(page)).toContainText("API key created"); +}); + +test("rotation reports failures, cancellation and successful completion without revoking early", async ({ + page, +}) => { + const state = await setup(page); + await page.getByRole("button", { name: /daily-report.*No expiration/ }).click(); + await page.getByRole("button", { name: "Rotate key", exact: true }).click(); + state.failure = { method: "POST", status: 500 }; + await page.getByRole("button", { name: "Generate replacement" }).click(); + await expect(toast(page)).toContainText( + "Refresh your keys to check whether the change was saved", + ); + expect(state.requests).not.toContain("DELETE"); + state.failure = null; + await page.getByRole("button", { name: "Generate replacement" }).click(); + await expect(toast(page)).toContainText("Replacement created"); + await dialog(page).getByRole("button", { name: "Close", exact: true }).click(); + await dialog(page).getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(toast(page)).toContainText("has not been revoked"); + expect(state.keys).toHaveLength(2); + await page.getByRole("button", { name: /daily-report No expiration/ }).click(); + await page.getByRole("button", { name: "Rotate key", exact: true }).click(); + await page.getByRole("button", { name: "Generate replacement" }).click(); + await dialog(page).getByRole("button", { name: "Close", exact: true }).click(); + state.failure = { method: "DELETE", status: 500 }; + await page.getByRole("button", { name: "Confirm revoke", exact: true }).click(); + await expect(toast(page)).toContainText("CDA could not complete"); + expect(state.keys.some((key) => key["key-name"] === "daily-report")).toBe(true); + state.failure = null; + await page.getByRole("button", { name: "Confirm revoke", exact: true }).click(); + await expect(toast(page)).toContainText("Rotation complete"); +}); + +test("list errors recover, vanished keys are cleared and expired keys warn", async ({ + page, +}) => { + const state = await setup(page); + state.failure = { method: "GET", status: 403 }; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "API key access required" }), + ).toBeVisible(); + state.failure = null; + state.keys[0].expires = "2020-01-01T00:00:00+0000[Z]"; + await page.getByRole("button", { name: "Check access again", exact: true }).click(); + await expect(toast(page)).toContainText("up to date"); + await page.getByRole("button", { name: /daily-report.*Expired/ }).click(); + await expect(toast(page)).toContainText("has expired"); + state.keys = []; + await page.getByRole("button", { name: /daily-report.*Expired/ }).click(); + await expect(toast(page)).toContainText("no longer exists"); + await expect( + page.getByRole("heading", { name: "Key details", exact: true }), + ).toBeVisible(); +}); + +test("mobile clipboard errors remain dismissible inside the save dialog", async ({ + page, +}) => { + await setup(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: () => Promise.reject(new Error("Unavailable")) }, + }), + ); + await create(page); + await page.getByRole("button", { name: "Copy key", exact: true }).click(); + await expect(toast(page)).toContainText("copy the key manually"); + const bounds = await toast(page).boundingBox(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(390); + await page.getByRole("button", { name: "Dismiss notification" }).click(); + await expect(toast(page)).toHaveCount(0); + await expect( + dialog(page).getByRole("textbox", { name: "Generated API key" }), + ).toBeVisible(); +}); + +test("success feedback expires, errors persist, and long rotation names stay valid", async ({ + page, +}) => { + await page.clock.install(); + const state = await setup(page); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(toast(page)).toContainText("up to date"); + await page.clock.fastForward(9000); + await expect(toast(page)).toHaveCount(0); + state.failure = { method: "GET", status: 401 }; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(toast(page)).toContainText("sign-in could not be verified"); + await page.clock.fastForward(9000); + await expect(toast(page)).toContainText("sign-in could not be verified"); + state.failure = null; + const longName = "a".repeat(64); + state.keys = [{ ...original, "key-name": longName }]; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("button", { name: new RegExp(longName) }).click(); + await page.getByRole("button", { name: "Rotate key", exact: true }).click(); + expect( + await page.getByRole("textbox", { name: "Key name", exact: true }).inputValue(), + ).toHaveLength(64); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + state.keys = []; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Key details", exact: true }), + ).toBeVisible(); +}); diff --git a/cda-gui/vite.config.js b/cda-gui/vite.config.js index 020e63ad9..2852478c4 100644 --- a/cda-gui/vite.config.js +++ b/cda-gui/vite.config.js @@ -45,7 +45,7 @@ export default defineConfig(({ mode }) => { }, 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(?:/|$)|user-lists(?:/|$)|api-keys(?:/|$)|legacy-format(?:/|$)|location-search(?:/|$)|assets/|src/|node_modules/|@).*)": { target: cdaApiRoot, changeOrigin: true, diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/ApiKeyController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/ApiKeyController.java index 1d6180f5a..0f4fdebfa 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/auth/ApiKeyController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/ApiKeyController.java @@ -59,6 +59,11 @@ import org.jooq.DSLContext; public class ApiKeyController implements CrudHandler { + private static final String KEY_MANAGEMENT_HELP = " Manage your own keys in the " + + "API Keys page (sign-in required). " + + "Keys belong to the current user and use that user's office permissions; " + + "they are not office-owned or limited to one office. " + + "The secret is returned only when created. Save it securely."; private static final ObjectReader KEY_READER = JsonV1.buildObjectMapper().readerFor(ApiKey.class) .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); public final MetricRegistry metrics; @@ -88,7 +93,7 @@ public ApiKeyController(MetricRegistry metrics) { description = "An API key with this name already exists for this user.") }, description = "Create a new API Key for user. The randomly generated key is returned " - + "to the caller. A provided key will be ignored.", + + "to the caller. A provided key will be ignored." + KEY_MANAGEMENT_HELP, tags = {"Authorization"} ) @Override @@ -132,7 +137,7 @@ public void create(Context ctx) { description = "Name of the specific key to get more information for. NOTE: Case-sensitive.") }, responses = @OpenApiResponse(status = STATUS_204), - description = "Delete API key for a user", + description = "Delete API key for a user." + KEY_MANAGEMENT_HELP, tags = {"Authorization"} ) @Override @@ -155,7 +160,7 @@ public void delete(@NotNull Context ctx, @NotNull String keyName) { security = { @OpenApiSecurity(name = "gets overridden allows lock icon.") }, - description = "View all keys for the current user", + description = "View all keys for the current user." + KEY_MANAGEMENT_HELP, tags = {"Authorization"} ) public void getAll(Context ctx) { @@ -183,7 +188,7 @@ public void getAll(Context ctx) { security = { @OpenApiSecurity(name = "gets overridden allows lock icon.") }, - description = "View specific key", + description = "View specific key metadata. The secret cannot be retrieved." + KEY_MANAGEMENT_HELP, tags = {"Authorization"} ) @Override diff --git a/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java index 86bcb71ec..e3d999e55 100644 --- a/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java +++ b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java @@ -21,6 +21,8 @@ public final class SpaErrorStatusFilter implements Filter { // Keep these paths synchronized with cda-gui/src/route-paths.js. private static final Set SPA_ROUTES = Set.of( + "/api-keys", + "/api-keys/help", "/data-query", "/filter-expressions", "/legacy-format", diff --git a/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java index 7e6b27e30..4fb8a0c44 100644 --- a/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java @@ -32,6 +32,10 @@ void registersForIndexErrorDispatches() { @ParameterizedTest @ValueSource(strings = { + "/api-keys", + "/api-keys/", + "/api-keys/help", + "/api-keys/help/", "/data-query", "/filter-expressions", "/legacy-format",