!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}
+
+
+ );
+}
+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]) => (
+ setExpiration(amount, unit)}
+ >
+ {label}
+
+ ))}
+ setExpires("")}
+ >
+ Clear date
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
API key access required
+
+ Your account or current sign-in does not have access to create or manage
+ API keys. Both of these are required:
+
+
+
+ CWMS Users permission
+ {missingRoles.includes("CWMS Users") && (
+ — missing from your current access
+ )}
+ .
+
+
+ A signed-in user session (cac_auth)
+ {missingRoles.includes("cac_auth") && (
+ — missing from your current sign-in
+ )}
+ . Sign in with your user account; an API key cannot manage other keys.
+
+
+
+ Reach out to your CWMS Admin to request the required permission and verify
+ your sign-in access.
+
+
+ After your access is updated, sign out and sign in again, then check
+ access below.
+
+
+
+
+ {loading ? "Checking access…" : "Check access again"}
+
+
+ How to use keys
+
+
+
+
+ {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.
+
+
+
+ Rotate key
+
+ {
+ setError("");
+ setRevokeOpen(true);
+ }}
+ >
+ Revoke 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)}
+ >
+
+
+
{notification.message}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ How to use keys
+
+ {
+ setError("");
+ setCreateOpen(true);
+ }}
+ >
+
+ Create key
+
+
+
+
+ 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 (
+
+
+
+ Back to API Keys
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to API Keys
+
+
+ );
+}
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 (
+
+
+
+
+ {number}
+
+
+ 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}
+
+
+ Refresh
+
+
+
+
+ setSearch(event.target.value)}
+ />
+
+ {loading ? (
+
+ ) : visibleKeys.length === 0 ? (
+
+ Create a key to connect a script or application.
+
+ ) : (
+
+ {visibleKeys.map((key) => (
+
+ view(key["key-name"])}
+ className={`w-full rounded-lg border p-4 text-left focus-visible:ring-2 focus-visible:ring-blue-600 ${keyStatus(key, now) === "Expired" ? "border-red-300 bg-red-50" : selected?.["key-name"] === key["key-name"] ? "border-blue-500 bg-blue-50" : "border-zinc-200 hover:bg-zinc-50"} ${selected?.["key-name"] === key["key-name"] ? "ring-2 ring-blue-500" : ""}`}
+ >
+ {key["key-name"]}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+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.{" "}
+
+ queryClient.invalidateQueries({ queryKey: ["auth", "profile"] })
+ }
+ >
+ Retry profile
+
+
+ )}
+
+
+
+
+
+
+
+ }
+ {...{
+ 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
+
+
+ Select the old key and choose Rotate key .
+
+
+ Choose a new name and expiration date, then select{" "}
+ Generate replacement .
+
+
+ Copy the new key and save it securely.{" "}
+ You can only see it once. CDA cannot show it again.
+
+
+ Update your application to use the new key and check that its requests work.
+
+
+ Select Close , then confirm revocation of the old key. If
+ you are not ready, cancel the confirmation to keep both keys.
+
+
+
+
+
Revoke a key without replacing it
+
+ Select the key you want to remove.
+
+ Choose Revoke key .
+
+ Check the key name in the confirmation, then confirm revocation.
+
+
+
+ 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 (
+
+
+
+
+ Regenerate keys after the May 2026 security update
+
+
+ When upgrading to CDA 2026.05.12 or later, replace keys created before the
+ security update. Older keys are no longer accepted by default. Sign in,
+ generate a new key, update your applications, and revoke the old key. Follow
+ the steps below to complete the replacement.
+
+
+
+ );
+}
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" && (
+
+ )}
+ {status === "Expired" ? "Expired — no longer usable" : status}
+
+ );
+}
+
+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…"}
+
+
+ Office context
+ setOfficeChoice(event.target.value)}
+ disabled={!offices.length}
+ >
+ {offices.length
+ ? offices.map((id) => (
+
+ {id}
+
+ ))
+ : [
+
+ No office roles available
+ ,
+ ]}
+
+
+ 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.
+
+
+ setRevokeOpen(false)}
+ >
+ Cancel
+
+
+ {working ? "Revoking…" : "Confirm revoke"}
+
+
+
+ );
+}
+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}
+
+
+
+
+
Save this key now
+
+ Copy the key and store it securely.{" "}
+
+ This is the only time you can see it. After you close this dialog, you
+ cannot retrieve it.
+
+
+
+
+ {created?.["api-key"] ? (
+ <>
+
+
+ Copy 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.
+
+ )}
+
+ Close
+
+
+
+ );
+}
+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",