Merge pull request #2438 from automatisch/aut-1528

feat(web): introduce API tokens in admin dashboard
This commit is contained in:
Ali BARIN
2025-04-23 08:52:29 +02:00
committed by GitHub
20 changed files with 500 additions and 10 deletions

View File

@@ -95,5 +95,6 @@
"extends": [
"./.eslintrc.js"
]
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -15,6 +15,7 @@ import AdminApplication from 'pages/AdminApplication';
import AdminTemplates from 'pages/AdminTemplates';
import AdminCreateTemplate from 'pages/AdminCreateTemplate';
import AdminUpdateTemplate from 'pages/AdminUpdateTemplate';
import AdminApiTokensPage from 'pages/AdminApiTokens';
// TODO: consider introducing redirections to `/` as fallback
export default (
@@ -140,6 +141,15 @@ export default (
}
/>
<Route
path={`${URLS.ADMIN_API_TOKENS}/*`}
element={
<Can I="manage" a="ApiToken">
<AdminApiTokensPage />
</Can>
}
/>
<Route
path={URLS.ADMIN_SETTINGS}
element={<Navigate to={URLS.USERS} replace />}

View File

@@ -4,6 +4,8 @@ import GroupsIcon from '@mui/icons-material/Groups';
import LockIcon from '@mui/icons-material/LockPerson';
import BrushIcon from '@mui/icons-material/Brush';
import AppsIcon from '@mui/icons-material/Apps';
import PinIcon from '@mui/icons-material/Pin';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Outlet } from 'react-router-dom';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
@@ -28,6 +30,7 @@ function createDrawerLinks({
canUpdateConfig,
canManageSamlAuthProvider,
canUpdateApp,
canManageApiTokens,
}) {
const items = [
canReadUser
@@ -72,12 +75,20 @@ function createDrawerLinks({
: null,
canUpdateConfig
? {
Icon: AppsIcon,
Icon: ContentCopyIcon,
primary: 'adminSettingsDrawer.templates',
to: URLS.ADMIN_TEMPLATES,
dataTest: 'templates-drawer-link',
}
: null,
canManageApiTokens
? {
Icon: PinIcon,
primary: 'adminSettingsDrawer.apiTokens',
to: URLS.ADMIN_API_TOKENS,
dataTest: 'api-tokens-drawer-link',
}
: null,
].filter(Boolean);
return items;
@@ -102,6 +113,7 @@ function SettingsLayout() {
'SamlAuthProvider',
),
canUpdateApp: currentUserAbility.can('manage', 'App'),
canManageApiTokens: currentUserAbility.can('manage', 'ApiToken'),
});
const drawerBottomLinks = [

View File

@@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { DateTime } from 'luxon';
import * as React from 'react';
import ListLoader from 'components/ListLoader';
import useFormatMessage from 'hooks/useFormatMessage';
import DeleteApiTokenButton from 'components/DeleteApiTokenButton/index.ee';
export default function ApiTokenList({ loading, apiTokens }) {
const formatMessage = useFormatMessage();
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell component="th">
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', fontWeight: 700 }}
>
{formatMessage('adminApiTokenList.token')}
</Typography>
</TableCell>
<TableCell component="th">
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', fontWeight: 700 }}
>
{formatMessage('adminApiTokenList.createdAt')}
</Typography>
</TableCell>
<TableCell component="th" />
</TableRow>
</TableHead>
<TableBody>
{loading && (
<ListLoader
data-test="apiTokens-list-loader"
rowsNumber={3}
columnsNumber={3}
/>
)}
{!loading &&
apiTokens.map((apiToken) => (
<TableRow
key={apiToken.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
data-test="api-token-row"
>
<TableCell scope="row">
<Typography variant="subtitle2" data-test="api-token-token">
{apiToken.token}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="subtitle2"
data-test="api-token-created-at"
>
{DateTime.fromMillis(
parseInt(apiToken.createdAt, 10),
).toLocaleString(DateTime.DATETIME_MED)}
</Typography>
</TableCell>
<TableCell>
<Stack direction="row" gap={1} justifyContent="right">
<DeleteApiTokenButton
data-test="api-token-delete"
apiTokenId={apiToken.id}
/>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
}
ApiTokenList.propTypes = {
apiTokens: PropTypes.array,
loading: PropTypes.bool,
};

View File

@@ -4,6 +4,7 @@ import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
function Can(props) {
const currentUserAbility = useCurrentUserAbility();
return <OriginalCan ability={currentUserAbility} {...props} />;
}

View File

@@ -18,6 +18,7 @@ function ConditionalIconButton(props) {
size={buttonProps.size}
component={buttonProps.component}
to={buttonProps.to}
onClick={buttonProps.onClick}
disabled={buttonProps.disabled}
data-test={buttonProps['data-test']}
>

View File

@@ -0,0 +1,86 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import MuiTextField from '@mui/material/TextField';
import PropTypes from 'prop-types';
import * as React from 'react';
import copyValue from 'helpers/copyValue';
import { makeBold } from 'helpers/translationValues';
import useFormatMessage from 'hooks/useFormatMessage';
function CreatedApiTokenDialog(props) {
const {
open = true,
'data-test': dataTest = 'created-api-token-dialog',
onClose,
apiToken,
} = props;
const formatMessage = useFormatMessage();
return (
<Dialog open={open} onClose={onClose} data-test={dataTest}>
<DialogTitle>{formatMessage('createdApiTokenDialog.title')}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 4 }}>
{formatMessage('createdApiTokenDialog.description')}
</DialogContentText>
<MuiTextField
label={formatMessage('createdApiTokenDialog.apiTokenFieldLabel')}
variant="outlined"
value={apiToken}
fullWidth
InputLabelProps={{ shrink: true }}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => copyValue(apiToken)} edge="end">
<ContentCopyIcon color="primary" />
</IconButton>
</InputAdornment>
),
}}
inputProps={{
'data-test': 'api-token-field',
}}
/>
<Alert severity="error" sx={{ mt: 1 }}>
{formatMessage('createdApiTokenDialog.warningForApiToken', {
strong: makeBold,
})}
</Alert>
</DialogContent>
<DialogActions sx={{ mb: 1 }}>
<Button
variant="outlined"
onClick={onClose}
data-test="import-flow-dialog-close-button"
>
{formatMessage('createdApiTokenDialog.close')}
</Button>
</DialogActions>
</Dialog>
);
}
CreatedApiTokenDialog.propTypes = {
open: PropTypes.bool,
'data-test': PropTypes.string,
apiToken: PropTypes.string,
onClose: PropTypes.func,
};
export default CreatedApiTokenDialog;

View File

@@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import DeleteIcon from '@mui/icons-material/Delete';
import IconButton from '@mui/material/IconButton';
import { getGeneralErrorMessage } from 'helpers/errors';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react';
import ConfirmationDialog from 'components/ConfirmationDialog';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminDeleteApiToken from 'hooks/useAdminDeleteApiToken.ee';
function DeleteApiTokenButton(props) {
const { apiTokenId } = props;
const [showConfirmation, setShowConfirmation] = React.useState(false);
const {
mutateAsync: deleteApiToken,
error: deleteApiTokenError,
reset: resetDeleteApiToken,
} = useAdminDeleteApiToken(apiTokenId);
const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const generalErrorMessage = getGeneralErrorMessage({
error: deleteApiTokenError,
fallbackMessage: formatMessage('deleteApiTokenButton.deleteError'),
});
const handleConfirm = React.useCallback(async () => {
try {
await deleteApiToken();
setShowConfirmation(false);
enqueueSnackbar(
formatMessage('deleteApiTokenButton.successfullyDeleted'),
{
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-delete-api-token-success',
},
},
);
} catch {}
}, [deleteApiToken]);
const handleClose = () => {
setShowConfirmation(false);
resetDeleteApiToken();
};
return (
<>
<IconButton
data-test="delete-button"
onClick={() => setShowConfirmation(true)}
size="small"
>
<DeleteIcon />
</IconButton>
<ConfirmationDialog
open={showConfirmation}
title={formatMessage('deleteApiTokenButton.title')}
description={formatMessage('deleteApiTokenButton.description')}
onClose={handleClose}
onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteApiTokenButton.cancel')}
confirmButtonChildren={formatMessage('deleteApiTokenButton.confirm')}
data-test="delete-api-token-modal"
errorMessage={generalErrorMessage}
/>
</>
);
}
DeleteApiTokenButton.propTypes = {
apiTokenId: PropTypes.string.isRequired,
};
export default DeleteApiTokenButton;

