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:
parent
7b075954a1
commit
997dbbbea5
@ -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>
|
||||
|
@ -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 ||
|
||||
|
@ -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 &&
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
@ -3,6 +3,7 @@ export interface IPersonalAPIToken {
|
||||
description: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
seenAt: string;
|
||||
}
|
||||
|
||||
export interface INewPersonalAPIToken extends IPersonalAPIToken {
|
||||
|
6
frontend/src/interfaces/service-account.ts
Normal file
6
frontend/src/interfaces/service-account.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IPersonalAPIToken } from './personalAPIToken';
|
||||
import { IUser } from './user';
|
||||
|
||||
export interface IServiceAccount extends IUser {
|
||||
tokens: IPersonalAPIToken[];
|
||||
}
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user