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
This commit is contained in:
Alexander Zobnin 2023-11-01 11:57:02 +01:00 committed by GitHub
parent d1798819c0
commit cf7a2ea733
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 227 additions and 76 deletions

View File

@ -1377,8 +1377,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"]

View File

@ -1,12 +1,11 @@
import React, { FormEvent, useCallback, useEffect, useState, useRef } from 'react';
import { ClickOutsideWrapper, Spinner, useStyles2, useTheme2 } from '@grafana/ui';
import { ClickOutsideWrapper, useTheme2 } from '@grafana/ui';
import { Role, OrgRole } from 'app/types';
import { RolePickerInput } from './RolePickerInput';
import { RolePickerMenu } from './RolePickerMenu';
import { MENU_MAX_HEIGHT, ROLE_PICKER_SUBMENU_MIN_WIDTH, ROLE_PICKER_WIDTH } from './constants';
import { getStyles } from './styles';
export interface Props {
basicRole?: OrgRole;
@ -50,7 +49,6 @@ export const RolePicker = ({
const [query, setQuery] = useState('');
const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 });
const ref = useRef<HTMLDivElement>(null);
const styles = useStyles2(getStyles);
const theme = useTheme2();
const widthPx = typeof width === 'number' ? theme.spacing(width) : width;
@ -152,15 +150,6 @@ export const RolePicker = ({
return options;
};
if (isLoading) {
return (
<div style={{ maxWidth: widthPx || maxWidth, width: widthPx }}>
<span>Loading...</span>
<Spinner inline className={styles.loadingSpinner} />
</div>
);
}
return (
<div
data-testid="role-picker"
@ -183,6 +172,7 @@ export const RolePicker = ({
disabled={disabled}
showBasicRole={showBasicRole}
width={widthPx}
isLoading={isLoading}
/>
{isOpen && (
<RolePickerMenu

View File

@ -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';
@ -19,6 +19,7 @@ interface InputProps extends HTMLProps<HTMLInputElement> {
isFocused?: boolean;
disabled?: boolean;
width?: string;
isLoading?: boolean;
onQueryChange: (query?: string) => void;
onOpen: (event: FormEvent<HTMLElement>) => void;
onClose: () => void;
@ -32,6 +33,7 @@ export const RolePickerInput = ({
query,
showBasicRole,
width,
isLoading,
onOpen,
onClose,
onQueryChange,
@ -63,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}>
@ -141,22 +148,22 @@ const getRolePickerInputStyles = (
${styleMixins.focusCss(theme.v1)}
`,
disabled && styles.inputDisabled,
css`
min-width: ${width || ROLE_PICKER_WIDTH + 'px'};
width: ${width};
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;
@ -184,6 +191,11 @@ const getRolePickerInputStyles = (
margin-bottom: ${theme.spacing(0.5)};
}
`,
spinner: css({
display: 'flex',
flexGrow: 1,
justifyContent: 'flex-end',
}),
};
};

View File

@ -12,6 +12,7 @@ export interface Props {
orgId?: number;
roleOptions: Role[];
disabled?: boolean;
roles?: Role[];
onApplyRoles?: (newRoles: Role[]) => void;
pendingRoles?: Role[];
/**
@ -28,20 +29,26 @@ 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;
}
@ -53,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) {
@ -78,7 +85,7 @@ export const TeamRolePicker = ({
onRolesChange={onRolesChange}
roleOptions={roleOptions}
appliedRoles={appliedRoles}
isLoading={loading}
isLoading={loading || isLoading}
disabled={disabled}
basicRoleDisabled={true}
canUpdateRoles={canUpdateRoles}

View File

@ -9,6 +9,7 @@ import { fetchUserRoles, updateUserRoles } from './api';
export interface Props {
basicRole: OrgRole;
roles?: Role[];
userId: number;
orgId?: number;
onBasicRoleChange: (newRole: OrgRole) => void;
@ -32,10 +33,12 @@ export interface Props {
pendingRoles?: Role[];
maxWidth?: string | number;
width?: string | number;
isLoading?: boolean;
}
export const UserRolePicker = ({
basicRole,
roles,
userId,
orgId,
onBasicRoleChange,
@ -48,9 +51,13 @@ export const UserRolePicker = ({
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;
}
@ -63,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) {
@ -92,7 +99,7 @@ export const UserRolePicker = ({
onRolesChange={onRolesChange}
onBasicRoleChange={onBasicRoleChange}
roleOptions={roleOptions}
isLoading={loading}
isLoading={loading || isLoading}
disabled={disabled}
basicRoleDisabled={basicRoleDisabled}
basicRoleDisabledMessage={basicRoleDisabledMessage}

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 }) => (
<>

View File

@ -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[]>([]);
@ -127,6 +129,8 @@ export const OrgUsersTable = ({
return contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={original.userId}
roles={original.roles || []}
isLoading={rolesLoading}
orgId={orgId}
roleOptions={roleOptions}
basicRole={value}
@ -211,7 +215,7 @@ export const OrgUsersTable = ({
},
},
],
[orgId, roleOptions, onRoleChange]
[rolesLoading, orgId, roleOptions, onRoleChange]
);
return (

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 });
};

View File

@ -77,6 +77,7 @@ const ServiceAccountListItem = memo(
userId={serviceAccount.id}
orgId={serviceAccount.orgId}
basicRole={serviceAccount.role}
roles={serviceAccount.roles || []}
onBasicRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
roleOptions={roleOptions}
basicRoleDisabled={!canUpdateRole}

View File

@ -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) {

View File

@ -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 = {

View File

@ -31,6 +31,7 @@ const setup = (propOverrides?: object) => {
page: 0,
hasFetched: false,
perPage: 10,
rolesLoading: false,
};
Object.assign(props, propOverrides);

View File

@ -43,6 +43,7 @@ export const TeamList = ({
changeQuery,
totalPages,
page,
rolesLoading,
changePage,
changeSort,
}: Props) => {
@ -96,7 +97,17 @@ export const TeamList = ({
AccessControlAction.ActionTeamsRolesList,
original
);
return canSeeTeamRoles && <TeamRolePicker teamId={original.id} roleOptions={roleOptions} width={40} />;
return (
canSeeTeamRoles && (
<TeamRolePicker
teamId={original.id}
roles={original.roles || []}
isLoading={rolesLoading}
roleOptions={roleOptions}
width={40}
/>
)
);
},
},
]
@ -132,7 +143,7 @@ export const TeamList = ({
},
},
],
[displayRolePicker, roleOptions, deleteTeam]
[displayRolePicker, rolesLoading, roleOptions, deleteTeam]
);
return (
@ -203,6 +214,7 @@ function mapStateToProps(state: StoreState) {
noTeams: state.teams.noTeams,
totalPages: state.teams.totalPages,
hasFetched: state.teams.hasFetched,
rolesLoading: state.teams.rolesLoading,
};
}

View File

@ -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 }));
};
}

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;

View File

@ -38,6 +38,7 @@ const setup = (propOverrides?: object) => {
changePage: mockToolkitActionCreator(pageChanged),
changeSort: mockToolkitActionCreator(sortChanged),
isLoading: false,
rolesLoading: false,
};
Object.assign(props, propOverrides);

View File

@ -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}

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);
};

View File

@ -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;

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[];
}

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 {

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;