diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx index 2974ca2a49..be112d15f6 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx @@ -1,6 +1,7 @@ import { Alert, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { Dialogue } from 'component/common/Dialogue/Dialogue'; -import { IUser } from 'interfaces/user'; +import { IServiceAccount } from 'interfaces/service-account'; import { ServiceAccountTokens } from '../ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens'; const StyledTableContainer = styled('div')(({ theme }) => ({ @@ -12,10 +13,10 @@ const StyledLabel = styled('p')(({ theme }) => ({ })); interface IServiceAccountDeleteDialogProps { - serviceAccount?: IUser; + serviceAccount?: IServiceAccount; open: boolean; setOpen: React.Dispatch>; - onConfirm: (serviceAccount: IUser) => void; + onConfirm: (serviceAccount: IServiceAccount) => void; } export const ServiceAccountDeleteDialog = ({ @@ -39,13 +40,20 @@ export const ServiceAccountDeleteDialog = ({ Deleting this service account may break any existing implementations currently using it. - Service account tokens - - - + + Service account tokens + + + + + } + /> You are about to delete service account:{' '} {serviceAccount?.name} diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx index 09e5992e92..3806d07c46 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx @@ -32,6 +32,7 @@ import { import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi'; import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens'; +import { IServiceAccount } from 'interfaces/service-account'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -110,7 +111,7 @@ interface IServiceAccountModalErrors { const DEFAULT_EXPIRATION = ExpirationOption['30DAYS']; interface IServiceAccountModalProps { - serviceAccount?: IUser; + serviceAccount?: IServiceAccount; open: boolean; setOpen: React.Dispatch>; newToken: (token: INewPersonalAPIToken) => void; @@ -222,7 +223,8 @@ export const ServiceAccountModal = ({ const isUnique = (value: string) => !users?.some((user: IUser) => user.username === value) && !serviceAccounts?.some( - (serviceAccount: IUser) => serviceAccount.username === value + (serviceAccount: IServiceAccount) => + serviceAccount.username === value ); const isPATValid = tokenGeneration === TokenGeneration.LATER || 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 index dc8b9aad32..94e6fcdf48 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx @@ -7,15 +7,14 @@ import { 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'; +import { IServiceAccount } from 'interfaces/service-account'; const DEFAULT_EXPIRATION = ExpirationOption['30DAYS']; interface IServiceAccountCreateTokenDialogProps { open: boolean; setOpen: React.Dispatch>; - serviceAccount: IUser; + serviceAccount: IServiceAccount; onCreateClick: (newToken: ICreatePersonalApiTokenPayload) => void; } @@ -25,8 +24,6 @@ export const ServiceAccountCreateTokenDialog = ({ serviceAccount, onCreateClick, }: IServiceAccountCreateTokenDialogProps) => { - const { tokens = [] } = usePersonalAPITokens(serviceAccount.id); - const [patDescription, setPatDescription] = useState(''); const [patExpiration, setPatExpiration] = useState(DEFAULT_EXPIRATION); @@ -43,7 +40,9 @@ export const ServiceAccountCreateTokenDialog = ({ }, [open]); const isDescriptionUnique = (description: string) => - !tokens?.some(token => token.description === description); + !serviceAccount.tokens?.some( + token => token.description === description + ); const isPATValid = patDescription.length && diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx index 31bd839d44..0a1ee2d06e 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx @@ -30,7 +30,6 @@ import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/Servi 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, @@ -38,6 +37,8 @@ import { } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; +import { IServiceAccount } from 'interfaces/service-account'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; const StyledHeader = styled('div')(({ theme }) => ({ display: 'flex', @@ -70,13 +71,6 @@ const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({ 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> >; @@ -84,7 +78,7 @@ export type PageQueryType = Partial< const defaultSort: SortingRule = { id: 'createdAt' }; interface IServiceAccountTokensProps { - serviceAccount: IUser; + serviceAccount: IServiceAccount; readOnly?: boolean; } @@ -96,11 +90,10 @@ export const ServiceAccountTokens = ({ 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 { tokens = [], refetchTokens } = usePersonalAPITokens( + serviceAccount.id + ); + const { refetch } = useServiceAccounts(); const { createUserPersonalAPIToken, deleteUserPersonalAPIToken } = usePersonalAPITokensApi(); @@ -121,6 +114,7 @@ export const ServiceAccountTokens = ({ serviceAccount.id, newToken ); + refetch(); refetchTokens(); setCreateOpen(false); setNewToken(token); @@ -141,6 +135,7 @@ export const ServiceAccountTokens = ({ serviceAccount.id, selectedToken?.id ); + refetch(); refetchTokens(); setDeleteOpen(false); setToastData({ @@ -216,18 +211,10 @@ export const ServiceAccountTokens = ({ [setSelectedToken, setDeleteOpen] ); - const { - data: searchedData, - getSearchText, - getSearchContext, - } = useSearch(columns, searchValue, tokens); - - const data = useMemo( - () => - searchedData?.length === 0 && loading - ? tokensPlaceholder - : searchedData, - [searchedData, loading] + const { data, getSearchText, getSearchContext } = useSearch( + columns, + searchValue, + tokens ); const { diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx new file mode 100644 index 0000000000..f001b92c1e --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx @@ -0,0 +1,64 @@ +import { VFC } from 'react'; +import { Link, styled, Typography } from '@mui/material'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { IServiceAccount } from 'interfaces/service-account'; + +const StyledItem = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, +})); + +const StyledLink = styled(Link, { + shouldForwardProp: prop => prop !== 'highlighted', +})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({ + backgroundColor: highlighted ? theme.palette.highlight : 'transparent', +})); + +interface IServiceAccountTokensCellProps { + row: { + original: IServiceAccount; + }; + value: string; +} + +export const ServiceAccountTokensCell: VFC = ({ + row, + value, +}) => { + const { searchQuery } = useSearchHighlightContext(); + + if (!row.original.tokens || row.original.tokens.length === 0) + return ; + + return ( + + + {row.original.tokens?.map(({ id, description }) => ( + + + {description} + + + ))} + + } + > + 0 && + value.toLowerCase().includes(searchQuery.toLowerCase()) + } + > + {row.original.tokens?.length === 1 + ? '1 token' + : `${row.original.tokens?.length} tokens`} + + + + ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx index b0d280280c..104a6b6735 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react'; import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { IUser } from 'interfaces/user'; import IRole from 'interfaces/role'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; @@ -26,6 +25,9 @@ import { ServiceAccountDeleteDialog } from './ServiceAccountDeleteDialog/Service import { ServiceAccountsActionsCell } from './ServiceAccountsActionsCell/ServiceAccountsActionsCell'; import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog'; +import { ServiceAccountTokensCell } from './ServiceAccountTokensCell/ServiceAccountTokensCell'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { IServiceAccount } from 'interfaces/service-account'; export const ServiceAccountsTable = () => { const { setToastData, setToastApiError } = useToast(); @@ -39,9 +41,9 @@ export const ServiceAccountsTable = () => { const [newToken, setNewToken] = useState(); const [deleteOpen, setDeleteOpen] = useState(false); const [selectedServiceAccount, setSelectedServiceAccount] = - useState(); + useState(); - const onDeleteConfirm = async (serviceAccount: IUser) => { + const onDeleteConfirm = async (serviceAccount: IServiceAccount) => { try { await removeServiceAccount(serviceAccount.id); setToastData({ @@ -60,14 +62,6 @@ export const ServiceAccountsTable = () => { const columns = useMemo( () => [ - { - Header: 'Created', - accessor: 'createdAt', - Cell: DateCell, - sortType: 'date', - width: 120, - maxWidth: 120, - }, { Header: 'Avatar', accessor: 'imageUrl', @@ -85,10 +79,7 @@ export const ServiceAccountsTable = () => { accessor: (row: any) => row.name || '', minWidth: 200, Cell: ({ row: { original: user } }: any) => ( - + ), searchable: true, }, @@ -100,6 +91,37 @@ export const ServiceAccountsTable = () => { ?.name || '', maxWidth: 120, }, + { + id: 'tokens', + Header: 'Tokens', + accessor: (row: IServiceAccount) => + row.tokens + ?.map(({ description }) => description) + .join('\n') || '', + Cell: ServiceAccountTokensCell, + searchable: true, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + width: 120, + maxWidth: 120, + }, + { + id: 'seenAt', + Header: 'Last seen', + accessor: (row: IServiceAccount) => + row.tokens.sort((a, b) => { + const aSeenAt = new Date(a.seenAt || 0); + const bSeenAt = new Date(b.seenAt || 0); + return bSeenAt?.getTime() - aSeenAt?.getTime(); + })[0]?.seenAt, + Cell: TimeAgoCell, + sortType: 'date', + maxWidth: 150, + }, { Header: 'Actions', id: 'Actions', @@ -149,6 +171,7 @@ export const ServiceAccountsTable = () => { autoResetSortBy: false, disableSortRemove: true, disableMultiSort: true, + autoResetHiddenColumns: false, defaultColumn: { Cell: TextCell, }, @@ -161,11 +184,11 @@ export const ServiceAccountsTable = () => { [ { condition: isExtraSmallScreen, - columns: ['imageUrl', 'role'], + columns: ['role', 'seenAt'], }, { condition: isSmallScreen, - columns: ['createdAt', 'last-login'], + columns: ['imageUrl', 'tokens', 'createdAt'], }, ], setHiddenColumns, diff --git a/frontend/src/hooks/api/getters/useServiceAccounts/useServiceAccounts.ts b/frontend/src/hooks/api/getters/useServiceAccounts/useServiceAccounts.ts index 758f2d9c1b..b5eeaaf619 100644 --- a/frontend/src/hooks/api/getters/useServiceAccounts/useServiceAccounts.ts +++ b/frontend/src/hooks/api/getters/useServiceAccounts/useServiceAccounts.ts @@ -1,5 +1,5 @@ import IRole from 'interfaces/role'; -import { IUser } from 'interfaces/user'; +import { IServiceAccount } from 'interfaces/service-account'; import { useMemo } from 'react'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; @@ -11,14 +11,14 @@ export const useServiceAccounts = () => { const { data, error, mutate } = useConditionalSWR( Boolean(uiConfig.flags.serviceAccounts) && isEnterprise(), - { users: [], rootRoles: [] }, + { serviceAccounts: [], rootRoles: [] }, formatApiPath(`api/admin/service-account`), fetcher ); return useMemo( () => ({ - serviceAccounts: (data?.users ?? []) as IUser[], + serviceAccounts: (data?.serviceAccounts ?? []) as IServiceAccount[], roles: (data?.rootRoles ?? []) as IRole[], loading: !error && !data, refetch: () => mutate(), diff --git a/frontend/src/interfaces/personalAPIToken.ts b/frontend/src/interfaces/personalAPIToken.ts index 045c990d66..8a3ce504df 100644 --- a/frontend/src/interfaces/personalAPIToken.ts +++ b/frontend/src/interfaces/personalAPIToken.ts @@ -3,6 +3,7 @@ export interface IPersonalAPIToken { description: string; expiresAt: string; createdAt: string; + seenAt: string; } export interface INewPersonalAPIToken extends IPersonalAPIToken { diff --git a/frontend/src/interfaces/service-account.ts b/frontend/src/interfaces/service-account.ts new file mode 100644 index 0000000000..49844ad9a2 --- /dev/null +++ b/frontend/src/interfaces/service-account.ts @@ -0,0 +1,6 @@ +import { IPersonalAPIToken } from './personalAPIToken'; +import { IUser } from './user'; + +export interface IServiceAccount extends IUser { + tokens: IPersonalAPIToken[]; +} diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index 50af160180..33b8a253fe 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -225,7 +225,7 @@ class UserStore implements IUserStore { } async getUserByPersonalAccessToken(secret: string): Promise { - const row = await this.activeUsers() + const row = await this.activeAll() .select(USER_COLUMNS.map((column) => `${TABLE}.${column}`)) .leftJoin( 'personal_access_tokens',