View File

@@ -3,28 +3,32 @@ import { Link } from 'react-router-dom';
import Card from '@mui/material/Card';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import CardActionArea from '@mui/material/CardActionArea';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import PropTypes from 'prop-types';
import { CardContent } from './style';
function NoResultFound(props) {
const { text, to } = props;
const { onClick, text, to } = props;
const ActionAreaLink = React.useMemo(
() =>
React.forwardRef(function InlineLink(linkProps, ref) {
if (!to) return <div>{linkProps.children}</div>;
return <Link ref={ref} to={to} {...linkProps} />;
if (to) return <Link ref={ref} to={to} {...linkProps} />;
if (onClick) return <Button onClick={onClick} {...linkProps} />;
return <div>{linkProps.children}</div>;
}),
[to],
[to, onClick],
);
return (
<Card elevation={0} data-test="no-results">
<CardActionArea component={ActionAreaLink} {...props}>
<CardContent>
{!!to && <AddCircleIcon color="primary" />}
{(!!to || !!onClick) && <AddCircleIcon color="primary" />}
<Typography variant="body1">{text}</Typography>
</CardContent>
</CardActionArea>
@@ -35,6 +39,7 @@ function NoResultFound(props) {
NoResultFound.propTypes = {
text: PropTypes.string,
to: PropTypes.string,
onClick: PropTypes.func,
};
export default NoResultFound;

View File

@@ -88,6 +88,7 @@ export const ADMIN_APP_CONNECTIONS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/con
export const ADMIN_TEMPLATES = `${ADMIN_SETTINGS}/templates`;
export const ADMIN_CREATE_TEMPLATE_PATTERN = `${ADMIN_SETTINGS}/templates/create/:flowId`;
export const ADMIN_UPDATE_TEMPLATE_PATTERN = `${ADMIN_SETTINGS}/templates/update/:templateId`;
export const ADMIN_API_TOKENS = `${ADMIN_SETTINGS}/api-tokens`;
export const CREATE_FLOW_FROM_TEMPLATE = (templateId) =>
`/editor/create?templateId=${templateId}`;

View File

@@ -1,4 +1,5 @@
import copy from 'clipboard-copy';
import copyValue from './copyValue';
export default function copyInputValue(element) {
copy(element.value);
copyValue(element.value);
}

View File

@@ -0,0 +1,5 @@
import copy from 'clipboard-copy';
export default function copyInputValue(value) {
copy(value);
}

View File

@@ -12,3 +12,5 @@ export const generateExternalLink = (link) => (str) => (
{str}
</Link>
);
export const makeBold = (str) => <strong>{str}</strong>;

View File

@@ -19,5 +19,9 @@ export default function userAbility(user) {
return new PureAbility([], options);
}
if (role.isAdmin) {
return new PureAbility([{ subject: 'all', action: 'manage' }], options);
}
return new PureAbility(permissions, options);
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminApiTokens() {
const query = useQuery({
queryKey: ['admin', 'apiTokens'],
queryFn: async ({ signal }) => {
const { data } = await api.get(`/v1/admin/api-tokens`, {
signal,
});
return data;
},
});
return query;
}

View File

@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminCreateApiToken() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/v1/admin/api-tokens');
return data;
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'apiTokens'],
});
},
});
return mutation;
}

