From 50538d5309abd5660a0af8820e57198ad141b609 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 1 Jun 2022 10:35:16 +0300 Subject: [PATCH] ServiceAccounts: refactor UI (#49508) * ServiceAccounts: refactor ServiceAccountRoleRow * Refactor ServiceAccountRoleRow * Refactor ServiceAccountProfile * Refactor components * Change service accounts icon * Refine service accounts page header * Improve service accounts filtering * Change delete button style * Tweak account id * Auto focus name field when create service account * Add disable/enable button * Enable/disable service accounts * Optimize updating service account (do not fetch all) * Remove status column (replace by enable/disable button) * Add banner with service accounts description * Add tokens from main page * Update tokens count when add token from main page * Fix action buttons column * Fix tokens count when change role * Refine table row classes * Fix buttons * Simplify working with state * Show message when service account updated * Able to filter disabled accounts * Mark disabled accounts in a table * Refine disabled account view * Move non-critical components to separate folder * Remove confusing focusing * Fix date picker position when creating new token * DatePicker: able to set minimum date that can be selected * Don't allow to select expiration dates prior today * Set tomorrow as a default token expiration date * Fix displaying expiration period * Rename Add token button * Refine page styles * Show modal when disabling SA from main page * Arrange role picker * Refine SA page styles * Generate default token name * More smooth navigation between SA pages * Stop loading indicator in case of error * Remove legacy styles usage * Tweaks after code review Co-authored-by: Alex Khomenko * Get rid of useDisapatch in favor of mapDispatchToProps * Tests for ServiceAccountsListPage * Tests for service account page * Show new role picker only with license * Get rid of deprecated css classes * Apply suggestion from code review Co-authored-by: Alex Khomenko * Fix service accounts icon * Tests for service account create page * Return service account info when update * Add behaviour tests for ServiceAccountsListPage * Fix disabled cursor on confirm button * More behavior tests for service account page * Temporary disable service account migration banner * Use safe where condition Co-authored-by: Jguer * Apply review suggestions Co-authored-by: Alex Khomenko * Remove autofocus from search Co-authored-by: Alex Khomenko Co-authored-by: Jguer --- .../ConfirmButton/ConfirmButton.tsx | 26 +- pkg/api/index.go | 5 +- pkg/services/serviceaccounts/api/api.go | 19 +- pkg/services/serviceaccounts/api/api_test.go | 5 +- .../serviceaccounts/database/database.go | 5 + pkg/services/serviceaccounts/models.go | 2 + public/app/features/admin/OrgRolePicker.tsx | 1 + public/app/features/api-keys/ApiKeysPage.tsx | 8 + .../ServiceAccountCreatePage.test.tsx | 82 +++++ .../ServiceAccountCreatePage.tsx | 32 +- .../ServiceAccountPage.test.tsx | 180 ++++++++++ .../serviceaccounts/ServiceAccountPage.tsx | 241 ++++++++++--- .../serviceaccounts/ServiceAccountProfile.tsx | 292 ---------------- .../serviceaccounts/ServiceAccountRoleRow.tsx | 60 ---- .../ServiceAccountsListPage.test.tsx | 163 +++++++++ .../ServiceAccountsListPage.tsx | 323 ++++++++++++------ .../CreateTokenModal.tsx} | 72 ++-- .../components/ServiceAccountProfile.tsx | 72 ++++ .../components/ServiceAccountProfileRow.tsx | 106 ++++++ .../components/ServiceAccountRoleRow.tsx | 62 ++++ .../ServiceAccountTokensTable.tsx | 73 ++-- .../ServiceAccountsListItem.tsx | 136 +++++--- .../features/serviceaccounts/state/actions.ts | 149 +++----- .../state/actionsServiceAccountPage.ts | 74 ++++ .../serviceaccounts/state/reducers.ts | 38 +-- public/app/types/serviceaccount.ts | 12 +- 26 files changed, 1468 insertions(+), 770 deletions(-) create mode 100644 public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx create mode 100644 public/app/features/serviceaccounts/ServiceAccountPage.test.tsx delete mode 100644 public/app/features/serviceaccounts/ServiceAccountProfile.tsx delete mode 100644 public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx create mode 100644 public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx rename public/app/features/serviceaccounts/{CreateServiceAccountTokenModal.tsx => components/CreateTokenModal.tsx} (72%) create mode 100644 public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx create mode 100644 public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx create mode 100644 public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx rename public/app/features/serviceaccounts/{ => components}/ServiceAccountTokensTable.tsx (62%) rename public/app/features/serviceaccounts/{ => components}/ServiceAccountsListItem.tsx (50%) create mode 100644 public/app/features/serviceaccounts/state/actionsServiceAccountPage.ts diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index a9c39369c6d..f2b60332052 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -117,17 +117,19 @@ class UnThemedConfirmButton extends PureComponent { return ( - {typeof children === 'string' ? ( - - + + ) : ( + {children} - - - ) : ( - - {children} - - )} + + )} + ); @@ -142,8 +150,4 @@ const ServiceAccountCreatePage: React.FC = ({ nav ); }; -const mapStateToProps = (state: StoreState) => ({ - navModel: getNavModel(state.navIndex, 'serviceaccounts'), -}); - -export default connect(mapStateToProps)(ServiceAccountCreatePage); +export default connect(mapStateToProps)(ServiceAccountCreatePageUnconnected); diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx new file mode 100644 index 00000000000..142b4045507 --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx @@ -0,0 +1,180 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { ApiKey, OrgRole, ServiceAccountDTO } from 'app/types'; + +import { ServiceAccountPageUnconnected, Props } from './ServiceAccountPage'; + +jest.mock('app/core/core', () => ({ + contextSrv: { + licensedAccessControlEnabled: () => false, + hasPermission: () => true, + hasPermissionInMetadata: () => true, + }, +})); + +const setup = (propOverrides: Partial) => { + const createServiceAccountTokenMock = jest.fn(); + const deleteServiceAccountMock = jest.fn(); + const deleteServiceAccountTokenMock = jest.fn(); + const loadServiceAccountMock = jest.fn(); + const loadServiceAccountTokensMock = jest.fn(); + const updateServiceAccountMock = jest.fn(); + + const props: Props = { + navModel: { + main: { + text: 'Configuration', + }, + node: { + text: 'Service accounts', + }, + }, + serviceAccount: {} as ServiceAccountDTO, + tokens: [], + builtInRoles: {}, + isLoading: false, + roleOptions: [], + match: { + params: { id: '1' }, + isExact: true, + path: '/org/serviceaccounts/1', + url: 'http://localhost:3000/org/serviceaccounts/1', + }, + history: {} as any, + location: {} as any, + queryParams: {}, + route: {} as any, + timezone: '', + createServiceAccountToken: createServiceAccountTokenMock, + deleteServiceAccount: deleteServiceAccountMock, + deleteServiceAccountToken: deleteServiceAccountTokenMock, + loadServiceAccount: loadServiceAccountMock, + loadServiceAccountTokens: loadServiceAccountTokensMock, + updateServiceAccount: updateServiceAccountMock, + }; + + Object.assign(props, propOverrides); + + const { rerender } = render(); + return { + rerender, + props, + createServiceAccountTokenMock, + deleteServiceAccountMock, + deleteServiceAccountTokenMock, + loadServiceAccountMock, + loadServiceAccountTokensMock, + updateServiceAccountMock, + }; +}; + +const getDefaultServiceAccount = (): ServiceAccountDTO => ({ + id: 42, + name: 'Data source scavenger', + login: 'sa-data-source-scavenger', + orgId: 1, + role: OrgRole.Editor, + isDisabled: false, + teams: [], + tokens: 1, + createdAt: '2022-01-01 00:00:00', +}); + +const getDefaultToken = (): ApiKey => ({ + id: 142, + name: 'sa-data-source-scavenger-74f1634b-3273-4da6-994b-24bd32f5bdc6', + role: OrgRole.Viewer, + secondsToLive: null, + created: '2022-01-01 00:00:00', +}); + +describe('ServiceAccountPage tests', () => { + it('Should display service account info', () => { + setup({ + serviceAccount: getDefaultServiceAccount(), + tokens: [getDefaultToken()], + }); + expect(screen.getAllByText(/Data source scavenger/)).toHaveLength(2); + expect(screen.getByText(/^sa-data-source-scavenger$/)).toBeInTheDocument(); + expect(screen.getByText(/Editor/)).toBeInTheDocument(); + }); + + it('Should display enable button for disabled account', () => { + setup({ + serviceAccount: { + ...getDefaultServiceAccount(), + isDisabled: true, + }, + tokens: [getDefaultToken()], + }); + expect(screen.getByRole('button', { name: 'Enable service account' })).toBeInTheDocument(); + }); + + it('Should display Add token button for account without tokens', () => { + setup({ + serviceAccount: { + ...getDefaultServiceAccount(), + tokens: 0, + }, + }); + expect(screen.getByRole('button', { name: 'Add service account token' })).toBeInTheDocument(); + }); + + it('Should display token info', () => { + setup({ + serviceAccount: getDefaultServiceAccount(), + tokens: [getDefaultToken()], + }); + expect(screen.getByText(/sa-data-source-scavenger-74f1634b-3273-4da6-994b-24bd32f5bdc6/)).toBeInTheDocument(); + }); + + it('Should display expired status for expired tokens', () => { + setup({ + serviceAccount: getDefaultServiceAccount(), + tokens: [ + { + ...getDefaultToken(), + expiration: '2022-01-02 00:00:00', + hasExpired: true, + }, + ], + }); + expect(screen.getByText(/Expired/)).toBeInTheDocument(); + }); + + it('Should call API with proper params when edit service account info', async () => { + const updateServiceAccountMock = jest.fn(); + setup({ + serviceAccount: getDefaultServiceAccount(), + updateServiceAccount: updateServiceAccountMock, + }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Edit' })); + await userEvent.clear(screen.getByLabelText('Name')); + await userEvent.type(screen.getByLabelText('Name'), 'Foo bar'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(updateServiceAccountMock).toHaveBeenCalledWith({ + ...getDefaultServiceAccount(), + name: 'Foo bar', + }); + }); + + it('Should call API with proper params when delete service account token', async () => { + const deleteServiceAccountTokenMock = jest.fn(); + setup({ + serviceAccount: getDefaultServiceAccount(), + tokens: [getDefaultToken()], + deleteServiceAccountToken: deleteServiceAccountTokenMock, + }); + + const user = userEvent.setup(); + await userEvent.click(screen.getByLabelText(/Delete service account token/)); + await user.click(screen.getByRole('button', { name: /^Delete$/ })); + + expect(deleteServiceAccountTokenMock).toHaveBeenCalledWith(42, 142); + }); +}); diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx index 4a5fb5f3963..82fed275471 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -1,26 +1,27 @@ +import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { getTimeZone, NavModel } from '@grafana/data'; -import { Button } from '@grafana/ui'; +import { getTimeZone, GrafanaTheme2, NavModel } from '@grafana/data'; +import { Button, ConfirmModal, IconButton, useStyles2 } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; -import { StoreState, ServiceAccountDTO, ApiKey, Role, AccessControlAction } from 'app/types'; +import { AccessControlAction, ApiKey, Role, ServiceAccountDTO, StoreState } from 'app/types'; -import { CreateTokenModal, ServiceAccountToken } from './CreateServiceAccountTokenModal'; -import { ServiceAccountProfile } from './ServiceAccountProfile'; -import { ServiceAccountTokensTable } from './ServiceAccountTokensTable'; +import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal'; +import { ServiceAccountProfile } from './components/ServiceAccountProfile'; +import { ServiceAccountTokensTable } from './components/ServiceAccountTokensTable'; +import { fetchACOptions } from './state/actions'; import { + createServiceAccountToken, + deleteServiceAccount, deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens, - createServiceAccountToken, - fetchACOptions, updateServiceAccount, - deleteServiceAccount, -} from './state/actions'; +} from './state/actionsServiceAccountPage'; interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { navModel: NavModel; @@ -42,20 +43,21 @@ function mapStateToProps(state: StoreState) { timezone: getTimeZone(state.user), }; } + const mapDispatchToProps = { + createServiceAccountToken, + deleteServiceAccount, + deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens, - createServiceAccountToken, - deleteServiceAccountToken, - deleteServiceAccount, updateServiceAccount, - fetchACOptions, }; const connector = connect(mapStateToProps, mapDispatchToProps); -type Props = OwnProps & ConnectedProps; -const ServiceAccountPageUnconnected = ({ +export type Props = OwnProps & ConnectedProps; + +export const ServiceAccountPageUnconnected = ({ navModel, match, serviceAccount, @@ -64,36 +66,67 @@ const ServiceAccountPageUnconnected = ({ isLoading, roleOptions, builtInRoles, + createServiceAccountToken, + deleteServiceAccount, + deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens, - createServiceAccountToken, - deleteServiceAccountToken, - deleteServiceAccount, updateServiceAccount, - fetchACOptions, -}: Props) => { - const [isModalOpen, setIsModalOpen] = useState(false); +}: Props): JSX.Element => { const [newToken, setNewToken] = useState(''); + const [isTokenModalOpen, setIsTokenModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDisableModalOpen, setIsDisableModalOpen] = useState(false); + const styles = useStyles2(getStyles); + const serviceAccountId = parseInt(match.params.id, 10); + const tokenActionsDisabled = + !contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) || serviceAccount.isDisabled; + + const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite); useEffect(() => { - const serviceAccountId = parseInt(match.params.id, 10); loadServiceAccount(serviceAccountId); loadServiceAccountTokens(serviceAccountId); if (contextSrv.licensedAccessControlEnabled()) { fetchACOptions(); } - }, [match, loadServiceAccount, loadServiceAccountTokens, fetchACOptions]); + }, [loadServiceAccount, loadServiceAccountTokens, serviceAccountId]); + + const onProfileChange = (serviceAccount: ServiceAccountDTO) => { + updateServiceAccount(serviceAccount); + }; + + const showDeleteServiceAccountModal = (show: boolean) => () => { + setIsDeleteModalOpen(show); + }; + + const showDisableServiceAccountModal = (show: boolean) => () => { + setIsDisableModalOpen(show); + }; + + const handleServiceAccountDelete = () => { + deleteServiceAccount(serviceAccount.id); + }; + + const handleServiceAccountDisable = () => { + updateServiceAccount({ ...serviceAccount, isDisabled: true }); + setIsDisableModalOpen(false); + }; + + const handleServiceAccountEnable = () => { + updateServiceAccount({ ...serviceAccount, isDisabled: false }); + }; const onDeleteServiceAccountToken = (key: ApiKey) => { - deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!); + deleteServiceAccountToken(serviceAccount?.id, key.id!); }; const onCreateToken = (token: ServiceAccountToken) => { - createServiceAccountToken(serviceAccount.id, token, setNewToken); + createServiceAccountToken(serviceAccount?.id, token, setNewToken); }; - const onModalClose = () => { - setIsModalOpen(false); + const onTokenModalClose = () => { + setIsTokenModalOpen(false); setNewToken(''); }; @@ -101,42 +134,142 @@ const ServiceAccountPageUnconnected = ({ {serviceAccount && ( - <> +
+ + + +
+ {`Avatar +
+

{serviceAccount.name}

+
+ + {serviceAccount.isDisabled ? ( + + ) : ( + + )} +
+
+ )} +
+ {serviceAccount && ( - - )} -
-

- Tokens -

- + )} +
+

Tokens

+ +
+ {tokens && ( + + )}
- {tokens && ( - - )} - {contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && ( - - )} + + + ); }; +const getStyles = (theme: GrafanaTheme2) => { + return { + headerContainer: css` + display: flex; + margin-bottom: ${theme.spacing(2)}; + align-items: center; + + h3 { + margin-bottom: ${theme.spacing(0.5)}; + flex-grow: 1; + } + `, + headerAvatar: css` + margin-right: ${theme.spacing(1)}; + margin-bottom: ${theme.spacing(0.6)}; + img { + width: 25px; + height: 25px; + border-radius: 50%; + } + `, + returnButton: css` + margin-right: ${theme.spacing(1)}; + `, + buttonRow: css` + > * { + margin-right: ${theme.spacing(2)}; + } + `, + pageBody: css` + padding-left: ${theme.spacing(5.5)}; + `, + tokensListHeader: css` + display: flex; + justify-content: space-between; + align-items: center; + `, + }; +}; + export const ServiceAccountPage = connector(ServiceAccountPageUnconnected); diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx deleted file mode 100644 index ee3bb166da1..00000000000 --- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { PureComponent, useRef, useState } from 'react'; - -import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data'; -import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; -import { Role, ServiceAccountDTO, AccessControlAction } from 'app/types'; - -import { ServiceAccountRoleRow } from './ServiceAccountRoleRow'; - -interface Props { - serviceAccount: ServiceAccountDTO; - timeZone: TimeZone; - - roleOptions: Role[]; - builtInRoles: Record; - deleteServiceAccount: (serviceAccountId: number) => void; - updateServiceAccount: (serviceAccount: ServiceAccountDTO) => void; -} - -export function ServiceAccountProfile({ - serviceAccount, - timeZone, - roleOptions, - builtInRoles, - deleteServiceAccount, - updateServiceAccount, -}: Props) { - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [showDisableModal, setShowDisableModal] = useState(false); - - const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite); - - const deleteServiceAccountRef = useRef(null); - const showDeleteServiceAccountModal = (show: boolean) => () => { - setShowDeleteModal(show); - if (!show && deleteServiceAccountRef.current) { - deleteServiceAccountRef.current.focus(); - } - }; - - const disableServiceAccountRef = useRef(null); - const showDisableServiceAccountModal = (show: boolean) => () => { - setShowDisableModal(show); - if (!show && disableServiceAccountRef.current) { - disableServiceAccountRef.current.focus(); - } - }; - - const handleServiceAccountDelete = () => { - deleteServiceAccount(serviceAccount.id); - }; - const handleServiceAccountDisable = () => { - updateServiceAccount({ ...serviceAccount, isDisabled: true }); - setShowDisableModal(false); - }; - - const handleServiceAccountEnable = () => { - updateServiceAccount({ ...serviceAccount, isDisabled: false }); - }; - - const handleServiceAccountRoleChange = (role: OrgRole) => { - updateServiceAccount({ ...serviceAccount, role: role }); - }; - - const onServiceAccountNameChange = (newValue: string) => { - updateServiceAccount({ ...serviceAccount, name: newValue }); - }; - - const styles = useStyles2(getStyles); - - return ( - <> -
- -
- Information -
-
- - - - - - {/* */} - - -
-
-
- <> - - - - {serviceAccount.isDisabled ? ( - - ) : ( - <> - - - - )} -
-
- - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - buttonRow: css` - margin-top: ${theme.spacing(1.5)}; - > * { - margin-right: ${theme.spacing(2)}; - } - `, - }; -}; - -interface ServiceAccountProfileRowProps { - label: string; - value?: string; - inputType?: string; - onChange?: (value: string) => void; - disabled?: boolean; -} - -interface ServiceAccountProfileRowState { - value: string; - editing: boolean; -} - -export class ServiceAccountProfileRow extends PureComponent< - ServiceAccountProfileRowProps, - ServiceAccountProfileRowState -> { - inputElem?: HTMLInputElement; - - static defaultProps: Partial = { - value: '', - inputType: 'text', - }; - - state = { - editing: false, - value: this.props.value || '', - }; - - setInputElem = (elem: any) => { - this.inputElem = elem; - }; - - onEditClick = () => { - this.setState({ editing: true }, this.focusInput); - }; - - onCancelClick = () => { - this.setState({ editing: false, value: this.props.value || '' }); - }; - - onInputChange = (event: React.ChangeEvent, status?: LegacyInputStatus) => { - if (status === LegacyInputStatus.Invalid) { - return; - } - - this.setState({ value: event.target.value }); - }; - - onInputBlur = (event: React.FocusEvent, status?: LegacyInputStatus) => { - if (status === LegacyInputStatus.Invalid) { - return; - } - - this.setState({ value: event.target.value }); - }; - - focusInput = () => { - if (this.inputElem && this.inputElem.focus) { - this.inputElem.focus(); - } - }; - - onSave = () => { - this.setState({ editing: false }); - if (this.props.onChange) { - this.props.onChange(this.state.value); - } - }; - - render() { - const { label, inputType } = this.props; - const { value } = this.state; - const labelClass = cx( - 'width-16', - css` - font-weight: 500; - ` - ); - - const inputId = `${label}-input`; - return ( - - - - - - {!this.props.disabled && this.state.editing ? ( - - ) : ( - {this.props.value} - )} - - - {this.props.onChange && ( - - Edit - - )} - - - ); - } -} diff --git a/public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx b/public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx deleted file mode 100644 index ea1d0eb6f57..00000000000 --- a/public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { PureComponent } from 'react'; - -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'; - -interface Props { - label: string; - serviceAccount: ServiceAccountDTO; - onRoleChange: (role: OrgRole) => void; - roleOptions: Role[]; - builtInRoles: Record; -} - -export class ServiceAccountRoleRow extends PureComponent { - render() { - const { label, serviceAccount, roleOptions, builtInRoles, onRoleChange } = this.props; - const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); - const rolePickerDisabled = !canUpdateRole; - const labelClass = cx( - 'width-16', - css` - font-weight: 500; - ` - ); - - const inputId = `${label}-input`; - return ( - - - - - - {contextSrv.licensedAccessControlEnabled() ? ( - - ) : ( - - )} - - - - ); - } -} diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx new file mode 100644 index 00000000000..48dedee0a4b --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { OrgRole, ServiceAccountDTO, ServiceAccountStateFilter } from 'app/types'; + +import { Props, ServiceAccountsListPageUnconnected } from './ServiceAccountsListPage'; + +jest.mock('app/core/core', () => ({ + contextSrv: { + licensedAccessControlEnabled: () => false, + hasPermission: () => true, + hasPermissionInMetadata: () => true, + }, +})); + +const setup = (propOverrides: Partial) => { + const changeQueryMock = jest.fn(); + const fetchACOptionsMock = jest.fn(); + const fetchServiceAccountsMock = jest.fn(); + const deleteServiceAccountMock = jest.fn(); + const updateServiceAccountMock = jest.fn(); + const changeStateFilterMock = jest.fn(); + const createServiceAccountTokenMock = jest.fn(); + const props: Props = { + navModel: { + main: { + text: 'Configuration', + }, + node: { + text: 'Service accounts', + }, + }, + builtInRoles: {}, + isLoading: false, + page: 0, + perPage: 10, + query: '', + roleOptions: [], + serviceAccountStateFilter: ServiceAccountStateFilter.All, + showPaging: false, + totalPages: 1, + serviceAccounts: [], + changeQuery: changeQueryMock, + fetchACOptions: fetchACOptionsMock, + fetchServiceAccounts: fetchServiceAccountsMock, + deleteServiceAccount: deleteServiceAccountMock, + updateServiceAccount: updateServiceAccountMock, + changeStateFilter: changeStateFilterMock, + createServiceAccountToken: createServiceAccountTokenMock, + }; + + Object.assign(props, propOverrides); + + const { rerender } = render(); + return { + rerender, + props, + changeQueryMock, + fetchACOptionsMock, + fetchServiceAccountsMock, + deleteServiceAccountMock, + updateServiceAccountMock, + changeStateFilterMock, + createServiceAccountTokenMock, + }; +}; + +const getDefaultServiceAccount: () => ServiceAccountDTO = () => ({ + id: 42, + name: 'Data source scavenger', + login: 'sa-data-source-scavenger', + orgId: 1, + role: OrgRole.Editor, + isDisabled: false, + teams: [], + tokens: 1, + createdAt: '2022-01-01 00:00:00', +}); + +describe('ServiceAccountsListPage tests', () => { + it('Should display list of service accounts', () => { + setup({ + serviceAccounts: [getDefaultServiceAccount()], + }); + expect(screen.getByText(/Data source scavenger/)).toBeInTheDocument(); + expect(screen.getByText(/sa-data-source-scavenger/)).toBeInTheDocument(); + expect(screen.getByText(/Editor/)).toBeInTheDocument(); + }); + + it('Should display enable button for disabled account', () => { + setup({ + serviceAccounts: [ + { + ...getDefaultServiceAccount(), + isDisabled: true, + }, + ], + }); + expect(screen.getByRole('button', { name: 'Enable' })).toBeInTheDocument(); + }); + + it('Should display Add token button for account without tokens', () => { + setup({ + serviceAccounts: [ + { + ...getDefaultServiceAccount(), + tokens: 0, + }, + ], + }); + expect(screen.getByRole('button', { name: 'Add token' })).toBeInTheDocument(); + expect(screen.getByText(/No tokens/)).toBeInTheDocument(); + }); + + it('Should update service account role', async () => { + const updateServiceAccountMock = jest.fn(); + setup({ + serviceAccounts: [getDefaultServiceAccount()], + updateServiceAccount: updateServiceAccountMock, + }); + + const user = userEvent.setup(); + await user.click(screen.getByText('Editor')); + await user.click(screen.getByText('Admin')); + + expect(updateServiceAccountMock).toHaveBeenCalledWith({ + ...getDefaultServiceAccount(), + role: OrgRole.Admin, + }); + }); + + it('Should disable service account', async () => { + const updateServiceAccountMock = jest.fn(); + setup({ + serviceAccounts: [getDefaultServiceAccount()], + updateServiceAccount: updateServiceAccountMock, + }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Disable/ })); + await user.click(screen.getByLabelText(/Confirm Modal Danger Button/)); + + expect(updateServiceAccountMock).toHaveBeenCalledWith({ + ...getDefaultServiceAccount(), + isDisabled: true, + }); + }); + + it('Should remove service account', async () => { + const deleteServiceAccountMock = jest.fn(); + setup({ + serviceAccounts: [getDefaultServiceAccount()], + deleteServiceAccount: deleteServiceAccountMock, + }); + + const user = userEvent.setup(); + await user.click(screen.getByLabelText(/Delete service account/)); + await user.click(screen.getByLabelText(/Confirm Modal Danger Button/)); + + expect(deleteServiceAccountMock).toHaveBeenCalledWith(42); + }); +}); diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 9f0e5778a45..e760f7016b3 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -1,31 +1,32 @@ import { css, cx } from '@emotion/css'; import pluralize from 'pluralize'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; -import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import Page from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { contextSrv } from 'app/core/core'; import { getNavModel } from 'app/core/selectors/navModel'; -import { StoreState, ServiceAccountDTO, AccessControlAction } from 'app/types'; +import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types'; -import ServiceAccountListItem from './ServiceAccountsListItem'; +import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal'; +import ServiceAccountListItem from './components/ServiceAccountsListItem'; import { - changeFilter, changeQuery, fetchACOptions, fetchServiceAccounts, - removeServiceAccount, + deleteServiceAccount, updateServiceAccount, - setServiceAccountToRemove, + changeStateFilter, + createServiceAccountToken, } from './state/actions'; interface OwnProps {} -type Props = OwnProps & ConnectedProps; +export type Props = OwnProps & ConnectedProps; function mapStateToProps(state: StoreState) { return { @@ -35,84 +36,162 @@ function mapStateToProps(state: StoreState) { } const mapDispatchToProps = { - fetchServiceAccounts, - fetchACOptions, - updateServiceAccount, - removeServiceAccount, - setServiceAccountToRemove, - changeFilter, changeQuery, + fetchACOptions, + fetchServiceAccounts, + deleteServiceAccount, + updateServiceAccount, + changeStateFilter, + createServiceAccountToken, }; const connector = connect(mapStateToProps, mapDispatchToProps); -const ServiceAccountsListPage = ({ - fetchServiceAccounts, - removeServiceAccount, - fetchACOptions, - updateServiceAccount, - setServiceAccountToRemove, +export const ServiceAccountsListPageUnconnected = ({ navModel, serviceAccounts, isLoading, roleOptions, builtInRoles, - changeFilter, - changeQuery, query, - filters, - serviceAccountToRemove, + serviceAccountStateFilter, + changeQuery, + fetchACOptions, + fetchServiceAccounts, + deleteServiceAccount, + updateServiceAccount, + changeStateFilter, + createServiceAccountToken, }: Props): JSX.Element => { const styles = useStyles2(getStyles); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); + const [isDisableModalOpen, setIsDisableModalOpen] = useState(false); + const [newToken, setNewToken] = useState(''); + const [currentServiceAccount, setCurrentServiceAccount] = useState(null); useEffect(() => { - const fetchData = async () => { - await fetchServiceAccounts(); - if (contextSrv.licensedAccessControlEnabled()) { - await fetchACOptions(); - } - }; - fetchData(); - }, [fetchServiceAccounts, fetchACOptions]); + fetchServiceAccounts({ withLoadingIndicator: true }); + if (contextSrv.licensedAccessControlEnabled()) { + fetchACOptions(); + } + }, [fetchACOptions, fetchServiceAccounts]); + + const noServiceAccountsCreated = + serviceAccounts.length === 0 && serviceAccountStateFilter === ServiceAccountStateFilter.All && !query; const onRoleChange = async (role: OrgRole, serviceAccount: ServiceAccountDTO) => { const updatedServiceAccount = { ...serviceAccount, role: role }; - await updateServiceAccount(updatedServiceAccount); - // need to refetch to display the new value in the list - await fetchServiceAccounts(); + updateServiceAccount(updatedServiceAccount); if (contextSrv.licensedAccessControlEnabled()) { fetchACOptions(); } }; + const onQueryChange = (value: string) => { + changeQuery(value); + }; + + const onStateFilterChange = (value: ServiceAccountStateFilter) => { + changeStateFilter(value); + }; + + const onRemoveButtonClick = (serviceAccount: ServiceAccountDTO) => { + setCurrentServiceAccount(serviceAccount); + setIsRemoveModalOpen(true); + }; + + const onServiceAccountRemove = async () => { + if (currentServiceAccount) { + deleteServiceAccount(currentServiceAccount.id); + } + onRemoveModalClose(); + }; + + const onDisableButtonClick = (serviceAccount: ServiceAccountDTO) => { + setCurrentServiceAccount(serviceAccount); + setIsDisableModalOpen(true); + }; + + const onDisable = () => { + if (currentServiceAccount) { + updateServiceAccount({ ...currentServiceAccount, isDisabled: true }); + } + onDisableModalClose(); + }; + + const onEnable = (serviceAccount: ServiceAccountDTO) => { + updateServiceAccount({ ...serviceAccount, isDisabled: false }); + }; + + const onTokenAdd = (serviceAccount: ServiceAccountDTO) => { + setCurrentServiceAccount(serviceAccount); + setIsAddModalOpen(true); + }; + + const onTokenCreate = async (token: ServiceAccountToken) => { + if (currentServiceAccount) { + createServiceAccountToken(currentServiceAccount.id, token, setNewToken); + } + }; + + const onAddModalClose = () => { + setIsAddModalOpen(false); + setCurrentServiceAccount(null); + setNewToken(''); + }; + + const onRemoveModalClose = () => { + setIsRemoveModalOpen(false); + setCurrentServiceAccount(null); + }; + + const onDisableModalClose = () => { + setIsDisableModalOpen(false); + setCurrentServiceAccount(null); + }; + return ( -

Service accounts

-
- - changeFilter({ name: 'expiredTokens', value })} - value={filters.find((f) => f.name === 'expiredTokens')?.value} - className={styles.filter} - /> - {serviceAccounts.length !== 0 && contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( +
+

Service accounts

+
+ + API keys are now service Accounts with tokens. Read more + + } + > + + + Looking for API keys? +
+ {!noServiceAccountsCreated && contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( Add service account )}
+
+ +
+ +
{isLoading && } - {!isLoading && serviceAccounts.length === 0 && ( + {!isLoading && noServiceAccountsCreated && ( <> )} - {!isLoading && serviceAccounts.length !== 0 && ( - <> -
- - - - - - - - - - - - - {serviceAccounts.map((serviceAccount: ServiceAccountDTO) => ( + + <> +
+
AccountIDRolesStatusTokens -
+ + + + + + + + + + + {!isLoading && + serviceAccounts.length !== 0 && + serviceAccounts.map((serviceAccount: ServiceAccountDTO) => ( ))} - -
AccountIDRolesTokens +
-
+ + +
+ + {currentServiceAccount && ( + <> + + + )} - {serviceAccountToRemove && ( - - Are you sure you want to delete '{serviceAccountToRemove.name}' - {Boolean(serviceAccountToRemove.tokens) && - ` and ${serviceAccountToRemove.tokens} accompanying ${pluralize( - 'token', - serviceAccountToRemove.tokens - )}`} - ? -
- } - confirmText="Delete" - title="Delete service account" - onDismiss={() => { - setServiceAccountToRemove(null); - }} - isOpen={true} - onConfirm={() => { - removeServiceAccount(serviceAccountToRemove.id); - setServiceAccountToRemove(null); - }} - /> - )}
); @@ -196,11 +287,6 @@ export const getStyles = (theme: GrafanaTheme2) => { filter: css` margin: 0 ${theme.spacing(1)}; `, - iconRow: css` - svg { - margin-left: ${theme.spacing(0.5)}; - } - `, row: css` display: flex; align-items: center; @@ -227,7 +313,32 @@ export const getStyles = (theme: GrafanaTheme2) => { cursor: pointer; text-decoration: underline; `, + pageHeader: css` + display: flex; + margin-bottom: ${theme.spacing(2)}; + `, + apiKeyInfoLabel: css` + margin-left: ${theme.spacing(1)}; + line-height: 2.2; + flex-grow: 1; + color: ${theme.colors.text.secondary}; + + span { + padding: ${theme.spacing(0.5)}; + } + `, + filterRow: cx( + 'page-action-bar', + css` + display: flex; + justifycontent: flex-end; + ` + ), + filterDelimiter: css` + flex-grow: 1; + `, }; }; -export default connector(ServiceAccountsListPage); +const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected); +export default ServiceAccountsListPage; diff --git a/public/app/features/serviceaccounts/CreateServiceAccountTokenModal.tsx b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx similarity index 72% rename from public/app/features/serviceaccounts/CreateServiceAccountTokenModal.tsx rename to public/app/features/serviceaccounts/components/CreateTokenModal.tsx index 8823c862d8a..ba48c05a717 100644 --- a/public/app/features/serviceaccounts/CreateServiceAccountTokenModal.tsx +++ b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { @@ -24,34 +25,54 @@ const EXPIRATION_OPTIONS = [ export type ServiceAccountToken = { name: string; - secondsToLive: number; + secondsToLive?: number; }; -interface CreateTokenModalProps { +interface Props { isOpen: boolean; token: string; + serviceAccountLogin: string; onCreateToken: (token: ServiceAccountToken) => void; onClose: () => void; } -export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: CreateTokenModalProps) => { +export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateToken, onClose }: Props) => { + let tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const [defaultTokenName, setDefaultTokenName] = useState(''); const [newTokenName, setNewTokenName] = useState(''); const [isWithExpirationDate, setIsWithExpirationDate] = useState(false); - const [newTokenExpirationDate, setNewTokenExpirationDate] = useState(''); - const [isExpirationDateValid, setIsExpirationDateValid] = useState(false); + const [newTokenExpirationDate, setNewTokenExpirationDate] = useState(tomorrow); + const [isExpirationDateValid, setIsExpirationDateValid] = useState(newTokenExpirationDate !== ''); const styles = useStyles2(getStyles); + useEffect(() => { + // Generate new token name every time we open modal + if (isOpen) { + setDefaultTokenName(`${serviceAccountLogin}-${uuidv4()}`); + } + }, [serviceAccountLogin, isOpen]); + const onExpirationDateChange = (value: Date | string) => { const isValid = value !== ''; setIsExpirationDateValid(isValid); setNewTokenExpirationDate(value); }; + const onGenerateToken = () => { + onCreateToken({ + name: newTokenName || defaultTokenName, + secondsToLive: isWithExpirationDate ? getSecondsToLive(newTokenExpirationDate) : undefined, + }); + }; + const onCloseInternal = () => { setNewTokenName(''); + setDefaultTokenName(''); setIsWithExpirationDate(false); - setNewTokenExpirationDate(''); - setIsExpirationDateValid(false); + setNewTokenExpirationDate(tomorrow); + setIsExpirationDateValid(newTokenExpirationDate !== ''); onClose(); }; @@ -63,13 +84,19 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea ); return ( - + {!token ? ( - <> +
{ setNewTokenName(e.currentTarget.value); }} @@ -92,22 +120,19 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea /> {isWithExpirationDate && ( - + )}
- - +
) : ( <>
@@ -157,6 +182,9 @@ const getStyles = (theme: GrafanaTheme2) => { modal: css` width: 550px; `, + modalContent: css` + overflow: visible; + `, modalRow: css` margin-bottom: ${theme.spacing(4)}; `, diff --git a/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx new file mode 100644 index 00000000000..98a29df3ee0 --- /dev/null +++ b/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx @@ -0,0 +1,72 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction, Role, ServiceAccountDTO } from 'app/types'; + +import { ServiceAccountProfileRow } from './ServiceAccountProfileRow'; +import { ServiceAccountRoleRow } from './ServiceAccountRoleRow'; + +interface Props { + serviceAccount: ServiceAccountDTO; + timeZone: TimeZone; + roleOptions: Role[]; + builtInRoles: Record; + onChange: (serviceAccount: ServiceAccountDTO) => void; +} + +export function ServiceAccountProfile({ + serviceAccount, + timeZone, + roleOptions, + builtInRoles, + onChange, +}: Props): JSX.Element { + const styles = useStyles2(getStyles); + const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite); + + const onRoleChange = (role: OrgRole) => { + onChange({ ...serviceAccount, role: role }); + }; + + const onNameChange = (newValue: string) => { + onChange({ ...serviceAccount, name: newValue }); + }; + + return ( +
+

Information

+ + + + + + + +
+
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => ({ + section: css` + margin-bottom: ${theme.spacing(4)}; + `, +}); diff --git a/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx b/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx new file mode 100644 index 00000000000..f32ce3ee720 --- /dev/null +++ b/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx @@ -0,0 +1,106 @@ +import { css, cx } from '@emotion/css'; +import React, { useEffect, useRef, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { ConfirmButton, Input, Label, LegacyInputStatus, useStyles2 } from '@grafana/ui'; + +interface Props { + label: string; + value?: string; + inputType?: string; + disabled?: boolean; + onChange?: (value: string) => void; +} + +export const ServiceAccountProfileRow = ({ label, value, inputType, disabled, onChange }: Props): JSX.Element => { + const inputElem = useRef(null); + const [inputValue, setInputValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); + const styles = useStyles2(getStyles); + const inputId = `${label}-input`; + + useEffect(() => { + if (isEditing) { + focusInput(); + } + }, [isEditing]); + + const onEditClick = () => { + setIsEditing(true); + }; + + const onCancelClick = () => { + setIsEditing(false); + setInputValue(value || ''); + }; + + const onInputChange = (event: React.ChangeEvent, status?: LegacyInputStatus) => { + if (status === LegacyInputStatus.Invalid) { + return; + } + setInputValue(event.target.value); + }; + + const onInputBlur = (event: React.FocusEvent, status?: LegacyInputStatus) => { + if (status === LegacyInputStatus.Invalid) { + return; + } + setInputValue(event.target.value); + }; + + const focusInput = () => { + inputElem?.current?.focus(); + }; + + const onSave = () => { + setIsEditing(false); + if (onChange) { + onChange(inputValue!); + } + }; + + return ( + + + + + + {!disabled && isEditing ? ( + + ) : ( + {value} + )} + + + {onChange && ( + + Edit + + )} + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + disabled: css` + color: ${theme.colors.text.secondary}; + `, + }; +}; diff --git a/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx b/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx new file mode 100644 index 00000000000..878eba3bace --- /dev/null +++ b/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { Label } from '@grafana/ui'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { contextSrv } from 'app/core/core'; +import { OrgRolePicker } from 'app/features/admin/OrgRolePicker'; +import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types'; + +interface Props { + label: string; + serviceAccount: ServiceAccountDTO; + onRoleChange: (role: OrgRole) => void; + roleOptions: Role[]; + builtInRoles: Record; +} + +export const ServiceAccountRoleRow = ({ + label, + serviceAccount, + roleOptions, + builtInRoles, + onRoleChange, +}: Props): JSX.Element => { + const inputId = `${label}-input`; + const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); + const rolePickerDisabled = !canUpdateRole || serviceAccount.isDisabled; + + return ( + + + + + {contextSrv.licensedAccessControlEnabled() ? ( + + + + ) : ( + <> + + + + + + )} + + ); +}; diff --git a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx b/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx similarity index 62% rename from public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx rename to public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx index 6d076ed8086..580e465daa2 100644 --- a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx @@ -1,54 +1,53 @@ import { css } from '@emotion/css'; -import React, { FC } from 'react'; +import React from 'react'; import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; -import { AccessControlAction } from 'app/types'; - -import { ApiKey } from '../../types'; +import { ApiKey } from 'app/types'; interface Props { tokens: ApiKey[]; timeZone: TimeZone; + tokenActionsDisabled?: boolean; onDelete: (token: ApiKey) => void; } -export const ServiceAccountTokensTable: FC = ({ tokens, timeZone, onDelete }) => { +export const ServiceAccountTokensTable = ({ tokens, timeZone, tokenActionsDisabled, onDelete }: Props): JSX.Element => { const theme = useTheme2(); const styles = getStyles(theme); return ( - <> - - - - - - - - - - {tokens.map((key) => { - return ( - - - - - {contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete) && ( - - )} - - ); - })} - -
NameExpiresCreated -
{key.name} - - {formatDate(timeZone, key.created)} - onDelete(key)} /> -
- + + + + + + + + + + {tokens.map((key) => { + return ( + + + + + + + ); + })} + +
NameExpiresCreated +
{key.name} + + {formatDate(timeZone, key.created)} + onDelete(key)} + disabled={tokenActionsDisabled} + /> +
); }; @@ -60,7 +59,7 @@ function formatDate(timeZone: TimeZone, expiration?: string): string { } function formatSecondsLeftUntilExpiration(secondsUntilExpiration: number): string { - const days = Math.floor(secondsUntilExpiration / (3600 * 24)); + const days = Math.ceil(secondsUntilExpiration / (3600 * 24)); const daysFormat = days > 1 ? `${days} days` : `${days} day`; return `Expires in ${daysFormat}`; } diff --git a/public/app/features/serviceaccounts/ServiceAccountsListItem.tsx b/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx similarity index 50% rename from public/app/features/serviceaccounts/ServiceAccountsListItem.tsx rename to public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx index b1d7c8af80f..acd5b57ecf5 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListItem.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx @@ -1,33 +1,39 @@ -import { cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { memo } from 'react'; -import { OrgRole } from '@grafana/data'; -import { Button, Icon, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, OrgRole } from '@grafana/data'; +import { Button, HorizontalGroup, Icon, IconButton, useStyles2 } from '@grafana/ui'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { contextSrv } from 'app/core/core'; +import { OrgRolePicker } from 'app/features/admin/OrgRolePicker'; import { AccessControlAction, Role, ServiceAccountDTO } from 'app/types'; -import { OrgRolePicker } from '../admin/OrgRolePicker'; - -import { getStyles } from './ServiceAccountsListPage'; - type ServiceAccountListItemProps = { serviceAccount: ServiceAccountDTO; onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void; roleOptions: Role[]; builtInRoles: Record; - onSetToRemove: (serviceAccount: ServiceAccountDTO) => void; + onRemoveButtonClick: (serviceAccount: ServiceAccountDTO) => void; + onDisable: (serviceAccount: ServiceAccountDTO) => void; + onEnable: (serviceAccount: ServiceAccountDTO) => void; + onAddTokenClick: (serviceAccount: ServiceAccountDTO) => void; }; const getServiceAccountsAriaLabel = (name: string) => { return `Edit service account's ${name} details`; }; -const getServiceAccountsEnabledStatus = (disabled: boolean) => { - return disabled ? 'Disabled' : 'Enabled'; -}; const ServiceAccountListItem = memo( - ({ serviceAccount, onRoleChange, roleOptions, builtInRoles, onSetToRemove }: ServiceAccountListItemProps) => { + ({ + serviceAccount, + onRoleChange, + roleOptions, + builtInRoles, + onRemoveButtonClick, + onDisable, + onEnable, + onAddTokenClick, + }: ServiceAccountListItemProps) => { const editUrl = `org/serviceaccounts/${serviceAccount.id}`; const styles = useStyles2(getStyles); const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); @@ -37,7 +43,7 @@ const ServiceAccountListItem = memo( const enableRolePicker = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate) && canUpdateRole; return ( - + {contextSrv.licensedAccessControlEnabled() ? ( - + {displayRolePicker && ( onRoleChange(newRole, serviceAccount)} roleOptions={roleOptions} builtInRoles={builtInRoles} - disabled={!enableRolePicker} + disabled={!enableRolePicker || serviceAccount.isDisabled} /> )} ) : ( - + onRoleChange(newRole, serviceAccount)} /> )} - - - {getServiceAccountsEnabledStatus(serviceAccount.isDisabled)} - - - - - -   - {serviceAccount.tokens} +
+ + + + {serviceAccount.tokens || 'No tokens'} +
- {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && ( - - + )} + {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount) && + (serviceAccount.isDisabled ? ( + + ) : ( + + ))} + {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && ( + onRemoveButtonClick(serviceAccount)} + aria-label={`Delete service account ${serviceAccount.name}`} + /> + )} + + ); } ); ServiceAccountListItem.displayName = 'ServiceAccountListItem'; +const getStyles = (theme: GrafanaTheme2) => { + return { + iconRow: css` + svg { + margin-left: ${theme.spacing(0.5)}; + } + `, + accountId: cx( + 'ellipsis', + css` + color: ${theme.colors.text.secondary}; + ` + ), + deleteButton: css` + color: ${theme.colors.text.secondary}; + `, + tokensInfo: css` + span { + margin-right: ${theme.spacing(1)}; + } + `, + tokensInfoSecondary: css` + color: ${theme.colors.text.secondary}; + `, + disabled: css` + td a { + color: ${theme.colors.text.secondary}; + } + `, + }; +}; + export default ServiceAccountListItem; diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index ca2fe258506..2448de4b901 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -1,25 +1,21 @@ import { debounce } from 'lodash'; -import { getBackendSrv, locationService } from '@grafana/runtime'; +import { getBackendSrv } from '@grafana/runtime'; import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; -import { accessControlQueryParam } from 'app/core/utils/accessControl'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction, ServiceAccountDTO, ServiceAccountStateFilter, ThunkResult } from 'app/types'; -import { contextSrv } from '../../../core/services/context_srv'; -import { ServiceAccountDTO, ThunkResult, ServiceAccountFilter, AccessControlAction } from '../../../types'; -import { ServiceAccountToken } from '../CreateServiceAccountTokenModal'; +import { ServiceAccountToken } from '../components/CreateTokenModal'; import { acOptionsLoaded, builtInRolesLoaded, - filterChanged, pageChanged, queryChanged, - serviceAccountLoaded, serviceAccountsFetchBegin, - serviceAccountsFetchEnd, serviceAccountsFetched, - serviceAccountTokensLoaded, - serviceAccountToRemoveLoaded, + serviceAccountsFetchEnd, + stateFilterChanged, } from './reducers'; const BASE_URL = `/api/serviceaccounts`; @@ -45,24 +41,50 @@ export function fetchACOptions(): ThunkResult { }; } -export function setServiceAccountToRemove(serviceAccount: ServiceAccountDTO | null): ThunkResult { - return async (dispatch) => { +interface FetchServiceAccountsParams { + withLoadingIndicator: boolean; +} + +export function fetchServiceAccounts( + { withLoadingIndicator }: FetchServiceAccountsParams = { withLoadingIndicator: false } +): ThunkResult { + return async (dispatch, getState) => { try { - dispatch(serviceAccountToRemoveLoaded(serviceAccount)); + if (withLoadingIndicator) { + dispatch(serviceAccountsFetchBegin()); + } + const { perPage, page, query, serviceAccountStateFilter } = getState().serviceAccounts; + const result = await getBackendSrv().get( + `/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}${getStateFilter( + serviceAccountStateFilter + )}&accesscontrol=true` + ); + dispatch(serviceAccountsFetched(result)); } catch (error) { console.error(error); + } finally { + serviceAccountsFetchEnd(); } }; } -export function loadServiceAccount(saID: number): ThunkResult { +const fetchServiceAccountsWithDebounce = debounce((dispatch) => dispatch(fetchServiceAccounts()), 500, { + leading: true, +}); + +export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { return async (dispatch) => { - try { - const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam()); - dispatch(serviceAccountLoaded(response)); - } catch (error) { - console.error(error); - } + await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}?accesscontrol=true`, { + ...serviceAccount, + }); + dispatch(fetchServiceAccounts()); + }; +} + +export function deleteServiceAccount(serviceAccountId: number): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); + dispatch(fetchServiceAccounts()); }; } @@ -74,102 +96,39 @@ export function createServiceAccountToken( return async (dispatch) => { const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, token); onTokenCreated(result.key); - dispatch(loadServiceAccountTokens(saID)); - }; -} - -export function deleteServiceAccountToken(saID: number, id: number): ThunkResult { - return async (dispatch) => { - await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`); - dispatch(loadServiceAccountTokens(saID)); - }; -} - -export function loadServiceAccountTokens(saID: number): ThunkResult { - return async (dispatch) => { - try { - const response = await getBackendSrv().get(`${BASE_URL}/${saID}/tokens`); - dispatch(serviceAccountTokensLoaded(response)); - } catch (error) { - console.error(error); - } - }; -} - -export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { - return async (dispatch) => { - const response = await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}?accesscontrol=true`, { - ...serviceAccount, - }); - dispatch(serviceAccountLoaded(response)); - }; -} - -export function removeServiceAccount(serviceAccountId: number): ThunkResult { - return async (dispatch) => { - await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); dispatch(fetchServiceAccounts()); }; } // search / filtering of serviceAccounts -const getFilters = (filters: ServiceAccountFilter[]) => { - return filters - .map((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.map((v) => `${filter.name}=${v.value}`).join('&'); - } - return `${filter.name}=${filter.value}`; - }) - .join('&'); +const getStateFilter = (value: ServiceAccountStateFilter) => { + switch (value) { + case ServiceAccountStateFilter.WithExpiredTokens: + return '&expiredTokens=true'; + case ServiceAccountStateFilter.Disabled: + return '&disabled=true'; + default: + return ''; + } }; -export function fetchServiceAccounts(): ThunkResult { - return async (dispatch, getState) => { - try { - const { perPage, page, query, filters } = getState().serviceAccounts; - const result = await getBackendSrv().get( - `/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}&${getFilters( - filters - )}&accesscontrol=true` - ); - dispatch(serviceAccountsFetched(result)); - } catch (error) { - serviceAccountsFetchEnd(); - console.error(error); - } - }; -} - -const fetchServiceAccountsWithDebounce = debounce((dispatch) => dispatch(fetchServiceAccounts()), 500); - export function changeQuery(query: string): ThunkResult { return async (dispatch) => { - dispatch(serviceAccountsFetchBegin()); dispatch(queryChanged(query)); fetchServiceAccountsWithDebounce(dispatch); }; } -export function changeFilter(filter: ServiceAccountFilter): ThunkResult { +export function changeStateFilter(filter: ServiceAccountStateFilter): ThunkResult { return async (dispatch) => { - dispatch(serviceAccountsFetchBegin()); - dispatch(filterChanged(filter)); - fetchServiceAccountsWithDebounce(dispatch); + dispatch(stateFilterChanged(filter)); + dispatch(fetchServiceAccounts()); }; } export function changePage(page: number): ThunkResult { return async (dispatch) => { - dispatch(serviceAccountsFetchBegin()); dispatch(pageChanged(page)); dispatch(fetchServiceAccounts()); }; } - -export function deleteServiceAccount(serviceAccountId: number): ThunkResult { - return async () => { - await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); - locationService.push('/org/serviceaccounts'); - }; -} diff --git a/public/app/features/serviceaccounts/state/actionsServiceAccountPage.ts b/public/app/features/serviceaccounts/state/actionsServiceAccountPage.ts new file mode 100644 index 00000000000..99b8701372c --- /dev/null +++ b/public/app/features/serviceaccounts/state/actionsServiceAccountPage.ts @@ -0,0 +1,74 @@ +import { getBackendSrv, locationService } from '@grafana/runtime'; +import { accessControlQueryParam } from 'app/core/utils/accessControl'; +import { ServiceAccountDTO, ThunkResult } from 'app/types'; + +import { ServiceAccountToken } from '../components/CreateTokenModal'; + +import { + serviceAccountFetchBegin, + serviceAccountFetchEnd, + serviceAccountLoaded, + serviceAccountTokensLoaded, +} from './reducers'; + +const BASE_URL = `/api/serviceaccounts`; + +export function loadServiceAccount(saID: number): ThunkResult { + return async (dispatch) => { + dispatch(serviceAccountFetchBegin()); + try { + const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam()); + dispatch(serviceAccountLoaded(response)); + } catch (error) { + console.error(error); + } finally { + dispatch(serviceAccountFetchEnd()); + } + }; +} + +export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { + return async (dispatch) => { + await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}?accesscontrol=true`, { + ...serviceAccount, + }); + dispatch(loadServiceAccount(serviceAccount.id)); + }; +} + +export function deleteServiceAccount(serviceAccountId: number): ThunkResult { + return async () => { + await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); + locationService.push('/org/serviceaccounts'); + }; +} + +export function createServiceAccountToken( + saID: number, + token: ServiceAccountToken, + onTokenCreated: (key: string) => void +): ThunkResult { + return async (dispatch) => { + const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, token); + onTokenCreated(result.key); + dispatch(loadServiceAccountTokens(saID)); + }; +} + +export function deleteServiceAccountToken(saID: number, id: number): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`); + dispatch(loadServiceAccountTokens(saID)); + }; +} + +export function loadServiceAccountTokens(saID: number): ThunkResult { + return async (dispatch) => { + try { + const response = await getBackendSrv().get(`${BASE_URL}/${saID}/tokens`); + dispatch(serviceAccountTokensLoaded(response)); + } catch (error) { + console.error(error); + } + }; +} diff --git a/public/app/features/serviceaccounts/state/reducers.ts b/public/app/features/serviceaccounts/state/reducers.ts index 4c474b63976..fc672060286 100644 --- a/public/app/features/serviceaccounts/state/reducers.ts +++ b/public/app/features/serviceaccounts/state/reducers.ts @@ -4,9 +4,9 @@ import { ApiKey, Role, ServiceAccountDTO, - ServiceAccountFilter, ServiceAccountProfileState, ServiceAccountsState, + ServiceAccountStateFilter, } from 'app/types'; // serviceAccountsProfilePage @@ -20,6 +20,12 @@ export const serviceAccountProfileSlice = createSlice({ name: 'serviceaccount', initialState: initialStateProfile, reducers: { + serviceAccountFetchBegin: (state) => { + return { ...state, isLoading: true }; + }, + serviceAccountFetchEnd: (state) => { + return { ...state, isLoading: false }; + }, serviceAccountLoaded: (state, action: PayloadAction): ServiceAccountProfileState => { return { ...state, serviceAccount: action.payload, isLoading: false }; }, @@ -30,7 +36,8 @@ export const serviceAccountProfileSlice = createSlice({ }); export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer; -export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions; +export const { serviceAccountLoaded, serviceAccountTokensLoaded, serviceAccountFetchBegin, serviceAccountFetchEnd } = + serviceAccountProfileSlice.actions; // serviceAccountsListPage export const initialStateList: ServiceAccountsState = { @@ -38,13 +45,12 @@ export const initialStateList: ServiceAccountsState = { isLoading: true, builtInRoles: {}, roleOptions: [], - serviceAccountToRemove: null, query: '', page: 0, perPage: 50, totalPages: 1, showPaging: false, - filters: [{ name: 'expiredTokens', value: false }], + serviceAccountStateFilter: ServiceAccountStateFilter.All, }; interface ServiceAccountsFetched { @@ -83,9 +89,6 @@ const serviceAccountsSlice = createSlice({ builtInRolesLoaded: (state, action: PayloadAction>): ServiceAccountsState => { return { ...state, builtInRoles: action.payload }; }, - serviceAccountToRemoveLoaded: (state, action: PayloadAction): ServiceAccountsState => { - return { ...state, serviceAccountToRemove: action.payload }; - }, queryChanged: (state, action: PayloadAction) => { return { ...state, @@ -97,20 +100,10 @@ const serviceAccountsSlice = createSlice({ ...state, page: action.payload, }), - filterChanged: (state, action: PayloadAction) => { - const { name, value } = action.payload; - - if (state.filters.some((filter) => filter.name === name)) { - return { - ...state, - filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)), - }; - } - return { - ...state, - filters: [...state.filters, action.payload], - }; - }, + stateFilterChanged: (state, action: PayloadAction) => ({ + ...state, + serviceAccountStateFilter: action.payload, + }), }, }); export const serviceAccountsReducer = serviceAccountsSlice.reducer; @@ -121,9 +114,8 @@ export const { serviceAccountsFetched, acOptionsLoaded, builtInRolesLoaded, - serviceAccountToRemoveLoaded, pageChanged, - filterChanged, + stateFilterChanged, queryChanged, } = serviceAccountsSlice.actions; diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index 1139e25ac82..ba0989251fa 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -1,4 +1,4 @@ -import { SelectableValue, WithAccessControlMetadata } from '@grafana/data'; +import { WithAccessControlMetadata } from '@grafana/data'; import { ApiKey, OrgRole, Role } from '.'; @@ -55,12 +55,16 @@ export interface ServiceAccountProfileState { tokens: ApiKey[]; } -export type ServiceAccountFilter = Record; +export enum ServiceAccountStateFilter { + All = 'All', + WithExpiredTokens = 'WithExpiredTokens', + Disabled = 'Disabled', +} + export interface ServiceAccountsState { serviceAccounts: ServiceAccountDTO[]; isLoading: boolean; roleOptions: Role[]; - serviceAccountToRemove: ServiceAccountDTO | null; builtInRoles: Record; // search / filtering @@ -69,5 +73,5 @@ export interface ServiceAccountsState { page: number; totalPages: number; showPaging: boolean; - filters: ServiceAccountFilter[]; + serviceAccountStateFilter: ServiceAccountStateFilter; }