diff --git a/public/app/features/serviceaccounts/ServiceAccountTable.tsx b/public/app/features/serviceaccounts/ServiceAccountTable.tsx new file mode 100644 index 00000000000..dcb8aeec5ee --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountTable.tsx @@ -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 = 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> = 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 ( + + String(service.id)} /> + {showPaging && totalPages > 1 && ( + + + + )} + + ); +}; + +const getCellContent = ( + value: string, + original: ServiceAccountDTO, + isLoading: boolean, + columnName?: Column['id'] +) => { + if (isLoading) { + return columnName === 'avatarUrl' ? : ; + } + const href = `/org/serviceaccounts/${original.id}`; + const ariaLabel = `Edit service account's ${name} details`; + switch (columnName) { + case 'avatarUrl': + return ( + + + + ); + case 'id': + return ( + + {original.login} + + ); + case 'tokens': + return ( + + + + {value || 'No tokens'} + + + ); + default: + return ( + + {value} + + ); + } +}; + +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 ; + } else { + return contextSrv.licensedAccessControlEnabled() ? ( + displayRolePicker && ( + onRoleChange(newRole, original)} + roleOptions={roleOptions} + basicRoleDisabled={!canUpdateRole} + disabled={original.isExternal || original.isDisabled} + width={40} + /> + ) + ) : ( + 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 ; + } else { + return !original.isExternal ? ( + + {contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !original.tokens && ( + + )} + {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, original) && + (original.isDisabled ? ( + + ) : ( + + ))} + + {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, original) && ( + onRemoveButtonClick(original)} + /> + )} + + ) : ( + + + + ); + } +}; + +ServiceAccountTable.displayName = 'ServiceAccountTable'; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx index bce6b83e0d0..abe41f5650c 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx @@ -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); diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index c0e733f8bfc..bc51b94d918 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -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} /> - + + + {!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && } {!isLoading && noServiceAccountsCreated && ( @@ -238,48 +227,20 @@ export const ServiceAccountsListPageUnconnected = ({ )} {(isLoading || serviceAccounts.length !== 0) && ( - <> -
- - - - - - - - - - - - {isLoading ? ( - <> - - - - - ) : ( - serviceAccounts.map((serviceAccount) => ( - - )) - )} - -
AccountIDRolesTokens -
- - - - -
- + )} {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;