Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/web-gui-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions cda-gui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ node_modules
dist
dist-ssr
storybook-static
test-results/
playwright-report/
test-results
playwright-report
*.local

# Editor directories and files
Expand Down
3 changes: 2 additions & 1 deletion cda-gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions cda-gui/playwright.api-keys.config.js
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
4 changes: 3 additions & 1 deletion cda-gui/src/components/AppAuthProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ export default function AppAuthProvider({ children }) {

return (
<AuthConfigurationContext.Provider value={{ error: state.error }}>
<AuthProvider method={state.method}>{children}</AuthProvider>
<AuthProvider method={state.method} cdaUrl={import.meta.env.VITE_CDA_API_ROOT}>
{children}
</AuthProvider>
</AuthConfigurationContext.Provider>
);
}
Expand Down
4 changes: 3 additions & 1 deletion cda-gui/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SiteWrapper
links={headerLinks}
links={headerLinks.filter((link) => !link.requiresAuth || isAuth)}
usaBanner
army250Logo
subtitle="CWMS Restful API for Data Retrieval"
Expand Down
1 change: 1 addition & 0 deletions cda-gui/src/links/header-links.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions cda-gui/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
Expand Down
87 changes: 87 additions & 0 deletions cda-gui/src/pages/api-keys/README.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 50 additions & 0 deletions cda-gui/src/pages/api-keys/api-keys.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
136 changes: 136 additions & 0 deletions cda-gui/src/pages/api-keys/api.js
Original file line number Diff line number Diff line change
@@ -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";
}
Loading
Loading