diff --git a/packages/web/package.json b/packages/web/package.json index db77a792..2d54761a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -95,5 +95,6 @@ "extends": [ "./.eslintrc.js" ] - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/web/src/adminSettingsRoutes.jsx b/packages/web/src/adminSettingsRoutes.jsx index 408c40bf..72c5c730 100644 --- a/packages/web/src/adminSettingsRoutes.jsx +++ b/packages/web/src/adminSettingsRoutes.jsx @@ -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 ( } /> + + + + } + /> + } diff --git a/packages/web/src/components/AdminSettingsLayout/index.jsx b/packages/web/src/components/AdminSettingsLayout/index.jsx index e8aad6e1..6fbc024a 100644 --- a/packages/web/src/components/AdminSettingsLayout/index.jsx +++ b/packages/web/src/components/AdminSettingsLayout/index.jsx @@ -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 = [ diff --git a/packages/web/src/components/ApiTokenList/index.jsx b/packages/web/src/components/ApiTokenList/index.jsx new file mode 100644 index 00000000..919189a5 --- /dev/null +++ b/packages/web/src/components/ApiTokenList/index.jsx @@ -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 ( + <> + + + + + + + {formatMessage('adminApiTokenList.token')} + + + + + + {formatMessage('adminApiTokenList.createdAt')} + + + + + + + + {loading && ( + + )} + {!loading && + apiTokens.map((apiToken) => ( + + + + {apiToken.token} + + + + + + {DateTime.fromMillis( + parseInt(apiToken.createdAt, 10), + ).toLocaleString(DateTime.DATETIME_MED)} + + + + + + + + + + ))} + +
+
+ + ); +} + +ApiTokenList.propTypes = { + apiTokens: PropTypes.array, + loading: PropTypes.bool, +}; diff --git a/packages/web/src/components/Can/index.jsx b/packages/web/src/components/Can/index.jsx index 242eeb4b..2f28827e 100644 --- a/packages/web/src/components/Can/index.jsx +++ b/packages/web/src/components/Can/index.jsx @@ -4,6 +4,7 @@ import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; function Can(props) { const currentUserAbility = useCurrentUserAbility(); + return ; } diff --git a/packages/web/src/components/ConditionalIconButton/index.jsx b/packages/web/src/components/ConditionalIconButton/index.jsx index 1f859eb6..1bffd4fc 100644 --- a/packages/web/src/components/ConditionalIconButton/index.jsx +++ b/packages/web/src/components/ConditionalIconButton/index.jsx @@ -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']} > diff --git a/packages/web/src/components/CreatedApiTokenDialog/index.jsx b/packages/web/src/components/CreatedApiTokenDialog/index.jsx new file mode 100644 index 00000000..d7547d32 --- /dev/null +++ b/packages/web/src/components/CreatedApiTokenDialog/index.jsx @@ -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 ( + + {formatMessage('createdApiTokenDialog.title')} + + + + {formatMessage('createdApiTokenDialog.description')} + + + + copyValue(apiToken)} edge="end"> + + + + ), + }} + inputProps={{ + 'data-test': 'api-token-field', + }} + /> + + + {formatMessage('createdApiTokenDialog.warningForApiToken', { + strong: makeBold, + })} + + + + + + + + ); +} + +CreatedApiTokenDialog.propTypes = { + open: PropTypes.bool, + 'data-test': PropTypes.string, + apiToken: PropTypes.string, + onClose: PropTypes.func, +}; + +export default CreatedApiTokenDialog; diff --git a/packages/web/src/components/DeleteApiTokenButton/index.ee.jsx b/packages/web/src/components/DeleteApiTokenButton/index.ee.jsx new file mode 100644 index 00000000..16a7e71c --- /dev/null +++ b/packages/web/src/components/DeleteApiTokenButton/index.ee.jsx @@ -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 ( + <> + setShowConfirmation(true)} + size="small" + > + + + + + + ); +} + +DeleteApiTokenButton.propTypes = { + apiTokenId: PropTypes.string.isRequired, +}; + +export default DeleteApiTokenButton; diff --git a/packages/web/src/components/NoResultFound/index.jsx b/packages/web/src/components/NoResultFound/index.jsx index db4f65ba..4a156846 100644 --- a/packages/web/src/components/NoResultFound/index.jsx +++ b/packages/web/src/components/NoResultFound/index.jsx @@ -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
{linkProps.children}
; - return ; + if (to) return ; + + if (onClick) return