feat: introduce connections page in the admin panel
This commit is contained in:
@@ -0,0 +1,168 @@
|
|||||||
|
import type { IApp, IField, IJSONObject } 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 * as React from 'react';
|
||||||
|
import { FieldValues, SubmitHandler } from 'react-hook-form';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee';
|
||||||
|
import InputCreator from 'components/InputCreator';
|
||||||
|
import * as URLS from 'config/urls';
|
||||||
|
import useAuthenticateApp from 'hooks/useAuthenticateApp.ee';
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
import { generateExternalLink } from '../../helpers/translationValues';
|
||||||
|
import { Form } from './style';
|
||||||
|
|
||||||
|
type AdminApplicationConnectionCreateProps = {
|
||||||
|
onClose: (response: Record<string, unknown>) => void;
|
||||||
|
application: IApp;
|
||||||
|
connectionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminApplicationConnectionCreate(
|
||||||
|
props: AdminApplicationConnectionCreateProps
|
||||||
|
): React.ReactElement {
|
||||||
|
const { application, connectionId, onClose } = props;
|
||||||
|
const { name, authDocUrl, key, auth } = application;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
const [error, setError] = React.useState<IJSONObject | null>(null);
|
||||||
|
const [inProgress, setInProgress] = React.useState(false);
|
||||||
|
const hasConnection = Boolean(connectionId);
|
||||||
|
const useShared = searchParams.get('shared') === 'true';
|
||||||
|
const appAuthClientId = searchParams.get('appAuthClientId') || undefined;
|
||||||
|
const { authenticate } = useAuthenticateApp({
|
||||||
|
appKey: key,
|
||||||
|
connectionId,
|
||||||
|
appAuthClientId,
|
||||||
|
useShared: !!appAuthClientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(function relayProviderData() {
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.postMessage({
|
||||||
|
source: 'automatisch',
|
||||||
|
payload: { search: window.location.search, hash: window.location.hash },
|
||||||
|
});
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
function initiateSharedAuthenticationForGivenAuthClient() {
|
||||||
|
if (!appAuthClientId) return;
|
||||||
|
if (!authenticate) return;
|
||||||
|
|
||||||
|
const asyncAuthenticate = async () => {
|
||||||
|
await authenticate();
|
||||||
|
|
||||||
|
navigate(URLS.ADMIN_APP_CONNECTIONS(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
asyncAuthenticate();
|
||||||
|
},
|
||||||
|
[appAuthClientId, authenticate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClientClick = (appAuthClientId: string) =>
|
||||||
|
navigate(
|
||||||
|
URLS.ADMIN_APP_CONNECTIONS_CREATE_WITH_AUTH_CLIENT_ID(
|
||||||
|
key,
|
||||||
|
appAuthClientId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuthClientsDialogClose = () =>
|
||||||
|
navigate(URLS.ADMIN_APP_CONNECTIONS(key));
|
||||||
|
|
||||||
|
const submitHandler: SubmitHandler<FieldValues> = React.useCallback(
|
||||||
|
async (data) => {
|
||||||
|
if (!authenticate) return;
|
||||||
|
|
||||||
|
setInProgress(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authenticate({
|
||||||
|
fields: data,
|
||||||
|
});
|
||||||
|
onClose(response as Record<string, unknown>);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as IJSONObject;
|
||||||
|
console.log(error);
|
||||||
|
setError((error.graphQLErrors as IJSONObject[])?.[0]);
|
||||||
|
} finally {
|
||||||
|
setInProgress(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authenticate]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useShared)
|
||||||
|
return (
|
||||||
|
<AppAuthClientsDialog
|
||||||
|
appKey={key}
|
||||||
|
onClose={handleAuthClientsDialogClose}
|
||||||
|
onClientClick={handleClientClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appAuthClientId) return <React.Fragment />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{hasConnection
|
||||||
|
? formatMessage('adminAppsConnections.reconnectConnection')
|
||||||
|
: formatMessage('adminAppsConnections.createConnection')}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{authDocUrl && (
|
||||||
|
<Alert severity="info" sx={{ fontWeight: 300 }}>
|
||||||
|
{formatMessage('adminAppsConnections.callToDocs', {
|
||||||
|
appName: name,
|
||||||
|
docsLink: generateExternalLink(authDocUrl),
|
||||||
|
})}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
{error.details && (
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{JSON.stringify(error.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText tabIndex={-1} component="div">
|
||||||
|
<Form onSubmit={submitHandler}>
|
||||||
|
{auth?.fields?.map((field: IField) => (
|
||||||
|
<InputCreator key={field.key} schema={field} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ boxShadow: 2 }}
|
||||||
|
loading={inProgress}
|
||||||
|
>
|
||||||
|
{formatMessage('adminAppsConnections.submit')}
|
||||||
|
</LoadingButton>
|
||||||
|
</Form>
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -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),
|
||||||
|
}));
|
@@ -0,0 +1,70 @@
|
|||||||
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
import ControlledCheckbox from 'components/ControlledCheckbox';
|
||||||
|
import { Stack } from '@mui/material';
|
||||||
|
|
||||||
|
type Roles = { id: string; name: string; checked: boolean }[];
|
||||||
|
|
||||||
|
function RolesFieldArray() {
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
const { control, watch, setValue } = useFormContext();
|
||||||
|
const fieldArrayData = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'roles',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = fieldArrayData.fields as Roles;
|
||||||
|
const watchedFields = watch('roles') as Roles;
|
||||||
|
const allFieldsSelected = watchedFields.every((field) => field.checked);
|
||||||
|
const allFieldsDeselected = watchedFields.every((field) => !field.checked);
|
||||||
|
|
||||||
|
const handleSelectAllClick = () => {
|
||||||
|
setValue(
|
||||||
|
'roles',
|
||||||
|
watchedFields.map((field) => ({ ...field, checked: !allFieldsSelected })),
|
||||||
|
{ shouldDirty: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="column" spacing={1}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
color="primary"
|
||||||
|
indeterminate={!(allFieldsSelected || allFieldsDeselected)}
|
||||||
|
checked={allFieldsSelected}
|
||||||
|
onChange={handleSelectAllClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
allFieldsSelected
|
||||||
|
? formatMessage('adminAppsConnections.deselectAll')
|
||||||
|
: formatMessage('adminAppsConnections.selectAll')
|
||||||
|
}
|
||||||
|
sx={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
{fields.map((role, index) => {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={role.id}
|
||||||
|
control={
|
||||||
|
<ControlledCheckbox
|
||||||
|
name={`roles.${index}.checked`}
|
||||||
|
defaultValue={role.checked}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={role.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RolesFieldArray;
|
@@ -0,0 +1,140 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { IApp, IRole } from '@automatisch/types';
|
||||||
|
import { FieldValues, SubmitHandler } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { SHARE_CONNECTION } from 'graphql/mutations/share-connection';
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
import useSharedConnectionRoleIds from 'hooks/useSharedConnectionRoleIds';
|
||||||
|
import useRoles from 'hooks/useRoles.ee';
|
||||||
|
|
||||||
|
import RolesFieldArray from './RolesFieldArray';
|
||||||
|
import { Form } from './style';
|
||||||
|
|
||||||
|
type AdminApplicationConnectionShareProps = {
|
||||||
|
onClose: (response: Record<string, unknown>) => void;
|
||||||
|
application: IApp;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
connectionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateRolesData(roles: IRole[], roleIds: string[]) {
|
||||||
|
return roles.map(({ id, name }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
checked: roleIds.includes(id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminApplicationConnectionShare(
|
||||||
|
props: AdminApplicationConnectionShareProps
|
||||||
|
): React.ReactElement {
|
||||||
|
const { onClose } = props;
|
||||||
|
const { connectionId } = useParams() as Params;
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
const [
|
||||||
|
shareConnection,
|
||||||
|
{ loading: loadingShareConnection, error: shareConnectionError },
|
||||||
|
] = useMutation(SHARE_CONNECTION, {
|
||||||
|
context: { autoSnackbar: false },
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
roleIds,
|
||||||
|
loading: roleIdsLoading,
|
||||||
|
error: roleIdsError,
|
||||||
|
} = useSharedConnectionRoleIds(connectionId, {
|
||||||
|
context: { autoSnackbar: false },
|
||||||
|
});
|
||||||
|
const { roles, loading: rolesLoading, error: rolesError } = useRoles();
|
||||||
|
|
||||||
|
const error = shareConnectionError || roleIdsError || rolesError;
|
||||||
|
const showDialogContent =
|
||||||
|
!roleIdsLoading && !rolesLoading && !roleIdsError && !rolesError;
|
||||||
|
|
||||||
|
const submitHandler: SubmitHandler<FieldValues> = React.useCallback(
|
||||||
|
async (data) => {
|
||||||
|
const roles = data.roles as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
checked: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const response = await shareConnection({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: connectionId,
|
||||||
|
roleIds: roles
|
||||||
|
.filter((role) => role.checked)
|
||||||
|
.map((role) => role.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClose(response as Record<string, unknown>);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultValues = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
roles: generateRolesData(roles, roleIds),
|
||||||
|
}),
|
||||||
|
[roles, roleIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{formatMessage('adminAppsConnections.shareConnection')}
|
||||||
|
</DialogTitle>
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{(roleIdsLoading || rolesLoading) && (
|
||||||
|
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||||
|
)}
|
||||||
|
{showDialogContent && (
|
||||||
|
<DialogContent sx={{ pt: '0px !important' }}>
|
||||||
|
<DialogContentText tabIndex={-1} component="div">
|
||||||
|
<Form
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
onSubmit={submitHandler}
|
||||||
|
render={({ formState: { isDirty } }) => {
|
||||||
|
return (
|
||||||
|
<Stack direction="column">
|
||||||
|
<RolesFieldArray />
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ boxShadow: 2, mt: 5 }}
|
||||||
|
disabled={!isDirty}
|
||||||
|
loading={loadingShareConnection}
|
||||||
|
>
|
||||||
|
{formatMessage('adminAppsConnections.submit')}
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></Form>
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -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),
|
||||||
|
}));
|
@@ -0,0 +1,85 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import type { PopoverProps } from '@mui/material/Popover';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import type { IConnection } from '@automatisch/types';
|
||||||
|
|
||||||
|
import * as URLS from 'config/urls';
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
type: 'test' | 'reconnect' | 'delete' | 'shareConnection';
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContextMenuProps = {
|
||||||
|
appKey: string;
|
||||||
|
connection: IConnection;
|
||||||
|
onClose: () => void;
|
||||||
|
onMenuItemClick: (event: React.MouseEvent, action: Action) => void;
|
||||||
|
anchorEl: PopoverProps['anchorEl'];
|
||||||
|
disableReconnection: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContextMenu(
|
||||||
|
props: ContextMenuProps
|
||||||
|
): React.ReactElement {
|
||||||
|
const {
|
||||||
|
appKey,
|
||||||
|
connection,
|
||||||
|
onClose,
|
||||||
|
onMenuItemClick,
|
||||||
|
anchorEl,
|
||||||
|
disableReconnection,
|
||||||
|
} = props;
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
|
||||||
|
const createActionHandler = React.useCallback(
|
||||||
|
(action: Action) => {
|
||||||
|
return function clickHandler(event: React.MouseEvent) {
|
||||||
|
onMenuItemClick(event, action);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[onMenuItemClick, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
hideBackdrop={false}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={createActionHandler({ type: 'test' })}>
|
||||||
|
{formatMessage('adminAppsConnections.testConnection')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
disabled={disableReconnection}
|
||||||
|
to={URLS.ADMIN_APP_RECONNECT_CONNECTION(
|
||||||
|
appKey,
|
||||||
|
connection.id,
|
||||||
|
connection.appAuthClientId
|
||||||
|
)}
|
||||||
|
onClick={createActionHandler({ type: 'reconnect' })}
|
||||||
|
>
|
||||||
|
{formatMessage('adminAppsConnections.reconnect')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
to={URLS.ADMIN_APP_SHARE_CONNECTION(appKey, connection.id)}
|
||||||
|
onClick={createActionHandler({ type: 'shareConnection' })}
|
||||||
|
>
|
||||||
|
{formatMessage('adminAppsConnections.shareConnection')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={createActionHandler({ type: 'delete' })}>
|
||||||
|
{formatMessage('adminAppsConnections.delete')}
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,155 @@
|
|||||||
|
import type { IConnection } from '@automatisch/types';
|
||||||
|
import { useLazyQuery, useMutation } from '@apollo/client';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import CardActionArea from '@mui/material/CardActionArea';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection';
|
||||||
|
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
|
||||||
|
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
|
||||||
|
import ConnectionContextMenu from '../AppConnectionContextMenu';
|
||||||
|
import { CardContent, Typography } from './style';
|
||||||
|
|
||||||
|
type AppConnectionRowProps = {
|
||||||
|
connection: IConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
|
||||||
|
const enqueueSnackbar = useEnqueueSnackbar();
|
||||||
|
const [verificationVisible, setVerificationVisible] = React.useState(false);
|
||||||
|
const [testConnection, { called: testCalled, loading: testLoading }] =
|
||||||
|
useLazyQuery(TEST_CONNECTION, {
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
onCompleted: () => {
|
||||||
|
setTimeout(() => setVerificationVisible(false), 3000);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setTimeout(() => setVerificationVisible(false), 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [deleteConnection] = useMutation(DELETE_CONNECTION);
|
||||||
|
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
const { id, key, formattedData, verified, createdAt, reconnectable, shared } =
|
||||||
|
props.connection;
|
||||||
|
|
||||||
|
const contextButtonRef = React.useRef<SVGSVGElement | null>(null);
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenuClick = () => setAnchorEl(contextButtonRef.current);
|
||||||
|
const onContextMenuAction = React.useCallback(
|
||||||
|
async (event, action: { [key: string]: string }) => {
|
||||||
|
if (action.type === 'delete') {
|
||||||
|
await deleteConnection({
|
||||||
|
variables: { input: { id } },
|
||||||
|
update: (cache) => {
|
||||||
|
const connectionCacheId = cache.identify({
|
||||||
|
__typename: 'Connection',
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.evict({
|
||||||
|
id: connectionCacheId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
enqueueSnackbar(formatMessage('adminAppsConnections.deletedMessage'), {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
} else if (action.type === 'test') {
|
||||||
|
setVerificationVisible(true);
|
||||||
|
testConnection({ variables: { id } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteConnection, id, testConnection, formatMessage, enqueueSnackbar]
|
||||||
|
);
|
||||||
|
|
||||||
|
const relativeCreatedAt = DateTime.fromMillis(
|
||||||
|
parseInt(createdAt, 10)
|
||||||
|
).toRelative();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card sx={{ my: 2 }}>
|
||||||
|
<CardActionArea onClick={onContextMenuClick}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack justifyContent="center" alignItems="flex-start" spacing={1}>
|
||||||
|
<Typography variant="h6" sx={{ textAlign: 'left' }}>
|
||||||
|
{formattedData?.screenName} {shared && 'shared'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption">
|
||||||
|
{formatMessage('adminAppsConnections.addedAt', {
|
||||||
|
datetime: relativeCreatedAt,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
{verificationVisible && testCalled && testLoading && (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="caption">
|
||||||
|
{formatMessage('adminAppsConnections.testing')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{verificationVisible && testCalled && !testLoading && verified && (
|
||||||
|
<>
|
||||||
|
<CheckCircleIcon fontSize="small" color="success" />
|
||||||
|
<Typography variant="caption">
|
||||||
|
{formatMessage('adminAppsConnections.testSuccessful')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{verificationVisible &&
|
||||||
|
testCalled &&
|
||||||
|
!testLoading &&
|
||||||
|
!verified && (
|
||||||
|
<>
|
||||||
|
<ErrorIcon fontSize="small" color="error" />
|
||||||
|
<Typography variant="caption">
|
||||||
|
{formatMessage('adminAppsConnections.testFailed')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<MoreHorizIcon ref={contextButtonRef} />
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{anchorEl && (
|
||||||
|
<ConnectionContextMenu
|
||||||
|
appKey={key}
|
||||||
|
connection={props.connection}
|
||||||
|
disableReconnection={!reconnectable}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMenuItemClick={onContextMenuAction}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppConnectionRow;
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import MuiCardContent from '@mui/material/CardContent';
|
||||||
|
import MuiTypography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
export const CardContent = styled(MuiCardContent)(({ theme }) => ({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'auto',
|
||||||
|
gridTemplateColumns: '1fr auto auto auto',
|
||||||
|
gridColumnGap: theme.spacing(2),
|
||||||
|
alignItems: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Typography = styled(MuiTypography)(() => ({
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'inline-block',
|
||||||
|
}));
|
@@ -0,0 +1,55 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@apollo/client';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import type { IConnection } from '@automatisch/types';
|
||||||
|
|
||||||
|
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
|
||||||
|
import * as URLS from 'config/urls';
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
import NoResultFound from 'components/NoResultFound';
|
||||||
|
|
||||||
|
import AppConnectionRow from './AppConnectionRow';
|
||||||
|
|
||||||
|
type AdminApplicationConnectionsProps = { appKey: string };
|
||||||
|
|
||||||
|
function AdminApplicationConnections(
|
||||||
|
props: AdminApplicationConnectionsProps
|
||||||
|
): React.ReactElement {
|
||||||
|
const { appKey } = props;
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
const { data, loading } = useQuery(GET_APP_CONNECTIONS, {
|
||||||
|
variables: { key: appKey },
|
||||||
|
});
|
||||||
|
const appConnections: IConnection[] = data?.getApp?.connections || [];
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />;
|
||||||
|
|
||||||
|
if (appConnections.length === 0) {
|
||||||
|
return (
|
||||||
|
<NoResultFound
|
||||||
|
to={URLS.ADMIN_APP_CONNECTIONS_CREATE(appKey)}
|
||||||
|
text={formatMessage('adminAppsConnections.noConnections')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{appConnections.map((appConnection) => (
|
||||||
|
<AppConnectionRow key={appConnection.id} connection={appConnection} />
|
||||||
|
))}
|
||||||
|
<Stack justifyContent="flex-end" direction="row">
|
||||||
|
<Link to={URLS.ADMIN_APP_CONNECTIONS_CREATE(appKey)}>
|
||||||
|
<Button variant="contained" sx={{ mt: 2 }} component="div">
|
||||||
|
{formatMessage('adminAppsConnections.createConnection')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminApplicationConnections;
|
@@ -103,6 +103,13 @@ export const ADMIN_APP_AUTH_CLIENTS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/au
|
|||||||
export const ADMIN_APP_CONNECTIONS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/connections`;
|
export const ADMIN_APP_CONNECTIONS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/connections`;
|
||||||
export const ADMIN_APP_CONNECTIONS = (appKey: string) =>
|
export const ADMIN_APP_CONNECTIONS = (appKey: string) =>
|
||||||
`${ADMIN_SETTINGS}/apps/${appKey}/connections`;
|
`${ADMIN_SETTINGS}/apps/${appKey}/connections`;
|
||||||
|
export const ADMIN_APP_CONNECTIONS_CREATE = (appKey: string, shared = false) =>
|
||||||
|
`${ADMIN_SETTINGS}/apps/${appKey}/connections/create?shared=${shared}`;
|
||||||
|
export const ADMIN_APP_CONNECTIONS_CREATE_WITH_AUTH_CLIENT_ID = (
|
||||||
|
appKey: string,
|
||||||
|
appAuthClientId: string
|
||||||
|
) =>
|
||||||
|
`${ADMIN_SETTINGS}/apps/${appKey}/connections/create?appAuthClientId=${appAuthClientId}`;
|
||||||
export const ADMIN_APP_SETTINGS = (appKey: string) =>
|
export const ADMIN_APP_SETTINGS = (appKey: string) =>
|
||||||
`${ADMIN_SETTINGS}/apps/${appKey}/settings`;
|
`${ADMIN_SETTINGS}/apps/${appKey}/settings`;
|
||||||
export const ADMIN_APP_AUTH_CLIENTS = (appKey: string) =>
|
export const ADMIN_APP_AUTH_CLIENTS = (appKey: string) =>
|
||||||
@@ -111,6 +118,23 @@ export const ADMIN_APP_AUTH_CLIENT = (appKey: string, id: string) =>
|
|||||||
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients/${id}`;
|
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients/${id}`;
|
||||||
export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey: string) =>
|
export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey: string) =>
|
||||||
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients/create`;
|
`${ADMIN_SETTINGS}/apps/${appKey}/auth-clients/create`;
|
||||||
|
export const ADMIN_APP_RECONNECT_CONNECTION = (
|
||||||
|
appKey: string,
|
||||||
|
connectionId: string,
|
||||||
|
appAuthClientId?: string
|
||||||
|
) => {
|
||||||
|
const path = `${ADMIN_SETTINGS}/apps/${appKey}/connections/${connectionId}/reconnect`;
|
||||||
|
|
||||||
|
if (appAuthClientId) {
|
||||||
|
return `${path}?appAuthClientId=${appAuthClientId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
export const ADMIN_APP_SHARE_CONNECTION = (
|
||||||
|
appKey: string,
|
||||||
|
connectionId: string
|
||||||
|
) => `${ADMIN_SETTINGS}/apps/${appKey}/connections/${connectionId}/share`;
|
||||||
|
|
||||||
export const DASHBOARD = FLOWS;
|
export const DASHBOARD = FLOWS;
|
||||||
|
|
||||||
|
9
packages/web/src/graphql/mutations/share-connection.ts
Normal file
9
packages/web/src/graphql/mutations/share-connection.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const SHARE_CONNECTION = gql`
|
||||||
|
mutation ShareConnection($input: ShareConnectionInput) {
|
||||||
|
shareConnection(input: $input) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
@@ -0,0 +1,7 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const GET_SHARED_CONNECTION_ROLE_IDS = gql`
|
||||||
|
query GetSharedConnectionRoleIds($id: String!) {
|
||||||
|
getSharedConnectionRoleIds(id: $id)
|
||||||
|
}
|
||||||
|
`;
|
@@ -5,13 +5,16 @@ import { GET_ROLES } from 'graphql/queries/get-roles.ee';
|
|||||||
|
|
||||||
type QueryResponse = {
|
type QueryResponse = {
|
||||||
getRoles: IRole[];
|
getRoles: IRole[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function useRoles() {
|
export default function useRoles() {
|
||||||
const { data, loading } = useQuery<QueryResponse>(GET_ROLES, { context: { autoSnackbar: false } });
|
const { data, loading, error } = useQuery<QueryResponse>(GET_ROLES, {
|
||||||
|
context: { autoSnackbar: false },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roles: data?.getRoles || [],
|
roles: data?.getRoles || [],
|
||||||
loading
|
loading,
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
32
packages/web/src/hooks/useSharedConnectionRoleIds.ts
Normal file
32
packages/web/src/hooks/useSharedConnectionRoleIds.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { LazyQueryHookOptions, useLazyQuery } from '@apollo/client';
|
||||||
|
|
||||||
|
import { GET_SHARED_CONNECTION_ROLE_IDS } from 'graphql/queries/get-shared-connection-role-ids';
|
||||||
|
|
||||||
|
type QueryResponse = {
|
||||||
|
getSharedConnectionRoleIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useSharedConnectionRoleIds(
|
||||||
|
connectionId: string,
|
||||||
|
options?: LazyQueryHookOptions
|
||||||
|
) {
|
||||||
|
const [getSharedConnectionRoleIds, { data, loading, error }] =
|
||||||
|
useLazyQuery<QueryResponse>(GET_SHARED_CONNECTION_ROLE_IDS, options);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (connectionId) {
|
||||||
|
getSharedConnectionRoleIds({
|
||||||
|
variables: {
|
||||||
|
id: connectionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [connectionId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleIds: data?.getSharedConnectionRoleIds || [],
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
@@ -265,5 +265,21 @@
|
|||||||
"authClient.buttonSubmit": "Submit",
|
"authClient.buttonSubmit": "Submit",
|
||||||
"authClient.inputName": "Name",
|
"authClient.inputName": "Name",
|
||||||
"authClient.inputActive": "Active",
|
"authClient.inputActive": "Active",
|
||||||
"updateAuthClient.title": "Update auth client"
|
"updateAuthClient.title": "Update auth client",
|
||||||
|
"adminAppsConnections.noConnections": "You don't have any connections yet.",
|
||||||
|
"adminAppsConnections.createConnection": "Create connection",
|
||||||
|
"adminAppsConnections.deletedMessage": "The connection has been deleted.",
|
||||||
|
"adminAppsConnections.addedAt": "added {datetime}",
|
||||||
|
"adminAppsConnections.testing": "Testing...",
|
||||||
|
"adminAppsConnections.testSuccessful": "Test successful",
|
||||||
|
"adminAppsConnections.testFailed": "Test failed",
|
||||||
|
"adminAppsConnections.testConnection": "Test connection",
|
||||||
|
"adminAppsConnections.delete": "Delete",
|
||||||
|
"adminAppsConnections.reconnect": "Reconnect",
|
||||||
|
"adminAppsConnections.shareConnection": "Share connection",
|
||||||
|
"adminAppsConnections.reconnectConnection": "Reconnect connection",
|
||||||
|
"adminAppsConnections.callToDocs": "Visit <docsLink>our documentation</docsLink> to see how to add connection for {appName}.",
|
||||||
|
"adminAppsConnections.submit": "Submit",
|
||||||
|
"adminAppsConnections.selectAll": "Select all roles",
|
||||||
|
"adminAppsConnections.deselectAll": "Deselect all roles"
|
||||||
}
|
}
|
||||||
|
@@ -27,9 +27,26 @@ import AdminApplicationSettings from 'components/AdminApplicationSettings';
|
|||||||
import AdminApplicationAuthClients from 'components/AdminApplicationAuthClients';
|
import AdminApplicationAuthClients from 'components/AdminApplicationAuthClients';
|
||||||
import AdminApplicationCreateAuthClient from 'components/AdminApplicationCreateAuthClient';
|
import AdminApplicationCreateAuthClient from 'components/AdminApplicationCreateAuthClient';
|
||||||
import AdminApplicationUpdateAuthClient from 'components/AdminApplicationUpdateAuthClient';
|
import AdminApplicationUpdateAuthClient from 'components/AdminApplicationUpdateAuthClient';
|
||||||
|
import AdminApplicationConnections from 'components/AdminApplicationConnections';
|
||||||
|
import AdminApplicationConnectionCreate from 'components/AdminApplicationConnectionCreate';
|
||||||
|
import AdminApplicationConnectionShare from 'components/AdminApplicationConnectionShare';
|
||||||
|
|
||||||
type AdminApplicationParams = {
|
type AdminApplicationParams = {
|
||||||
appKey: string;
|
appKey: string;
|
||||||
|
connectionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReconnectConnection = (props: any): React.ReactElement => {
|
||||||
|
const { application, onClose } = props;
|
||||||
|
const { connectionId } = useParams() as AdminApplicationParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminApplicationConnectionCreate
|
||||||
|
onClose={onClose}
|
||||||
|
application={application}
|
||||||
|
connectionId={connectionId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminApplication(): React.ReactElement | null {
|
export default function AdminApplication(): React.ReactElement | null {
|
||||||
@@ -57,6 +74,7 @@ export default function AdminApplication(): React.ReactElement | null {
|
|||||||
const app = data?.getApp || {};
|
const app = data?.getApp || {};
|
||||||
|
|
||||||
const goToAuthClientsPage = () => navigate('auth-clients');
|
const goToAuthClientsPage = () => navigate('auth-clients');
|
||||||
|
const goToConnectionsPage = () => navigate('connections');
|
||||||
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
|
|
||||||
@@ -120,7 +138,7 @@ export default function AdminApplication(): React.ReactElement | null {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={`/connections/*`}
|
path={`/connections/*`}
|
||||||
element={<div>App connections</div>}
|
element={<AdminApplicationConnections appKey={appKey} />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
@@ -153,6 +171,33 @@ export default function AdminApplication(): React.ReactElement | null {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/connections/create"
|
||||||
|
element={
|
||||||
|
<AdminApplicationConnectionCreate
|
||||||
|
onClose={goToConnectionsPage}
|
||||||
|
application={app}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/connections/:connectionId/reconnect"
|
||||||
|
element={
|
||||||
|
<ReconnectConnection
|
||||||
|
application={app}
|
||||||
|
onClose={goToConnectionsPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/connections/:connectionId/share"
|
||||||
|
element={
|
||||||
|
<AdminApplicationConnectionShare
|
||||||
|
onClose={goToConnectionsPage}
|
||||||
|
application={app}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -65,8 +65,8 @@ function RoleMappings({ provider, providerLoading }: RoleMappingsProps) {
|
|||||||
enqueueSnackbar(formatMessage('roleMappingsForm.successfullySaved'), {
|
enqueueSnackbar(formatMessage('roleMappingsForm.successfullySaved'), {
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
SnackbarProps: {
|
SnackbarProps: {
|
||||||
'data-test': 'snackbar-update-role-mappings-success'
|
'data-test': 'snackbar-update-role-mappings-success',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@@ -5,13 +5,12 @@ import Stack from '@mui/material/Stack';
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
import { Divider, Typography } from '@mui/material';
|
||||||
|
|
||||||
import useRoles from 'hooks/useRoles.ee';
|
import useRoles from 'hooks/useRoles.ee';
|
||||||
import useFormatMessage from 'hooks/useFormatMessage';
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
|
||||||
import ControlledAutocomplete from 'components/ControlledAutocomplete';
|
import ControlledAutocomplete from 'components/ControlledAutocomplete';
|
||||||
import TextField from 'components/TextField';
|
import TextField from 'components/TextField';
|
||||||
import { Divider, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
function generateRoleOptions(roles: IRole[]) {
|
function generateRoleOptions(roles: IRole[]) {
|
||||||
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
||||||
|
Reference in New Issue
Block a user