Skip to content

Commit

Permalink
JNG-6023 better auth error handling
Browse files Browse the repository at this point in the history
  • Loading branch information
noherczeg committed Nov 20, 2024
1 parent f5139df commit e2f55aa
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 52 deletions.
46 changes: 46 additions & 0 deletions docs/pages/01_ui_react.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,52 @@ export class DefaultApplicationCustomizer implements ApplicationCustomizer {
}
----

=== Filtering access for logged in users

If our application supports authentication, letting users access pages might need restrictions
based on certain business criteria. In order to achieve this we can register a Component where
we can implement the filtering logic and grant or block access.

*src/custom/application-customizer.tsx:*
[source,typescriptjsx]
----
import { type FC } from 'react';
import { useTranslation } from 'react-i18next';
import Grid from '@mui/material/Grid';
import type { BundleContext } from '@pandino/pandino-api';
import { ACCESS_FILTER_COMPONENT_INTERFACE_KEY, type AccessFilterComponentProps, AuthErrorBox } from '~/auth';
import type { ApplicationCustomizer } from './interfaces';
export class DefaultApplicationCustomizer implements ApplicationCustomizer {
async customize(context: BundleContext): Promise<void> {
context.registerService(ACCESS_FILTER_COMPONENT_INTERFACE_KEY, AccessFilter);
}
}
const AccessFilter: FC<AccessFilterComponentProps> = ({ principal, children }) => {
// We may use any number of hooks here.
const { t } = useTranslation();
// Here we check if the logged in Principal has his/her `approved` property set to true.
// If so, we load the original children, if not, then we display a full screen error component.
return principal.approved ? children : <Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
sx={{ minHeight: '100vh' }}
>
<Grid item xs={3}>
<AuthErrorBox
title={t('custom.title', { defaultValue: 'Access Denied' })}
message={t('custom.message', { defaultValue: 'You don\'t have access to this application' })}
/>
</Grid>
</Grid>;
};
----

=== (Global) Hotkey support

