mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d1798819c0
commit
cf7a2ea733
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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[]>([]);
|
||||
@ -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 (
|
||||
|
38
public/app/features/admin/api.ts
Normal file
38
public/app/features/admin/api.ts
Normal 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 });
|
||||
};
|
@ -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}
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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 }));
|
||||
};
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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[];
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user