From 7b397184a0e87922997f29834860d200cd48def2 Mon Sep 17 00:00:00 2001 From: J Guerreiro Date: Thu, 10 Feb 2022 13:04:07 +0000 Subject: [PATCH] ServiceAccounts: add role picker to service accounts list (#45127) --- .../ServiceAccountsListPage.tsx | 186 +++++++++++------- .../serviceaccounts/ServiceAccountsTable.tsx | 137 ------------- .../features/serviceaccounts/state/actions.ts | 32 ++- .../serviceaccounts/state/reducers.ts | 19 +- public/app/types/accessControl.ts | 2 + public/app/types/serviceaccount.ts | 8 +- 6 files changed, 162 insertions(+), 222 deletions(-) delete mode 100644 public/app/features/serviceaccounts/ServiceAccountsTable.tsx diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 1e07d6ceceb..2ef753f437f 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -4,17 +4,17 @@ import { Icon, LinkButton, useStyles2 } from '@grafana/ui'; import { css, cx } from '@emotion/css'; import Page from 'app/core/components/Page/Page'; -import { StoreState, ServiceAccountDTO, AccessControlAction } from 'app/types'; -import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions'; +import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types'; +import { fetchACOptions, loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { contextSrv } from 'app/core/core'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { OrgRolePicker } from '../admin/OrgRolePicker'; export type Props = ConnectedProps; -export interface State {} - function mapStateToProps(state: StoreState) { return { navModel: getNavModel(state.navIndex, 'serviceaccounts'), @@ -22,23 +22,44 @@ function mapStateToProps(state: StoreState) { searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts), searchPage: getServiceAccountsSearchPage(state.serviceAccounts), isLoading: state.serviceAccounts.isLoading, + roleOptions: state.serviceAccounts.roleOptions, + builtInRoles: state.serviceAccounts.builtInRoles, }; } const mapDispatchToProps = { loadServiceAccounts, + fetchACOptions, updateServiceAccount, removeServiceAccount, }; const connector = connect(mapStateToProps, mapDispatchToProps); -const ServiceAccountsListPage: React.FC = ({ loadServiceAccounts, navModel, serviceAccounts, isLoading }) => { +const ServiceAccountsListPage = ({ + loadServiceAccounts, + fetchACOptions, + updateServiceAccount, + navModel, + serviceAccounts, + isLoading, + roleOptions, + builtInRoles, +}: Props) => { const styles = useStyles2(getStyles); - useEffect(() => { loadServiceAccounts(); - }, [loadServiceAccounts]); + if (contextSrv.accessControlEnabled()) { + fetchACOptions(); + } + }, [loadServiceAccounts, fetchACOptions]); + + const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => { + const updatedServiceAccount = { ...serviceAccount, role: role }; + + updateServiceAccount(updatedServiceAccount); + }; + return ( @@ -66,8 +87,14 @@ const ServiceAccountsListPage: React.FC = ({ loadServiceAccounts, navMode - {serviceAccounts.map((serviceaccount: ServiceAccountDTO) => ( - + {serviceAccounts.map((serviceAccount: ServiceAccountDTO) => ( + ))} @@ -80,78 +107,91 @@ const ServiceAccountsListPage: React.FC = ({ loadServiceAccounts, navMode }; type ServiceAccountListItemProps = { - serviceaccount: ServiceAccountDTO; + serviceAccount: ServiceAccountDTO; + onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void; + roleOptions: Role[]; + builtInRoles: Record; }; const getServiceAccountsAriaLabel = (name: string) => { return `Edit service account's ${name} details`; }; -const ServiceAccountListItem = memo(({ serviceaccount }: ServiceAccountListItemProps) => { - const editUrl = `org/serviceaccounts/${serviceaccount.id}`; - const styles = useStyles2(getStyles); +const ServiceAccountListItem = memo( + ({ serviceAccount, onRoleChange, roleOptions, builtInRoles }: ServiceAccountListItemProps) => { + const editUrl = `org/serviceAccounts/${serviceAccount.id}`; + const styles = useStyles2(getStyles); + const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); + const rolePickerDisabled = !canUpdateRole; - return ( - - - - {`Avatar - - - - - {serviceaccount.name} - - - - - {serviceaccount.login} - - - - - {serviceaccount.role === 'None' ? ( - Not assigned + return ( + + + + {`Avatar + + + + + {serviceAccount.name} + + + + + {serviceAccount.login} + + + + {contextSrv.licensedAccessControlEnabled() ? ( + onRoleChange(newRole, serviceAccount)} + roleOptions={roleOptions} + builtInRoles={builtInRoles} + disabled={rolePickerDisabled} + /> ) : ( - serviceaccount.role + onRoleChange(newRole, serviceAccount)} + /> )} - - - - - - - - {serviceaccount.tokens} - - - - ); -}); + + + + + + + {serviceAccount.tokens} + + + + ); + } +); ServiceAccountListItem.displayName = 'ServiceAccountListItem'; const getStyles = (theme: GrafanaTheme2) => { diff --git a/public/app/features/serviceaccounts/ServiceAccountsTable.tsx b/public/app/features/serviceaccounts/ServiceAccountsTable.tsx deleted file mode 100644 index 9e35adef38e..00000000000 --- a/public/app/features/serviceaccounts/ServiceAccountsTable.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { FC, useEffect, useState } from 'react'; -import { AccessControlAction, Role, OrgServiceAccount } from 'app/types'; -import { OrgRolePicker } from '../admin/OrgRolePicker'; -import { Button, ConfirmModal } from '@grafana/ui'; -import { OrgRole } from '@grafana/data'; -import { contextSrv } from 'app/core/core'; -import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; -import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; - -export interface Props { - serviceAccounts: OrgServiceAccount[]; - orgId?: number; - onRoleChange: (role: OrgRole, serviceAccount: OrgServiceAccount) => void; - onRemoveServiceAccount: (serviceAccount: OrgServiceAccount) => void; -} - -const ServiceAccountsTable: FC = (props) => { - const { serviceAccounts, orgId, onRoleChange, onRemoveServiceAccount } = props; - const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate); - const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove); - const rolePickerDisabled = !canUpdateRole; - - const [roleOptions, setRoleOptions] = useState([]); - const [toRemove, setToRemove] = useState(null); - const [builtinRoles, setBuiltinRoles] = useState>({}); - const [showRemoveModal, setShowRemoveModal] = useState(false); - - useEffect(() => { - async function fetchOptions() { - try { - if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { - let options = await fetchRoleOptions(orgId); - setRoleOptions(options); - } - - if (contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)) { - const builtInRoles = await fetchBuiltinRoles(orgId); - setBuiltinRoles(builtInRoles); - } - } catch (e) { - console.error('Error loading options'); - } - } - if (contextSrv.accessControlEnabled()) { - fetchOptions(); - } - }, [orgId]); - - return ( - <> - - - - - - - - - - - - {serviceAccounts.map((serviceAccount, index) => { - return ( - - - - - - - - - {canRemoveFromOrg && ( - - )} - - ); - })} - -
- LoginEmailNameSeenRole -
- serviceaccount avatar - - - {serviceAccount.login} - - - - {serviceAccount.email} - - - - {serviceAccount.name} - - - {contextSrv.accessControlEnabled() ? ( - onRoleChange(newRole, serviceAccount)} - roleOptions={roleOptions} - builtInRoles={builtinRoles} - disabled={rolePickerDisabled} - /> - ) : ( - onRoleChange(newRole, serviceAccount)} - /> - )} - -
- {toRemove !== null && ( - setToRemove(null)} - isOpen={toRemove.login === showRemoveModal} - onConfirm={() => onRemoveServiceAccount(toRemove)} - /> - )} - - ); -}; - -export default ServiceAccountsTable; diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index 4b45b16103d..0e2133c435e 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -1,9 +1,30 @@ -import { ThunkResult } from '../../../types'; +import { ServiceAccountDTO, ThunkResult } from '../../../types'; import { getBackendSrv } from '@grafana/runtime'; -import { serviceAccountLoaded, serviceAccountsLoaded, serviceAccountTokensLoaded } from './reducers'; +import { + acOptionsLoaded, + builtInRolesLoaded, + serviceAccountLoaded, + serviceAccountsLoaded, + serviceAccountTokensLoaded, +} from './reducers'; +import { accessControlQueryParam } from 'app/core/utils/accessControl'; +import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; const BASE_URL = `/api/serviceaccounts`; +export function fetchACOptions(): ThunkResult { + return async (dispatch) => { + try { + const options = await fetchRoleOptions(); + dispatch(acOptionsLoaded(options)); + const builtInRoles = await fetchBuiltinRoles(); + dispatch(builtInRolesLoaded(builtInRoles)); + } catch (error) { + console.error(error); + } + }; +} + export function loadServiceAccount(saID: number): ThunkResult { return async (dispatch) => { try { @@ -48,7 +69,7 @@ export function loadServiceAccountTokens(saID: number): ThunkResult { export function loadServiceAccounts(): ThunkResult { return async (dispatch) => { try { - const response = await getBackendSrv().get(BASE_URL); + const response = await getBackendSrv().get(BASE_URL, accessControlQueryParam()); dispatch(serviceAccountsLoaded(response)); } catch (error) { console.error(error); @@ -56,10 +77,9 @@ export function loadServiceAccounts(): ThunkResult { }; } -export function updateServiceAccount(saID: number): ThunkResult { +export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { return async (dispatch) => { - // TODO: implement on backend - await getBackendSrv().patch(`${BASE_URL}/${saID}`, {}); + await getBackendSrv().patch(`/api/org/users/${serviceAccount.id}`, { role: serviceAccount.role }); dispatch(loadServiceAccounts()); }; } diff --git a/public/app/features/serviceaccounts/state/reducers.ts b/public/app/features/serviceaccounts/state/reducers.ts index db63a3bc7ea..a816be422fd 100644 --- a/public/app/features/serviceaccounts/state/reducers.ts +++ b/public/app/features/serviceaccounts/state/reducers.ts @@ -1,12 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { ApiKey, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types'; +import { ApiKey, Role, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types'; export const initialState: ServiceAccountsState = { serviceAccounts: [] as ServiceAccountDTO[], searchQuery: '', searchPage: 1, isLoading: true, + builtInRoles: {}, + roleOptions: [], }; export const initialStateProfile: ServiceAccountProfileState = { @@ -42,11 +44,22 @@ const serviceAccountsSlice = createSlice({ setServiceAccountsSearchPage: (state, action: PayloadAction): ServiceAccountsState => { return { ...state, searchPage: action.payload }; }, + acOptionsLoaded: (state, action: PayloadAction): ServiceAccountsState => { + return { ...state, roleOptions: action.payload }; + }, + builtInRolesLoaded: (state, action: PayloadAction>): ServiceAccountsState => { + return { ...state, builtInRoles: action.payload }; + }, }, }); -export const { setServiceAccountsSearchQuery, setServiceAccountsSearchPage, serviceAccountsLoaded } = - serviceAccountsSlice.actions; +export const { + setServiceAccountsSearchQuery, + setServiceAccountsSearchPage, + serviceAccountsLoaded, + acOptionsLoaded, + builtInRolesLoaded, +} = serviceAccountsSlice.actions; export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions; diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index b0fed8659ac..ca4fb253656 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -26,6 +26,8 @@ export enum AccessControlAction { UsersQuotasUpdate = 'users.quotas:update', ServiceAccountsCreate = 'serviceaccounts:create', + ServiceAccountsWrite = 'serviceaccounts:write', + ServiceAccountsDelete = 'serviceaccounts:delete', OrgsRead = 'orgs:read', OrgsPreferencesRead = 'orgs.preferences:read', diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index dfb54931e88..2239cfdaa3e 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -1,7 +1,7 @@ import { WithAccessControlMetadata } from '@grafana/data'; -import { ApiKey, OrgRole } from '.'; +import { ApiKey, OrgRole, Role } from '.'; -export interface OrgServiceAccount { +export interface OrgServiceAccount extends WithAccessControlMetadata { serviceAccountId: number; avatarUrl: string; email: string; @@ -31,7 +31,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata { name: string; login: string; avatarUrl?: string; - role: string; + role: OrgRole; } export interface ServiceAccountProfileState { @@ -45,4 +45,6 @@ export interface ServiceAccountsState { searchQuery: string; searchPage: number; isLoading: boolean; + roleOptions: Role[]; + builtInRoles: Record; }