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', () => {
|
describe('ServiceAccountsListPage tests', () => {
|
||||||
it('Should display list of service accounts', () => {
|
it('Should display list of service accounts', async () => {
|
||||||
setup({
|
setup({
|
||||||
serviceAccounts: [getDefaultServiceAccount()],
|
serviceAccounts: [getDefaultServiceAccount()],
|
||||||
});
|
});
|
||||||
@@ -153,7 +153,7 @@ describe('ServiceAccountsListPage tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
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' }));
|
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
|
|
||||||
expect(deleteServiceAccountMock).toHaveBeenCalledWith(42);
|
expect(deleteServiceAccountMock).toHaveBeenCalledWith(42);
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
|
||||||
import pluralize from 'pluralize';
|
import pluralize from 'pluralize';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
|
|
||||||
import { GrafanaTheme2, OrgRole } from '@grafana/data';
|
import { OrgRole } from '@grafana/data';
|
||||||
import {
|
import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, InlineField, EmptyState, Box } from '@grafana/ui';
|
||||||
ConfirmModal,
|
|
||||||
FilterInput,
|
|
||||||
LinkButton,
|
|
||||||
RadioButtonGroup,
|
|
||||||
useStyles2,
|
|
||||||
InlineField,
|
|
||||||
Pagination,
|
|
||||||
Stack,
|
|
||||||
EmptyState,
|
|
||||||
} from '@grafana/ui';
|
|
||||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
|
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
|
||||||
|
|
||||||
|
import { ServiceAccountTable } from './ServiceAccountTable';
|
||||||
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
|
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
|
||||||
import ServiceAccountListItem from './components/ServiceAccountsListItem';
|
|
||||||
import {
|
import {
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changePage,
|
changePage,
|
||||||
@@ -84,7 +73,6 @@ export const ServiceAccountsListPageUnconnected = ({
|
|||||||
changeStateFilter,
|
changeStateFilter,
|
||||||
createServiceAccountToken,
|
createServiceAccountToken,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
|
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
|
||||||
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
|
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
|
||||||
@@ -213,12 +201,13 @@ export const ServiceAccountsListPageUnconnected = ({
|
|||||||
width={50}
|
width={50}
|
||||||
/>
|
/>
|
||||||
</InlineField>
|
</InlineField>
|
||||||
<RadioButtonGroup
|
<Box marginBottom={1}>
|
||||||
options={availableFilters}
|
<RadioButtonGroup
|
||||||
onChange={onStateFilterChange}
|
options={availableFilters}
|
||||||
value={serviceAccountStateFilter}
|
onChange={onStateFilterChange}
|
||||||
className={styles.filter}
|
value={serviceAccountStateFilter}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && <EmptyState variant="not-found" />}
|
{!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && <EmptyState variant="not-found" />}
|
||||||
{!isLoading && noServiceAccountsCreated && (
|
{!isLoading && noServiceAccountsCreated && (
|
||||||
@@ -238,48 +227,20 @@ export const ServiceAccountsListPageUnconnected = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(isLoading || serviceAccounts.length !== 0) && (
|
{(isLoading || serviceAccounts.length !== 0) && (
|
||||||
<>
|
<ServiceAccountTable
|
||||||
<div className={cx(styles.table, 'admin-list-table')}>
|
services={serviceAccounts}
|
||||||
<table className="filter-table filter-table--hover">
|
showPaging={true}
|
||||||
<thead>
|
totalPages={totalPages}
|
||||||
<tr>
|
onChangePage={changePage}
|
||||||
<th></th>
|
currentPage={page}
|
||||||
<th>Account</th>
|
onRoleChange={onRoleChange}
|
||||||
<th>ID</th>
|
roleOptions={roleOptions}
|
||||||
<th>Roles</th>
|
onRemoveButtonClick={onRemoveButtonClick}
|
||||||
<th>Tokens</th>
|
onDisable={onDisableButtonClick}
|
||||||
<th style={{ width: '120px' }} />
|
onEnable={onEnable}
|
||||||
</tr>
|
onAddTokenClick={onTokenAdd}
|
||||||
</thead>
|
isLoading={isLoading}
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{currentServiceAccount && (
|
{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);
|
const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected);
|
||||||
export default ServiceAccountsListPage;
|
export default ServiceAccountsListPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user