Merge pull request #2247 from automatisch/AUT-1381

feat: introduce inline error messages in SamlConfiguration and RoleMappings forms
This commit is contained in:
Ali BARIN
2025-01-17 12:04:04 +01:00
committed by GitHub
6 changed files with 271 additions and 176 deletions

View File

@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api'; import api from 'helpers/api';
import { enqueueSnackbar } from 'notistack';
export default function useAdminCreateSamlAuthProvider() { export default function useAdminCreateSamlAuthProvider() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -16,20 +15,6 @@ export default function useAdminCreateSamlAuthProvider() {
queryKey: ['admin', 'samlAuthProviders'], queryKey: ['admin', 'samlAuthProviders'],
}); });
}, },
onError: (error) => {
const errors = Object.entries(
error.response.data.errors || [['', 'Failed while saving!']],
);
for (const error of errors) {
enqueueSnackbar(`${error[0] ? error[0] + ': ' : ''} ${error[1]}`, {
variant: 'error',
SnackbarProps: {
'data-test': 'snackbar-create-saml-auth-provider-error',
},
});
}
},
}); });
return query; return query;

View File

@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api'; import api from 'helpers/api';
import { enqueueSnackbar } from 'notistack';
export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) { export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -19,20 +18,6 @@ export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) {
queryKey: ['admin', 'samlAuthProviders'], queryKey: ['admin', 'samlAuthProviders'],
}); });
}, },
onError: (error) => {
const errors = Object.entries(
error.response.data.errors || [['', 'Failed while saving!']],
);
for (const error of errors) {
enqueueSnackbar(`${error[0] ? error[0] + ': ' : ''} ${error[1]}`, {
variant: 'error',
SnackbarProps: {
'data-test': 'snackbar-update-saml-auth-provider-error',
},
});
}
},
}); });
return query; return query;

View File

