diff --git a/.betterer.results b/.betterer.results index 230da2f44fa..b459043b687 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1455,8 +1455,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"], [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] + [0, 0, 0, "Styles should be written using objects.", "6"] ], "public/app/core/components/RolePicker/RolePickerMenu.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -1464,27 +1463,6 @@ exports[`better eslint`] = { "public/app/core/components/RolePicker/ValueContainer.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/core/components/RolePicker/styles.ts:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"], - [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"], - [0, 0, 0, "Styles should be written using objects.", "15"], - [0, 0, 0, "Styles should be written using objects.", "16"], - [0, 0, 0, "Styles should be written using objects.", "17"], - [0, 0, 0, "Styles should be written using objects.", "18"] - ], "public/app/core/components/Select/OldFolderPicker.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx index bdbb78e432b..2cd858380b5 100644 --- a/public/app/core/components/RolePicker/RolePicker.tsx +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -1,6 +1,6 @@ import React, { FormEvent, useCallback, useEffect, useState, useRef } from 'react'; -import { ClickOutsideWrapper, HorizontalGroup, Spinner } from '@grafana/ui'; +import { ClickOutsideWrapper, useTheme2 } from '@grafana/ui'; import { Role, OrgRole } from 'app/types'; import { RolePickerInput } from './RolePickerInput'; @@ -24,6 +24,7 @@ export interface Props { */ apply?: boolean; maxWidth?: string | number; + width?: string | number; } export const RolePicker = ({ @@ -40,6 +41,7 @@ export const RolePicker = ({ canUpdateRoles = true, apply = false, maxWidth = ROLE_PICKER_WIDTH, + width, }: Props): JSX.Element | null => { const [isOpen, setOpen] = useState(false); const [selectedRoles, setSelectedRoles] = useState(appliedRoles); @@ -47,6 +49,8 @@ export const RolePicker = ({ const [query, setQuery] = useState(''); const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 }); const ref = useRef(null); + const theme = useTheme2(); + const widthPx = typeof width === 'number' ? theme.spacing(width) : width; useEffect(() => { setSelectedBuiltInRole(basicRole); @@ -146,21 +150,13 @@ export const RolePicker = ({ return options; }; - if (isLoading) { - return ( - - Loading... - - - ); - } - return (
@@ -175,6 +171,8 @@ export const RolePicker = ({ isFocused={isOpen} disabled={disabled} showBasicRole={showBasicRole} + width={widthPx} + isLoading={isLoading} /> {isOpen && ( { showBasicRole?: boolean; isFocused?: boolean; disabled?: boolean; + width?: string; + isLoading?: boolean; onQueryChange: (query?: string) => void; onOpen: (event: FormEvent) => void; onClose: () => void; @@ -30,12 +32,14 @@ export const RolePickerInput = ({ isFocused, query, showBasicRole, + width, + isLoading, onOpen, onClose, onQueryChange, ...rest }: InputProps): JSX.Element => { - const styles = useStyles2(getRolePickerInputStyles, false, !!isFocused, !!disabled, false); + const styles = useStyles2(getRolePickerInputStyles, false, !!isFocused, !!disabled, false, width); const inputRef = useRef(null); useEffect(() => { @@ -61,6 +65,11 @@ export const RolePickerInput = ({ numberOfRoles={appliedRoles.length} showBuiltInRole={showBasicRoleOnLabel} /> + {isLoading && ( +
+ +
+ )}
) : (
@@ -125,7 +134,8 @@ const getRolePickerInputStyles = ( invalid: boolean, focused: boolean, disabled: boolean, - withPrefix: boolean + withPrefix: boolean, + width?: string ) => { const styles = getInputStyles({ theme, invalid }); @@ -138,21 +148,22 @@ const getRolePickerInputStyles = ( ${styleMixins.focusCss(theme.v1)} `, disabled && styles.inputDisabled, - css` - min-width: ${ROLE_PICKER_WIDTH}px; - min-height: 32px; - height: auto; - flex-direction: row; - padding-right: 24px; - max-width: 100%; - align-items: center; - display: flex; - flex-wrap: wrap; - justify-content: flex-start; - position: relative; - box-sizing: border-box; - cursor: default; - `, + css({ + minWidth: width || ROLE_PICKER_WIDTH + 'px', + width: width, + minHeight: '32px', + height: 'auto', + flexDirection: 'row', + paddingRight: theme.spacing(1), + maxWidth: '100%', + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'flex-start', + position: 'relative', + boxSizing: 'border-box', + cursor: 'default', + }), withPrefix && css` padding-left: 0; @@ -180,6 +191,11 @@ const getRolePickerInputStyles = ( margin-bottom: ${theme.spacing(0.5)}; } `, + spinner: css({ + display: 'flex', + flexGrow: 1, + justifyContent: 'flex-end', + }), }; }; diff --git a/public/app/core/components/RolePicker/TeamRolePicker.tsx b/public/app/core/components/RolePicker/TeamRolePicker.tsx index f4dec6622a9..c380a120944 100644 --- a/public/app/core/components/RolePicker/TeamRolePicker.tsx +++ b/public/app/core/components/RolePicker/TeamRolePicker.tsx @@ -12,6 +12,7 @@ export interface Props { orgId?: number; roleOptions: Role[]; disabled?: boolean; + roles?: Role[]; onApplyRoles?: (newRoles: Role[]) => void; pendingRoles?: Role[]; /** @@ -27,19 +28,27 @@ export interface Props { */ apply?: boolean; maxWidth?: string | number; + width?: string | number; + isLoading?: boolean; } export const TeamRolePicker = ({ teamId, roleOptions, disabled, + roles, onApplyRoles, pendingRoles, apply = false, maxWidth, + width, + isLoading, }: Props) => { - const [{ loading, value: appliedRoles = [] }, getTeamRoles] = useAsyncFn(async () => { + const [{ loading, value: appliedRoles = roles || [] }, getTeamRoles] = useAsyncFn(async () => { try { + if (roles) { + return roles; + } if (apply && Boolean(pendingRoles?.length)) { return pendingRoles; } @@ -51,11 +60,11 @@ export const TeamRolePicker = ({ console.error('Error loading options', e); } return []; - }, [teamId, pendingRoles]); + }, [teamId, pendingRoles, roles]); useEffect(() => { getTeamRoles(); - }, [teamId, getTeamRoles, pendingRoles]); + }, [getTeamRoles]); const onRolesChange = async (roles: Role[]) => { if (!apply) { @@ -76,11 +85,12 @@ export const TeamRolePicker = ({ onRolesChange={onRolesChange} roleOptions={roleOptions} appliedRoles={appliedRoles} - isLoading={loading} + isLoading={loading || isLoading} disabled={disabled} basicRoleDisabled={true} canUpdateRoles={canUpdateRoles} maxWidth={maxWidth} + width={width} /> ); }; diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx index 73bac42b7d8..56834457191 100644 --- a/public/app/core/components/RolePicker/UserRolePicker.tsx +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -9,6 +9,7 @@ import { fetchUserRoles, updateUserRoles } from './api'; export interface Props { basicRole: OrgRole; + roles?: Role[]; userId: number; orgId?: number; onBasicRoleChange: (newRole: OrgRole) => void; @@ -31,10 +32,13 @@ export interface Props { onApplyRoles?: (newRoles: Role[], userId: number, orgId: number | undefined) => void; pendingRoles?: Role[]; maxWidth?: string | number; + width?: string | number; + isLoading?: boolean; } export const UserRolePicker = ({ basicRole, + roles, userId, orgId, onBasicRoleChange, @@ -46,9 +50,14 @@ export const UserRolePicker = ({ onApplyRoles, pendingRoles, maxWidth, + width, + isLoading, }: Props) => { - const [{ loading, value: appliedRoles = [] }, getUserRoles] = useAsyncFn(async () => { + const [{ loading, value: appliedRoles = roles || [] }, getUserRoles] = useAsyncFn(async () => { try { + if (roles) { + return roles; + } if (apply && Boolean(pendingRoles?.length)) { return pendingRoles; } @@ -61,14 +70,14 @@ export const UserRolePicker = ({ console.error('Error loading options'); } return []; - }, [orgId, userId, pendingRoles]); + }, [orgId, userId, pendingRoles, roles]); useEffect(() => { // only load roles when there is an Org selected if (orgId) { getUserRoles(); } - }, [orgId, getUserRoles, pendingRoles]); + }, [getUserRoles, orgId]); const onRolesChange = async (roles: Role[]) => { if (!apply) { @@ -90,7 +99,7 @@ export const UserRolePicker = ({ onRolesChange={onRolesChange} onBasicRoleChange={onBasicRoleChange} roleOptions={roleOptions} - isLoading={loading} + isLoading={loading || isLoading} disabled={disabled} basicRoleDisabled={basicRoleDisabled} basicRoleDisabledMessage={basicRoleDisabledMessage} @@ -98,6 +107,7 @@ export const UserRolePicker = ({ apply={apply} canUpdateRoles={canUpdateRoles} maxWidth={maxWidth} + width={width} /> ); }; diff --git a/public/app/core/components/RolePicker/styles.ts b/public/app/core/components/RolePicker/styles.ts index 4caed7800e0..74ce953af80 100644 --- a/public/app/core/components/RolePicker/styles.ts +++ b/public/app/core/components/RolePicker/styles.ts @@ -4,119 +4,119 @@ import { GrafanaTheme2 } from '@grafana/data'; import { ROLE_PICKER_SUBMENU_MIN_WIDTH } from './constants'; -export const getStyles = (theme: GrafanaTheme2) => { - return { - hideScrollBar: css` - .scrollbar-view { - /* Hide scrollbar for Chrome, Safari, and Opera */ - &::-webkit-scrollbar { - display: none; - } - /* Hide scrollbar for Firefox */ - scrollbar-width: none; - } - `, - menuWrapper: css` - display: flex; - max-height: 650px; - position: absolute; - z-index: ${theme.zIndex.dropdown}; - overflow: hidden; - min-width: auto; - `, - menu: css` - min-width: ${ROLE_PICKER_SUBMENU_MIN_WIDTH}px; +export const getStyles = (theme: GrafanaTheme2) => ({ + hideScrollBar: css({ + '.scrollbar-view': { + /* Hide scrollbar for Chrome, Safari, and Opera */ + '&::-webkit-scrollbar': { + display: 'none', + }, + /* Hide scrollbar for Firefox */ + scrollbarWidth: 'none', + }, + }), + menuWrapper: css({ + display: 'flex', + maxHeight: '650px', + position: 'absolute', + zIndex: theme.zIndex.dropdown, + overflow: 'hidden', + minWidth: 'auto', + }), + menu: css({ + minWidth: `${ROLE_PICKER_SUBMENU_MIN_WIDTH}px`, + '& > div': { + paddingTop: theme.spacing(1), + }, + }), + menuLeft: css({ + right: 0, + flexDirection: 'row-reverse', + }), + subMenu: css({ + height: '100%', + minWidth: `${ROLE_PICKER_SUBMENU_MIN_WIDTH}px`, + display: 'flex', + flexDirection: 'column', + borderLeft: `1px solid ${theme.components.input.borderColor}`, - & > div { - padding-top: ${theme.spacing(1)}; - } - `, - menuLeft: css` - right: 0; - flex-direction: row-reverse; - `, - subMenu: css` - height: 100%; - min-width: ${ROLE_PICKER_SUBMENU_MIN_WIDTH}px; - display: flex; - flex-direction: column; - border-left: 1px solid ${theme.components.input.borderColor}; + '& > div': { + paddingTop: theme.spacing(1), + }, + }), + subMenuLeft: css({ + borderRight: `1px solid ${theme.components.input.borderColor}`, + borderLeft: 'unset', + }), + groupHeader: css({ + padding: theme.spacing(0, 4.5), + display: 'flex', + alignItems: 'center', + color: theme.colors.text.primary, + fontWeight: theme.typography.fontWeightBold, + }), + container: css({ + padding: theme.spacing(1), + border: `1px ${theme.colors.border.weak} solid`, + borderRadius: theme.shape.radius.default, + backgroundColor: theme.colors.background.primary, + zIndex: theme.zIndex.modal, + }), + menuSection: css({ + marginBottom: theme.spacing(2), + }), + menuOptionCheckbox: css({ + display: 'flex', + margin: theme.spacing(0, 1, 0, 0.25), + }), + menuButtonRow: css({ + backgroundColor: theme.colors.background.primary, + padding: theme.spacing(1), + }), + menuOptionBody: css({ + fontWeight: theme.typography.fontWeightRegular, + padding: theme.spacing(0, 1.5, 0, 0), + }), + menuOptionDisabled: css({ + color: theme.colors.text.disabled, + cursor: 'not-allowed', + }), + menuOptionExpand: css({ + position: 'absolute', + right: theme.spacing(1.25), + color: theme.colors.text.disabled, - & > div { - padding-top: ${theme.spacing(1)}; - } - `, - subMenuLeft: css` - border-right: 1px solid ${theme.components.input.borderColor}; - border-left: unset; - `, - groupHeader: css` - padding: ${theme.spacing(0, 4.5)}; - display: flex; - align-items: center; - color: ${theme.colors.text.primary}; - font-weight: ${theme.typography.fontWeightBold}; - `, - container: css` - padding: ${theme.spacing(1)}; - border: 1px ${theme.colors.border.weak} solid; - border-radius: ${theme.shape.radius.default}; - background-color: ${theme.colors.background.primary}; - z-index: ${theme.zIndex.modal}; - `, - menuSection: css` - margin-bottom: ${theme.spacing(2)}; - `, - menuOptionCheckbox: css` - display: flex; - margin: ${theme.spacing(0, 1, 0, 0.25)}; - `, - menuButtonRow: css` - background-color: ${theme.colors.background.primary}; - padding: ${theme.spacing(1)}; - `, - menuOptionBody: css` - font-weight: ${theme.typography.fontWeightRegular}; - padding: ${theme.spacing(0, 1.5, 0, 0)}; - `, - menuOptionDisabled: css` - color: ${theme.colors.text.disabled}; - cursor: not-allowed; - `, - menuOptionExpand: css` - position: absolute; - right: ${theme.spacing(1.25)}; - color: ${theme.colors.text.disabled}; - - &:after { - content: '>'; - } - `, - menuOptionInfoSign: css` - color: ${theme.colors.text.disabled}; - `, - basicRoleSelector: css` - margin: ${theme.spacing(1, 1.25, 1, 1.5)}; - `, - subMenuPortal: css` - height: 100%; - > div { - height: 100%; - } - `, - subMenuButtonRow: css` - background-color: ${theme.colors.background.primary}; - padding: ${theme.spacing(1)}; - `, - checkboxPartiallyChecked: css` - input { - &:checked + span { - &:after { - border-width: 0 3px 0px 0; - transform: rotate(90deg); - } - } - } - `, - }; -}; + '&:after': { + content: '">"', + }, + }), + menuOptionInfoSign: css({ + color: theme.colors.text.disabled, + }), + basicRoleSelector: css({ + margin: theme.spacing(1, 1.25, 1, 1.5), + }), + subMenuPortal: css({ + height: '100%', + '> div': { + height: '100%', + }, + }), + subMenuButtonRow: css({ + backgroundColor: theme.colors.background.primary, + padding: theme.spacing(1), + }), + checkboxPartiallyChecked: css({ + input: { + '&:checked + span': { + '&:after': { + borderWidth: '0 3px 0px 0', + transform: 'rotate(90deg)', + }, + }, + }, + }), + loadingSpinner: css({ + marginLeft: theme.spacing(1), + }), +}); diff --git a/public/app/features/admin/AdminEditOrgPage.tsx b/public/app/features/admin/AdminEditOrgPage.tsx index 7c4400acc66..e1cab49a294 100644 --- a/public/app/features/admin/AdminEditOrgPage.tsx +++ b/public/app/features/admin/AdminEditOrgPage.tsx @@ -1,42 +1,20 @@ import React, { useState, useEffect } from 'react'; import { useAsyncFn } from 'react-use'; -import { NavModelItem, UrlQueryValue } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { NavModelItem } from '@grafana/data'; import { Form, Field, Input, Button, Legend, Alert } 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 { accessControlQueryParam } from 'app/core/utils/accessControl'; import { OrgUser, AccessControlAction, OrgRole } from 'app/types'; import { OrgUsersTable } from './Users/OrgUsersTable'; - -const perPage = 30; +import { getOrg, getOrgUsers, getUsersRoles, removeOrgUser, updateOrgName, updateOrgUserRole } from './api'; interface OrgNameDTO { orgName: string; } -const getOrg = async (orgId: UrlQueryValue) => { - return await getBackendSrv().get(`/api/orgs/${orgId}`); -}; - -const getOrgUsers = async (orgId: UrlQueryValue, page: number) => { - if (contextSrv.hasPermission(AccessControlAction.OrgUsersRead)) { - return getBackendSrv().get(`/api/orgs/${orgId}/users/search`, accessControlQueryParam({ perpage: perPage, page })); - } - return { orgUsers: [] }; -}; - -const updateOrgUserRole = (orgUser: OrgUser, orgId: UrlQueryValue) => { - return getBackendSrv().patch(`/api/orgs/${orgId}/users/${orgUser.userId}`, orgUser); -}; - -const removeOrgUser = (orgUser: OrgUser, orgId: UrlQueryValue) => { - return getBackendSrv().delete(`/api/orgs/${orgId}/users/${orgUser.userId}`); -}; - interface Props extends GrafanaRouteComponentProps<{ id: string }> {} const AdminEditOrgPage = ({ match }: Props) => { @@ -51,6 +29,11 @@ const AdminEditOrgPage = ({ match }: Props) => { const [orgState, fetchOrg] = useAsyncFn(() => getOrg(orgId), []); const [, fetchOrgUsers] = useAsyncFn(async (page) => { const result = await getOrgUsers(orgId, page); + + if (contextSrv.licensedAccessControlEnabled()) { + await getUsersRoles(orgId, result.orgUsers); + } + const totalPages = result?.perPage !== 0 ? Math.ceil(result.totalCount / result.perPage) : 0; setTotalPages(totalPages); setUsers(result.orgUsers); @@ -62,8 +45,8 @@ const AdminEditOrgPage = ({ match }: Props) => { fetchOrgUsers(page); }, [fetchOrg, fetchOrgUsers, page]); - const updateOrgName = async (name: string) => { - return await getBackendSrv().put(`/api/orgs/${orgId}`, { ...orgState.value, name }); + const onUpdateOrgName = async (name: string) => { + await updateOrgName(name, orgId); }; const renderMissingPermissionMessage = () => ( @@ -101,7 +84,7 @@ const AdminEditOrgPage = ({ match }: Props) => { {orgState.value && (
updateOrgName(values.orgName)} + onSubmit={(values: OrgNameDTO) => onUpdateOrgName(values.orgName)} > {({ register, errors }) => ( <> diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 84a30b5ceb9..0617cc46281 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -57,6 +57,7 @@ export interface Props { changePage: (page: number) => void; page: number; totalPages: number; + rolesLoading?: boolean; } export const OrgUsersTable = ({ @@ -68,6 +69,7 @@ export const OrgUsersTable = ({ changePage, page, totalPages, + rolesLoading, }: Props) => { const [userToRemove, setUserToRemove] = useState(null); const [roleOptions, setRoleOptions] = useState([]); @@ -128,12 +130,15 @@ export const OrgUsersTable = ({ return contextSrv.licensedAccessControlEnabled() ? ( onRoleChange(newRole, original)} basicRoleDisabled={basicRoleDisabled} basicRoleDisabledMessage={disabledRoleMessage} + width={40} /> ) : ( { + return await getBackendSrv().get(`/api/orgs/${orgId}`); +}; + +export const getOrgUsers = async (orgId: UrlQueryValue, page: number) => { + if (contextSrv.hasPermission(AccessControlAction.OrgUsersRead)) { + return getBackendSrv().get(`/api/orgs/${orgId}/users/search`, accessControlQueryParam({ perpage: perPage, page })); + } + return { orgUsers: [] }; +}; + +export const getUsersRoles = async (orgId: number, users: OrgUser[]) => { + const userIds = users.map((u) => u.userId); + const roles = await getBackendSrv().post(`/api/access-control/users/roles/search`, { userIds, orgId }); + users.forEach((u) => { + u.roles = roles ? roles[u.userId] || [] : []; + }); +}; + +export const updateOrgUserRole = (orgUser: OrgUser, orgId: UrlQueryValue) => { + return getBackendSrv().patch(`/api/orgs/${orgId}/users/${orgUser.userId}`, orgUser); +}; + +export const removeOrgUser = (orgUser: OrgUser, orgId: UrlQueryValue) => { + return getBackendSrv().delete(`/api/orgs/${orgId}/users/${orgUser.userId}`); +}; + +export const updateOrgName = (name: string, orgId: number) => { + return getBackendSrv().put(`/api/orgs/${orgId}`, { name }); +}; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index a5b1c7cdbd0..fada1b38af0 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -220,7 +220,7 @@ export const ServiceAccountsListPageUnconnected = ({ ID Roles Tokens - + diff --git a/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx b/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx index d0c21a74e60..f8677e2c100 100644 --- a/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountsListItem.tsx @@ -77,10 +77,12 @@ const ServiceAccountListItem = memo( userId={serviceAccount.id} orgId={serviceAccount.orgId} basicRole={serviceAccount.role} + roles={serviceAccount.roles || []} onBasicRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)} roleOptions={roleOptions} basicRoleDisabled={!canUpdateRole} disabled={serviceAccount.isDisabled} + width={40} /> )} diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index 7409beccba5..9a9433a24f1 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -11,6 +11,8 @@ import { acOptionsLoaded, pageChanged, queryChanged, + rolesFetchBegin, + rolesFetchEnd, serviceAccountsFetchBegin, serviceAccountsFetched, serviceAccountsFetchEnd, @@ -51,6 +53,18 @@ export function fetchServiceAccounts( serviceAccountStateFilter )}&accesscontrol=true` ); + + if (contextSrv.licensedAccessControlEnabled()) { + dispatch(rolesFetchBegin()); + const orgId = contextSrv.user.orgId; + const userIds = result?.serviceAccounts.map((u: ServiceAccountDTO) => u.id); + const roles = await getBackendSrv().post(`/api/access-control/users/roles/search`, { userIds, orgId }); + result.serviceAccounts.forEach((u: ServiceAccountDTO) => { + u.roles = roles ? roles[u.id] || [] : []; + }); + dispatch(rolesFetchEnd()); + } + dispatch(serviceAccountsFetched(result)); } } catch (error) { diff --git a/public/app/features/serviceaccounts/state/reducers.ts b/public/app/features/serviceaccounts/state/reducers.ts index 0286d295cc9..5794233d434 100644 --- a/public/app/features/serviceaccounts/state/reducers.ts +++ b/public/app/features/serviceaccounts/state/reducers.ts @@ -32,12 +32,24 @@ export const serviceAccountProfileSlice = createSlice({ serviceAccountTokensLoaded: (state, action: PayloadAction): ServiceAccountProfileState => { return { ...state, tokens: action.payload, isLoading: false }; }, + rolesFetchBegin: (state) => { + return { ...state, rolesLoading: true }; + }, + rolesFetchEnd: (state) => { + return { ...state, rolesLoading: false }; + }, }, }); export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer; -export const { serviceAccountLoaded, serviceAccountTokensLoaded, serviceAccountFetchBegin, serviceAccountFetchEnd } = - serviceAccountProfileSlice.actions; +export const { + serviceAccountLoaded, + serviceAccountTokensLoaded, + serviceAccountFetchBegin, + serviceAccountFetchEnd, + rolesFetchBegin, + rolesFetchEnd, +} = serviceAccountProfileSlice.actions; // serviceAccountsListPage export const initialStateList: ServiceAccountsState = { diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 76dd014644a..c9f4dc4b686 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -31,6 +31,7 @@ const setup = (propOverrides?: object) => { page: 0, hasFetched: false, perPage: 10, + rolesLoading: false, }; Object.assign(props, propOverrides); diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 04a269e23bb..05bfed1129c 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -46,6 +46,7 @@ export const TeamList = ({ changeQuery, totalPages, page, + rolesLoading, changePage, changeSort, }: Props) => { @@ -100,7 +101,17 @@ export const TeamList = ({ AccessControlAction.ActionTeamsRolesList, original ); - return canSeeTeamRoles && ; + return ( + canSeeTeamRoles && ( + + ) + ); }, }, ] @@ -136,7 +147,7 @@ export const TeamList = ({ }, }, ], - [displayRolePicker, roleOptions, deleteTeam] + [displayRolePicker, rolesLoading, roleOptions, deleteTeam] ); return ( @@ -225,6 +236,7 @@ function mapStateToProps(state: StoreState) { noTeams: state.teams.noTeams, totalPages: state.teams.totalPages, hasFetched: state.teams.hasFetched, + rolesLoading: state.teams.rolesLoading, }; } diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index 3d28044b3eb..d4f5ec470aa 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -16,6 +16,8 @@ import { teamMembersLoaded, teamsLoaded, sortChanged, + rolesFetchBegin, + rolesFetchEnd, } from './reducers'; export function loadTeams(initial = false): ThunkResult { @@ -39,6 +41,16 @@ export function loadTeams(initial = false): ThunkResult { noTeams = response.teams.length === 0; } + if (contextSrv.licensedAccessControlEnabled()) { + dispatch(rolesFetchBegin()); + const teamIds = response?.teams.map((t: Team) => t.id); + const roles = await getBackendSrv().post(`/api/access-control/teams/roles/search`, { teamIds }); + response.teams.forEach((t: Team) => { + t.roles = roles ? roles[t.id] || [] : []; + }); + dispatch(rolesFetchEnd()); + } + dispatch(teamsLoaded({ noTeams, ...response })); }; } diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts index a854ddc1bd8..4602f33540c 100644 --- a/public/app/features/teams/state/reducers.ts +++ b/public/app/features/teams/state/reducers.ts @@ -38,10 +38,17 @@ const teamsSlice = createSlice({ sortChanged: (state, action: PayloadAction): TeamsState => { return { ...state, sort: action.payload, page: 1 }; }, + rolesFetchBegin: (state) => { + return { ...state, rolesLoading: true }; + }, + rolesFetchEnd: (state) => { + return { ...state, rolesLoading: false }; + }, }, }); -export const { teamsLoaded, queryChanged, pageChanged, sortChanged } = teamsSlice.actions; +export const { teamsLoaded, queryChanged, pageChanged, sortChanged, rolesFetchBegin, rolesFetchEnd } = + teamsSlice.actions; export const teamsReducer = teamsSlice.reducer; diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index 6b1e231166b..64b286a417a 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -38,6 +38,7 @@ const setup = (propOverrides?: object) => { changePage: mockToolkitActionCreator(pageChanged), changeSort: mockToolkitActionCreator(sortChanged), isLoading: false, + rolesLoading: false, }; Object.assign(props, propOverrides); diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 9fbf57a50b8..2664d689ba1 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -26,6 +26,7 @@ function mapStateToProps(state: StoreState) { invitees: selectInvitesMatchingQuery(state.invites, searchQuery), externalUserMngInfo: state.users.externalUserMngInfo, isLoading: state.users.isLoading, + rolesLoading: state.users.rolesLoading, }; } @@ -53,6 +54,7 @@ export const UsersListPageUnconnected = ({ invitees, externalUserMngInfo, isLoading, + rolesLoading, loadUsers, fetchInvitees, changePage, @@ -86,6 +88,7 @@ export const UsersListPageUnconnected = ({ { return async (dispatch, getState) => { try { + dispatch(usersFetchBegin()); const { perPage, page, searchQuery, sort } = getState().users; const users = await getBackendSrv().get( `/api/org/users/search`, accessControlQueryParam({ perpage: perPage, page, query: searchQuery, sort }) ); + + if (contextSrv.licensedAccessControlEnabled()) { + dispatch(rolesFetchBegin()); + const orgId = contextSrv.user.orgId; + const userIds = users?.orgUsers.map((u: OrgUser) => u.userId); + const roles = await getBackendSrv().post(`/api/access-control/users/roles/search`, { userIds, orgId }); + users.orgUsers.forEach((u: OrgUser) => { + u.roles = roles ? roles[u.userId] || [] : []; + }); + dispatch(rolesFetchEnd()); + } dispatch(usersLoaded(users)); } catch (error) { usersFetchEnd(); @@ -42,7 +64,6 @@ export function removeUser(userId: number): ThunkResult { export function changePage(page: number): ThunkResult { return async (dispatch) => { - dispatch(usersFetchBegin()); dispatch(pageChanged(page)); dispatch(loadUsers()); }; @@ -51,7 +72,6 @@ export function changePage(page: number): ThunkResult { export function changeSort({ sortBy }: FetchDataArgs): ThunkResult { const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined; return async (dispatch) => { - dispatch(usersFetchBegin()); dispatch(sortChanged(sort)); dispatch(loadUsers()); }; @@ -59,7 +79,6 @@ export function changeSort({ sortBy }: FetchDataArgs): ThunkResult { return async (dispatch) => { - dispatch(usersFetchBegin()); dispatch(searchQueryChanged(query)); fetchUsersWithDebounce(dispatch); }; diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts index 8b1a987a96e..5d9b576b9a0 100644 --- a/public/app/features/users/state/reducers.ts +++ b/public/app/features/users/state/reducers.ts @@ -13,6 +13,7 @@ export const initialState: UsersState = { externalUserMngLinkName: config.externalUserMngLinkName, externalUserMngLinkUrl: config.externalUserMngLinkUrl, isLoading: false, + rolesLoading: false, }; export interface UsersFetchResult { @@ -22,6 +23,13 @@ export interface UsersFetchResult { totalCount: number; } +export interface UsersRolesFetchResult { + orgUsers: OrgUser[]; + perPage: number; + page: number; + totalCount: number; +} + const usersSlice = createSlice({ name: 'users', initialState, @@ -60,6 +68,12 @@ const usersSlice = createSlice({ usersFetchEnd: (state) => { return { ...state, isLoading: false }; }, + rolesFetchBegin: (state) => { + return { ...state, rolesLoading: true }; + }, + rolesFetchEnd: (state) => { + return { ...state, rolesLoading: false }; + }, }, }); @@ -71,6 +85,8 @@ export const { usersFetchEnd, pageChanged, sortChanged, + rolesFetchBegin, + rolesFetchEnd, } = usersSlice.actions; export const usersReducer = usersSlice.reducer; diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index cad4fea10d9..b14751e122f 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -36,6 +36,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata { isDisabled: boolean; teams: string[]; role: OrgRole; + roles?: Role[]; } export interface ServiceAccountCreateApiResponse { @@ -52,6 +53,7 @@ export interface ServiceAccountCreateApiResponse { export interface ServiceAccountProfileState { serviceAccount: ServiceAccountDTO; isLoading: boolean; + rolesLoading?: boolean; tokens: ApiKey[]; } diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index 5e4c7bf16b2..38f5abcf01a 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -1,5 +1,6 @@ import { Team as TeamDTO } from '@grafana/schema/src/raw/team/x/team_types.gen'; +import { Role } from './accessControl'; import { TeamPermissionLevel } from './acl'; // The team resource @@ -37,6 +38,10 @@ export interface Team { * TODO - it seems it's a team_member.permission, unlikely it should belong to the team kind */ permission: TeamPermissionLevel; + /** + * RBAC roles assigned to the team. + */ + roles?: Role[]; } export interface TeamMember { @@ -64,6 +69,7 @@ export interface TeamsState { totalPages: number; hasFetched: boolean; sort?: string; + rolesLoading?: boolean; } export interface TeamState { diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 66107cd1f27..fcbf37ec4ee 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -1,6 +1,8 @@ import { SelectableValue, WithAccessControlMetadata } from '@grafana/data'; +import { Role } from 'app/types'; import { OrgRole } from '.'; + export interface OrgUser extends WithAccessControlMetadata { avatarUrl: string; email: string; @@ -10,6 +12,8 @@ export interface OrgUser extends WithAccessControlMetadata { name: string; orgId: number; role: OrgRole; + // RBAC roles + roles?: Role[]; userId: number; isDisabled: boolean; authLabels?: string[]; @@ -76,6 +80,7 @@ export interface UsersState { externalUserMngLinkName: string; externalUserMngInfo: string; isLoading: boolean; + rolesLoading?: boolean; page: number; perPage: number; totalPages: number;