feat(web): introduce API tokens in admin dashboard

This commit is contained in:
Ali BARIN
2025-04-15 16:01:34 +00:00
parent 2c1888e1b3
commit 15e8f1d5a8
20 changed files with 500 additions and 10 deletions

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;