View File

@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminDeleteApiToken(apiTokenid) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const { data } = await api.delete(`/v1/admin/api-tokens/${apiTokenid}`);
return data;
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'apiTokens'],
});
},
});
return mutation;
}

View File

@@ -21,6 +21,7 @@
"adminSettingsDrawer.goBack": "Go to the dashboard",
"adminSettingsDrawer.apps": "Applications",
"adminSettingsDrawer.templates": "Templates",
"adminSettingsDrawer.apiTokens": "API Tokens",
"adminSettingsFooter.version": "Version {version}",
"app.connectionCount": "{count} connections",
"app.flowCount": "{count} flows",
@@ -405,5 +406,21 @@
"executionFilters.startDateLabel": "Start Date",
"executionFilters.endDateLabel": "End Date",
"permissionCatalogField.ownEntitiesLabel": "(own entities)",
"permissionCatalogField.allEntitiesLabel": "(all entities)"
"permissionCatalogField.allEntitiesLabel": "(all entities)",
"adminApiTokensPage.title": "API Tokens",
"adminApiTokensPage.createApiToken": "Create API Token",
"adminApiTokenList.token": "Token",
"adminApiTokenList.createdAt": "Created",
"adminApiTokensPage.noApiTokens": "You don't have any API tokens yet.",
"deleteApiTokenButton.deleteError": "An error occurred while deleting the API token.",
"deleteApiTokenButton.successfullyDeleted": "The API token has been successfully deleted.",
"deleteApiTokenButton.title": "Delete API Token",
"deleteApiTokenButton.description": "Are you sure you want to delete the API token?",
"deleteApiTokenButton.cancel": "Cancel",
"deleteApiTokenButton.confirm": "Confirm",
"createdApiTokenDialog.title": "Your API Token",
"createdApiTokenDialog.description": "Here is your API Token. Keep it secure, as anyone with your API key can make authenticated requests with it.",
"createdApiTokenDialog.apiTokenFieldLabel": "API Token",
"createdApiTokenDialog.warningForApiToken": "Please save your API token in a safe place since <strong>you won't be able to view it again</strong>.",
"createdApiTokenDialog.close": "Close"
}

