1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-09 01:17:06 +02:00

Feat sa table info (#2848)

https://linear.app/unleash/issue/2-543/show-relevant-information-on-the-service-accounts-table

Shows relevant information on the table, like total PATs and the last
time a service account was active based on latest seen PAT for that
account. Adapts to the latest related PR on enterprise.


![image](https://user-images.githubusercontent.com/14320932/211312719-c4ed940a-723b-4b2e-a79e-8e7cdbda7c58.png)
This commit is contained in:
Nuno Góis 2023-01-09 16:18:37 +00:00 committed by GitHub
parent 7b075954a1
commit 997dbbbea5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 155 additions and 65 deletions

View File

@ -1,6 +1,7 @@
import { Alert, styled } from '@mui/material'; import { Alert, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { IUser } from 'interfaces/user'; import { IServiceAccount } from 'interfaces/service-account';
import { ServiceAccountTokens } from '../ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens'; import { ServiceAccountTokens } from '../ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens';
const StyledTableContainer = styled('div')(({ theme }) => ({ const StyledTableContainer = styled('div')(({ theme }) => ({
@ -12,10 +13,10 @@ const StyledLabel = styled('p')(({ theme }) => ({
})); }));
interface IServiceAccountDeleteDialogProps { interface IServiceAccountDeleteDialogProps {
serviceAccount?: IUser; serviceAccount?: IServiceAccount;
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: (serviceAccount: IUser) => void; onConfirm: (serviceAccount: IServiceAccount) => void;
} }
export const ServiceAccountDeleteDialog = ({ export const ServiceAccountDeleteDialog = ({
@ -39,6 +40,10 @@ export const ServiceAccountDeleteDialog = ({
Deleting this service account may break any existing Deleting this service account may break any existing
implementations currently using it. implementations currently using it.
</Alert> </Alert>
<ConditionallyRender
condition={Boolean(serviceAccount?.tokens.length)}
show={
<>
<StyledLabel>Service account tokens</StyledLabel> <StyledLabel>Service account tokens</StyledLabel>
<StyledTableContainer> <StyledTableContainer>
<ServiceAccountTokens <ServiceAccountTokens
@ -46,6 +51,9 @@ export const ServiceAccountDeleteDialog = ({
readOnly readOnly
/> />
</StyledTableContainer> </StyledTableContainer>
</>
}
/>
<StyledLabel> <StyledLabel>
You are about to delete service account:{' '} You are about to delete service account:{' '}
<strong>{serviceAccount?.name}</strong> <strong>{serviceAccount?.name}</strong>

View File

@ -32,6 +32,7 @@ import {
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'; import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens';
import { IServiceAccount } from 'interfaces/service-account';
const StyledForm = styled('form')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -110,7 +111,7 @@ interface IServiceAccountModalErrors {
const DEFAULT_EXPIRATION = ExpirationOption['30DAYS']; const DEFAULT_EXPIRATION = ExpirationOption['30DAYS'];
interface IServiceAccountModalProps { interface IServiceAccountModalProps {
serviceAccount?: IUser; serviceAccount?: IServiceAccount;
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
newToken: (token: INewPersonalAPIToken) => void; newToken: (token: INewPersonalAPIToken) => void;
@ -222,7 +223,8 @@ export const ServiceAccountModal = ({
const isUnique = (value: string) => const isUnique = (value: string) =>
!users?.some((user: IUser) => user.username === value) && !users?.some((user: IUser) => user.username === value) &&
!serviceAccounts?.some( !serviceAccounts?.some(
(serviceAccount: IUser) => serviceAccount.username === value (serviceAccount: IServiceAccount) =>
serviceAccount.username === value
); );
const isPATValid = const isPATValid =
tokenGeneration === TokenGeneration.LATER || tokenGeneration === TokenGeneration.LATER ||

View File

@ -7,15 +7,14 @@ import {
PersonalAPITokenForm, PersonalAPITokenForm,
} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm'; } from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
import { ICreatePersonalApiTokenPayload } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi'; import { ICreatePersonalApiTokenPayload } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
import { IUser } from 'interfaces/user'; import { IServiceAccount } from 'interfaces/service-account';
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
const DEFAULT_EXPIRATION = ExpirationOption['30DAYS']; const DEFAULT_EXPIRATION = ExpirationOption['30DAYS'];
interface IServiceAccountCreateTokenDialogProps { interface IServiceAccountCreateTokenDialogProps {
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
serviceAccount: IUser; serviceAccount: IServiceAccount;
onCreateClick: (newToken: ICreatePersonalApiTokenPayload) => void; onCreateClick: (newToken: ICreatePersonalApiTokenPayload) => void;
} }
@ -25,8 +24,6 @@ export const ServiceAccountCreateTokenDialog = ({
serviceAccount, serviceAccount,
onCreateClick, onCreateClick,
}: IServiceAccountCreateTokenDialogProps) => { }: IServiceAccountCreateTokenDialogProps) => {
const { tokens = [] } = usePersonalAPITokens(serviceAccount.id);
const [patDescription, setPatDescription] = useState(''); const [patDescription, setPatDescription] = useState('');
const [patExpiration, setPatExpiration] = const [patExpiration, setPatExpiration] =
useState<ExpirationOption>(DEFAULT_EXPIRATION); useState<ExpirationOption>(DEFAULT_EXPIRATION);
@ -43,7 +40,9 @@ export const ServiceAccountCreateTokenDialog = ({
}, [open]); }, [open]);
const isDescriptionUnique = (description: string) => const isDescriptionUnique = (description: string) =>
!tokens?.some(token => token.description === description); !serviceAccount.tokens?.some(
token => token.description === description
);
const isPATValid = const isPATValid =
patDescription.length && patDescription.length &&

View File

@ -30,7 +30,6 @@ import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/Servi
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IUser } from 'interfaces/user';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { import {
ICreatePersonalApiTokenPayload, ICreatePersonalApiTokenPayload,
@ -38,6 +37,8 @@ import {
} from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi'; } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { IServiceAccount } from 'interfaces/service-account';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
const StyledHeader = styled('div')(({ theme }) => ({ const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -70,13 +71,6 @@ const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
marginBottom: theme.spacing(1.5), 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< export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string> Record<'sort' | 'order' | 'search', string>
>; >;
@ -84,7 +78,7 @@ export type PageQueryType = Partial<
const defaultSort: SortingRule<string> = { id: 'createdAt' }; const defaultSort: SortingRule<string> = { id: 'createdAt' };
interface IServiceAccountTokensProps { interface IServiceAccountTokensProps {
serviceAccount: IUser; serviceAccount: IServiceAccount;
readOnly?: boolean; readOnly?: boolean;
} }
@ -96,11 +90,10 @@ export const ServiceAccountTokens = ({
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { const { tokens = [], refetchTokens } = usePersonalAPITokens(
tokens = [], serviceAccount.id
refetchTokens, );
loading, const { refetch } = useServiceAccounts();
} = usePersonalAPITokens(serviceAccount.id);
const { createUserPersonalAPIToken, deleteUserPersonalAPIToken } = const { createUserPersonalAPIToken, deleteUserPersonalAPIToken } =
usePersonalAPITokensApi(); usePersonalAPITokensApi();
@ -121,6 +114,7 @@ export const ServiceAccountTokens = ({
serviceAccount.id, serviceAccount.id,
newToken newToken
); );
refetch();
refetchTokens(); refetchTokens();
setCreateOpen(false); setCreateOpen(false);
setNewToken(token); setNewToken(token);
@ -141,6 +135,7 @@ export const ServiceAccountTokens = ({
serviceAccount.id, serviceAccount.id,
selectedToken?.id selectedToken?.id
); );
refetch();
refetchTokens(); refetchTokens();
setDeleteOpen(false); setDeleteOpen(false);
setToastData({ setToastData({
@ -216,18 +211,10 @@ export const ServiceAccountTokens = ({
[setSelectedToken, setDeleteOpen] [setSelectedToken, setDeleteOpen]
); );
const { const { data, getSearchText, getSearchContext } = useSearch(
data: searchedData, columns,
getSearchText, searchValue,
getSearchContext, tokens
} = useSearch(columns, searchValue, tokens);
const data = useMemo(
() =>
searchedData?.length === 0 && loading
? tokensPlaceholder
: searchedData,
[searchedData, loading]
); );
const { const {

View File

@ -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<IServiceAccountTokensCellProps> = ({
row,
value,
}) => {
const { searchQuery } = useSearchHighlightContext();
if (!row.original.tokens || row.original.tokens.length === 0)
return <TextCell />;
return (
<TextCell>
<HtmlTooltip
title={
<>
{row.original.tokens?.map(({ id, description }) => (
<StyledItem key={id}>
<Highlighter search={searchQuery}>
{description}
</Highlighter>
</StyledItem>
))}
</>
}
>
<StyledLink
underline="always"
highlighted={
searchQuery.length > 0 &&
value.toLowerCase().includes(searchQuery.toLowerCase())
}
>
{row.original.tokens?.length === 1
? '1 token'
: `${row.original.tokens?.length} tokens`}
</StyledLink>
</HtmlTooltip>
</TextCell>
);
};

View File

@ -1,7 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IUser } from 'interfaces/user';
import IRole from 'interfaces/role'; import IRole from 'interfaces/role';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
@ -26,6 +25,9 @@ import { ServiceAccountDeleteDialog } from './ServiceAccountDeleteDialog/Service
import { ServiceAccountsActionsCell } from './ServiceAccountsActionsCell/ServiceAccountsActionsCell'; import { ServiceAccountsActionsCell } from './ServiceAccountsActionsCell/ServiceAccountsActionsCell';
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog'; 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 = () => { export const ServiceAccountsTable = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
@ -39,9 +41,9 @@ export const ServiceAccountsTable = () => {
const [newToken, setNewToken] = useState<INewPersonalAPIToken>(); const [newToken, setNewToken] = useState<INewPersonalAPIToken>();
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedServiceAccount, setSelectedServiceAccount] = const [selectedServiceAccount, setSelectedServiceAccount] =
useState<IUser>(); useState<IServiceAccount>();
const onDeleteConfirm = async (serviceAccount: IUser) => { const onDeleteConfirm = async (serviceAccount: IServiceAccount) => {
try { try {
await removeServiceAccount(serviceAccount.id); await removeServiceAccount(serviceAccount.id);
setToastData({ setToastData({
@ -60,14 +62,6 @@ export const ServiceAccountsTable = () => {
const columns = useMemo( const columns = useMemo(
() => [ () => [
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
width: 120,
maxWidth: 120,
},
{ {
Header: 'Avatar', Header: 'Avatar',
accessor: 'imageUrl', accessor: 'imageUrl',
@ -85,10 +79,7 @@ export const ServiceAccountsTable = () => {
accessor: (row: any) => row.name || '', accessor: (row: any) => row.name || '',
minWidth: 200, minWidth: 200,
Cell: ({ row: { original: user } }: any) => ( Cell: ({ row: { original: user } }: any) => (
<HighlightCell <HighlightCell value={user.name} subtitle={user.username} />
value={user.name}
subtitle={user.email || user.username}
/>
), ),
searchable: true, searchable: true,
}, },
@ -100,6 +91,37 @@ export const ServiceAccountsTable = () => {
?.name || '', ?.name || '',
maxWidth: 120, 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', Header: 'Actions',
id: 'Actions', id: 'Actions',
@ -149,6 +171,7 @@ export const ServiceAccountsTable = () => {
autoResetSortBy: false, autoResetSortBy: false,
disableSortRemove: true, disableSortRemove: true,
disableMultiSort: true, disableMultiSort: true,
autoResetHiddenColumns: false,
defaultColumn: { defaultColumn: {
Cell: TextCell, Cell: TextCell,
}, },
@ -161,11 +184,11 @@ export const ServiceAccountsTable = () => {
[ [
{ {
condition: isExtraSmallScreen, condition: isExtraSmallScreen,
columns: ['imageUrl', 'role'], columns: ['role', 'seenAt'],
}, },
{ {
condition: isSmallScreen, condition: isSmallScreen,
columns: ['createdAt', 'last-login'], columns: ['imageUrl', 'tokens', 'createdAt'],
}, },
], ],
setHiddenColumns, setHiddenColumns,

View File

@ -1,5 +1,5 @@
import IRole from 'interfaces/role'; import IRole from 'interfaces/role';
import { IUser } from 'interfaces/user'; import { IServiceAccount } from 'interfaces/service-account';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
@ -11,14 +11,14 @@ export const useServiceAccounts = () => {
const { data, error, mutate } = useConditionalSWR( const { data, error, mutate } = useConditionalSWR(
Boolean(uiConfig.flags.serviceAccounts) && isEnterprise(), Boolean(uiConfig.flags.serviceAccounts) && isEnterprise(),
{ users: [], rootRoles: [] }, { serviceAccounts: [], rootRoles: [] },
formatApiPath(`api/admin/service-account`), formatApiPath(`api/admin/service-account`),
fetcher fetcher
); );
return useMemo( return useMemo(
() => ({ () => ({
serviceAccounts: (data?.users ?? []) as IUser[], serviceAccounts: (data?.serviceAccounts ?? []) as IServiceAccount[],
roles: (data?.rootRoles ?? []) as IRole[], roles: (data?.rootRoles ?? []) as IRole[],
loading: !error && !data, loading: !error && !data,
refetch: () => mutate(), refetch: () => mutate(),

View File

@ -3,6 +3,7 @@ export interface IPersonalAPIToken {
description: string; description: string;
expiresAt: string; expiresAt: string;
createdAt: string; createdAt: string;
seenAt: string;
} }
export interface INewPersonalAPIToken extends IPersonalAPIToken { export interface INewPersonalAPIToken extends IPersonalAPIToken {

View File

@ -0,0 +1,6 @@
import { IPersonalAPIToken } from './personalAPIToken';
import { IUser } from './user';
export interface IServiceAccount extends IUser {
tokens: IPersonalAPIToken[];
}

View File

@ -225,7 +225,7 @@ class UserStore implements IUserStore {
} }
async getUserByPersonalAccessToken(secret: string): Promise<User> { async getUserByPersonalAccessToken(secret: string): Promise<User> {
const row = await this.activeUsers() const row = await this.activeAll()
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`)) .select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
.leftJoin( .leftJoin(
'personal_access_tokens', 'personal_access_tokens',