feat: introduce application auth clients tab in the admin panel (#1423)

* feat: introduce application auth clients tab in the admin panel

* feat: introduce improvements

* feat: use loading state returned from useMutation

* feat: use error returned by useMutation hook
This commit is contained in:
kattoczko
2023-11-10 13:09:23 +00:00
committed by GitHub
parent 878fab347a
commit c461cc4878
14 changed files with 580 additions and 97 deletions

View File

@@ -462,6 +462,7 @@ type AppAuthClient = {
appConfigId: string;
authDefaults: string;
formattedAuthDefaults: IJSONObject;
active: boolean;
};
type Notification = {

View File

@@ -0,0 +1,104 @@
import React from 'react';
import type { IField } from '@automatisch/types';
import LoadingButton from '@mui/lab/LoadingButton';
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import CircularProgress from '@mui/material/CircularProgress';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import type { UseFormProps } from 'react-hook-form';
import type { ApolloError } from '@apollo/client';
import useFormatMessage from 'hooks/useFormatMessage';
import InputCreator from 'components/InputCreator';
import Switch from 'components/Switch';
import TextField from 'components/TextField';
import { Form } from './style';
type AdminApplicationAuthClientDialogProps = {
title: string;
authFields?: IField[];
defaultValues: UseFormProps['defaultValues'];
loading: boolean;
submitting: boolean;
disabled?: boolean;
error?: ApolloError;
submitHandler: SubmitHandler<FieldValues>;
onClose: () => void;
};
export default function AdminApplicationAuthClientDialog(
props: AdminApplicationAuthClientDialogProps
): React.ReactElement {
const {
error,
onClose,
title,
loading,
submitHandler,
authFields,
submitting,
defaultValues,
disabled = false,
} = props;
const formatMessage = useFormatMessage();
return (
<Dialog open={true} onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
{error && (
<Alert
severity="error"
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
>
{error.message}
</Alert>
)}
<DialogContent>
{loading ? (
<CircularProgress
data-test="search-for-app-loader"
sx={{ display: 'block', margin: '20px auto' }}
/>
) : (
<DialogContentText tabIndex={-1} component="div">
<Form
onSubmit={submitHandler}
defaultValues={defaultValues}
render={({ formState: { isDirty } }) => (
<>
<Switch
name="active"
label={formatMessage('authClient.inputActive')}
/>
<TextField
required={true}
name="name"
label={formatMessage('authClient.inputName')}
fullWidth
/>
{authFields?.map((field: IField) => (
<InputCreator key={field.key} schema={field} />
))}
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={submitting}
disabled={disabled || !isDirty}
>
{formatMessage('authClient.buttonSubmit')}
</LoadingButton>
</>
)}
></Form>
</DialogContentText>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,9 @@
import { styled } from '@mui/material/styles';
import BaseForm from 'components/Form';
export const Form = styled(BaseForm)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
paddingTop: theme.spacing(1),
}));

View File

@@ -0,0 +1,89 @@
import { Link } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Button from '@mui/material/Button';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import NoResultFound from 'components/NoResultFound';
type AdminApplicationAuthClientsProps = {
appKey: string;
};
function AdminApplicationAuthClients(
props: AdminApplicationAuthClientsProps
): React.ReactElement {
const { appKey } = props;
const formatMessage = useFormatMessage();
const { appAuthClients, loading } = useAppAuthClients({ appKey });
if (loading)
return <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />;
if (!appAuthClients?.length) {
return (
<NoResultFound
to={URLS.ADMIN_APP_AUTH_CLIENTS_CREATE(appKey)}
text={formatMessage('adminAppsAuthClients.noAuthClients')}
/>
);
}
const sortedAuthClients = appAuthClients.slice().sort((a, b) => {
if (a.id < b.id) {
return -1;
}
if (a.id > b.id) {
return 1;
}
return 0;
});
return (
<div>
{sortedAuthClients.map((client) => (
<Card sx={{ mb: 1 }} key={client.id}>
<CardActionArea
component={Link}
to={URLS.ADMIN_APP_AUTH_CLIENT(appKey, client.id)}
>
<CardContent>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h6" noWrap>
{client.name}
</Typography>
<Chip
size="small"
color={client?.active ? 'success' : 'info'}
variant={client?.active ? 'filled' : 'outlined'}
label={formatMessage(
client?.active
? 'adminAppsAuthClients.statusActive'
: 'adminAppsAuthClients.statusInactive'
)}
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
))}
<Stack justifyContent="flex-end" direction="row">
<Link to={URLS.ADMIN_APP_AUTH_CLIENTS_CREATE(appKey)}>
<Button variant="contained" sx={{ mt: 2 }} component="div">
{formatMessage('createAuthClient.button')}
</Button>
</Link>
</Stack>
</div>
);
}
export default AdminApplicationAuthClients;

View File

@@ -0,0 +1,112 @@
import React, { useCallback, useMemo } from 'react';
import type { IApp } from '@automatisch/types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { CREATE_APP_AUTH_CLIENT } from 'graphql/mutations/create-app-auth-client';
import useAppConfig from 'hooks/useAppConfig.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
type AdminApplicationCreateAuthClientProps = {
appKey: string;
application: IApp;
onClose: () => void;
};
export default function AdminApplicationCreateAuthClient(
props: AdminApplicationCreateAuthClientProps
): React.ReactElement {
const { appKey, application, onClose } = props;
const { auth } = application;
const formatMessage = useFormatMessage();
const { appConfig, loading: loadingAppConfig } = useAppConfig(appKey);
const [
createAppConfig,
{ loading: loadingCreateAppConfig, error: createAppConfigError },
] = useMutation(CREATE_APP_CONFIG, {
refetchQueries: ['GetAppConfig'],
context: { autoSnackbar: false },
});
const [
createAppAuthClient,
{ loading: loadingCreateAppAuthClient, error: createAppAuthClientError },
] = useMutation(CREATE_APP_AUTH_CLIENT, {
refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false },
});
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
let appConfigId = appConfig?.id;
if (!appConfigId) {
const { data: appConfigData } = await createAppConfig({
variables: {
input: {
key: appKey,
allowCustomConnection: false,
shared: false,
disabled: false,
},
},
});
appConfigId = appConfigData.createAppConfig.id;
}
const { name, active, ...formattedAuthDefaults } = values;
await createAppAuthClient({
variables: {
input: {
appConfigId,
name,
active,
formattedAuthDefaults,
},
},
});
onClose();
};
const getAuthFieldsDefaultValues = useCallback(() => {
if (!auth?.fields) {
return {};
}
const defaultValues: {
[key: string]: any;
} = {};
auth.fields.forEach((field) => {
if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value;
} else if (field.type === 'string') {
defaultValues[field.key] = '';
}
});
return defaultValues;
}, [auth?.fields]);
const defaultValues = useMemo(
() => ({
name: '',
active: false,
...getAuthFieldsDefaultValues(),
}),
[getAuthFieldsDefaultValues]
);
return (
<AdminApplicationAuthClientDialog
onClose={onClose}
error={createAppConfigError || createAppAuthClientError}
title={formatMessage('createAuthClient.title')}
loading={loadingAppConfig}
submitHandler={submitHandler}
authFields={auth?.fields}
submitting={loadingCreateAppConfig || loadingCreateAppAuthClient}
defaultValues={defaultValues}
/>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import type { IApp } from '@automatisch/types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client';
import { UPDATE_APP_AUTH_CLIENT } from 'graphql/mutations/update-app-auth-client';
import useAppAuthClient from 'hooks/useAppAuthClient.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
type AdminApplicationUpdateAuthClientProps = {
application: IApp;
onClose: () => void;
};
export default function AdminApplicationUpdateAuthClient(
props: AdminApplicationUpdateAuthClientProps
): React.ReactElement {
const { application, onClose } = props;
const { auth } = application;
const authFields = auth?.fields?.map((field) => ({
...field,
required: false,
}));
const formatMessage = useFormatMessage();
const { clientId } = useParams();
const { appAuthClient, loading: loadingAuthClient } =
useAppAuthClient(clientId);
const [updateAppAuthClient, { loading: loadingUpdateAppAuthClient, error }] =
useMutation(UPDATE_APP_AUTH_CLIENT, {
refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false },
});
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
if (!appAuthClient) {
return;
}
const { name, active, ...formattedAuthDefaults } = values;
await updateAppAuthClient({
variables: {
input: {
id: appAuthClient.id,
name,
active,
formattedAuthDefaults,
},
},
});
onClose();
};
const getAuthFieldsDefaultValues = useCallback(() => {
if (!authFields) {
return {};
}
const defaultValues: {
[key: string]: any;
} = {};
authFields.forEach((field) => {
if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value;
} else if (field.type === 'string') {
defaultValues[field.key] = '';
}
});
return defaultValues;
}, [auth?.fields]);
const defaultValues = useMemo(
() => ({
name: appAuthClient?.name || '',
active: appAuthClient?.active || false,
...getAuthFieldsDefaultValues(),
}),
[appAuthClient, getAuthFieldsDefaultValues]
);
return (
<AdminApplicationAuthClientDialog
onClose={onClose}
error={error}
title={formatMessage('updateAuthClient.title')}
loading={loadingAuthClient}
submitHandler={submitHandler}
authFields={authFields}
submitting={loadingUpdateAppAuthClient}
defaultValues={defaultValues}
disabled={!appAuthClient}
/>
);
}

