mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
feat: manage SA tokens through UI (#2840)
https://linear.app/unleash/issue/2-542/manage-service-account-tokens-through-the-ui 
This commit is contained in:
parent
2b8f1ee0d7
commit
b10d9c435e
@ -31,6 +31,7 @@ import {
|
||||
} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
|
||||
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
|
||||
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||
import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens';
|
||||
|
||||
const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
@ -86,9 +87,7 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
paddingTop: theme.spacing(4),
|
||||
}));
|
||||
|
||||
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||
@ -225,16 +224,18 @@ export const ServiceAccountModal = ({
|
||||
!serviceAccounts?.some(
|
||||
(serviceAccount: IUser) => serviceAccount.username === value
|
||||
);
|
||||
const isPATValid =
|
||||
tokenGeneration === TokenGeneration.LATER ||
|
||||
(isNotEmpty(patDescription) && patExpiresAt > new Date());
|
||||
const isValid =
|
||||
isNotEmpty(name) &&
|
||||
isNotEmpty(username) &&
|
||||
(editing || isUnique(username)) &&
|
||||
(tokenGeneration === TokenGeneration.LATER ||
|
||||
isNotEmpty(patDescription));
|
||||
isPATValid;
|
||||
|
||||
const suggestUsername = () => {
|
||||
if (isNotEmpty(name) && !isNotEmpty(username)) {
|
||||
const normalizedFromName = `service:${name
|
||||
const normalizedFromName = `service-${name
|
||||
.toLowerCase()
|
||||
.replace(/ /g, '-')
|
||||
.replace(/[^\w_-]/g, '')}`;
|
||||
@ -411,6 +412,16 @@ export const ServiceAccountModal = ({
|
||||
</StyledInlineContainer>
|
||||
</StyledSecondaryContainer>
|
||||
}
|
||||
elseShow={
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
Service account tokens
|
||||
</StyledInputDescription>
|
||||
<ServiceAccountTokens
|
||||
serviceAccount={serviceAccount!}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import {
|
||||
calculateExpirationDate,
|
||||
ExpirationOption,
|
||||
IPersonalAPITokenFormErrors,
|
||||
PersonalAPITokenForm,
|
||||
} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
|
||||
import { ICreatePersonalApiTokenPayload } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
||||
|
||||
const DEFAULT_EXPIRATION = ExpirationOption['30DAYS'];
|
||||
|
||||
interface IServiceAccountCreateTokenDialogProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
serviceAccount: IUser;
|
||||
onCreateClick: (newToken: ICreatePersonalApiTokenPayload) => void;
|
||||
}
|
||||
|
||||
export const ServiceAccountCreateTokenDialog = ({
|
||||
open,
|
||||
setOpen,
|
||||
serviceAccount,
|
||||
onCreateClick,
|
||||
}: IServiceAccountCreateTokenDialogProps) => {
|
||||
const { tokens = [] } = usePersonalAPITokens(serviceAccount.id);
|
||||
|
||||
const [patDescription, setPatDescription] = useState('');
|
||||
const [patExpiration, setPatExpiration] =
|
||||
useState<ExpirationOption>(DEFAULT_EXPIRATION);
|
||||
const [patExpiresAt, setPatExpiresAt] = useState(
|
||||
calculateExpirationDate(DEFAULT_EXPIRATION)
|
||||
);
|
||||
const [patErrors, setPatErrors] = useState<IPersonalAPITokenFormErrors>({});
|
||||
|
||||
useEffect(() => {
|
||||
setPatDescription('');
|
||||
setPatExpiration(DEFAULT_EXPIRATION);
|
||||
setPatExpiresAt(calculateExpirationDate(DEFAULT_EXPIRATION));
|
||||
setPatErrors({});
|
||||
}, [open]);
|
||||
|
||||
const isDescriptionUnique = (description: string) =>
|
||||
!tokens?.some(token => token.description === description);
|
||||
|
||||
const isPATValid =
|
||||
patDescription.length &&
|
||||
isDescriptionUnique(patDescription) &&
|
||||
patExpiresAt > new Date();
|
||||
|
||||
return (
|
||||
<Dialogue
|
||||
open={open}
|
||||
primaryButtonText="Create token"
|
||||
secondaryButtonText="Cancel"
|
||||
onClick={() =>
|
||||
onCreateClick({
|
||||
description: patDescription,
|
||||
expiresAt: patExpiresAt,
|
||||
})
|
||||
}
|
||||
disabledPrimaryButton={!isPATValid}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
title="New token"
|
||||
>
|
||||
<PersonalAPITokenForm
|
||||
description={patDescription}
|
||||
setDescription={setPatDescription}
|
||||
isDescriptionUnique={isDescriptionUnique}
|
||||
expiration={patExpiration}
|
||||
setExpiration={setPatExpiration}
|
||||
expiresAt={patExpiresAt}
|
||||
setExpiresAt={setPatExpiresAt}
|
||||
errors={patErrors}
|
||||
setErrors={setPatErrors}
|
||||
/>
|
||||
</Dialogue>
|
||||
);
|
||||
};
|
@ -0,0 +1,364 @@
|
||||
import { Delete } from '@mui/icons-material';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
styled,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { PAT_LIMIT } from '@server/util/constants';
|
||||
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import {
|
||||
INewPersonalAPIToken,
|
||||
IPersonalAPIToken,
|
||||
} from 'interfaces/personalAPIToken';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
|
||||
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
||||
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
|
||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import {
|
||||
ICreatePersonalApiTokenPayload,
|
||||
usePersonalAPITokensApi,
|
||||
} from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
|
||||
const StyledHeader = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing(2),
|
||||
gap: theme.spacing(2),
|
||||
'& > div': {
|
||||
[theme.breakpoints.down('md')]: {
|
||||
marginTop: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTablePlaceholder = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(3),
|
||||
}));
|
||||
|
||||
const StyledPlaceholderTitle = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
marginBottom: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
export const tokensPlaceholder: IPersonalAPIToken[] = Array(15).fill({
|
||||
description: 'Short description of the feature',
|
||||
type: '-',
|
||||
createdAt: new Date(2022, 1, 1),
|
||||
project: 'projectID',
|
||||
});
|
||||
|
||||
export type PageQueryType = Partial<
|
||||
Record<'sort' | 'order' | 'search', string>
|
||||
>;
|
||||
|
||||
const defaultSort: SortingRule<string> = { id: 'createdAt' };
|
||||
|
||||
interface IServiceAccountTokensProps {
|
||||
serviceAccount: IUser;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ServiceAccountTokens = ({
|
||||
serviceAccount,
|
||||
readOnly,
|
||||
}: IServiceAccountTokensProps) => {
|
||||
const theme = useTheme();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const {
|
||||
tokens = [],
|
||||
refetchTokens,
|
||||
loading,
|
||||
} = usePersonalAPITokens(serviceAccount.id);
|
||||
const { createUserPersonalAPIToken, deleteUserPersonalAPIToken } =
|
||||
usePersonalAPITokensApi();
|
||||
|
||||
const [initialState] = useState(() => ({
|
||||
sortBy: [defaultSort],
|
||||
}));
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [tokenOpen, setTokenOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [newToken, setNewToken] = useState<INewPersonalAPIToken>();
|
||||
const [selectedToken, setSelectedToken] = useState<IPersonalAPIToken>();
|
||||
|
||||
const onCreateClick = async (newToken: ICreatePersonalApiTokenPayload) => {
|
||||
try {
|
||||
const token = await createUserPersonalAPIToken(
|
||||
serviceAccount.id,
|
||||
newToken
|
||||
);
|
||||
refetchTokens();
|
||||
setCreateOpen(false);
|
||||
setNewToken(token);
|
||||
setTokenOpen(true);
|
||||
setToastData({
|
||||
title: 'Token created successfully',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
if (selectedToken) {
|
||||
try {
|
||||
await deleteUserPersonalAPIToken(
|
||||
serviceAccount.id,
|
||||
selectedToken?.id
|
||||
);
|
||||
refetchTokens();
|
||||
setDeleteOpen(false);
|
||||
setToastData({
|
||||
title: 'Token deleted successfully',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Description',
|
||||
accessor: 'description',
|
||||
Cell: HighlightCell,
|
||||
minWidth: 100,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Expires',
|
||||
accessor: 'expiresAt',
|
||||
Cell: ({ value }: { value: string }) => {
|
||||
const date = new Date(value);
|
||||
if (date.getFullYear() > new Date().getFullYear() + 100) {
|
||||
return <TextCell>Never</TextCell>;
|
||||
}
|
||||
return <DateCell value={value} />;
|
||||
},
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
},
|
||||
{
|
||||
Header: 'Created',
|
||||
accessor: 'createdAt',
|
||||
Cell: DateCell,
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
},
|
||||
{
|
||||
Header: 'Last seen',
|
||||
accessor: 'seenAt',
|
||||
Cell: TimeAgoCell,
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
Cell: ({ row: { original: rowToken } }: any) => (
|
||||
<ActionCell>
|
||||
<Tooltip title="Delete token" arrow describeChild>
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setSelectedToken(rowToken);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ActionCell>
|
||||
),
|
||||
maxWidth: 100,
|
||||
disableSortBy: true,
|
||||
},
|
||||
],
|
||||
[setSelectedToken, setDeleteOpen]
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchedData,
|
||||
getSearchText,
|
||||
getSearchContext,
|
||||
} = useSearch(columns, searchValue, tokens);
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
searchedData?.length === 0 && loading
|
||||
? tokensPlaceholder
|
||||
: searchedData,
|
||||
[searchedData, loading]
|
||||
);
|
||||
|
||||
const {
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
state: { sortBy },
|
||||
setHiddenColumns,
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetSortBy: false,
|
||||
disableSortRemove: true,
|
||||
disableMultiSort: true,
|
||||
},
|
||||
useSortBy,
|
||||
useFlexLayout
|
||||
);
|
||||
|
||||
useConditionallyHiddenColumns(
|
||||
[
|
||||
{
|
||||
condition: isExtraSmallScreen,
|
||||
columns: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
condition: isSmallScreen,
|
||||
columns: ['createdAt'],
|
||||
},
|
||||
{
|
||||
condition: Boolean(readOnly),
|
||||
columns: ['Actions', 'expiresAt', 'createdAt'],
|
||||
},
|
||||
],
|
||||
setHiddenColumns,
|
||||
columns
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!readOnly}
|
||||
show={
|
||||
<StyledHeader>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={tokens.length >= PAT_LIMIT}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
New token
|
||||
</Button>
|
||||
</StyledHeader>
|
||||
}
|
||||
/>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
prepareRow={prepareRow}
|
||||
/>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No tokens found matching “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<StyledTablePlaceholder>
|
||||
<StyledPlaceholderTitle>
|
||||
You have no tokens for this service account
|
||||
yet.
|
||||
</StyledPlaceholderTitle>
|
||||
<StyledPlaceholderSubtitle>
|
||||
Create a service account token for access to
|
||||
the Unleash API.
|
||||
</StyledPlaceholderSubtitle>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
Create new service account token
|
||||
</Button>
|
||||
</StyledTablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ServiceAccountCreateTokenDialog
|
||||
open={createOpen}
|
||||
setOpen={setCreateOpen}
|
||||
serviceAccount={serviceAccount}
|
||||
onCreateClick={onCreateClick}
|
||||
/>
|
||||
<ServiceAccountTokenDialog
|
||||
open={tokenOpen}
|
||||
setOpen={setTokenOpen}
|
||||
token={newToken}
|
||||
/>
|
||||
<Dialogue
|
||||
open={deleteOpen}
|
||||
primaryButtonText="Delete token"
|
||||
secondaryButtonText="Cancel"
|
||||
onClick={onDeleteClick}
|
||||
onClose={() => {
|
||||
setDeleteOpen(false);
|
||||
}}
|
||||
title="Delete token?"
|
||||
>
|
||||
<Typography>
|
||||
Any applications or scripts using this token "
|
||||
<strong>{selectedToken?.description}</strong>" will no
|
||||
longer be able to access the Unleash API. You cannot undo
|
||||
this action.
|
||||
</Typography>
|
||||
</Dialogue>
|
||||
</>
|
||||
);
|
||||
};
|
@ -13,7 +13,6 @@ import { useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||
import theme from 'themes/theme';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
@ -29,7 +28,6 @@ import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||
import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
||||
|
||||
export const ServiceAccountsTable = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
|
||||
const { serviceAccounts, roles, refetch, loading } = useServiceAccounts();
|
||||
@ -128,7 +126,7 @@ export const ServiceAccountsTable = () => {
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
[roles, navigate]
|
||||
[roles]
|
||||
);
|
||||
|
||||
const [initialState] = useState({
|
||||
|
@ -155,7 +155,7 @@ export const PersonalAPITokenForm = ({
|
||||
if (isDescriptionUnique && !isDescriptionUnique(description)) {
|
||||
setError(
|
||||
ErrorField.DESCRIPTION,
|
||||
'A personal API token with that description already exists.'
|
||||
'A token with that description already exists.'
|
||||
);
|
||||
}
|
||||
setDescription(description);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||
import useAPI from '../useApi/useApi';
|
||||
|
||||
interface ICreatePersonalApiTokenPayload {
|
||||
export interface ICreatePersonalApiTokenPayload {
|
||||
description: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
@ -53,10 +53,22 @@ export const usePersonalAPITokensApi = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUserPersonalAPIToken = async (userId: number, id: string) => {
|
||||
const req = createRequest(`api/admin/user-admin/${userId}/pat/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
try {
|
||||
await makeRequest(req.caller, req.id);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createPersonalAPIToken,
|
||||
deletePersonalAPIToken,
|
||||
createUserPersonalAPIToken,
|
||||
deleteUserPersonalAPIToken,
|
||||
errors,
|
||||
loading,
|
||||
};
|
||||
|
@ -10,9 +10,15 @@ export interface IUsePersonalAPITokensOutput {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export const usePersonalAPITokens = (): IUsePersonalAPITokensOutput => {
|
||||
export const usePersonalAPITokens = (
|
||||
userId?: number
|
||||
): IUsePersonalAPITokensOutput => {
|
||||
const { data, error, mutate } = useSWR(
|
||||
formatApiPath('api/admin/user/tokens'),
|
||||
formatApiPath(
|
||||
userId
|
||||
? `api/admin/user-admin/${userId}/pat`
|
||||
: 'api/admin/user/tokens'
|
||||
),
|
||||
fetcher
|
||||
);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user