@@ -4,7 +4,7 @@ import api from 'helpers/api';
export default function useSamlAuthProvider({ samlAuthProviderId } = {}) { export default function useSamlAuthProvider({ samlAuthProviderId } = {}) {
const query = useQuery({ const query = useQuery({
queryKey: ['samlAuthProviders', samlAuthProviderId], queryKey: ['admin', 'samlAuthProviders', samlAuthProviderId],
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const { data } = await api.get( const { data } = await api.get(
`/v1/admin/saml-auth-providers/${samlAuthProviderId}`, `/v1/admin/saml-auth-providers/${samlAuthProviderId}`,

View File

@@ -290,12 +290,13 @@
"authenticationForm.defaultRole": "Default role", "authenticationForm.defaultRole": "Default role",
"authenticationForm.successfullySaved": "The provider has been saved.", "authenticationForm.successfullySaved": "The provider has been saved.",
"authenticationForm.save": "Save", "authenticationForm.save": "Save",
"authenticationForm.mandatoryInput": "{inputName} is required.",
"roleMappingsForm.title": "Role mappings", "roleMappingsForm.title": "Role mappings",
"roleMappingsForm.remoteRoleName": "Remote role name", "roleMappingsForm.remoteRoleName": "Remote role name",
"roleMappingsForm.role": "Role", "roleMappingsForm.role": "Role",
"roleMappingsForm.appendRoleMapping": "Append", "roleMappingsForm.appendRoleMapping": "Append",
"roleMappingsForm.save": "Save", "roleMappingsForm.save": "Save",
"roleMappingsForm.notFound": "No role mappings have found.", "roleMappingsForm.notFound": "No role mappings have been found.",
"roleMappingsForm.successfullySaved": "Role mappings have been saved.", "roleMappingsForm.successfullySaved": "Role mappings have been saved.",
"adminApps.title": "Apps", "adminApps.title": "Apps",
"adminApps.connections": "Connections", "adminApps.connections": "Connections",

View File

@@ -3,8 +3,8 @@ import LoadingButton from '@mui/lab/LoadingButton';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import Alert from '@mui/material/Alert';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup'; import * as yup from 'yup';
@@ -63,11 +63,11 @@ const getValidationSchema = (formatMessage) =>
function RoleMappings({ provider, providerLoading }) { function RoleMappings({ provider, providerLoading }) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const { const {
mutateAsync: updateRoleMappings, mutateAsync: updateRoleMappings,
isPending: isUpdateRoleMappingsPending, isPending: isUpdateRoleMappingsPending,
isSuccess: isUpdateRoleMappingsSuccess,
} = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id); } = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id);
const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } = const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } =
@@ -75,9 +75,12 @@ function RoleMappings({ provider, providerLoading }) {
adminSamlAuthProviderId: provider?.id, adminSamlAuthProviderId: provider?.id,
}); });
const roleMappings = data?.data; const roleMappings = data?.data;
const fieldNames = ['remoteRoleName', 'roleId'];
const [fieldErrors, setFieldErrors] = useState(null);
const handleRoleMappingsUpdate = async (values) => { const handleRoleMappingsUpdate = async (values) => {
try { try {
setFieldErrors(null);
if (provider?.id) { if (provider?.id) {
await updateRoleMappings( await updateRoleMappings(
values.roleMappings.map(({ roleId, remoteRoleName }) => ({ values.roleMappings.map(({ roleId, remoteRoleName }) => ({
@@ -85,29 +88,20 @@ function RoleMappings({ provider, providerLoading }) {
remoteRoleName, remoteRoleName,
})), })),
); );
enqueueSnackbar(formatMessage('roleMappingsForm.successfullySaved'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-update-role-mappings-success',
},
});
} }
} catch (error) { } catch (error) {
const errors = Object.values( const errors = error?.response?.data?.errors;
error.response.data.errors || [['Failed while saving!']], if (errors) {
); Object.entries(errors).forEach(([fieldName, fieldErrors]) => {
if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) {
for (const [error] of errors) { setFieldErrors((prevErrors) => [
enqueueSnackbar(error, { ...(prevErrors || []),
variant: 'error', `${fieldName}: ${fieldErrors.join(', ')}`,
SnackbarProps: { ]);
'data-test': 'snackbar-update-role-mappings-error', }
},
}); });
} }
throw errors || error;
throw new Error('Failed while saving!');
} }
}; };
@@ -118,6 +112,25 @@ function RoleMappings({ provider, providerLoading }) {
[roleMappings], [roleMappings],
); );
const renderErrors = (errors) => {
const generalError = errors?.root?.general?.message;
if (fieldErrors) {
return fieldErrors.map((error, index) => (
<Alert key={index} data-test="error-alert" severity="error">
{error}
</Alert>
));
}
if (generalError) {
return (
<Alert data-test="error-alert" severity="error">
{generalError}
</Alert>
);
}
};
if ( if (
providerLoading || providerLoading ||
!provider?.id || !provider?.id ||
@@ -140,27 +153,35 @@ function RoleMappings({ provider, providerLoading }) {
reValidateMode="onChange" reValidateMode="onChange"
noValidate noValidate
automaticValidation={false} automaticValidation={false}
> render={({ formState: { errors, isDirty } }) => (
<Stack direction="column" spacing={2}> <Stack direction="column" spacing={2}>
<RoleMappingsFieldArray /> <RoleMappingsFieldArray />
{renderErrors(errors)}
{isUpdateRoleMappingsSuccess && !isDirty && (
<Alert data-test="success-alert" severity="success">
{formatMessage('roleMappingsForm.successfullySaved')}
</Alert>
)}
<LoadingButton <LoadingButton
type="submit" type="submit"
variant="contained" variant="contained"
color="primary" color="primary"
sx={{ boxShadow: 2 }} sx={{ boxShadow: 2 }}
loading={isUpdateRoleMappingsPending} loading={isUpdateRoleMappingsPending}
disabled={!isDirty}
> >
{formatMessage('roleMappingsForm.save')} {formatMessage('roleMappingsForm.save')}
</LoadingButton> </LoadingButton>
</Stack> </Stack>
</Form> )}
/>
</> </>
); );
} }
RoleMappings.propTypes = { RoleMappings.propTypes = {
provider: PropTypes.shape({ provider: PropTypes.shape({
id: PropTypes.oneOf([PropTypes.number, PropTypes.string]).isRequired, id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
}), }),
providerLoading: PropTypes.bool, providerLoading: PropTypes.bool,
}; };

View File