View File

@@ -0,0 +1,96 @@
import * as React from 'react';
import Grid from '@mui/material/Grid';
import AddIcon from '@mui/icons-material/Add';
import PageTitle from 'components/PageTitle';
import Container from 'components/Container';
import ApiTokenList from 'components/ApiTokenList';
import ConditionalIconButton from 'components/ConditionalIconButton';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminCreateApiToken from 'hooks/useAdminCreateApiToken.ee';
import CreatedApiTokenDialog from 'components/CreatedApiTokenDialog';
import useAdminApiTokens from 'hooks/useAdminApiTokens.ee';
import NoResultFound from 'components/NoResultFound';
function AdminApiTokensPage() {
const formatMessage = useFormatMessage();
const { data: apiTokensData, isLoading } = useAdminApiTokens();
const {
mutate: createApiToken,
reset,
data: createdApiTokenData,
isPending,
} = useAdminCreateApiToken();
const [open, setOpen] = React.useState(false);
const apiTokens = apiTokensData?.data;
const createdApiToken = createdApiTokenData?.data;
const onCreateApiToken = async () => {
await createApiToken();
setOpen(true);
};
const onDialogClose = () => {
reset();
setOpen(false);
};
return (
<>
<CreatedApiTokenDialog
open={open}
onClose={onDialogClose}
apiToken={createdApiToken?.token}
/>
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
<Grid
container
sx={{ mb: [0, 3] }}
columnSpacing={1.5}
rowSpacing={3}
>
<Grid container item xs sm alignItems="center">
<PageTitle data-test="admin-api-tokens-page-title">
{formatMessage('adminApiTokensPage.title')}
</PageTitle>
</Grid>
<Grid container item xs="auto" sm="auto" alignItems="center">
<ConditionalIconButton
type="submit"
variant="contained"
color="primary"
size="large"
onClick={onCreateApiToken}
disabled={isPending}
fullWidth
icon={<AddIcon />}
data-test="create-user"
>
{formatMessage('adminApiTokensPage.createApiToken')}
</ConditionalIconButton>
</Grid>
</Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
{!isLoading && apiTokensData?.meta.count === 0 && (
<NoResultFound
onClick={onCreateApiToken}
text={formatMessage('adminApiTokensPage.noApiTokens')}
/>
)}
{(isLoading || apiTokensData?.meta.count > 0) && (
<ApiTokenList apiTokens={apiTokens} loading={isLoading} />
)}
</Grid>
</Grid>
</Container>
</>
);
}
export default AdminApiTokensPage;

View File

@@ -276,6 +276,15 @@ export const defaultTheme = createTheme({
}),
},
},
MuiDialogActions: {
styleOverrides: {
root: ({ theme }) => ({
'&&': {
paddingRight: theme.spacing(3),
},
}),
},
},
MuiDialogTitle: {
styleOverrides: {
root: ({ theme }) => ({