View File

@@ -17,7 +17,7 @@ type AppAuthClientsDialogProps = {
export default function AppAuthClientsDialog(props: AppAuthClientsDialogProps) {
const { appKey, onClientClick, onClose } = props;
const { appAuthClients } = useAppAuthClients(appKey);
const { appAuthClients } = useAppAuthClients({ appKey, active: true });
const formatMessage = useFormatMessage();
React.useEffect(

View File

@@ -107,6 +107,10 @@ export const ADMIN_APP_SETTINGS = (appKey: string) =>
`${ADMIN_SETTINGS}/apps/${appKey}/settings`;
export const ADMIN_APP_AUTH_CLIENTS = (appKey: string) =>
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients`;
export const ADMIN_APP_AUTH_CLIENT = (appKey: string, id: string) =>
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients/${id}`;
export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey: string) =>
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients/create`;
export const DASHBOARD = FLOWS;

View File

@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const CREATE_APP_AUTH_CLIENT = gql`
mutation CreateAppAuthClient($input: CreateAppAuthClientInput) {
createAppAuthClient(input: $input) {
id
appConfigId
name
active
}
}
`;

View File

@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_APP_AUTH_CLIENT = gql`
mutation UpdateAppAuthClient($input: UpdateAppAuthClientInput) {
updateAppAuthClient(input: $input) {
id
appConfigId
name
active
}
}
`;

View File

@@ -1,28 +1,26 @@
import { useLazyQuery } from '@apollo/client';
import { AppConfig } from '@automatisch/types';
import { AppAuthClient } from '@automatisch/types';
import * as React from 'react';
import { GET_APP_AUTH_CLIENT } from 'graphql/queries/get-app-auth-client.ee';
type QueryResponse = {
getAppAuthClient: AppConfig;
}
getAppAuthClient: AppAuthClient;
};
export default function useAppAuthClient(id: string) {
const [
getAppAuthClient,
{
data,
loading
}
] = useLazyQuery<QueryResponse>(GET_APP_AUTH_CLIENT);
export default function useAppAuthClient(id?: string) {
const [getAppAuthClient, { data, loading }] =
useLazyQuery<QueryResponse>(GET_APP_AUTH_CLIENT);
const appAuthClient = data?.getAppAuthClient;
React.useEffect(function fetchUponId() {
if (!id) return;
React.useEffect(
function fetchUponId() {
if (!id) return;
getAppAuthClient({ variables: { id } });
}, [id]);
getAppAuthClient({ variables: { id } });
},
[id]
);
return {
appAuthClient,

View File

@@ -6,25 +6,33 @@ import { GET_APP_AUTH_CLIENTS } from 'graphql/queries/get-app-auth-clients.ee';
type QueryResponse = {
getAppAuthClients: AppAuthClient[];
}
};
export default function useAppAuthClient(appKey: string) {
const [
getAppAuthClients,
export default function useAppAuthClient({
appKey,
active,
}: {
appKey: string;
active?: boolean;
}) {
const [getAppAuthClients, { data, loading }] = useLazyQuery<QueryResponse>(
GET_APP_AUTH_CLIENTS,
{
data,
loading
context: { autoSnackbar: false },
}
] = useLazyQuery<QueryResponse>(GET_APP_AUTH_CLIENTS, {
context: { autoSnackbar: false },
});
);
const appAuthClients = data?.getAppAuthClients;
React.useEffect(function fetchUponAppKey() {
if (!appKey) return;
React.useEffect(
function fetchUponAppKey() {
if (!appKey) return;
getAppAuthClients({ variables: { appKey, active: true } });
}, [appKey]);
getAppAuthClients({
variables: { appKey, ...(typeof active === 'boolean' && { active }) },
});
},
[appKey]
);
return {
appAuthClients,

View File

@@ -256,5 +256,14 @@
"adminAppsSettings.shared": "Shared",
"adminAppsSettings.disabled": "Disabled",
"adminAppsSettings.save": "Save",
"adminAppsSettings.successfullySaved": "Settings have been saved."
"adminAppsSettings.successfullySaved": "Settings have been saved.",
"adminAppsAuthClients.noAuthClients": "You don't have any auth clients yet.",
"adminAppsAuthClients.statusActive": "Active",
"adminAppsAuthClients.statusInactive": "Inactive",
"createAuthClient.button": "Create auth client",
"createAuthClient.title": "Create auth client",
"authClient.buttonSubmit": "Submit",
"authClient.inputName": "Name",
"authClient.inputActive": "Active",
"updateAuthClient.title": "Update auth client"
}

View File

@@ -7,6 +7,7 @@ import {
Routes,
useParams,
useMatch,
useNavigate,
} from 'react-router-dom';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
@@ -23,6 +24,9 @@ import AppIcon from 'components/AppIcon';
import Container from 'components/Container';
import PageTitle from 'components/PageTitle';
import AdminApplicationSettings from 'components/AdminApplicationSettings';
import AdminApplicationAuthClients from 'components/AdminApplicationAuthClients';
import AdminApplicationCreateAuthClient from 'components/AdminApplicationCreateAuthClient';
import AdminApplicationUpdateAuthClient from 'components/AdminApplicationUpdateAuthClient';
type AdminApplicationParams = {
appKey: string;
@@ -32,6 +36,7 @@ export default function AdminApplication(): React.ReactElement | null {
const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
const formatMessage = useFormatMessage();
const navigate = useNavigate();
const connectionsPathMatch = useMatch({
path: URLS.ADMIN_APP_CONNECTIONS_PATTERN,
@@ -51,79 +56,104 @@ export default function AdminApplication(): React.ReactElement | null {
const app = data?.getApp || {};
const goToAuthClientsPage = () => navigate('auth-clients');
if (loading) return null;
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
<Grid container sx={{ mb: 3 }} alignItems="center">
<Grid item xs="auto" sx={{ mr: 3 }}>
<AppIcon
url={app.iconUrl}
color={app.primaryColor}
name={app.name}
/>
<>
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
<Grid container sx={{ mb: 3 }} alignItems="center">
<Grid item xs="auto" sx={{ mr: 3 }}>
<AppIcon
url={app.iconUrl}
color={app.primaryColor}
name={app.name}
/>
</Grid>
<Grid item xs>
<PageTitle>{app.name}</PageTitle>
</Grid>
</Grid>
<Grid item xs>
<PageTitle>{app.name}</PageTitle>
</Grid>
</Grid>
<Grid container>
<Grid item xs>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs
variant={matchSmallScreens ? 'fullWidth' : undefined}
value={
settingsPathMatch?.pattern?.path ||
connectionsPathMatch?.pattern?.path ||
authClientsPathMatch?.pattern?.path
}
>
<Tab
label={formatMessage('adminApps.settings')}
to={URLS.ADMIN_APP_SETTINGS(appKey)}
value={URLS.ADMIN_APP_SETTINGS_PATTERN}
component={Link}
/>
<Tab
label={formatMessage('adminApps.authClients')}
to={URLS.ADMIN_APP_AUTH_CLIENTS(appKey)}
value={URLS.ADMIN_APP_AUTH_CLIENTS_PATTERN}
component={Link}
/>
<Tab
label={formatMessage('adminApps.connections')}
to={URLS.ADMIN_APP_CONNECTIONS(appKey)}
value={URLS.ADMIN_APP_CONNECTIONS_PATTERN}
disabled={!app.supportsConnections}
component={Link}
/>
</Tabs>
</Box>
<Grid container>
<Grid item xs>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs
variant={matchSmallScreens ? 'fullWidth' : undefined}
value={
settingsPathMatch?.pattern?.path ||
connectionsPathMatch?.pattern?.path ||
authClientsPathMatch?.pattern?.path
}
>
<Tab
label={formatMessage('adminApps.settings')}
to={URLS.ADMIN_APP_SETTINGS(appKey)}
value={URLS.ADMIN_APP_SETTINGS_PATTERN}
component={Link}
/>
<Tab
label={formatMessage('adminApps.authClients')}
to={URLS.ADMIN_APP_AUTH_CLIENTS(appKey)}
value={URLS.ADMIN_APP_AUTH_CLIENTS_PATTERN}
component={Link}
/>
<Tab
label={formatMessage('adminApps.connections')}
to={URLS.ADMIN_APP_CONNECTIONS(appKey)}
value={URLS.ADMIN_APP_CONNECTIONS_PATTERN}
disabled={!app.supportsConnections}
component={Link}
/>
</Tabs>
</Box>
<Routes>
<Route
path={`/settings/*`}
element={<AdminApplicationSettings appKey={appKey} />}
/>
<Route
path={`/auth-clients/*`}
element={<div>Auth clients</div>}
/>
<Route
path={`/connections/*`}
element={<div>App connections</div>}
/>
<Route
path="/"
element={
<Navigate to={URLS.ADMIN_APP_SETTINGS(appKey)} replace />
}
/>
</Routes>
<Routes>
<Route
path={`/settings/*`}
element={<AdminApplicationSettings appKey={appKey} />}
/>
<Route
path={`/auth-clients/*`}
element={<AdminApplicationAuthClients appKey={appKey} />}
/>
<Route
path={`/connections/*`}
element={<div>App connections</div>}
/>
<Route
path="/"
element={
<Navigate to={URLS.ADMIN_APP_SETTINGS(appKey)} replace />
}
/>
</Routes>
</Grid>
</Grid>
</Grid>
</Grid>
</Container>
</Container>
<Routes>
<Route
path="/auth-clients/create"
element={
<AdminApplicationCreateAuthClient
application={app}
onClose={goToAuthClientsPage}
appKey={appKey}
/>
}
/>
<Route
path="/auth-clients/:clientId"
element={
<AdminApplicationUpdateAuthClient
application={app}
onClose={goToAuthClientsPage}
/>
}
/>
</Routes>
</>
);
}