1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01: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 { 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<React.SetStateAction<boolean>>;
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.
</Alert>
<StyledLabel>Service account tokens</StyledLabel>
<StyledTableContainer>
<ServiceAccountTokens
serviceAccount={serviceAccount!}
readOnly
/>
</StyledTableContainer>
<ConditionallyRender
condition={Boolean(serviceAccount?.tokens.length)}
show={
<>
<StyledLabel>Service account tokens</StyledLabel>
<StyledTableContainer>
<ServiceAccountTokens
serviceAccount={serviceAccount!}
readOnly
/>
</StyledTableContainer>
</>
}
/>
<StyledLabel>
You are about to delete service account:{' '}
<strong>{serviceAccount?.name}</strong>

View File

@ -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<React.SetStateAction<boolean>>;
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 ||

View File

@ -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<React.SetStateAction<boolean>>;
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<ExpirationOption>(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 &&

View File

@ -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<string> = { 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 {

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 { 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<INewPersonalAPIToken>();
const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedServiceAccount, setSelectedServiceAccount] =
useState<IUser>();
useState<IServiceAccount>();
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) => (
<HighlightCell
value={user.name}
subtitle={user.email || user.username}
/>
<HighlightCell value={user.name} subtitle={user.username} />
),
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,

View File

@ -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(),

View File

@ -3,6 +3,7 @@ export interface IPersonalAPIToken {
description: string;
expiresAt: string;
createdAt: string;
seenAt: string;
}
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> {
const row = await this.activeUsers()
const row = await this.activeAll()
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
.leftJoin(
'personal_access_tokens',