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
1 change: 1 addition & 0 deletions cda-gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"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",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"prepare": "cd .. && husky cda-gui/.husky",
Expand Down
121 changes: 121 additions & 0 deletions cda-gui/src/components/AppAuthProvider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
AuthProvider,
createKeycloakAuthMethod,
} from "@usace-watermanagement/groundwork-water";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";

import { getBasePath } from "../utils/base";
import { getKeycloakConfig, normalizeOpenIdConnectUrls } from "../utils/auth-config";
import { AuthConfigurationContext } from "./auth-configuration-context";

function createLocalAuthMethod() {
Comment thread
krowvin marked this conversation as resolved.
let token;
return {
async login() {
const response = await fetch(
`${import.meta.env.VITE_AUTH_HOST}/realms/${import.meta.env.VITE_AUTH_REALM}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: "cwms",
username: import.meta.env.VITE_AUTH_USER,
password: import.meta.env.VITE_AUTH_PASSWORD,
}),
},
);
if (!response.ok) {
throw new Error(`Local Keycloak login failed (${response.status})`);
}
token = (await response.json()).access_token;
},
async logout() {
token = undefined;
},
async isAuth() {
return !!token;
},
get token() {
return token;
},
};
}

const unavailableAuthMethod = {
async login() {},
async logout() {},
async isAuth() {
return false;
},
get token() {
return undefined;
},
};

async function loadDeployedAuthMethod() {
const response = await fetch(`${getBasePath()}/swagger-docs`, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`OpenAPI request failed with HTTP ${response.status}`);
}

const spec = await response.json();
normalizeOpenIdConnectUrls(spec, window.location.origin);
const config = getKeycloakConfig(spec, window.location.href);
if (!config) {
throw new Error("The OpenAPI document does not advertise a usable OpenID client.");
}
return createKeycloakAuthMethod(config);
}

export default function AppAuthProvider({ children }) {
const [state, setState] = useState(() =>
import.meta.env.MODE === "dev-cda-compose"
? { method: createLocalAuthMethod(), error: null }
: { method: null, error: null },
);

useEffect(() => {
if (state.method) return undefined;

let cancelled = false;
loadDeployedAuthMethod()
.then((method) => {
if (!cancelled) setState({ method, error: null });
})
.catch((error) => {
if (!cancelled) {
setState({
method: unavailableAuthMethod,
error: `Sign-in is unavailable: ${error?.message ?? "invalid authentication configuration"}`,
});
}
});

return () => {
cancelled = true;
};
}, [state.method]);

if (!state.method) {
return (
<div className="p-6 text-slate-700" role="status">
Loading authentication configuration…
</div>
);
}

return (
<AuthConfigurationContext.Provider value={{ error: state.error }}>
<AuthProvider method={state.method}>{children}</AuthProvider>
</AuthConfigurationContext.Provider>
);
}

AppAuthProvider.propTypes = {
children: PropTypes.node.isRequired,
};
10 changes: 10 additions & 0 deletions cda-gui/src/components/AuthButton.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { useAuth } from "@usace-watermanagement/groundwork-water";
import { LoginButton } from "@usace/groundwork";
import { useAuthConfiguration } from "./auth-configuration-context";

export default function AuthButton() {
const auth = useAuth();
const { error } = useAuthConfiguration();

if (error) {
return (
<span className="text-sm text-white" title={error}>
Sign-in unavailable
</span>
);
}

return auth.isAuth ? (
<button className="text-white underline" type="button" onClick={auth.logout}>
Expand Down
39 changes: 39 additions & 0 deletions cda-gui/src/components/GlobalErrorBoundary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import PropTypes from "prop-types";
import { Component } from "react";

export default class GlobalErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}

static getDerivedStateFromError(error) {
return { error };
}

componentDidCatch(error, errorInfo) {
console.error("Uncaught CDA GUI error", error, errorInfo);
}

render() {
if (this.state.error) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold text-red-700">Something went wrong</h1>
<p className="mt-2 text-slate-700">
{this.state.error?.message ?? "An unexpected error occurred."}
</p>
<a className="mt-4 inline-block text-blue-700 underline" href="/cwms-data/">
Return to CDA
</a>
</main>
);
}

return this.props.children;
}
}

GlobalErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
};
7 changes: 7 additions & 0 deletions cda-gui/src/components/auth-configuration-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createContext, useContext } from "react";

export const AuthConfigurationContext = createContext({ error: null });

export function useAuthConfiguration() {
return useContext(AuthConfigurationContext);
}
70 changes: 12 additions & 58 deletions cda-gui/src/main.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
// Routing
import React from "react";
// Routing
import ReactDOM from "react-dom/client";
import { Link, createBrowserRouter, RouterProvider } from "react-router-dom";

import { LinkProvider } from "@usace/groundwork";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
AuthProvider,
createKeycloakAuthMethod,
} from "@usace-watermanagement/groundwork-water";

// Pages
import Home from "./pages/Home";
Expand All @@ -28,54 +24,10 @@ import Timestamps from "./pages/timestamps";
import LegacyFormat from "./pages/legacy-format/index.jsx";
import UserLists from "./pages/user-lists/index.jsx";
import { routePaths } from "./route-paths";
import AppAuthProvider from "./components/AppAuthProvider.jsx";
import GlobalErrorBoundary from "./components/GlobalErrorBoundary.jsx";

const queryClient = new QueryClient();

function createLocalAuthMethod() {
let token;
return {
async login() {
const response = await fetch(
`${import.meta.env.VITE_AUTH_HOST}/realms/${import.meta.env.VITE_AUTH_REALM}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: "cwms",
username: import.meta.env.VITE_AUTH_USER,
password: import.meta.env.VITE_AUTH_PASSWORD,
}),
},
);
if (!response.ok) {
throw new Error(`Local Keycloak login failed (${response.status})`);
}
token = (await response.json()).access_token;
},
async logout() {
token = undefined;
},
async isAuth() {
return !!token;
},
get token() {
return token;
},
};
}

