mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
refactor: port tokens list to react-table (#1026)
* refactor: extract ApiTokenDocs component * refactor: extract CreateApiTokenButton component * refactor: extract RemoveApiTokenButton component * refactor: extract CopyApiTokenButton component * refactor: port tokens list to react-table * refactor: remove unused imports * fix: api token table default sort order * fix: updates to table of api tokens * fix: add highlighting when searching Co-authored-by: Tymoteusz Czech <tymek+gpg@getunleash.ai> Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
parent
504a4af274
commit
25c25c9206
@ -1,11 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { Table, TableBody, TableCell, TableRow } from 'component/common/Table';
|
import { Table, TableBody, TableCell, TableRow } from 'component/common/Table';
|
||||||
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import useAddons from 'hooks/api/getters/useAddons/useAddons';
|
import useAddons from 'hooks/api/getters/useAddons/useAddons';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
|
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { IAddon } from 'interfaces/addons';
|
import { IAddon } from 'interfaces/addons';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
import { Alert } from '@mui/material';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
|
export const ApiTokenDocs = () => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert severity="info">
|
||||||
|
<p>
|
||||||
|
Read the{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.getunleash.io/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Getting started guide
|
||||||
|
</a>{' '}
|
||||||
|
to learn how to connect to the Unleash API from your application
|
||||||
|
or programmatically. Please note it can take up to 1 minute
|
||||||
|
before a new API key is activated.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<strong>API URL: </strong>{' '}
|
||||||
|
<pre style={{ display: 'inline' }}>{uiConfig.unleashUrl}/api/</pre>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
@ -7,7 +7,7 @@ import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
|||||||
import Input from 'component/common/Input/Input';
|
import Input from 'component/common/Input/Input';
|
||||||
import { useStyles } from './ApiTokenForm.styles';
|
import { useStyles } from './ApiTokenForm.styles';
|
||||||
import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
|
import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
|
||||||
import { ApiTokenFormErrorType } from '../hooks/useApiTokenForm';
|
import { ApiTokenFormErrorType } from 'component/admin/apiToken/ApiTokenForm/useApiTokenForm';
|
||||||
interface IApiTokenFormProps {
|
interface IApiTokenFormProps {
|
||||||
username: string;
|
username: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
tableRow: {
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.grey[200],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
[theme.breakpoints.down('sm')]: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
center: {
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
actionsContainer: {
|
|
||||||
textAlign: 'center',
|
|
||||||
display: 'flex-inline',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
},
|
|
||||||
hideSM: {
|
|
||||||
[theme.breakpoints.down('md')]: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hideMD: {
|
|
||||||
[theme.breakpoints.down('lg')]: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hideXS: {
|
|
||||||
[theme.breakpoints.down('sm')]: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
token: {
|
|
||||||
textAlign: 'left',
|
|
||||||
[theme.breakpoints.up('sm')]: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
@ -1,264 +0,0 @@
|
|||||||
import { useContext, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Tooltip,
|
|
||||||
} from '@mui/material';
|
|
||||||
import AccessContext from 'contexts/AccessContext';
|
|
||||||
import useToast from 'hooks/useToast';
|
|
||||||
import useLoading from 'hooks/useLoading';
|
|
||||||
import useApiTokens from 'hooks/api/getters/useApiTokens/useApiTokens';
|
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|
||||||
import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
|
||||||
import ApiError from 'component/common/ApiError/ApiError';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { DELETE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import { Delete, FileCopy } from '@mui/icons-material';
|
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
|
||||||
import copy from 'copy-to-clipboard';
|
|
||||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
|
||||||
import { formatDateYMD } from 'utils/formatDate';
|
|
||||||
import { ProjectsList } from './ProjectsList/ProjectsList';
|
|
||||||
import { useStyles } from './ApiTokenList.styles';
|
|
||||||
|
|
||||||
interface IApiToken {
|
|
||||||
createdAt: Date;
|
|
||||||
username: string;
|
|
||||||
secret: string;
|
|
||||||
type: string;
|
|
||||||
project?: string;
|
|
||||||
projects?: string | string[];
|
|
||||||
environment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApiTokenList = () => {
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
|
||||||
const { uiConfig } = useUiConfig();
|
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
|
||||||
const [delToken, setDeleteToken] = useState<IApiToken>();
|
|
||||||
const { locationSettings } = useLocationSettings();
|
|
||||||
const { setToastData } = useToast();
|
|
||||||
const { tokens, loading, refetch, error } = useApiTokens();
|
|
||||||
const { deleteToken } = useApiTokensApi();
|
|
||||||
const ref = useLoading(loading);
|
|
||||||
|
|
||||||
const renderError = () => {
|
|
||||||
return <ApiError onClick={refetch} text="Error fetching api tokens" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToken = (value: string) => {
|
|
||||||
if (copy(value)) {
|
|
||||||
setToastData({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Token copied',
|
|
||||||
text: `Token is copied to clipboard`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteToken = async () => {
|
|
||||||
if (delToken) {
|
|
||||||
await deleteToken(delToken.secret);
|
|
||||||
}
|
|
||||||
setDeleteToken(undefined);
|
|
||||||
setShowDelete(false);
|
|
||||||
refetch();
|
|
||||||
setToastData({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Deleted successfully',
|
|
||||||
text: 'Successfully deleted API token.',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderApiTokens = (tokens: IApiToken[]) => {
|
|
||||||
return (
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className={styles.hideSM}>Created</TableCell>
|
|
||||||
<TableCell className={styles.hideSM}>
|
|
||||||
Username
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`${styles.center} ${styles.hideXS}`}
|
|
||||||
>
|
|
||||||
Type
|
|
||||||
</TableCell>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={uiConfig.flags.E}
|
|
||||||
show={
|
|
||||||
<>
|
|
||||||
<TableCell
|
|
||||||
className={`${styles.center} ${styles.hideXS}`}
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`${styles.center} ${styles.hideXS}`}
|
|
||||||
>
|
|
||||||
Environment
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TableCell className={styles.hideMD}>Secret</TableCell>
|
|
||||||
<TableCell className={styles.token}>Token</TableCell>
|
|
||||||
<TableCell className={styles.actionsContainer}>
|
|
||||||
Actions
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{tokens.map(item => {
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={item.secret}
|
|
||||||
className={styles.tableRow}
|
|
||||||
>
|
|
||||||
<TableCell
|
|
||||||
align="left"
|
|
||||||
className={styles.hideSM}
|
|
||||||
>
|
|
||||||
{formatDateYMD(
|
|
||||||
item.createdAt,
|
|
||||||
locationSettings.locale
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
align="left"
|
|
||||||
className={styles.hideSM}
|
|
||||||
>
|
|
||||||
{item.username}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`${styles.center} ${styles.hideXS}`}
|
|
||||||
>
|
|
||||||
{item.type}
|
|
||||||
</TableCell>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={uiConfig.flags.E}
|
|
||||||
show={
|
|
||||||
<>
|
|
||||||
<TableCell
|
|
||||||
className={`${styles.center} ${styles.hideXS}`}
|
|
||||||
>
|
|
||||||
<ProjectsList
|
|
||||||
project={item.project}
|
|
||||||
projects={item.projects}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`${styles.center} ${styles.hideXS}`}
|
|
||||||
>
|
|
||||||
{item.environment}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={styles.token}>
|
|
||||||
<b>Type:</b> {item.type}
|
|
||||||
<br />
|
|
||||||
<b>Env:</b> {item.environment}
|
|
||||||
<br />
|
|
||||||
<b>Projects:</b>{' '}
|
|
||||||
<ProjectsList
|
|
||||||
project={item.project}
|
|
||||||
projects={item.projects}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
elseShow={
|
|
||||||
<>
|
|
||||||
<TableCell className={styles.token}>
|
|
||||||
<b>Type:</b> {item.type}
|
|
||||||
<br />
|
|
||||||
<b>Username:</b> {item.username}
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TableCell className={styles.hideMD}>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
display="inline-block"
|
|
||||||
width="250px"
|
|
||||||
>
|
|
||||||
************************************
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={styles.actionsContainer}>
|
|
||||||
<Tooltip title="Copy token" arrow>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
copyToken(item.secret);
|
|
||||||
}}
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<FileCopy />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasAccess(DELETE_API_TOKEN)}
|
|
||||||
show={
|
|
||||||
<Tooltip title="Delete token" arrow>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteToken(item);
|
|
||||||
setShowDelete(true);
|
|
||||||
}}
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<Delete />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
<ConditionallyRender condition={error} show={renderError()} />
|
|
||||||
<div className={styles.container}>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={tokens.length < 1 && !loading}
|
|
||||||
show={<div>No API tokens available.</div>}
|
|
||||||
elseShow={renderApiTokens(tokens)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Dialogue
|
|
||||||
open={showDelete}
|
|
||||||
onClick={onDeleteToken}
|
|
||||||
onClose={() => {
|
|
||||||
setShowDelete(false);
|
|
||||||
setDeleteToken(undefined);
|
|
||||||
}}
|
|
||||||
title="Confirm deletion"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
Are you sure you want to delete the following API token?
|
|
||||||
<br />
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>username</strong>:{' '}
|
|
||||||
<code>{delToken?.username}</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>type</strong>: <code>{delToken?.type}</code>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Dialogue>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,7 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
infoBoxContainer: {
|
|
||||||
marginBottom: 40,
|
|
||||||
},
|
|
||||||
}));
|
|
@ -1,77 +1,18 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Button } from '@mui/material';
|
|
||||||
import AccessContext from 'contexts/AccessContext';
|
import AccessContext from 'contexts/AccessContext';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import {
|
import { READ_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||||
CREATE_API_TOKEN,
|
|
||||||
READ_API_TOKEN,
|
|
||||||
} from 'component/providers/AccessProvider/permissions';
|
|
||||||
import { useStyles } from './ApiTokenPage.styles';
|
|
||||||
import { CREATE_API_TOKEN_BUTTON } from 'utils/testIds';
|
|
||||||
import { Alert } from '@mui/material';
|
|
||||||
import { ApiTokenList } from 'component/admin/apiToken/ApiTokenList/ApiTokenList';
|
|
||||||
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||||
|
import { ApiTokenTable } from 'component/admin/apiToken/ApiTokenTable/ApiTokenTable';
|
||||||
|
|
||||||
export const ApiTokenPage = () => {
|
export const ApiTokenPage = () => {
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const { uiConfig } = useUiConfig();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<ConditionallyRender
|
||||||
header={
|
condition={hasAccess(READ_API_TOKEN)}
|
||||||
<PageHeader
|
show={() => <ApiTokenTable />}
|
||||||
title="API Access"
|
elseShow={() => <AdminAlert />}
|
||||||
actions={
|
/>
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasAccess(CREATE_API_TOKEN)}
|
|
||||||
show={
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() =>
|
|
||||||
navigate('/admin/api/create-token')
|
|
||||||
}
|
|
||||||
data-testid={CREATE_API_TOKEN_BUTTON}
|
|
||||||
>
|
|
||||||
New API token
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Alert severity="info" className={styles.infoBoxContainer}>
|
|
||||||
<p>
|
|
||||||
Read the{' '}
|
|
||||||
<a
|
|
||||||
href="https://docs.getunleash.io/docs"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Getting started guide
|
|
||||||
</a>{' '}
|
|
||||||
to learn how to connect to the Unleash API from your
|
|
||||||
application or programmatically. Please note it can take up
|
|
||||||
to 1 minute before a new API key is activated.
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<strong>API URL: </strong>{' '}
|
|
||||||
<pre style={{ display: 'inline' }}>
|
|
||||||
{uiConfig.unleashUrl}/api/
|
|
||||||
</pre>
|
|
||||||
</Alert>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasAccess(READ_API_TOKEN)}
|
|
||||||
show={() => <ApiTokenList />}
|
|
||||||
elseShow={() => <AdminAlert />}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,219 @@
|
|||||||
|
import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||||
|
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import {
|
||||||
|
SortableTableHeader,
|
||||||
|
TableSearch,
|
||||||
|
TableCell,
|
||||||
|
TablePlaceholder,
|
||||||
|
} from 'component/common/Table';
|
||||||
|
import { Table, TableBody, Box, TableRow, useMediaQuery } from '@mui/material';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { ApiTokenDocs } from 'component/admin/apiToken/ApiTokenDocs/ApiTokenDocs';
|
||||||
|
import { CreateApiTokenButton } from 'component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton';
|
||||||
|
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { Key } from '@mui/icons-material';
|
||||||
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { CopyApiTokenButton } from 'component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton';
|
||||||
|
import { RemoveApiTokenButton } from 'component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton';
|
||||||
|
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import theme from 'themes/theme';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { ProjectsList } from 'component/admin/apiToken/ProjectsList/ProjectsList';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
|
||||||
|
export const ApiTokenTable = () => {
|
||||||
|
const { tokens } = useApiTokens();
|
||||||
|
const hiddenColumns = useHiddenColumns();
|
||||||
|
const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
rows,
|
||||||
|
prepareRow,
|
||||||
|
state: { globalFilter },
|
||||||
|
setGlobalFilter,
|
||||||
|
setHiddenColumns,
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
columns: COLUMNS as any,
|
||||||
|
data: tokens as any,
|
||||||
|
initialState,
|
||||||
|
sortTypes,
|
||||||
|
disableSortRemove: true,
|
||||||
|
},
|
||||||
|
useGlobalFilter,
|
||||||
|
useSortBy
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHiddenColumns(hiddenColumns);
|
||||||
|
}, [setHiddenColumns, hiddenColumns]);
|
||||||
|
|
||||||
|
const headerSearch = (
|
||||||
|
<TableSearch initialValue={globalFilter} onChange={setGlobalFilter} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerActions = (
|
||||||
|
<>
|
||||||
|
{headerSearch}
|
||||||
|
<PageHeader.Divider />
|
||||||
|
<CreateApiTokenButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tokens.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={`API access (${rows.length})`}
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<ApiTokenDocs />
|
||||||
|
</Box>
|
||||||
|
<SearchHighlightProvider value={globalFilter}>
|
||||||
|
<Table {...getTableProps()}>
|
||||||
|
<SortableTableHeader headerGroups={headerGroups as any} />
|
||||||
|
<TableBody {...getTableBodyProps()}>
|
||||||
|
{rows.map(row => {
|
||||||
|
prepareRow(row);
|
||||||
|
return (
|
||||||
|
<TableRow hover {...row.getRowProps()}>
|
||||||
|
{row.cells.map(cell => (
|
||||||
|
<TableCell {...cell.getCellProps()}>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={globalFilter?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No tokens found matching “
|
||||||
|
{globalFilter}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No tokens available. Get started by adding one.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useHiddenColumns = (): string[] => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const hidden: string[] = [];
|
||||||
|
|
||||||
|
if (!uiConfig.flags.E) {
|
||||||
|
hidden.push('projects');
|
||||||
|
hidden.push('environment');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMediumScreen) {
|
||||||
|
hidden.push('Icon');
|
||||||
|
hidden.push('createdAt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
hidden.push('username');
|
||||||
|
}
|
||||||
|
|
||||||
|
return hidden;
|
||||||
|
}, [uiConfig, isSmallScreen, isMediumScreen]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{
|
||||||
|
id: 'Icon',
|
||||||
|
width: '1%',
|
||||||
|
Cell: () => <IconCell icon={<Key color="disabled" />} />,
|
||||||
|
disableSortBy: true,
|
||||||
|
disableGlobalFilter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Username',
|
||||||
|
accessor: 'username',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
width: '60%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'type',
|
||||||
|
Cell: ({ value }: { value: string }) => (
|
||||||
|
<HighlightCell value={value.toUpperCase()} />
|
||||||
|
),
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Project',
|
||||||
|
accessor: 'project',
|
||||||
|
Cell: (props: any) => (
|
||||||
|
<TextCell>
|
||||||
|
<ProjectsList
|
||||||
|
project={props.row.original.project}
|
||||||
|
projects={props.row.original.projects}
|
||||||
|
/>
|
||||||
|
</TextCell>
|
||||||
|
),
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Environment',
|
||||||
|
accessor: 'environment',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Created',
|
||||||
|
accessor: 'createdAt',
|
||||||
|
Cell: DateCell,
|
||||||
|
minWidth: 150,
|
||||||
|
disableGlobalFilter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Actions',
|
||||||
|
id: 'Actions',
|
||||||
|
align: 'center',
|
||||||
|
width: '1%',
|
||||||
|
disableSortBy: true,
|
||||||
|
disableGlobalFilter: true,
|
||||||
|
Cell: (props: any) => (
|
||||||
|
<ActionCell>
|
||||||
|
<CopyApiTokenButton token={props.row.original} />
|
||||||
|
<RemoveApiTokenButton token={props.row.original} />
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
@ -0,0 +1,30 @@
|
|||||||
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
|
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import copy from 'copy-to-clipboard';
|
||||||
|
import { FileCopy } from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface ICopyApiTokenButtonProps {
|
||||||
|
token: IApiToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => {
|
||||||
|
const { setToastData } = useToast();
|
||||||
|
|
||||||
|
const copyToken = (value: string) => {
|
||||||
|
if (copy(value)) {
|
||||||
|
setToastData({
|
||||||
|
type: 'success',
|
||||||
|
title: `Token copied to clipboard`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title="Copy token" arrow>
|
||||||
|
<IconButton onClick={() => copyToken(token.secret)} size="large">
|
||||||
|
<FileCopy />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
@ -5,7 +5,7 @@ import { CreateButton } from 'component/common/CreateButton/CreateButton';
|
|||||||
import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { useApiTokenForm } from '../hooks/useApiTokenForm';
|
import { useApiTokenForm } from 'component/admin/apiToken/ApiTokenForm/useApiTokenForm';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
import { ConfirmToken } from '../ConfirmToken/ConfirmToken';
|
import { ConfirmToken } from '../ConfirmToken/ConfirmToken';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
||||||
|
import { CREATE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { CREATE_API_TOKEN_BUTTON } from 'utils/testIds';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Add } from '@mui/icons-material';
|
||||||
|
|
||||||
|
export const CreateApiTokenButton = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveButton
|
||||||
|
Icon={Add}
|
||||||
|
onClick={() => navigate('/admin/api/create-token')}
|
||||||
|
data-testid={CREATE_API_TOKEN_BUTTON}
|
||||||
|
permission={CREATE_API_TOKEN}
|
||||||
|
maxWidth="700px"
|
||||||
|
>
|
||||||
|
New API token
|
||||||
|
</ResponsiveButton>
|
||||||
|
);
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { ProjectsList } from './ProjectsList';
|
import { ProjectsList } from 'component/admin/apiToken/ProjectsList/ProjectsList';
|
||||||
|
|
||||||
describe('ProjectsList', () => {
|
describe('ProjectsList', () => {
|
||||||
it('should prioritize new "projects" array over deprecated "project"', async () => {
|
it('should prioritize new "projects" array over deprecated "project"', async () => {
|
@ -1,4 +1,6 @@
|
|||||||
import React, { Fragment, VFC } from 'react';
|
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||||
|
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { Fragment, VFC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
interface IProjectsListProps {
|
interface IProjectsListProps {
|
||||||
@ -10,6 +12,8 @@ export const ProjectsList: VFC<IProjectsListProps> = ({
|
|||||||
projects,
|
projects,
|
||||||
project,
|
project,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { searchQuery } = useSearchHighlightContext();
|
||||||
|
|
||||||
let fields: string[] =
|
let fields: string[] =
|
||||||
projects && Array.isArray(projects)
|
projects && Array.isArray(projects)
|
||||||
? projects
|
? projects
|
||||||
@ -18,7 +22,7 @@ export const ProjectsList: VFC<IProjectsListProps> = ({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return <>*</>;
|
return <Highlighter search={searchQuery}>*</Highlighter>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -27,9 +31,13 @@ export const ProjectsList: VFC<IProjectsListProps> = ({
|
|||||||
<Fragment key={item}>
|
<Fragment key={item}>
|
||||||
{index > 0 && ', '}
|
{index > 0 && ', '}
|
||||||
{!item || item === '*' ? (
|
{!item || item === '*' ? (
|
||||||
'*'
|
<Highlighter search={searchQuery}>*</Highlighter>
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/projects/${item}`}>{item}</Link>
|
<Link to={`/projects/${item}`}>
|
||||||
|
<Highlighter search={searchQuery}>
|
||||||
|
{item}
|
||||||
|
</Highlighter>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
@ -0,0 +1,70 @@
|
|||||||
|
import { DELETE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { Delete } from '@mui/icons-material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
|
import {
|
||||||
|
IApiToken,
|
||||||
|
useApiTokens,
|
||||||
|
} from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||||
|
import AccessContext from 'contexts/AccessContext';
|
||||||
|
import { useContext, useState } from 'react';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
||||||
|
|
||||||
|
interface IRemoveApiTokenButtonProps {
|
||||||
|
token: IApiToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => {
|
||||||
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { deleteToken } = useApiTokensApi();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { setToastData } = useToast();
|
||||||
|
const { refetch } = useApiTokens();
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
await deleteToken(token.secret);
|
||||||
|
setOpen(false);
|
||||||
|
refetch();
|
||||||
|
setToastData({
|
||||||
|
type: 'success',
|
||||||
|
title: 'API token removed',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={hasAccess(DELETE_API_TOKEN)}
|
||||||
|
show={
|
||||||
|
<Tooltip title="Delete token" arrow>
|
||||||
|
<IconButton onClick={() => setOpen(true)} size="large">
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
onClick={onRemove}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title="Confirm deletion"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Are you sure you want to delete the following API token?
|
||||||
|
<br />
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>username</strong>:{' '}
|
||||||
|
<code>{token.username}</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>type</strong>: <code>{token.type}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Dialogue>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,36 +1,40 @@
|
|||||||
import useSWR, { mutate, SWRConfiguration } from 'swr';
|
import useSWR, { SWRConfiguration } from 'swr';
|
||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
|
||||||
const useApiTokens = (options: SWRConfiguration = {}) => {
|
export interface IApiToken {
|
||||||
const fetcher = async () => {
|
createdAt: Date;
|
||||||
const path = formatApiPath(`api/admin/api-tokens`);
|
username: string;
|
||||||
const res = await fetch(path, {
|
secret: string;
|
||||||
method: 'GET',
|
type: string;
|
||||||
}).then(handleErrorResponses('Api tokens'));
|
project?: string;
|
||||||
return res.json();
|
projects?: string | string[];
|
||||||
};
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
const KEY = `api/admin/api-tokens`;
|
export const useApiTokens = (options: SWRConfiguration = {}) => {
|
||||||
|
const path = formatApiPath(`api/admin/api-tokens`);
|
||||||
|
const { data, error, mutate } = useSWR<IApiToken[]>(path, fetcher, options);
|
||||||
|
|
||||||
const { data, error } = useSWR(KEY, fetcher, options);
|
const tokens = useMemo(() => {
|
||||||
const [loading, setLoading] = useState(!error && !data);
|
return data ?? [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const refetch = () => {
|
const refetch = useCallback(() => {
|
||||||
mutate(KEY);
|
mutate().catch(console.warn);
|
||||||
};
|
}, [mutate]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(!error && !data);
|
|
||||||
}, [data, error]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokens: data?.tokens || [],
|
tokens,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading: !error && !data,
|
||||||
refetch,
|
refetch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useApiTokens;
|
const fetcher = async (path: string): Promise<IApiToken[]> => {
|
||||||
|
const res = await fetch(path).then(handleErrorResponses('Api tokens'));
|
||||||
|
const data = await res.json();
|
||||||
|
return data.tokens;
|
||||||
|
};
|
||||||
|
@ -3,6 +3,7 @@ import { formatApiPath } from 'utils/formatPath';
|
|||||||
import { defaultValue } from './defaultValue';
|
import { defaultValue } from './defaultValue';
|
||||||
import { IUiConfig } from 'interfaces/uiConfig';
|
import { IUiConfig } from 'interfaces/uiConfig';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
const REQUEST_KEY = 'api/admin/ui-config';
|
const REQUEST_KEY = 'api/admin/ui-config';
|
||||||
|
|
||||||
@ -33,8 +34,12 @@ const useUiConfig = (options: SWRConfiguration = {}) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uiConfig = useMemo(() => {
|
||||||
|
return { ...defaultValue, ...data };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiConfig: { ...defaultValue, ...data },
|
uiConfig,
|
||||||
loading: !error && !data,
|
loading: !error && !data,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
Loading…
Reference in New Issue
Block a user