diff --git a/client/package.json b/client/package.json index 66489c896..6b73c41b8 100644 --- a/client/package.json +++ b/client/package.json @@ -90,7 +90,8 @@ "resize-observer-polyfill": "^1.5.1", "serve": "^14.2.1", "styled-components": "^6.1.1", - "typescript": "5.1.6" + "typescript": "5.1.6", + "zod": "^3.23.8" }, "devDependencies": { "@babel/core": "7.25.8", diff --git a/client/src/page/LayoutPublic.tsx b/client/src/page/LayoutPublic.tsx index 9acc61376..25ae94135 100644 --- a/client/src/page/LayoutPublic.tsx +++ b/client/src/page/LayoutPublic.tsx @@ -4,7 +4,7 @@ import AppBar from '@mui/material/AppBar'; import Footer from 'page/Footer'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import { Container } from '@mui/material'; +import { Breakpoint, Container } from '@mui/material'; import LinkButtons from 'components/LinkButtons'; import toolbarLinkValues from 'util/toolbarUtil'; @@ -26,7 +26,10 @@ const DTappBar = () => ( ); -function LayoutPublic(props: { children: React.ReactNode }) { +function LayoutPublic(props: { + children: React.ReactNode; + containerMaxWidth?: Breakpoint; +}) { return ( - + {props.children} diff --git a/client/src/route/auth/ConfigItems.tsx b/client/src/route/auth/ConfigItems.tsx new file mode 100644 index 000000000..c27c287bc --- /dev/null +++ b/client/src/route/auth/ConfigItems.tsx @@ -0,0 +1,125 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { Tooltip } from '@mui/material'; +import React from 'react'; +import { validationType } from './VerifyConfig'; + +const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( + + {icon} + +); + +export const getConfigIcon = ( + validation: validationType, + label: string, +): JSX.Element => { + let icon = ; + let toolTipTitle = `${label} threw the following error: ${validation.error}`; + const configHasStatus = validation.status !== undefined; + const configHasError = validation.error !== undefined; + if (!configHasError) { + const statusMessage = configHasStatus + ? `${validation.value} responded with status code ${validation.status}.` + : ''; + const validationStatusIsOK = + configHasStatus && + ((validation.status! >= 200 && validation.status! <= 299) || + validation.status! === 302); + icon = + validationStatusIsOK || !configHasStatus ? ( + + ) : ( + + ); + toolTipTitle = + validationStatusIsOK || !configHasStatus + ? `${label} field is configured correctly.` + : `${label} field may not be configured correctly.`; + toolTipTitle += ` ${statusMessage}`; + } + return ConfigIcon(toolTipTitle, icon); +}; + +export const ConfigItem: React.FC<{ + label: string; + value: string; + validation?: validationType; +}> = ({ label, value, validation = { error: 'Validating unavailable' } }) => ( +
+ {getConfigIcon(validation, label)} +
+ {label}: {value} +
+
+); +ConfigItem.displayName = 'ConfigItem'; + +export const windowEnvironmentVariables: { value: string; key: string }[] = [ + { + value: window.env.REACT_APP_ENVIRONMENT, + key: 'environment', + }, + { + value: window.env.REACT_APP_URL, + key: 'url', + }, + { + value: window.env.REACT_APP_URL_BASENAME, + key: 'url_basename', + }, + { + value: window.env.REACT_APP_URL_DTLINK, + key: 'url_dtlink', + }, + { + value: window.env.REACT_APP_URL_LIBLINK, + key: 'url_liblink', + }, + { + value: window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP, + key: 'workbenchlink_vncdesktop', + }, + { + value: window.env.REACT_APP_WORKBENCHLINK_VSCODE, + key: 'workbenchlink_vscode', + }, + { + value: window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB, + key: 'workbenchlink_jupyterlab', + }, + { + value: window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + key: 'workbenchlink_jupyternotebook', + }, + { + value: window.env.REACT_APP_CLIENT_ID, + key: 'client_id', + }, + { + value: window.env.REACT_APP_AUTH_AUTHORITY, + key: 'auth_authority', + }, + { + value: window.env.REACT_APP_REDIRECT_URI, + key: 'redirect_uri', + }, + { + value: window.env.REACT_APP_LOGOUT_REDIRECT_URI, + key: 'logout_redirect_uri', + }, + { + value: window.env.REACT_APP_GITLAB_SCOPES, + key: 'gitlab_scopes', + }, +]; diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index a113ab265..336f1aeb5 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -2,66 +2,120 @@ import * as React from 'react'; import Avatar from '@mui/material/Avatar'; import Box from '@mui/material/Box'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; - import { useAuth } from 'react-oidc-context'; import Button from '@mui/material/Button'; +import { useState } from 'react'; +import { CircularProgress } from '@mui/material'; +import VerifyConfig, { + getValidationResults, + validationType, +} from './VerifyConfig'; + +const loadingComponent = ( + + Verifying configuration + + +); + +const signInComponent = (startAuthProcess: () => void) => ( + + + + + + +); function SignIn() { const auth = useAuth(); + const [validationResults, setValidationResults] = useState<{ + [key: string]: validationType; + }>({}); + const [isLoading, setIsLoading] = useState(true); + const configsToVerify = [ + 'url', + 'auth_authority', + 'redirect_uri', + 'logout_redirect_uri', + ]; + const verifyConfigComponent = VerifyConfig({ + keys: configsToVerify, + title: 'Config validation failed', + }); + React.useEffect(() => { + const fetchValidationResults = async () => { + const results = await getValidationResults(); + setValidationResults(results); + setIsLoading(false); + }; + fetchValidationResults(); + }, []); const startAuthProcess = () => { auth.signinRedirect(); }; - return ( - - - - - - - ); + let displayedComponent: React.ReactNode = loadingComponent; + if (!isLoading) { + const configHasKeyErrors = configsToVerify.reduce( + (accumulator, currentValue) => + accumulator || validationResults[currentValue].error !== undefined, + false, + ); + displayedComponent = configHasKeyErrors + ? verifyConfigComponent + : signInComponent(startAuthProcess); + } + return displayedComponent; } export default SignIn; diff --git a/client/src/route/auth/VerifyConfig.tsx b/client/src/route/auth/VerifyConfig.tsx new file mode 100644 index 000000000..87e188956 --- /dev/null +++ b/client/src/route/auth/VerifyConfig.tsx @@ -0,0 +1,151 @@ +import { Paper, Typography } from '@mui/material'; +import * as React from 'react'; +import { z } from 'zod'; +import { ConfigItem, windowEnvironmentVariables } from './ConfigItems'; + +const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); +const PathString = z.string(); +const ScopesString = z.literal('openid profile read_user read_repository api'); + +export type validationType = { + value?: string; + status?: number; + error?: string; +}; + +async function opaqueRequest(url: string): Promise { + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + try { + await fetch(url, { method: 'HEAD', mode: 'no-cors' }); + urlValidation.status = 0; + } catch (error) { + urlValidation.error = `An error occurred when fetching ${url}: ${error}`; + } + return urlValidation; +} + +async function corsRequest(url: string): Promise { + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + const response = await fetch(url, { method: 'HEAD' }); + const responseIsAcceptable = response.ok || response.status === 302; + if (!responseIsAcceptable) { + urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; + } + urlValidation.status = response.status; + return urlValidation; +} + +async function urlIsReachable(url: string): Promise { + let urlValidation: validationType; + try { + urlValidation = await corsRequest(url); + } catch { + urlValidation = await opaqueRequest(url); + } + return urlValidation; +} + +const parseField = ( + parser: { + safeParse: (value: string) => { + success: boolean; + error?: { message?: string }; + }; + }, + value: string, +): validationType => { + const result = parser.safeParse(value); + return result.success + ? { value, error: undefined } + : { value: undefined, error: result.error?.message }; +}; + +export const getValidationResults = async (): Promise<{ + [key: string]: validationType; +}> => { + const results: { [key: string]: validationType } = { + environment: parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + url: await urlIsReachable(window.env.REACT_APP_URL), + url_basename: parseField(PathString, window.env.REACT_APP_URL_BASENAME), + url_dtlink: parseField(PathString, window.env.REACT_APP_URL_DTLINK), + url_liblink: parseField(PathString, window.env.REACT_APP_URL_LIBLINK), + workbenchlink_vncdesktop: parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP, + ), + workbenchlink_vscode: parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_VSCODE, + ), + workbenchlink_jupyterlab: parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB, + ), + workbenchlink_jupyternotebook: parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + ), + client_id: parseField(PathString, window.env.REACT_APP_CLIENT_ID), + auth_authority: await urlIsReachable(window.env.REACT_APP_AUTH_AUTHORITY), + redirect_uri: await urlIsReachable(window.env.REACT_APP_REDIRECT_URI), + logout_redirect_uri: await urlIsReachable( + window.env.REACT_APP_LOGOUT_REDIRECT_URI, + ), + gitlab_scopes: parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + }; + return results; +}; + +const VerifyConfig: React.FC<{ keys?: string[]; title?: string }> = ({ + keys = [], + title = 'Config verification', +}) => { + const [validationResults, setValidationResults] = React.useState<{ + [key: string]: validationType; + }>({}); + + React.useEffect(() => { + const fetchValidations = async () => { + const results = await getValidationResults(); + setValidationResults(results); + }; + fetchValidations(); + }, []); + + const displayedConfigs = windowEnvironmentVariables.filter( + (configItem) => keys.length === 0 || keys.includes(configItem.key), + ); + return ( + + {title} +
+ {displayedConfigs.map(({ value, key }) => ( + + ))} +
+
+ ); +}; + +export default VerifyConfig; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 676481ae0..bc3b1f8d0 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -7,6 +7,7 @@ import DigitalTwins from './route/digitaltwins/DigitalTwins'; import DigitalTwinsPreview from './preview/route/digitaltwins/DigitalTwinsPreview'; import SignIn from './route/auth/Signin'; import Account from './route/auth/Account'; +import VerifyConfig from './route/auth/VerifyConfig'; export const routes = [ { @@ -17,6 +18,14 @@ export const routes = [ ), }, + { + path: 'verify', + element: ( + + + + ), + }, { path: 'library', element: (