diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx index 38acc54e51..09e5992e92 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx @@ -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 = ({ } + elseShow={ + <> + + Service account tokens + + + + } /> diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx new file mode 100644 index 0000000000..dc8b9aad32 --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx @@ -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>; + 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(DEFAULT_EXPIRATION); + const [patExpiresAt, setPatExpiresAt] = useState( + calculateExpirationDate(DEFAULT_EXPIRATION) + ); + const [patErrors, setPatErrors] = useState({}); + + 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 ( + + onCreateClick({ + description: patDescription, + expiresAt: patExpiresAt, + }) + } + disabledPrimaryButton={!isPATValid} + onClose={() => { + setOpen(false); + }} + title="New token" + > + + + ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx new file mode 100644 index 0000000000..b7047ea0c2 --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx @@ -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 = { 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(); + const [selectedToken, setSelectedToken] = useState(); + + 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 Never; + } + return ; + }, + 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) => ( + + + + { + setSelectedToken(rowToken); + setDeleteOpen(true); + }} + > + + + + + + ), + 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 ( + <> + + + + + } + /> + + + + 0} + show={ + + No tokens found matching “ + {searchValue} + ” + + } + elseShow={ + + + You have no tokens for this service account + yet. + + + Create a service account token for access to + the Unleash API. + + + + } + /> + } + /> + + + { + setDeleteOpen(false); + }} + title="Delete token?" + > + + Any applications or scripts using this token " + {selectedToken?.description}" will no + longer be able to access the Unleash API. You cannot undo + this action. + + + + ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx index c309a88f27..b0d280280c 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx @@ -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({ diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx index de8f854b9e..4c900261db 100644 --- a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx @@ -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); diff --git a/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts b/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts index bff677242c..da3ab31afc 100644 --- a/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts +++ b/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts @@ -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, }; diff --git a/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts b/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts index 44c60c4dd1..6f6fc38876 100644 --- a/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts +++ b/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts @@ -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 );