1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00
Nuno Góis 2023-01-06 12:56:54 +00:00 committed by GitHub
parent 2b8f1ee0d7
commit b10d9c435e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 487 additions and 13 deletions

View File

@ -31,6 +31,7 @@ import {
} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm'; } from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi'; import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens';
const StyledForm = styled('form')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -86,9 +87,7 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto', marginTop: 'auto',
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
[theme.breakpoints.down('sm')]: { paddingTop: theme.spacing(4),
marginTop: theme.spacing(4),
},
})); }));
const StyledCancelButton = styled(Button)(({ theme }) => ({ const StyledCancelButton = styled(Button)(({ theme }) => ({
@ -225,16 +224,18 @@ export const ServiceAccountModal = ({
!serviceAccounts?.some( !serviceAccounts?.some(
(serviceAccount: IUser) => serviceAccount.username === value (serviceAccount: IUser) => serviceAccount.username === value
); );
const isPATValid =
tokenGeneration === TokenGeneration.LATER ||
(isNotEmpty(patDescription) && patExpiresAt > new Date());
const isValid = const isValid =
isNotEmpty(name) && isNotEmpty(name) &&
isNotEmpty(username) && isNotEmpty(username) &&
(editing || isUnique(username)) && (editing || isUnique(username)) &&
(tokenGeneration === TokenGeneration.LATER || isPATValid;
isNotEmpty(patDescription));
const suggestUsername = () => { const suggestUsername = () => {
if (isNotEmpty(name) && !isNotEmpty(username)) { if (isNotEmpty(name) && !isNotEmpty(username)) {
const normalizedFromName = `service:${name const normalizedFromName = `service-${name
.toLowerCase() .toLowerCase()
.replace(/ /g, '-') .replace(/ /g, '-')
.replace(/[^\w_-]/g, '')}`; .replace(/[^\w_-]/g, '')}`;
@ -411,6 +412,16 @@ export const ServiceAccountModal = ({
</StyledInlineContainer> </StyledInlineContainer>
</StyledSecondaryContainer> </StyledSecondaryContainer>
} }
elseShow={
<>
<StyledInputDescription>
Service account tokens
</StyledInputDescription>
<ServiceAccountTokens
serviceAccount={serviceAccount!}
/>
</>
}
/> />
</div> </div>

View File

@ -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>
);
};

View File

@ -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 &ldquo;
{searchValue}
&rdquo;
</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>
</>
);
};

View File

@ -13,7 +13,6 @@ import { useFlexLayout, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes'; import { sortTypes } from 'utils/sortTypes';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; 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 { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import theme from 'themes/theme'; import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search'; import { Search } from 'component/common/Search/Search';
@ -29,7 +28,6 @@ import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog'; import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog';
export const ServiceAccountsTable = () => { export const ServiceAccountsTable = () => {
const navigate = useNavigate();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { serviceAccounts, roles, refetch, loading } = useServiceAccounts(); const { serviceAccounts, roles, refetch, loading } = useServiceAccounts();
@ -128,7 +126,7 @@ export const ServiceAccountsTable = () => {
searchable: true, searchable: true,
}, },
], ],
[roles, navigate] [roles]
); );
const [initialState] = useState({ const [initialState] = useState({

View File

@ -155,7 +155,7 @@ export const PersonalAPITokenForm = ({
if (isDescriptionUnique && !isDescriptionUnique(description)) { if (isDescriptionUnique && !isDescriptionUnique(description)) {
setError( setError(
ErrorField.DESCRIPTION, ErrorField.DESCRIPTION,
'A personal API token with that description already exists.' 'A token with that description already exists.'
); );
} }
setDescription(description); setDescription(description);

View File

@ -1,7 +1,7 @@
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import useAPI from '../useApi/useApi'; import useAPI from '../useApi/useApi';
interface ICreatePersonalApiTokenPayload { export interface ICreatePersonalApiTokenPayload {
description: string; description: string;
expiresAt: Date; 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 { return {
createPersonalAPIToken, createPersonalAPIToken,
deletePersonalAPIToken, deletePersonalAPIToken,
createUserPersonalAPIToken, createUserPersonalAPIToken,
deleteUserPersonalAPIToken,
errors, errors,
loading, loading,
}; };

View File

@ -10,9 +10,15 @@ export interface IUsePersonalAPITokensOutput {
error?: Error; error?: Error;
} }
export const usePersonalAPITokens = (): IUsePersonalAPITokensOutput => { export const usePersonalAPITokens = (
userId?: number
): IUsePersonalAPITokensOutput => {
const { data, error, mutate } = useSWR( const { data, error, mutate } = useSWR(
formatApiPath('api/admin/user/tokens'), formatApiPath(
userId
? `api/admin/user-admin/${userId}/pat`
: 'api/admin/user/tokens'
),
fetcher fetcher
); );