const authMethod =
import.meta.env.MODE === "dev-cda-compose"
? createLocalAuthMethod()
: createKeycloakAuthMethod({
host: import.meta.env.VITE_AUTH_HOST,
realm: import.meta.env.VITE_AUTH_REALM,
client: "cwms",
flow: "authorization-code-pkce",
redirectUri: window.location.href,
providerHint: "federation-eams",
});
const routeComponents = {
home: Home,
"swagger-ui": SwaggerUI,
Expand Down Expand Up @@ -110,12 +62,14 @@ const router = createBrowserRouter(

ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider method={authMethod}>
<LinkProvider component={Link} hrefMap="to">
<RouterProvider router={router} />
</LinkProvider>
</AuthProvider>
</QueryClientProvider>
<GlobalErrorBoundary>
<QueryClientProvider client={queryClient}>
<AppAuthProvider>
Comment thread
krowvin marked this conversation as resolved.
<LinkProvider component={Link} hrefMap="to">
<RouterProvider router={router} />
</LinkProvider>
</AppAuthProvider>
</QueryClientProvider>
</GlobalErrorBoundary>
</React.StrictMode>,
);
12 changes: 3 additions & 9 deletions cda-gui/src/pages/ErrorFallback.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Button } from "@usace/groundwork";
import PropTypes from "prop-types";
import { FaArrowLeft } from "react-icons/fa";
import { Link } from "react-router-dom";
import { Link, useRouteError } from "react-router-dom";

export default function ErrorFallback({ error }) {
export default function ErrorFallback() {
const error = useRouteError();
return (
<div className="p-6">
<h2 className="text-lg font-semibold text-red-600">Something went wrong</h2>
Expand All @@ -24,9 +24,3 @@ export default function ErrorFallback({ error }) {
</div>
);
}

ErrorFallback.propTypes = {
error: PropTypes.shape({
message: PropTypes.string,
}),
};
63 changes: 7 additions & 56 deletions cda-gui/src/pages/swagger-ui/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,65 +8,16 @@ import {
} from "@usace-watermanagement/groundwork-water";
import { useEffect, useMemo, useRef, useState } from "react";
import { getBasePath } from "../../utils/base";

function normalizeOpenIdConnectUrls(spec) {
const schemes = spec.components?.securitySchemes ?? {};
for (const scheme of Object.values(schemes)) {
if (scheme.type === "openIdConnect" && scheme.openIdConnectUrl) {
const openIdConnectUrl = new URL(scheme.openIdConnectUrl, window.location.origin);
if (openIdConnectUrl.hostname === "auth") {
openIdConnectUrl.protocol = window.location.protocol;
openIdConnectUrl.host = window.location.host;
scheme.openIdConnectUrl = openIdConnectUrl.toString();
}
}
}
}

function getOpenIdConnectScheme(spec) {
const schemes = spec.components?.securitySchemes ?? {};
return Object.values(schemes).find((scheme) => scheme.type === "openIdConnect");
}
import {
getKeycloakConfig,
isLoopbackHost,
normalizeOpenIdConnectUrls,
} from "../../utils/auth-config";

function getCwmsLoginScheme(spec) {
return spec.components?.securitySchemes?.CwmsAAACacAuth;
}

function isLoopbackHost(hostname) {
return ["localhost", "127.0.0.1", "::1"].includes(hostname);
}

function isLocalOrigin(url) {
return isLoopbackHost(new URL(url).hostname);
}

function getKeycloakConfig(spec) {
const scheme = getOpenIdConnectScheme(spec);
if (!scheme?.openIdConnectUrl) {
return null;
}

const openIdConnectUrl = new URL(scheme.openIdConnectUrl);
const realmMatch = openIdConnectUrl.pathname.match(/^(.*)\/realms\/([^/]+)\//);
if (!realmMatch) {
return null;
}

const providerHint = scheme["x-kc_idp_hint"]?.values?.[0];
const useLocalDevCredentials = isLocalOrigin(openIdConnectUrl);
return {
host: `${openIdConnectUrl.origin}${realmMatch[1]}`,
realm: realmMatch[2],
client: scheme["x-oidc-client-id"],
flow: useLocalDevCredentials ? "direct-grant" : "authorization-code-pkce",
username: useLocalDevCredentials ? "m5hectest" : undefined,
password: useLocalDevCredentials ? "m5hectest" : undefined,
redirectUri: window.location.href.split("?")[0],
postLogoutRedirectUri: window.location.href.split("?")[0],
providerHint: useLocalDevCredentials ? undefined : providerHint,
};
}

function isExternalOpenIdOnLocalhost(keycloakConfig) {
return (
keycloakConfig?.flow === "authorization-code-pkce" &&
Expand Down Expand Up @@ -164,8 +115,8 @@ export default function SwaggerUI() {
}
return;
}
normalizeOpenIdConnectUrls(spec);
const keycloakConfig = getKeycloakConfig(spec);
normalizeOpenIdConnectUrls(spec, window.location.origin);
const keycloakConfig = getKeycloakConfig(spec, window.location.href);
const hasCwmsLogin = Boolean(getCwmsLoginScheme(spec));
// Some non-T7 deployments advertise CwmsAAACacAuth even though their
// /CWMSLogin route is only a generic landing page. Prefer a usable
Expand Down
Loading
Loading