[v10.2.x] RolePicker: Optimise rendering inside lists of items (#78260)

* Role picker: Fix flickering at service accounts page (#77049)

* Role picker: Fix flickering at service accounts page

* Set role picker fixed width

* Fix betterer

* Fix styles

(cherry picked from commit aa7a6da985)

* RolePicker: Optimise rendering inside lists of items (#77297)

* Role picker: Load users roles in batch

* Use orgId in request

* Add roles to OrgUser type

* Improve loading logic

* Improve loading indicator

* Fix org page

* Update service accounts page

* Use bulk roles query for teams

* Use POST requests for search

* Use post request for teams

* Update betterer results

* Review suggestions

* AdminEditOrgPage: move API calls to separate file

(cherry picked from commit cf7a2ea733)

---------

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
This commit is contained in:
Jo
2023-11-16 13:04:15 +01:00
committed by GitHub
co-authored by Alexander Zobnin
parent 2bd48632f6
commit 12d569f8fd
24 changed files with 362 additions and 212 deletions
+1 -23
View File
@@ -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"]
@@ -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<Role[]>(appliedRoles);
@@ -47,6 +49,8 @@ export const RolePicker = ({
const [query, setQuery] = useState('');
const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 });
const ref = useRef<HTMLDivElement>(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 (
<HorizontalGroup justify="center">
<span>Loading...</span>
<Spinner size={16} />
</HorizontalGroup>
);
}
return (
<div
data-testid="role-picker"
style={{
position: 'relative',
maxWidth,
maxWidth: widthPx || maxWidth,
width: widthPx,
}}
ref={ref}
>
@@ -175,6 +171,8 @@ export const RolePicker = ({
isFocused={isOpen}
disabled={disabled}
showBasicRole={showBasicRole}
width={widthPx}
isLoading={isLoading}
/>
{isOpen && (
<RolePickerMenu
@@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import React, { FormEvent, HTMLProps, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, getInputStyles, sharedInputStyle, styleMixins, Tooltip, Icon } from '@grafana/ui';
import { useStyles2, getInputStyles, sharedInputStyle, styleMixins, Tooltip, Icon, Spinner } from '@grafana/ui';
import { Role } from '../../../types';
@@ -18,6 +18,8 @@ interface InputProps extends HTMLProps<HTMLInputElement> {
showBasicRole?: boolean;
isFocused?: boolean;
disabled?: boolean;
width?: string;
isLoading?: boolean;
onQueryChange: (query?: string) => void;
onOpen: (event: FormEvent<HTMLElement>) => 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<HTMLInputElement | null>(null);
useEffect(() => {
@@ -61,6 +65,11 @@ export const RolePickerInput = ({
numberOfRoles={appliedRoles.length}
showBuiltInRole={showBasicRoleOnLabel}
/>
{isLoading && (
<div className={styles.spinner}>
<Spinner size={16} inline />
</div>
)}
</div>
) : (
<div className={styles.wrapper}>
@@ -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',
}),
};
};
@@ -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}
/>
);
};
@@ -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}
/>
);
};
+114 -114
View File
@@ -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),
}),
});
+10 -27
View File
@@ -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 && (
<Form
defaultValues={{ orgName: orgState.value.name }}
onSubmit={(values: OrgNameDTO) => updateOrgName(values.orgName)}
onSubmit={(values: OrgNameDTO) => onUpdateOrgName(values.orgName)}
>
{({ register, errors }) => (
<>
@@ -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<OrgUser | null>(null);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
@@ -128,12 +130,15 @@ export const OrgUsersTable = ({
return contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={original.userId}
roles={original.roles || []}
isLoading={rolesLoading}
orgId={orgId}
roleOptions={roleOptions}
basicRole={value}
onBasicRoleChange={(newRole) => onRoleChange(newRole, original)}
basicRoleDisabled={basicRoleDisabled}
basicRoleDisabledMessage={disabledRoleMessage}
width={40}
/>
) : (
<OrgRolePicker
@@ -182,7 +187,7 @@ export const OrgUsersTable = ({
},
},
],
[orgId, roleOptions, onRoleChange]
[rolesLoading, orgId, roleOptions, onRoleChange]
);
return (
+38
View File
@@ -0,0 +1,38 @@
import { UrlQueryValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { OrgUser, AccessControlAction } from 'app/types';
const perPage = 30;
export const getOrg = async (orgId: UrlQueryValue) => {
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 });
};
@@ -220,7 +220,7 @@ export const ServiceAccountsListPageUnconnected = ({
<th>ID</th>
<th>Roles</th>
<th>Tokens</th>
<th style={{ width: '34px' }} />
<th style={{ width: '120px' }} />
</tr>
</thead>
<tbody>
@@ -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}
/>
)}
</td>
@@ -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) {
@@ -32,12 +32,24 @@ export const serviceAccountProfileSlice = createSlice({
serviceAccountTokensLoaded: (state, action: PayloadAction<ApiKey[]>): 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 = {
@@ -31,6 +31,7 @@ const setup = (propOverrides?: object) => {
page: 0,
hasFetched: false,
perPage: 10,
rolesLoading: false,
};
Object.assign(props, propOverrides);
+14 -2
View File
@@ -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 && <TeamRolePicker teamId={original.id} roleOptions={roleOptions} />;
return (
canSeeTeamRoles && (
<TeamRolePicker
teamId={original.id}
roles={original.roles || []}
isLoading={rolesLoading}
roleOptions={roleOptions}
width={40}
/>
)
);
},
},
]
@@ -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,
};
}
@@ -16,6 +16,8 @@ import {
teamMembersLoaded,
teamsLoaded,
sortChanged,
rolesFetchBegin,
rolesFetchEnd,
} from './reducers';
export function loadTeams(initial = false): ThunkResult<void> {
@@ -39,6 +41,16 @@ export function loadTeams(initial = false): ThunkResult<void> {
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 }));
};
}
+8 -1
View File
@@ -38,10 +38,17 @@ const teamsSlice = createSlice({
sortChanged: (state, action: PayloadAction<TeamsState['sort']>): 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;
@@ -38,6 +38,7 @@ const setup = (propOverrides?: object) => {
changePage: mockToolkitActionCreator(pageChanged),
changeSort: mockToolkitActionCreator(sortChanged),
isLoading: false,
rolesLoading: false,
};
Object.assign(props, propOverrides);
@@ -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 = ({
<OrgUsersTable
users={users}
orgId={contextSrv.user.orgId}
rolesLoading={rolesLoading}
onRoleChange={onRoleChange}
onRemoveUser={onRemoveUser}
fetchData={changeSort}
+23 -4
View File
@@ -2,21 +2,43 @@ import { debounce } from 'lodash';
import { getBackendSrv } from '@grafana/runtime';
import { FetchDataArgs } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { OrgUser } from 'app/types';
import { ThunkResult } from '../../../types';
import { usersLoaded, pageChanged, usersFetchBegin, usersFetchEnd, searchQueryChanged, sortChanged } from './reducers';
import {
usersLoaded,
pageChanged,
usersFetchBegin,
usersFetchEnd,
searchQueryChanged,
sortChanged,
rolesFetchBegin,
rolesFetchEnd,
} from './reducers';
export function loadUsers(): ThunkResult<void> {
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<void> {
export function changePage(page: number): ThunkResult<void> {
return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(pageChanged(page));
dispatch(loadUsers());
};
@@ -51,7 +72,6 @@ export function changePage(page: number): ThunkResult<void> {
export function changeSort({ sortBy }: FetchDataArgs<OrgUser>): ThunkResult<void> {
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<OrgUser>): ThunkResult<void
export function changeSearchQuery(query: string): ThunkResult<void> {
return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(searchQueryChanged(query));
fetchUsersWithDebounce(dispatch);
};
@@ -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;
+2
View File
@@ -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[];
}
+6
View File
@@ -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 {
+5
View File
@@ -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;