Currently you can wire in hotkeys for access-based actions, such as triggering create dialogs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@
"judo.applications.available_applications": "Available applications",
"judo.applications.change": "Switch Application",
"judo.error.error": "Error",
"judo.error.security.unknown": "An unknown error occurred!",
"judo.error.security.unknown.message": "Please contact the system administrators.",
"judo.error.security.authenticatedEntityNotFoundTitle": "User not found",
"judo.error.security.authenticatedEntityNotFoundMessage": "Processing of your account is still in progress.",
"judo.error.unhandled": "An unhandled error occurred.",
"judo.error.unmappable": "An error occurred, but we could not display the error info.",
"judo.error.internal-server-error": "An internal server error occurred.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@
"judo.applications.available_applications": "Available applications",
"judo.applications.change": "Switch Application",
"judo.error.error": "Error",
"judo.error.security.unknown": "An unknown error occurred!",
"judo.error.security.unknown.message": "Please contact the system administrators.",
"judo.error.security.authenticatedEntityNotFoundTitle": "User not found",
"judo.error.security.authenticatedEntityNotFoundMessage": "Processing of your account is still in progress.",
"judo.error.unhandled": "An unhandled error occurred.",
"judo.error.unmappable": "An error occurred, but we could not display the error info.",
"judo.error.internal-server-error": "An internal server error occurred.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@
"judo.applications.available_applications": "Választható alkalmazások",
"judo.applications.change": "Alkalmazás váltás",
"judo.error.error": "Hiba",
"judo.error.security.unknown": "Ismeretlen hiba történt!",
"judo.error.security.unknown.message": "Kérjülk lépjen kapcsolatba a rendszer üzemeltetőkkel.",
"judo.error.security.authenticatedEntityNotFoundTitle": "Felhasználó nem található",
"judo.error.security.authenticatedEntityNotFoundMessage": "A fiókjának aktivációja még nem fejeződött be.",
"judo.error.unhandled": "Ismeretlen hiba történt.",
"judo.error.unmappable": "Hiba történt, nem tudjuk megjeleníteni a választ.",
"judo.error.internal-server-error": "Belső alkalmazás hiba történt",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
{{# if application.authentication }}
import { useAuth } from 'react-oidc-context';
{{/ if }}
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';

export interface AuthErrorBoxProps {
title: string;
message: string;
}

export function AuthErrorBox({ title, message }: AuthErrorBoxProps) {
const { t } = useTranslation();
{{# if application.authentication }}
const { signoutRedirect, isAuthenticated } = useAuth();
const doLogout = useCallback(() => {
const redirectUrl = window.location.href.split('#')[0];
signoutRedirect({
post_logout_redirect_uri: redirectUrl,
});
}, [isAuthenticated]);
{{/ if }}

return (
<Box
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
textAlign="center"
sx={ {
backgroundColor: '#f8f9fa',
borderRadius: '8px',
padding: '2rem',
boxShadow: '0px 4px 10px rgba(0, 0, 0, 0.1)',
} }
>
<Typography variant="h4" color="error" gutterBottom>
{title}
</Typography>
<Typography variant="body1" color="textSecondary" gutterBottom>
{message}
</Typography>
{{# if application.authentication }}
<Box display={'flex'}>
<Button variant="text" color="primary" onClick={doLogout} sx={ { marginTop: '1rem' } }>
{t('judo.security.logout', { defaultValue: 'Logout' })}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => window.location.reload()}
sx={ { marginTop: '1rem' } }
>
{t('judo.error.boundary.reload-page', { defaultValue: 'Reload Page' })}
</Button>
</Box>
{{/ if }}
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { FC, ReactNode } from 'react';
{{# if application.authentication }}
import { useTrackComponent } from '@pandino/react-hooks';
{{/ if }}

interface AuthProxyComponentProps {
filter: string;
children?: ReactNode;
[prop: string]: any;
}

export const AuthProxyComponent: FC<AuthProxyComponentProps> = ({ filter, children, ...other }) => {
{{# if application.authentication }}
const ExternalComponent = useTrackComponent<any>(filter);

if (ExternalComponent) {
return <ExternalComponent {...other} children={children} />;
}
{{/ if }}

return <>{children}</>;
};
11 changes: 11 additions & 0 deletions judo-ui-react/src/main/resources/actor/src/auth/constants.ts.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
{{> fragment.header.hbs }}

{{# if application.authentication }}
import { type ReactNode } from 'react';
import { type {{ classDataName application.principal 'Stored' }} } from '~/services/data-api/model/{{ classDataName application.principal '' }}';
{{/ if }}
import { endWithSlash } from '../utilities';

const base = document.querySelector('base')!.getAttribute('href') as string;
export const appBaseUri = endWithSlash(window.location.origin + base);
{{# if application.authentication }}
export const ACCESS_FILTER_COMPONENT_INTERFACE_KEY = 'AccessFilterComponent';
export interface AccessFilterComponentProps {
principal: {{ classDataName application.principal 'Stored' }};
children?: ReactNode;
}
{{/ if }}
2 changes: 2 additions & 0 deletions judo-ui-react/src/main/resources/actor/src/auth/index.tsx.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{{> fragment.header.hbs }}

export * from './Auth';
export * from './AuthErrorBox';
export * from './AuthProxyComponent';
export * from './axiosInterceptor';
export * from './constants';
export * from './principal-context';
Original file line number Diff line number Diff line change
@@ -1,58 +1,72 @@
{{> fragment.header.hbs }}

{{# if application.authentication }}
import { createContext, useContext, useState, useEffect, useMemo } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from 'react-oidc-context';
import { type JudoRestResponse } from '~/services/data-api/rest/requestResponse';
import { type {{ classDataName application.principal 'Stored' }} } from '~/services/data-api/model/{{ classDataName application.principal '' }}';
import { AccessServiceImpl } from '~/services/data-axios/AccessServiceImpl';
import { judoAxiosProvider } from '~/services/data-axios/JudoAxiosProvider';

export interface PrincipalContext {
principal: {{ classDataName application.principal 'Stored' }};
setPrincipal: (principal: {{ classDataName application.principal 'Stored' }}) => void;
getPrincipal: () => Promise<JudoRestResponse<{{ classDataName application.principal 'Stored' }}>>;
}

const PrincipalContext = createContext<PrincipalContext>({} as unknown as PrincipalContext);

export const PrincipalProvider = ({ children }: { children: ReactNode }) => {
const accessServiceImpl = useMemo(() => new AccessServiceImpl(judoAxiosProvider), []);
const [principal, setPrincipal] = useState<{{ classDataName application.principal 'Stored' }}>({} as unknown as {{ classDataName application.principal 'Stored' }});
const { isAuthenticated } = useAuth();

const fetchData = async () => {
try {
const { data } = await accessServiceImpl.getPrincipal();
setPrincipal({ ...data });
} catch (e) {
console.error(e);
}
};

const getPrincipal = (): Promise<JudoRestResponse<{{ classDataName application.principal 'Stored' }}>> => accessServiceImpl.getPrincipal();

useEffect(() => {
if (isAuthenticated) {
fetchData();
} else {
setPrincipal({} as unknown as {{ classDataName application.principal 'Stored' }});
}
}, [isAuthenticated]);

return (
<PrincipalContext.Provider value={ { principal, setPrincipal, getPrincipal } }>
{children}
</PrincipalContext.Provider>
);
};
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from 'react-oidc-context';
import { type {{ classDataName application.principal 'Stored' }} } from '~/services/data-api/model/{{ classDataName application.principal '' }}';
import { type JudoRestResponse } from '~/services/data-api/rest/requestResponse';
import { AccessServiceImpl } from '~/services/data-axios/AccessServiceImpl';
import { judoAxiosProvider } from '~/services/data-axios/JudoAxiosProvider';
import { type AxiosError } from 'axios';

export interface PrincipalContext {
principal: {{ classDataName application.principal 'Stored' }};
setPrincipal: (principal: {{ classDataName application.principal 'Stored' }}) => void;
getPrincipal: () => Promise<JudoRestResponse<{{ classDataName application.principal 'Stored' }}>>;
errorCode?: string | null;
}

export interface RestErrorData {
code: string;
level: string;
details: Record<string, any>;
}

export const usePrincipal = () => {
const { principal, setPrincipal, getPrincipal } = useContext(PrincipalContext);
const PrincipalContext = createContext<PrincipalContext>({} as unknown as PrincipalContext);

return { principal, setPrincipal, getPrincipal };
export const PrincipalProvider = ({ children }: { children: ReactNode }) => {
const accessServiceImpl = useMemo(() => new AccessServiceImpl(judoAxiosProvider), []);
const [principal, setPrincipal] = useState<{{ classDataName application.principal 'Stored' }}>({} as unknown as ServicesUserTransferStored);
const [errorCode, setErrorCode] = useState<string | null>(null);
const { isAuthenticated } = useAuth();

const fetchData = async () => {
try {
const { data } = await accessServiceImpl.getPrincipal();
setPrincipal({ ...data });
} catch (e: unknown) {
const axiosError = e as AxiosError;
if (axiosError?.response?.status === 403) {
setErrorCode((axiosError?.response?.data as RestErrorData).code);
console.error(e);
} else {
// Other error codes may be handled in the future
console.error(e);
}
}
};

const getPrincipal = (): Promise<JudoRestResponse<{{ classDataName application.principal 'Stored' }}>> => accessServiceImpl.getPrincipal();

useEffect(() => {
if (isAuthenticated) {
fetchData();
} else {
setPrincipal({} as unknown as ServicesUserTransferStored);
}
}, [isAuthenticated]);

return (
<PrincipalContext.Provider value={ { principal, setPrincipal, getPrincipal, errorCode } }>{children}</PrincipalContext.Provider>
);
};

export const usePrincipal = () => {
const { principal, setPrincipal, getPrincipal, errorCode } = useContext(PrincipalContext);

return { principal, setPrincipal, getPrincipal, errorCode };
};
{{ else }}
// nothing to export, application has no Principal
export default {};
Expand Down
23 changes: 20 additions & 3 deletions judo-ui-react/src/main/resources/actor/src/layout/index.tsx.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
{{# if application.authentication }}
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { usePrincipal } from "~/auth";
import { usePrincipal, AuthErrorBox, ACCESS_FILTER_COMPONENT_INTERFACE_KEY, AuthProxyComponent } from "~/auth";
import { OBJECTCLASS } from '@pandino/pandino-api';
{{/ if }}
import { useConfig } from '~/hooks';
import { MenuOrientation, DRAWER_WIDTH } from '~/config';
Expand All @@ -23,7 +26,7 @@ export const Layout = () => {
const downLG = useMediaQuery(theme.breakpoints.down('lg'));
{{# if application.authentication }}
const { t } = useTranslation();
const { principal } = usePrincipal();
const { principal, errorCode } = usePrincipal();
{{/ if }}

const { container, miniDrawer, menuOrientation, onChangeMiniDrawer } = useConfig();
Expand All @@ -32,11 +35,18 @@ export const Layout = () => {

{{# if application.authentication }}
const principalLoaded = () => principal && principal.__signedIdentifier;
const errorCodeAndTitleMapping: Record<string, string> = {
AUTHENTICATED_ENTITY_NOT_FOUND: t('judo.error.security.authenticatedEntityNotFoundTitle', { defaultValue: 'User not found' }),
};
const errorCodeAndMessageMapping: Record<string, string> = {
AUTHENTICATED_ENTITY_NOT_FOUND: t('judo.error.security.authenticatedEntityNotFoundMessage', { defaultValue: 'Processing of your account is still in progress.' }),
};
{{/ if }}

return (
{{# if application.authentication }}
principalLoaded() ? (
<AuthProxyComponent filter={`(${OBJECTCLASS}=${ACCESS_FILTER_COMPONENT_INTERFACE_KEY})`} principal={principal}>
{{/ if }}
<Box sx={ { display: 'flex', width: '100%' } }>
<Header />
Expand All @@ -60,10 +70,17 @@ export const Layout = () => {
</Box>
</Box>
{{# if application.authentication }}
</AuthProxyComponent>
) : (
<Grid container spacing={0} direction="column" alignItems="center" justifyContent="center" sx={ { minHeight: '100vh' } }>
<Grid item xs={3}>
{t('judo.security.loading-principal', { defaultValue: 'Loading principal data...' })}
{errorCode
? (<AuthErrorBox
title={errorCodeAndTitleMapping[errorCode] || t('judo.error.security.unknown', { defaultValue: 'An unknown error occurred!' })}
message={errorCodeAndMessageMapping[errorCode] || t('judo.error.security.unknown.message', { defaultValue: 'Please contact the system administrators.' })}
/>)
: (<span>{t('judo.security.loading-principal', { defaultValue: 'Loading principal data...' })}</span>)
}
</Grid>
</Grid>
)
Expand Down
8 changes: 8 additions & 0 deletions judo-ui-react/src/main/resources/ui-react.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,14 @@ templates:
pathExpression: "'src/auth/Auth.tsx'"
templateName: actor/src/auth/Auth.tsx.hbs

- name: actor/src/auth/AuthErrorBox.tsx
pathExpression: "'src/auth/AuthErrorBox.tsx'"
templateName: actor/src/auth/AuthErrorBox.tsx.hbs

- name: actor/src/auth/AuthProxyComponent.tsx
pathExpression: "'src/auth/AuthProxyComponent.tsx'"
templateName: actor/src/auth/AuthProxyComponent.tsx.hbs

- name: actor/src/auth/axiosInterceptor.ts
pathExpression: "'src/auth/axiosInterceptor.ts'"
templateName: actor/src/auth/axiosInterceptor.ts.hbs
Expand Down

0 comments on commit e2f55aa

Please sign in to comment.