feat: introduce app configs with shared auth clients (#1213)

This commit is contained in:
Ali BARIN
2023-08-16 15:46:43 +02:00
committed by GitHub
parent 25983e046c
commit 3b54b29a99
47 changed files with 1504 additions and 113 deletions

View File

@@ -1,17 +1,19 @@
import * as React from 'react';
import type { IApp, IField, IJSONObject } from '@automatisch/types';
import LoadingButton from '@mui/lab/LoadingButton';
import Alert from '@mui/material/Alert';
import DialogTitle from '@mui/material/DialogTitle';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import Dialog from '@mui/material/Dialog';
import LoadingButton from '@mui/lab/LoadingButton';
import DialogTitle from '@mui/material/DialogTitle';
import * as React from 'react';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import type { IApp, IJSONObject, IField } from '@automatisch/types';
import { useNavigate, useSearchParams } from 'react-router-dom';
import useFormatMessage from 'hooks/useFormatMessage';
import computeAuthStepVariables from 'helpers/computeAuthStepVariables';
import { processStep } from 'helpers/authenticationSteps';
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';
@@ -21,24 +23,27 @@ type AddAppConnectionProps = {
connectionId?: string;
};
type Response = {
[key: string]: any;
};
export default function AddAppConnection(
props: AddAppConnectionProps
): 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 steps = hasConnection
? auth?.reconnectionSteps
: auth?.authenticationSteps;
const useShared = searchParams.get('shared') === 'true';
const appAuthClientId = searchParams.get('appAuthClientId') || undefined;
const { authenticate } = useAuthenticateApp({
appKey: key,
connectionId,
appAuthClientId,
useShared: !!appAuthClientId,
});
React.useEffect(() => {
React.useEffect(function relayProviderData() {
if (window.opener) {
window.opener.postMessage({
source: 'automatisch',
@@ -48,51 +53,61 @@ export default function AddAppConnection(
}
}, []);
const submitHandler: SubmitHandler<FieldValues> = React.useCallback(
async (data) => {
if (!steps) return;
React.useEffect(
function initiateSharedAuthenticationForGivenAuthClient() {
if (!appAuthClientId) return;
if (!authenticate) return;
setInProgress(true);
setError(null);
const asyncAuthenticate = async () => {
await authenticate();
const response: Response = {
key,
connection: {
id: connectionId,
},
fields: data,
navigate(URLS.APP_CONNECTIONS(key));
};
let stepIndex = 0;
while (stepIndex < steps.length) {
const step = steps[stepIndex];
const variables = computeAuthStepVariables(step.arguments, response);
try {
const stepResponse = await processStep(step, variables);
response[step.name] = stepResponse;
} catch (err) {
const error = err as IJSONObject;
console.log(error);
setError((error.graphQLErrors as IJSONObject[])?.[0]);
setInProgress(false);
break;
}
stepIndex++;
if (stepIndex === steps.length) {
onClose(response);
}
}
setInProgress(false);
asyncAuthenticate();
},
[connectionId, key, steps, onClose]
[appAuthClientId, authenticate]
);
const handleClientClick = (appAuthClientId: string) =>
navigate(URLS.APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID(key, appAuthClientId));
const handleAuthClientsDialogClose = () =>
navigate(URLS.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} data-test="add-app-connection-dialog">
<DialogTitle>

View File

@@ -0,0 +1,50 @@
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import * as React from 'react';
import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import useFormatMessage from 'hooks/useFormatMessage';
type AppAuthClientsDialogProps = {
appKey: string;
onClientClick: (appAuthClientId: string) => void;
onClose: () => void;
};
export default function AppAuthClientsDialog(props: AppAuthClientsDialogProps) {
const { appKey, onClientClick, onClose } = props;
const { appAuthClients } = useAppAuthClients(appKey);
const formatMessage = useFormatMessage();
React.useEffect(
function autoAuthenticateSingleClient() {
if (appAuthClients?.length === 1) {
onClientClick(appAuthClients[0].id);
}
},
[appAuthClients]
);
if (!appAuthClients?.length || appAuthClients?.length === 1)
return <React.Fragment />;
return (
<Dialog onClose={onClose} open={true}>
<DialogTitle>{formatMessage('appAuthClientsDialog.title')}</DialogTitle>
<List sx={{ pt: 0 }}>
{appAuthClients.map((appAuthClient) => (
<ListItem disableGutters key={appAuthClient.id}>
<ListItemButton onClick={() => onClientClick(appAuthClient.id)}>
<ListItemText primary={appAuthClient.name} />
</ListItemButton>
</ListItem>
))}
</List>
</Dialog>
);
}

View File

@@ -3,6 +3,7 @@ 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';
@@ -13,16 +14,24 @@ type Action = {
type ContextMenuProps = {
appKey: string;
connectionId: 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, connectionId, onClose, onMenuItemClick, anchorEl } = props;
const {
appKey,
connection,
onClose,
onMenuItemClick,
anchorEl,
disableReconnection,
} = props;
const formatMessage = useFormatMessage();
const createActionHandler = React.useCallback(
@@ -45,7 +54,7 @@ export default function ContextMenu(
>
<MenuItem
component={Link}
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connectionId)}
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
onClick={createActionHandler({ type: 'viewFlows' })}
>
{formatMessage('connection.viewFlows')}
@@ -57,7 +66,12 @@ export default function ContextMenu(
<MenuItem
component={Link}
to={URLS.APP_RECONNECT_CONNECTION(appKey, connectionId)}
disabled={disableReconnection}
to={URLS.APP_RECONNECT_CONNECTION(
appKey,
connection.id,
connection.appAuthClientId
)}
onClick={createActionHandler({ type: 'reconnect' })}
>
{formatMessage('connection.reconnect')}

View File

@@ -45,8 +45,15 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
const [deleteConnection] = useMutation(DELETE_CONNECTION);
const formatMessage = useFormatMessage();
const { id, key, formattedData, verified, createdAt, flowCount } =
props.connection;
const {
id,
key,
formattedData,
verified,
createdAt,
flowCount,
reconnectable,
} = props.connection;
const contextButtonRef = React.useRef<SVGSVGElement | null>(null);
const [anchorEl, setAnchorEl] = React.useState<SVGSVGElement | null>(null);
@@ -159,7 +166,8 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
{anchorEl && (
<ConnectionContextMenu
appKey={key}
connectionId={id}
connection={props.connection}
disableReconnection={!reconnectable}
onClose={handleClose}
onMenuItemClick={onContextMenuAction}
anchorEl={anchorEl}

View File

@@ -1,18 +1,21 @@
import * as React from 'react';
import { useQuery, useLazyQuery } from '@apollo/client';
import TextField from '@mui/material/TextField';
import { useLazyQuery, useQuery } from '@apollo/client';
import Autocomplete from '@mui/material/Autocomplete';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
import ListItem from '@mui/material/ListItem';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import * as React from 'react';
import type { IApp, IConnection, IStep, ISubstep } from '@automatisch/types';
import useFormatMessage from 'hooks/useFormatMessage';
import { EditorContext } from 'contexts/Editor';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import AddAppConnection from 'components/AddAppConnection';
import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import useAppConfig from 'hooks/useAppConfig.ee';
import { EditorContext } from 'contexts/Editor';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
import useAuthenticateApp from 'hooks/useAuthenticateApp.ee';
import useFormatMessage from 'hooks/useFormatMessage';
type ChooseConnectionSubstepProps = {
application: IApp;
@@ -26,6 +29,7 @@ type ChooseConnectionSubstepProps = {
};
const ADD_CONNECTION_VALUE = 'ADD_CONNECTION';
const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION';
const optionGenerator = (
connection: IConnection
@@ -53,11 +57,18 @@ function ChooseConnectionSubstep(
const { connection, appKey } = step;
const formatMessage = useFormatMessage();
const editorContext = React.useContext(EditorContext);
const { authenticate } = useAuthenticateApp({
appKey: application.key,
useShared: true,
});
const [showAddConnectionDialog, setShowAddConnectionDialog] =
React.useState(false);
const [showAddSharedConnectionDialog, setShowAddSharedConnectionDialog] =
React.useState(false);
const { data, loading, refetch } = useQuery(GET_APP_CONNECTIONS, {
variables: { key: appKey },
});
const { appConfig } = useAppConfig(application.key);
// TODO: show detailed error when connection test/verification fails
const [
testConnection,
@@ -86,13 +97,49 @@ function ChooseConnectionSubstep(
optionGenerator(connection)
) || [];
options.push({
label: formatMessage('chooseConnectionSubstep.addNewConnection'),
value: ADD_CONNECTION_VALUE,
});
if (!appConfig || appConfig.canCustomConnect) {
options.push({
label: formatMessage('chooseConnectionSubstep.addNewConnection'),
value: ADD_CONNECTION_VALUE,
});
}
if (appConfig?.canConnect) {
options.push({
label: formatMessage('chooseConnectionSubstep.addNewSharedConnection'),
value: ADD_SHARED_CONNECTION_VALUE,
});
}
return options;
}, [data, formatMessage]);
}, [data, formatMessage, appConfig]);
const handleClientClick = async (appAuthClientId: string) => {
try {
const response = await authenticate?.({
appAuthClientId,
});
const connectionId = response?.createConnection.id;
if (connectionId) {
await refetch();
onChange({
step: {
...step,
connection: {
id: connectionId,
},
},
});
}
} catch (err) {
// void
} finally {
setShowAddSharedConnectionDialog(false);
}
};
const { name } = substep;
@@ -131,6 +178,11 @@ function ChooseConnectionSubstep(
return;
}
if (connectionId === ADD_SHARED_CONNECTION_VALUE) {
setShowAddSharedConnectionDialog(true);
return;
}
if (connectionId !== step.connection?.id) {
onChange({
step: {
@@ -216,6 +268,14 @@ function ChooseConnectionSubstep(
application={application}
/>
)}
{application && showAddSharedConnectionDialog && (
<AppAuthClientsDialog
appKey={application.key}
onClose={() => setShowAddSharedConnectionDialog(false)}
onClientClick={handleClientClick}
/>
)}
</React.Fragment>
);
}

View File

@@ -0,0 +1,116 @@
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Paper from '@mui/material/Paper';
import Popper from '@mui/material/Popper';
import * as React from 'react';
import { Link } from 'react-router-dom';
type SplitButtonProps = {
options: {
key: string;
'data-test'?: string;
label: React.ReactNode;
to: string;
}[];
disabled?: boolean;
defaultActionIndex?: number;
};
export default function SplitButton(props: SplitButtonProps) {
const { options, disabled, defaultActionIndex = 0 } = props;
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement>(null);
const multiOptions = options.length > 1;
const selectedOption = options[defaultActionIndex];
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event: Event) => {
if (
anchorRef.current &&
anchorRef.current.contains(event.target as HTMLElement)
) {
return;
}
setOpen(false);
};
return (
<React.Fragment>
<ButtonGroup
variant="contained"
ref={anchorRef}
aria-label="split button"
disabled={disabled}
>
<Button
size="large"
data-test={selectedOption['data-test']}
component={Link}
to={selectedOption.to}
sx={{
// Link component causes style loss in ButtonGroup
borderRadius: 0,
borderRight: '1px solid #bdbdbd',
}}
>
{selectedOption.label}
</Button>
{multiOptions && (
<Button size="small" onClick={handleToggle} sx={{ borderRadius: 0 }}>
<ArrowDropDownIcon />
</Button>
)}
</ButtonGroup>
{multiOptions && (
<Popper
sx={{
zIndex: 1,
}}
open={open}
anchorEl={anchorRef.current}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem>
{options.map((option, index) => (
<MenuItem
key={option.key}
selected={index === defaultActionIndex}
component={Link}
to={option.to}
>
{option.label}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
)}
</React.Fragment>
);
}