@@ -2,9 +2,11 @@ import PropTypes from 'prop-types';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import MuiTextField from '@mui/material/TextField'; import MuiTextField from '@mui/material/TextField';
import Alert from '@mui/material/Alert';
import * as React from 'react'; import * as React from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import ControlledAutocomplete from 'components/ControlledAutocomplete'; import ControlledAutocomplete from 'components/ControlledAutocomplete';
import Form from 'components/Form'; import Form from 'components/Form';
import Switch from 'components/Switch'; import Switch from 'components/Switch';
@@ -28,29 +30,94 @@ const defaultValues = {
defaultRoleId: '', defaultRoleId: '',
}; };
const getValidationSchema = (formatMessage) => {
const getMandatoryFieldMessage = (fieldTranslationId) =>
formatMessage('authenticationForm.mandatoryInput', {
inputName: formatMessage(fieldTranslationId),
});
return yup.object().shape({
active: yup.boolean(),
name: yup
.string()
.trim()
.required(getMandatoryFieldMessage('authenticationForm.name')),
certificate: yup
.string()
.trim()
.required(getMandatoryFieldMessage('authenticationForm.certificate')),
signatureAlgorithm: yup
.string()
.trim()
.required(
getMandatoryFieldMessage('authenticationForm.signatureAlgorithm'),
),
issuer: yup
.string()
.trim()
.required(getMandatoryFieldMessage('authenticationForm.issuer')),
entryPoint: yup
.string()
.trim()
.required(getMandatoryFieldMessage('authenticationForm.entryPoint')),
firstnameAttributeName: yup
.string()
.trim()
.required(
getMandatoryFieldMessage('authenticationForm.firstnameAttributeName'),
),
surnameAttributeName: yup
.string()
.trim()
.required(
getMandatoryFieldMessage('authenticationForm.surnameAttributeName'),
),
emailAttributeName: yup
.string()
.trim()
.required(
getMandatoryFieldMessage('authenticationForm.emailAttributeName'),
),
roleAttributeName: yup
.string()
.trim()
.required(
getMandatoryFieldMessage('authenticationForm.roleAttributeName'),
),
defaultRoleId: yup
.string()
.trim()
.required(getMandatoryFieldMessage('authenticationForm.defaultRole')),
});
};
function generateRoleOptions(roles) { function generateRoleOptions(roles) {
return roles?.map(({ name: label, id: value }) => ({ label, value })); return roles?.map(({ name: label, id: value }) => ({ label, value }));
} }
function SamlConfiguration({ provider, providerLoading }) { function SamlConfiguration({ provider, providerLoading }) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { data, loading: isRolesLoading } = useRoles(); const { data, isLoading: isRolesLoading } = useRoles();
const roles = data?.data; const roles = data?.data;
const enqueueSnackbar = useEnqueueSnackbar();
const { const {
mutateAsync: createSamlAuthProvider, mutateAsync: createSamlAuthProvider,
isPending: isCreateSamlAuthProviderPending, isPending: isCreateSamlAuthProviderPending,
isSuccess: isCreateSamlAuthProviderSuccess,
} = useAdminCreateSamlAuthProvider(); } = useAdminCreateSamlAuthProvider();
const { const {
mutateAsync: updateSamlAuthProvider, mutateAsync: updateSamlAuthProvider,
isPending: isUpdateSamlAuthProviderPending, isPending: isUpdateSamlAuthProviderPending,
isSuccess: isUpdateSamlAuthProviderSuccess,
} = useAdminUpdateSamlAuthProvider(provider?.id); } = useAdminUpdateSamlAuthProvider(provider?.id);
const isPending = const isPending =
isCreateSamlAuthProviderPending || isUpdateSamlAuthProviderPending; isCreateSamlAuthProviderPending || isUpdateSamlAuthProviderPending;
const isSuccess =
isCreateSamlAuthProviderSuccess || isUpdateSamlAuthProviderSuccess;
const handleSubmit = async (providerData) => { const handleSubmit = async (providerData) => {
try { try {
if (provider?.id) { if (provider?.id) {
@@ -58,15 +125,9 @@ function SamlConfiguration({ provider, providerLoading }) {
} else { } else {
await createSamlAuthProvider(providerData); await createSamlAuthProvider(providerData);
} }
} catch (error) {
enqueueSnackbar(formatMessage('authenticationForm.successfullySaved'), { const errors = error?.response?.data?.errors;
variant: 'success', throw errors || error;
SnackbarProps: {
'data-test': 'snackbar-save-saml-provider-success',
},
});
} catch {
throw new Error('Failed while saving!');
} }
}; };
@@ -75,7 +136,13 @@ function SamlConfiguration({ provider, providerLoading }) {
} }
return ( return (
<Form defaultValues={provider || defaultValues} onSubmit={handleSubmit}> <Form
defaultValues={provider || defaultValues}
onSubmit={handleSubmit}
noValidate
resolver={yupResolver(getValidationSchema(formatMessage))}
automaticValidation={false}
render={({ formState: { errors, isDirty } }) => (
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
<Switch <Switch
name="active" name="active"
@@ -86,6 +153,8 @@ function SamlConfiguration({ provider, providerLoading }) {
name="name" name="name"
label={formatMessage('authenticationForm.name')} label={formatMessage('authenticationForm.name')}
fullWidth fullWidth
error={!!errors?.name}
helperText={errors?.name?.message}
/> />
<TextField <TextField
required={true} required={true}
@@ -93,6 +162,8 @@ function SamlConfiguration({ provider, providerLoading }) {
label={formatMessage('authenticationForm.certificate')} label={formatMessage('authenticationForm.certificate')}
fullWidth fullWidth
multiline multiline
error={!!errors?.certificate}
helperText={errors?.certificate?.message}
/> />
<ControlledAutocomplete <ControlledAutocomplete
name="signatureAlgorithm" name="signatureAlgorithm"
@@ -104,10 +175,14 @@ function SamlConfiguration({ provider, providerLoading }) {
{ label: 'SHA256', value: 'sha256' }, { label: 'SHA256', value: 'sha256' },
{ label: 'SHA512', value: 'sha512' }, { label: 'SHA512', value: 'sha512' },
]} ]}
showHelperText={false}
renderInput={(params) => ( renderInput={(params) => (
<MuiTextField <MuiTextField
{...params} {...params}
label={formatMessage('authenticationForm.signatureAlgorithm')} label={formatMessage('authenticationForm.signatureAlgorithm')}
required
error={!!errors?.signatureAlgorithm}
helperText={errors?.signatureAlgorithm?.message}
/> />
)} )}
/> />
@@ -116,36 +191,48 @@ function SamlConfiguration({ provider, providerLoading }) {
name="issuer" name="issuer"
label={formatMessage('authenticationForm.issuer')} label={formatMessage('authenticationForm.issuer')}
fullWidth fullWidth
error={!!errors?.issuer}
helperText={errors?.issuer?.message}
/> />
<TextField <TextField
required={true} required={true}
name="entryPoint" name="entryPoint"
label={formatMessage('authenticationForm.entryPoint')} label={formatMessage('authenticationForm.entryPoint')}
fullWidth fullWidth
error={!!errors?.entryPoint}
helperText={errors?.entryPoint?.message}
/> />
<TextField <TextField
required={true} required={true}
name="firstnameAttributeName" name="firstnameAttributeName"
label={formatMessage('authenticationForm.firstnameAttributeName')} label={formatMessage('authenticationForm.firstnameAttributeName')}
fullWidth fullWidth
error={!!errors?.firstnameAttributeName}
helperText={errors?.firstnameAttributeName?.message}
/> />
<TextField <TextField
required={true} required={true}
name="surnameAttributeName" name="surnameAttributeName"
label={formatMessage('authenticationForm.surnameAttributeName')} label={formatMessage('authenticationForm.surnameAttributeName')}
fullWidth fullWidth
error={!!errors?.surnameAttributeName}
helperText={errors?.surnameAttributeName?.message}
/> />
<TextField <TextField
required={true} required={true}
name="emailAttributeName" name="emailAttributeName"
label={formatMessage('authenticationForm.emailAttributeName')} label={formatMessage('authenticationForm.emailAttributeName')}
fullWidth fullWidth
error={!!errors?.emailAttributeName}
helperText={errors?.emailAttributeName?.message}
/> />
<TextField <TextField
required={true} required={true}
name="roleAttributeName" name="roleAttributeName"
label={formatMessage('authenticationForm.roleAttributeName')} label={formatMessage('authenticationForm.roleAttributeName')}
fullWidth fullWidth
error={!!errors?.roleAttributeName}
helperText={errors?.roleAttributeName?.message}
/> />
<ControlledAutocomplete <ControlledAutocomplete
name="defaultRoleId" name="defaultRoleId"
@@ -153,25 +240,41 @@ function SamlConfiguration({ provider, providerLoading }) {
disablePortal disablePortal
disableClearable={true} disableClearable={true}
options={generateRoleOptions(roles)} options={generateRoleOptions(roles)}
showHelperText={false}
renderInput={(params) => ( renderInput={(params) => (
<MuiTextField <MuiTextField
{...params} {...params}
label={formatMessage('authenticationForm.defaultRole')} label={formatMessage('authenticationForm.defaultRole')}
required
error={!!errors?.defaultRoleId}
helperText={errors?.defaultRoleId?.message}
/> />
)} )}
loading={isRolesLoading} loading={isRolesLoading}
/> />
{errors?.root?.general && (
<Alert data-test="error-alert" severity="error">
{errors.root.general.message}
</Alert>
)}
{isSuccess && !isDirty && (
<Alert data-test="success-alert" severity="success">
{formatMessage('authenticationForm.successfullySaved')}
</Alert>
)}
<LoadingButton <LoadingButton
type="submit" type="submit"
variant="contained" variant="contained"
color="primary" color="primary"
sx={{ boxShadow: 2 }} sx={{ boxShadow: 2 }}
loading={isPending} loading={isPending}
disabled={!isDirty}
> >
{formatMessage('authenticationForm.save')} {formatMessage('authenticationForm.save')}
</LoadingButton> </LoadingButton>
</Stack> </Stack>
</Form> )}
/>
); );
} }