mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
ServiceAccount : use InteractiveTable (#85203)
This commit is contained in:
249
public/app/features/serviceaccounts/ServiceAccountTable.tsx
Normal file
249
public/app/features/serviceaccounts/ServiceAccountTable.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
CellProps,
|
||||
Column,
|
||||
InteractiveTable,
|
||||
Pagination,
|
||||
Stack,
|
||||
TextLink,
|
||||
Button,
|
||||
IconButton,
|
||||
Icon,
|
||||
} from '@grafana/ui';
|
||||
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types';
|
||||
|
||||
import { OrgRolePicker } from '../admin/OrgRolePicker';
|
||||
|
||||
type Cell<T extends keyof ServiceAccountDTO = keyof ServiceAccountDTO> = CellProps<
|
||||
ServiceAccountDTO,
|
||||
ServiceAccountDTO[T]
|
||||
>;
|
||||
|
||||
interface ServiceAccountTableProps {
|
||||
services: ServiceAccountDTO[];
|
||||
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void;
|
||||
roleOptions: Role[];
|
||||
onRemoveButtonClick: (serviceAccount: ServiceAccountDTO) => void;
|
||||
onDisable: (serviceAccount: ServiceAccountDTO) => void;
|
||||
onEnable: (serviceAccount: ServiceAccountDTO) => void;
|
||||
onAddTokenClick: (serviceAccount: ServiceAccountDTO) => void;
|
||||
showPaging?: boolean;
|
||||
totalPages: number;
|
||||
onChangePage: (page: number) => void;
|
||||
currentPage: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const ServiceAccountTable = ({
|
||||
services,
|
||||
onRoleChange,
|
||||
roleOptions,
|
||||
onRemoveButtonClick,
|
||||
onDisable,
|
||||
onEnable,
|
||||
onAddTokenClick,
|
||||
showPaging,
|
||||
totalPages,
|
||||
onChangePage,
|
||||
currentPage,
|
||||
isLoading,
|
||||
}: ServiceAccountTableProps) => {
|
||||
const columns: Array<Column<ServiceAccountDTO>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'avatarUrl',
|
||||
header: '',
|
||||
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
|
||||
return getCellContent(value, original, isLoading, 'avatarUrl');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Account',
|
||||
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
|
||||
return getCellContent(value, original, isLoading);
|
||||
},
|
||||
sortType: 'string',
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
|
||||
return getCellContent(value, original, isLoading, 'id');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
header: 'Roles',
|
||||
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
|
||||
return getRoleCell(value, original, isLoading, roleOptions, onRoleChange);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tokens',
|
||||
header: 'Tokens',
|
||||
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
|
||||
return getCellContent(value, original, isLoading, 'tokens');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row: { original } }: Cell) => {
|
||||
return getActionsCell(original, isLoading, onAddTokenClick, onEnable, onDisable, onRemoveButtonClick);
|
||||
},
|
||||
},
|
||||
],
|
||||
[isLoading, onAddTokenClick, onDisable, onEnable, onRemoveButtonClick, onRoleChange, roleOptions]
|
||||
);
|
||||
return (
|
||||
<Stack direction={'column'} gap={2}>
|
||||
<InteractiveTable columns={columns} data={services} getRowId={(service) => String(service.id)} />
|
||||
{showPaging && totalPages > 1 && (
|
||||
<Stack justifyContent={'flex-end'}>
|
||||
<Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getCellContent = (
|
||||
value: string,
|
||||
original: ServiceAccountDTO,
|
||||
isLoading: boolean,
|
||||
columnName?: Column<ServiceAccountDTO>['id']
|
||||
) => {
|
||||
if (isLoading) {
|
||||
return columnName === 'avatarUrl' ? <Skeleton circle width={24} height={24} /> : <Skeleton width={100} />;
|
||||
}
|
||||
const href = `/org/serviceaccounts/${original.id}`;
|
||||
const ariaLabel = `Edit service account's ${name} details`;
|
||||
switch (columnName) {
|
||||
case 'avatarUrl':
|
||||
return (
|
||||
<a aria-label={ariaLabel} href={href}>
|
||||
<Avatar src={value} alt={'User avatar'} />
|
||||
</a>
|
||||
);
|
||||
case 'id':
|
||||
return (
|
||||
<TextLink href={href} aria-label={ariaLabel} color="secondary">
|
||||
{original.login}
|
||||
</TextLink>
|
||||
);
|
||||
case 'tokens':
|
||||
return (
|
||||
<Stack alignItems="center">
|
||||
<Icon name="key-skeleton-alt" />
|
||||
<TextLink href={href} aria-label={ariaLabel} color="primary">
|
||||
{value || 'No tokens'}
|
||||
</TextLink>
|
||||
</Stack>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TextLink href={href} aria-label={ariaLabel} color="primary">
|
||||
{value}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleCell = (
|
||||
value: OrgRole,
|
||||
original: ServiceAccountDTO,
|
||||
isLoading: boolean,
|
||||
roleOptions: Role[],
|
||||
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void
|
||||
) => {
|
||||
const displayRolePicker =
|
||||
contextSrv.hasPermission(AccessControlAction.ActionRolesList) &&
|
||||
contextSrv.hasPermission(AccessControlAction.ActionUserRolesList);
|
||||
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, original);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton width={100} />;
|
||||
} else {
|
||||
return contextSrv.licensedAccessControlEnabled() ? (
|
||||
displayRolePicker && (
|
||||
<UserRolePicker
|
||||
userId={original.id}
|
||||
orgId={original.orgId}
|
||||
basicRole={value}
|
||||
roles={original.roles || []}
|
||||
onBasicRoleChange={(newRole) => onRoleChange(newRole, original)}
|
||||
roleOptions={roleOptions}
|
||||
basicRoleDisabled={!canUpdateRole}
|
||||
disabled={original.isExternal || original.isDisabled}
|
||||
width={40}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<OrgRolePicker
|
||||
aria-label="Role"
|
||||
value={value}
|
||||
disabled={original.isExternal || !canUpdateRole || original.isDisabled}
|
||||
onChange={(newRole) => onRoleChange(newRole, original)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionsCell = (
|
||||
original: ServiceAccountDTO,
|
||||
isLoading: boolean,
|
||||
onAddTokenClick: (serviceAccount: ServiceAccountDTO) => void,
|
||||
onEnable: (serviceAccount: ServiceAccountDTO) => void,
|
||||
onDisable: (serviceAccount: ServiceAccountDTO) => void,
|
||||
onRemoveButtonClick: (serviceAccount: ServiceAccountDTO) => void
|
||||
) => {
|
||||
if (isLoading) {
|
||||
return <Skeleton width={100} />;
|
||||
} else {
|
||||
return !original.isExternal ? (
|
||||
<Stack alignItems="center" justifyContent="flex-end">
|
||||
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !original.tokens && (
|
||||
<Button onClick={() => onAddTokenClick(original)} disabled={original.isDisabled}>
|
||||
Add token
|
||||
</Button>
|
||||
)}
|
||||
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, original) &&
|
||||
(original.isDisabled ? (
|
||||
<Button variant="secondary" size="md" onClick={() => onEnable(original)}>
|
||||
Enable
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="md" onClick={() => onDisable(original)}>
|
||||
Disable
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, original) && (
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
aria-label={`Delete service account ${original.name}`}
|
||||
variant="secondary"
|
||||
onClick={() => onRemoveButtonClick(original)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack alignItems="center" justifyContent="flex-end">
|
||||
<IconButton
|
||||
disabled={true}
|
||||
name="lock"
|
||||
size="md"
|
||||
tooltip={`This is a managed service account and cannot be modified.`}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ServiceAccountTable.displayName = 'ServiceAccountTable';
|
||||
@@ -77,7 +77,7 @@ const getDefaultServiceAccount: () => ServiceAccountDTO = () => ({
|
||||
});
|
||||
|
||||
describe('ServiceAccountsListPage tests', () => {
|
||||
it('Should display list of service accounts', () => {
|
||||
it('Should display list of service accounts', async () => {
|
||||
setup({
|
||||
serviceAccounts: [getDefaultServiceAccount()],
|
||||
});
|
||||
@@ -153,7 +153,7 @@ describe('ServiceAccountsListPage tests', () => {
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByLabelText(/Delete service account/));
|
||||
await user.click(screen.getByLabelText(`Delete service account ${getDefaultServiceAccount().name}`));
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(deleteServiceAccountMock).toHaveBeenCalledWith(42);
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { GrafanaTheme2, OrgRole } from '@grafana/data';
|
||||
import {
|
||||
ConfirmModal,
|
||||
FilterInput,
|
||||
LinkButton,
|
||||
RadioButtonGroup,
|
||||
useStyles2,
|
||||
InlineField,
|
||||
Pagination,
|
||||
Stack,
|
||||
EmptyState,
|
||||
} from '@grafana/ui';
|
||||
import { OrgRole } from '@grafana/data';
|
||||
import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, InlineField, EmptyState, Box } from '@grafana/ui';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import config from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
|
||||
|
||||
import { ServiceAccountTable } from './ServiceAccountTable';
|
||||
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
|
||||
import ServiceAccountListItem from './components/ServiceAccountsListItem';
|
||||
import {
|
||||
changeQuery,
|
||||
changePage,
|
||||
@@ -84,7 +73,6 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
changeStateFilter,
|
||||
createServiceAccountToken,
|
||||
}: Props): JSX.Element => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
|
||||
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
|
||||
@@ -213,12 +201,13 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
width={50}
|
||||
/>
|
||||
</InlineField>
|
||||
<RadioButtonGroup
|
||||
options={availableFilters}
|
||||
onChange={onStateFilterChange}
|
||||
value={serviceAccountStateFilter}
|
||||
className={styles.filter}
|
||||
/>
|
||||
<Box marginBottom={1}>
|
||||
<RadioButtonGroup
|
||||
options={availableFilters}
|
||||
onChange={onStateFilterChange}
|
||||
value={serviceAccountStateFilter}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
{!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && <EmptyState variant="not-found" />}
|
||||
{!isLoading && noServiceAccountsCreated && (
|
||||
@@ -238,48 +227,20 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
)}
|
||||
|
||||
{(isLoading || serviceAccounts.length !== 0) && (
|
||||
<>
|
||||
<div className={cx(styles.table, 'admin-list-table')}>
|
||||
<table className="filter-table filter-table--hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Account</th>
|
||||
<th>ID</th>
|
||||
<th>Roles</th>
|
||||
<th>Tokens</th>
|
||||
<th style={{ width: '120px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<ServiceAccountListItem.Skeleton />
|
||||
<ServiceAccountListItem.Skeleton />
|
||||
<ServiceAccountListItem.Skeleton />
|
||||
</>
|
||||
) : (
|
||||
serviceAccounts.map((serviceAccount) => (
|
||||
<ServiceAccountListItem
|
||||
serviceAccount={serviceAccount}
|
||||
key={serviceAccount.id}
|
||||
roleOptions={roleOptions}
|
||||
onRoleChange={onRoleChange}
|
||||
onRemoveButtonClick={onRemoveButtonClick}
|
||||
onDisable={onDisableButtonClick}
|
||||
onEnable={onEnable}
|
||||
onAddTokenClick={onTokenAdd}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Stack justifyContent="flex-end">
|
||||
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={totalPages} onNavigate={changePage} />
|
||||
</Stack>
|
||||
</div>
|
||||
</>
|
||||
<ServiceAccountTable
|
||||
services={serviceAccounts}
|
||||
showPaging={true}
|
||||
totalPages={totalPages}
|
||||
onChangePage={changePage}
|
||||
currentPage={page}
|
||||
onRoleChange={onRoleChange}
|
||||
roleOptions={roleOptions}
|
||||
onRemoveButtonClick={onRemoveButtonClick}
|
||||
onDisable={onDisableButtonClick}
|
||||
onEnable={onEnable}
|
||||
onAddTokenClick={onTokenAdd}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
{currentServiceAccount && (
|
||||
<>
|
||||
@@ -320,59 +281,5 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
table: css({
|
||||
marginTop: theme.spacing(3),
|
||||
}),
|
||||
filter: css({
|
||||
margin: `0 ${theme.spacing(1)}`,
|
||||
}),
|
||||
row: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100% !important',
|
||||
|
||||
a: {
|
||||
padding: `${theme.spacing(0.5)} 0 !important`,
|
||||
},
|
||||
}),
|
||||
unitTooltip: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
unitItem: css({
|
||||
cursor: 'pointer',
|
||||
padding: theme.spacing(0.5, 0),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
disabled: css({
|
||||
color: theme.colors.text.disabled,
|
||||
}),
|
||||
link: css({
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}),
|
||||
pageHeader: css({
|
||||
display: 'flex',
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
apiKeyInfoLabel: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
lineHeight: 2.2,
|
||||
flexGrow: 1,
|
||||
color: theme.colors.text.secondary,
|
||||
|
||||
span: {
|
||||
padding: theme.spacing(0.5),
|
||||
},
|
||||
}),
|
||||
filterDelimiter: css({
|
||||
flexGrow: 1,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected);
|
||||
export default ServiceAccountsListPage;
|
||||
|
||||
Reference in New Issue
Block a user