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 api from 'helpers/api';
import { enqueueSnackbar } from 'notistack';
export default function useAdminCreateSamlAuthProvider() {
const queryClient = useQueryClient();
@@ -16,20 +15,6 @@ export default function useAdminCreateSamlAuthProvider() {
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;

View File

@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
import { enqueueSnackbar } from 'notistack';
export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) {
const queryClient = useQueryClient();
@@ -19,20 +18,6 @@ export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) {
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;

View File

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

View File

@@ -290,12 +290,13 @@
"authenticationForm.defaultRole": "Default role",
"authenticationForm.successfullySaved": "The provider has been saved.",
"authenticationForm.save": "Save",
"authenticationForm.mandatoryInput": "{inputName} is required.",
"roleMappingsForm.title": "Role mappings",
"roleMappingsForm.remoteRoleName": "Remote role name",
"roleMappingsForm.role": "Role",
"roleMappingsForm.appendRoleMapping": "Append",
"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.",
"adminApps.title": "Apps",
"adminApps.connections": "Connections",

View File

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

View File

@@ -2,9 +2,11 @@ import PropTypes from 'prop-types';
import LoadingButton from '@mui/lab/LoadingButton';
import Stack from '@mui/material/Stack';
import MuiTextField from '@mui/material/TextField';
import Alert from '@mui/material/Alert';
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 Form from 'components/Form';
import Switch from 'components/Switch';
@@ -28,29 +30,94 @@ const defaultValues = {
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) {
return roles?.map(({ name: label, id: value }) => ({ label, value }));
}
function SamlConfiguration({ provider, providerLoading }) {
const formatMessage = useFormatMessage();
const { data, loading: isRolesLoading } = useRoles();
const { data, isLoading: isRolesLoading } = useRoles();
const roles = data?.data;
const enqueueSnackbar = useEnqueueSnackbar();
const {
mutateAsync: createSamlAuthProvider,
isPending: isCreateSamlAuthProviderPending,
isSuccess: isCreateSamlAuthProviderSuccess,
} = useAdminCreateSamlAuthProvider();
const {
mutateAsync: updateSamlAuthProvider,
isPending: isUpdateSamlAuthProviderPending,
isSuccess: isUpdateSamlAuthProviderSuccess,
} = useAdminUpdateSamlAuthProvider(provider?.id);
const isPending =
isCreateSamlAuthProviderPending || isUpdateSamlAuthProviderPending;
const isSuccess =
isCreateSamlAuthProviderSuccess || isUpdateSamlAuthProviderSuccess;
const handleSubmit = async (providerData) => {
try {
if (provider?.id) {
@@ -58,15 +125,9 @@ function SamlConfiguration({ provider, providerLoading }) {
} else {
await createSamlAuthProvider(providerData);
}
enqueueSnackbar(formatMessage('authenticationForm.successfullySaved'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-save-saml-provider-success',
},
});
} catch {
throw new Error('Failed while saving!');
} catch (error) {
const errors = error?.response?.data?.errors;
throw errors || error;
}
};
@@ -75,103 +136,145 @@ function SamlConfiguration({ provider, providerLoading }) {
}
return (
<Form defaultValues={provider || defaultValues} onSubmit={handleSubmit}>
<Stack direction="column" gap={2}>
<Switch
name="active"
label={formatMessage('authenticationForm.active')}
/>
<TextField
required={true}
name="name"
label={formatMessage('authenticationForm.name')}
fullWidth
/>
<TextField
required={true}
name="certificate"
label={formatMessage('authenticationForm.certificate')}
fullWidth
multiline
/>
<ControlledAutocomplete
name="signatureAlgorithm"
fullWidth
disablePortal
disableClearable={true}
options={[
{ label: 'SHA1', value: 'sha1' },
{ label: 'SHA256', value: 'sha256' },
{ label: 'SHA512', value: 'sha512' },
]}
renderInput={(params) => (
<MuiTextField
{...params}
label={formatMessage('authenticationForm.signatureAlgorithm')}
/>
<Form
defaultValues={provider || defaultValues}
onSubmit={handleSubmit}
noValidate
resolver={yupResolver(getValidationSchema(formatMessage))}
automaticValidation={false}
render={({ formState: { errors, isDirty } }) => (
<Stack direction="column" gap={2}>
<Switch
name="active"
label={formatMessage('authenticationForm.active')}
/>
<TextField
required={true}
name="name"
label={formatMessage('authenticationForm.name')}
fullWidth
error={!!errors?.name}
helperText={errors?.name?.message}
/>
<TextField
required={true}
name="certificate"
label={formatMessage('authenticationForm.certificate')}
fullWidth
multiline
error={!!errors?.certificate}
helperText={errors?.certificate?.message}
/>
<ControlledAutocomplete
name="signatureAlgorithm"
fullWidth
disablePortal
disableClearable={true}
options={[
{ label: 'SHA1', value: 'sha1' },
{ label: 'SHA256', value: 'sha256' },
{ label: 'SHA512', value: 'sha512' },
]}
showHelperText={false}
renderInput={(params) => (
<MuiTextField
{...params}
label={formatMessage('authenticationForm.signatureAlgorithm')}
required
error={!!errors?.signatureAlgorithm}
helperText={errors?.signatureAlgorithm?.message}
/>
)}
/>
<TextField
required={true}
name="issuer"
label={formatMessage('authenticationForm.issuer')}
fullWidth
error={!!errors?.issuer}
helperText={errors?.issuer?.message}
/>
<TextField
required={true}
name="entryPoint"
label={formatMessage('authenticationForm.entryPoint')}
fullWidth
error={!!errors?.entryPoint}
helperText={errors?.entryPoint?.message}
/>
<TextField
required={true}
name="firstnameAttributeName"
label={formatMessage('authenticationForm.firstnameAttributeName')}
fullWidth
error={!!errors?.firstnameAttributeName}
helperText={errors?.firstnameAttributeName?.message}
/>
<TextField
required={true}
name="surnameAttributeName"
label={formatMessage('authenticationForm.surnameAttributeName')}
fullWidth
error={!!errors?.surnameAttributeName}
helperText={errors?.surnameAttributeName?.message}
/>
<TextField
required={true}
name="emailAttributeName"
label={formatMessage('authenticationForm.emailAttributeName')}
fullWidth
error={!!errors?.emailAttributeName}
helperText={errors?.emailAttributeName?.message}
/>
<TextField
required={true}
name="roleAttributeName"
label={formatMessage('authenticationForm.roleAttributeName')}
fullWidth
error={!!errors?.roleAttributeName}
helperText={errors?.roleAttributeName?.message}
/>
<ControlledAutocomplete
name="defaultRoleId"
fullWidth
disablePortal
disableClearable={true}
options={generateRoleOptions(roles)}
showHelperText={false}
renderInput={(params) => (
<MuiTextField
{...params}
label={formatMessage('authenticationForm.defaultRole')}
required
error={!!errors?.defaultRoleId}
helperText={errors?.defaultRoleId?.message}
/>
)}
loading={isRolesLoading}
/>
{errors?.root?.general && (
<Alert data-test="error-alert" severity="error">
{errors.root.general.message}
</Alert>
)}
/>
<TextField
required={true}
name="issuer"
label={formatMessage('authenticationForm.issuer')}
fullWidth
/>
<TextField
required={true}
name="entryPoint"
label={formatMessage('authenticationForm.entryPoint')}
fullWidth
/>
<TextField
required={true}
name="firstnameAttributeName"
label={formatMessage('authenticationForm.firstnameAttributeName')}
fullWidth
/>
<TextField
required={true}
name="surnameAttributeName"
label={formatMessage('authenticationForm.surnameAttributeName')}
fullWidth
/>
<TextField
required={true}
name="emailAttributeName"
label={formatMessage('authenticationForm.emailAttributeName')}
fullWidth
/>
<TextField
required={true}
name="roleAttributeName"
label={formatMessage('authenticationForm.roleAttributeName')}
fullWidth
/>
<ControlledAutocomplete
name="defaultRoleId"
fullWidth
disablePortal
disableClearable={true}
options={generateRoleOptions(roles)}
renderInput={(params) => (
<MuiTextField
{...params}
label={formatMessage('authenticationForm.defaultRole')}
/>
{isSuccess && !isDirty && (
<Alert data-test="success-alert" severity="success">
{formatMessage('authenticationForm.successfullySaved')}
</Alert>
)}
loading={isRolesLoading}
/>
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={isPending}
>
{formatMessage('authenticationForm.save')}
</LoadingButton>
</Stack>
</Form>
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={isPending}
disabled={!isDirty}
>
{formatMessage('authenticationForm.save')}
</LoadingButton>
</Stack>
)}
/>
);
}