mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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'; | } 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> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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 { 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({ | ||||||
|  | |||||||
| @ -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); | ||||||
|  | |||||||
| @ -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, | ||||||
|     }; |     }; | ||||||
|  | |||||||
| @ -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 | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user