Chore: RBAC: Migrate role picker to rtkq (#116571)

What is this feature?

This PR introduce refactoring for role picker for teams and users. It uses RTK Query.
The related task: grafana/identity-access-team#1821

The previous PR with related logic: #113783

Why do we need this feature?

This refactoring is a cleaner way for handing refetching.
This commit is contained in:
Yudintsev George (Egor) 2026-02-03 13:46:41 +00:00 committed by GitHub
parent f937dfdcc6
commit e124ee79f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 175 additions and 125 deletions

1
.github/CODEOWNERS vendored
View file

@ -930,6 +930,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
/public/app/core/utils/timeRegions* @grafana/dataviz-squad /public/app/core/utils/timeRegions* @grafana/dataviz-squad
/public/app/core/utils/urlToken.ts @grafana/identity-access-team /public/app/core/utils/urlToken.ts @grafana/identity-access-team
/public/app/core/utils/version.ts @grafana/partner-datasources /public/app/core/utils/version.ts @grafana/partner-datasources
/public/app/core/utils/roles.ts @grafana/identity-access-team
/public/app/dev-utils.ts @grafana/grafana-frontend-platform /public/app/dev-utils.ts @grafana/grafana-frontend-platform
/public/app/features/actions/ @grafana/dataviz-squad /public/app/features/actions/ @grafana/dataviz-squad

View file

@ -0,0 +1,75 @@
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { generatedAPI, RoleDto } from '@grafana/api-clients/rtkq/legacy';
import { isFetchError } from '@grafana/runtime';
import { addDisplayNameForFixedRole, addFilteredDisplayName } from 'app/core/utils/roles';
import { Role } from 'app/types/accessControl';
const transformRolesResponse = (response: RoleDto[]): Role[] => {
if (!response?.length) {
return [];
}
return response.map(addFilteredDisplayName).map(addDisplayNameForFixedRole);
};
const transformRolesError = (error: FetchBaseQueryError) => {
if (isFetchError(error)) {
error.isHandled = true;
}
return error;
};
// rolesAPI is needed to be overriden to add transformResponse and transformErrorResponse for some endpoints.
export const rolesAPI = generatedAPI.injectEndpoints({
overrideExisting: true,
endpoints: (build) => ({
listUserRoles: build.query<
Role[],
{ userId: number; includeHidden?: boolean; includeMapped?: boolean; targetOrgId?: number }
>({
query: (queryArg) => ({
url: `/access-control/users/${queryArg.userId}/roles`,
params: {
includeHidden: queryArg.includeHidden,
includeMapped: queryArg.includeMapped,
targetOrgId: queryArg.targetOrgId,
},
}),
providesTags: ['access_control', 'enterprise'],
transformResponse: transformRolesResponse,
transformErrorResponse: transformRolesError,
}),
listTeamRoles: build.query<Role[], { teamId: number; includeHidden?: boolean; targetOrgId?: number }>({
query: (queryArg) => ({
url: `/access-control/teams/${queryArg.teamId}/roles`,
params: {
includeHidden: queryArg.includeHidden,
targetOrgId: queryArg.targetOrgId,
},
}),
providesTags: ['access_control', 'enterprise'],
transformResponse: transformRolesResponse,
transformErrorResponse: transformRolesError,
}),
listRoles: build.query<Role[], { delegatable?: boolean; includeHidden?: boolean; targetOrgId?: number }>({
query: (queryArg) => ({
url: '/access-control/roles',
params: {
delegatable: queryArg.delegatable,
includeHidden: queryArg.includeHidden,
targetOrgId: queryArg.targetOrgId,
},
}),
providesTags: ['access_control', 'enterprise'],
transformResponse: transformRolesResponse,
}),
}),
});
export const {
useListTeamRolesQuery,
useSetTeamRolesMutation,
useListUserRolesQuery,
useSetUserRolesMutation,
useListRolesQuery,
} = rolesAPI;

View file

@ -1,13 +1,13 @@
import { forwardRef, useCallback, useState } from 'react'; import { forwardRef, useCallback, useState } from 'react';
import { useStyles2, getSelectStyles, useTheme2 } from '@grafana/ui'; import { useStyles2, getSelectStyles, useTheme2 } from '@grafana/ui';
import { isNotDelegatable } from 'app/core/utils/roles';
import { Role } from 'app/types/accessControl'; import { Role } from 'app/types/accessControl';
import { RoleMenuGroupOption } from './RoleMenuGroupOption'; import { RoleMenuGroupOption } from './RoleMenuGroupOption';
import { RoleMenuOption } from './RoleMenuOption'; import { RoleMenuOption } from './RoleMenuOption';
import { RolePickerSubMenu } from './RolePickerSubMenu'; import { RolePickerSubMenu } from './RolePickerSubMenu';
import { getStyles } from './styles'; import { getStyles } from './styles';
import { isNotDelegatable } from './utils';
interface RoleMenuGroupsSectionProps { interface RoleMenuGroupsSectionProps {
roles: Role[]; roles: Role[];

View file

@ -2,7 +2,8 @@ import { FormEvent, useCallback, useEffect, useRef, useState, useSyncExternalSto
import { OrgRole } from '@grafana/data'; import { OrgRole } from '@grafana/data';
import { ClickOutsideWrapper, Portal, useTheme2 } from '@grafana/ui'; import { ClickOutsideWrapper, Portal, useTheme2 } from '@grafana/ui';
import { Role } from 'app/types/accessControl'; import { pickerStateStore } from 'app/core/utils/roles';
import type { Role } from 'app/types/accessControl';
import { RolePickerInput } from './RolePickerInput'; import { RolePickerInput } from './RolePickerInput';
import { RolePickerMenu } from './RolePickerMenu'; import { RolePickerMenu } from './RolePickerMenu';
@ -12,7 +13,6 @@ import {
ROLE_PICKER_MENU_MAX_WIDTH, ROLE_PICKER_MENU_MAX_WIDTH,
ROLE_PICKER_WIDTH, ROLE_PICKER_WIDTH,
} from './constants'; } from './constants';
import { pickerStateStore } from './utils';
export interface Props { export interface Props {
basicRole?: OrgRole; basicRole?: OrgRole;

View file

@ -4,12 +4,12 @@ import type { JSX } from 'react';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { Button, ScrollContainer, Stack, useStyles2, useTheme2 } from '@grafana/ui'; import { Button, ScrollContainer, Stack, useStyles2, useTheme2 } from '@grafana/ui';
import { getSelectStyles } from '@grafana/ui/internal'; import { getSelectStyles } from '@grafana/ui/internal';
import { isNotDelegatable } from 'app/core/utils/roles';
import { Role } from 'app/types/accessControl'; import { Role } from 'app/types/accessControl';
import { RoleMenuOption } from './RoleMenuOption'; import { RoleMenuOption } from './RoleMenuOption';
import { MENU_MAX_HEIGHT } from './constants'; import { MENU_MAX_HEIGHT } from './constants';
import { getStyles } from './styles'; import { getStyles } from './styles';
import { isNotDelegatable } from './utils';
interface RolePickerSubMenuProps { interface RolePickerSubMenuProps {
options: Role[]; options: Role[];

View file

@ -1,11 +1,11 @@
import { useEffect } from 'react'; import { skipToken } from '@reduxjs/toolkit/query';
import { useAsyncFn } from 'react-use'; import { useMemo } from 'react';
import { useListTeamRolesQuery, useSetTeamRolesMutation } from 'app/api/clients/roles';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, Role } from 'app/types/accessControl'; import { AccessControlAction, Role } from 'app/types/accessControl';
import { RolePicker } from './RolePicker'; import { RolePicker } from './RolePicker';
import { fetchTeamRoles, updateTeamRoles } from './api';
export interface Props { export interface Props {
teamId: number; teamId: number;
@ -34,6 +34,7 @@ export interface Props {
export const TeamRolePicker = ({ export const TeamRolePicker = ({
teamId, teamId,
orgId,
roleOptions, roleOptions,
disabled, disabled,
roles, roles,
@ -44,36 +45,44 @@ export const TeamRolePicker = ({
width, width,
isLoading, isLoading,
}: Props) => { }: Props) => {
const [{ loading, value: appliedRoles = roles || [] }, getTeamRoles] = useAsyncFn( const hasPermission = contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && teamId > 0;
async (force = false) => {
try { // In non-apply mode, always fetch to ensure we have fresh data after mutations
if (!force && roles) { // In apply mode, only skip fetch if we have pendingRoles
return roles; const shouldFetch = apply ? !Boolean(pendingRoles?.length) && hasPermission : hasPermission;
}
if (!force && apply && Boolean(pendingRoles?.length)) { const { data: fetchedRoles, isLoading: isFetching } = useListTeamRolesQuery(
return pendingRoles; shouldFetch ? { teamId, targetOrgId: orgId } : skipToken
}
if (contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && teamId > 0) {
return await fetchTeamRoles(teamId);
}
} catch (e) {
console.error('Error fetching roles', e);
}
return [];
},
[teamId, pendingRoles, roles]
); );
useEffect(() => { const [updateTeamRoles, { isLoading: isUpdating }] = useSetTeamRolesMutation();
getTeamRoles();
}, [getTeamRoles]);
const onRolesChange = async (roles: Role[]) => { const appliedRoles =
useMemo(() => {
if (apply && Boolean(pendingRoles?.length)) {
return pendingRoles;
}
// Otherwise prefer fetched data (which is always fresh due to cache invalidation)
// Fall back to roles prop if fetched data is not available yet
return fetchedRoles || roles || [];
}, [roles, pendingRoles, fetchedRoles, apply]) || [];
const onRolesChange = async (newRoles: Role[]) => {
if (!apply) { if (!apply) {
await updateTeamRoles(roles, teamId); try {
await getTeamRoles(true); // Force fetch from backend after update const roleUids = newRoles.map((role) => role.uid);
await updateTeamRoles({
teamId,
targetOrgId: orgId,
setTeamRolesCommand: {
roleUids,
},
}).unwrap();
} catch (error) {
console.error('Error updating team roles', error);
}
} else if (onApplyRoles) { } else if (onApplyRoles) {
onApplyRoles(roles); onApplyRoles(newRoles);
} }
}; };
@ -88,7 +97,7 @@ export const TeamRolePicker = ({
onRolesChange={onRolesChange} onRolesChange={onRolesChange}
roleOptions={roleOptions} roleOptions={roleOptions}
appliedRoles={appliedRoles} appliedRoles={appliedRoles}
isLoading={loading || isLoading} isLoading={isFetching || isUpdating || isLoading}
disabled={disabled} disabled={disabled}
basicRoleDisabled={true} basicRoleDisabled={true}
canUpdateRoles={canUpdateRoles} canUpdateRoles={canUpdateRoles}

View file

@ -1,12 +1,12 @@
import { useEffect } from 'react'; import { skipToken } from '@reduxjs/toolkit/query';
import { useAsyncFn } from 'react-use'; import { useMemo } from 'react';
import { OrgRole } from '@grafana/data'; import { OrgRole } from '@grafana/data';
import { useListUserRolesQuery, useSetUserRolesMutation } from 'app/api/clients/roles';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, Role } from 'app/types/accessControl'; import { AccessControlAction, Role } from 'app/types/accessControl';
import { RolePicker } from './RolePicker'; import { RolePicker } from './RolePicker';
import { fetchUserRoles, updateUserRoles } from './api';
export interface Props { export interface Props {
basicRole: OrgRole; basicRole: OrgRole;
@ -54,39 +54,46 @@ export const UserRolePicker = ({
width, width,
isLoading, isLoading,
}: Props) => { }: Props) => {
const [{ loading, value: appliedRoles = roles || [] }, getUserRoles] = useAsyncFn( const hasPermission = contextSrv.hasPermission(AccessControlAction.ActionUserRolesList) && userId > 0 && orgId;
async (force = false) => {
try { // Determine when to fetch:
if (!force && roles) { // - In apply mode: only fetch if we don't have roles prop AND no pendingRoles (prevents flicker)
return roles; // - In non-apply mode: always fetch to get fresh data after mutations
} const shouldFetch = apply ? !roles && !Boolean(pendingRoles?.length) && hasPermission : hasPermission;
if (!force && apply && Boolean(pendingRoles?.length)) {
return pendingRoles; const { data: fetchedRoles, isLoading: isFetching } = useListUserRolesQuery(
} shouldFetch ? { userId, includeHidden: true, includeMapped: true, targetOrgId: orgId } : skipToken
if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList) && userId > 0) {
return await fetchUserRoles(userId, orgId);
}
} catch (e) {
console.error('Error fetching user roles');
}
return [];
},
[orgId, userId, pendingRoles, roles]
); );
useEffect(() => { const [updateUserRoles, { isLoading: isUpdating }] = useSetUserRolesMutation();
// only load roles when there is an Org selected
if (orgId) {
getUserRoles();
}
}, [getUserRoles, orgId]);
const onRolesChange = async (roles: Role[]) => { const appliedRoles =
useMemo(() => {
// In apply mode: prioritize pendingRoles, then roles prop (never use fetched data to prevent flicker)
if (apply) {
return pendingRoles || roles || [];
}
// In non-apply mode: prefer fetched data (fresh from cache) over roles prop
return fetchedRoles || roles || [];
}, [roles, pendingRoles, fetchedRoles, apply]) || [];
const onRolesChange = async (newRoles: Role[]) => {
if (!apply) { if (!apply) {
await updateUserRoles(roles, userId, orgId); try {
await getUserRoles(true); // Force fetch from backend after update const filteredRoles = newRoles.filter((role) => !role.mapped);
const roleUids = filteredRoles.map((role) => role.uid);
await updateUserRoles({
userId,
targetOrgId: orgId,
setUserRolesCommand: {
roleUids,
},
}).unwrap();
} catch (error) {
console.error('Error updating user roles', error);
}
} else if (onApplyRoles) { } else if (onApplyRoles) {
onApplyRoles(roles, userId, orgId); onApplyRoles(newRoles, userId, orgId);
} }
}; };
@ -102,7 +109,7 @@ export const UserRolePicker = ({
onRolesChange={onRolesChange} onRolesChange={onRolesChange}
onBasicRoleChange={onBasicRoleChange} onBasicRoleChange={onBasicRoleChange}
roleOptions={roleOptions} roleOptions={roleOptions}
isLoading={loading || isLoading} isLoading={isFetching || isUpdating || isLoading}
disabled={disabled} disabled={disabled}
basicRoleDisabled={basicRoleDisabled} basicRoleDisabled={basicRoleDisabled}
basicRoleDisabledMessage={basicRoleDisabledMessage} basicRoleDisabledMessage={basicRoleDisabledMessage}

View file

@ -1,7 +1,16 @@
import { getBackendSrv, isFetchError } from '@grafana/runtime'; /**
import { Role } from 'app/types/accessControl'; * @deprecated These functions are legacy API calls. For new code, use the RTK Query API from:
* `app/api/clients/roles` which provides:
* - useListTeamRolesQuery
* - useSetTeamRolesMutation
* - useListUserRolesQuery
* - useSetUserRolesMutation
* - useListRolesQuery
*/
import { addDisplayNameForFixedRole, addFilteredDisplayName } from './utils'; import { getBackendSrv } from '@grafana/runtime';
import { addDisplayNameForFixedRole, addFilteredDisplayName } from 'app/core/utils/roles';
import { Role } from 'app/types/accessControl';
export const fetchRoleOptions = async (orgId?: number): Promise<Role[]> => { export const fetchRoleOptions = async (orgId?: number): Promise<Role[]> => {
let rolesUrl = '/api/access-control/roles?delegatable=true'; let rolesUrl = '/api/access-control/roles?delegatable=true';
@ -15,25 +24,6 @@ export const fetchRoleOptions = async (orgId?: number): Promise<Role[]> => {
return roles.map(addDisplayNameForFixedRole).map(addFilteredDisplayName); return roles.map(addDisplayNameForFixedRole).map(addFilteredDisplayName);
}; };
export const fetchUserRoles = async (userId: number, orgId?: number): Promise<Role[]> => {
let userRolesUrl = `/api/access-control/users/${userId}/roles?includeMapped=true&includeHidden=true`;
if (orgId) {
userRolesUrl += `&targetOrgId=${orgId}`;
}
try {
const roles = await getBackendSrv().get(userRolesUrl);
if (!roles || !roles.length) {
return [];
}
return roles.map(addDisplayNameForFixedRole).map(addFilteredDisplayName);
} catch (error) {
if (isFetchError(error)) {
error.isHandled = true;
}
return [];
}
};
export const updateUserRoles = (roles: Role[], userId: number, orgId?: number) => { export const updateUserRoles = (roles: Role[], userId: number, orgId?: number) => {
let userRolesUrl = `/api/access-control/users/${userId}/roles`; let userRolesUrl = `/api/access-control/users/${userId}/roles`;
if (orgId) { if (orgId) {
@ -46,35 +36,3 @@ export const updateUserRoles = (roles: Role[], userId: number, orgId?: number) =
roleUids, roleUids,
}); });
}; };
export const fetchTeamRoles = async (teamId: number, orgId?: number): Promise<Role[]> => {
let teamRolesUrl = `/api/access-control/teams/${teamId}/roles`;
if (orgId) {
teamRolesUrl += `?targetOrgId=${orgId}`;
}
try {
const roles = await getBackendSrv().get(teamRolesUrl);
if (!roles || !roles.length) {
return [];
}
return roles.map(addDisplayNameForFixedRole).map(addFilteredDisplayName);
} catch (error) {
if (isFetchError(error)) {
error.isHandled = true;
}
return [];
}
};
export const updateTeamRoles = (roles: Role[], teamId: number, orgId?: number) => {
let teamRolesUrl = `/api/access-control/teams/${teamId}/roles`;
if (orgId) {
teamRolesUrl += `?targetOrgId=${orgId}`;
}
const roleUids = roles.flatMap((x) => x.uid);
return getBackendSrv().put(teamRolesUrl, {
orgId,
roleUids,
});
};

View file

@ -3,19 +3,19 @@ import { useEffect, useMemo } from 'react';
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks'; import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { import {
useSearchTeamsQuery as useLegacySearchTeamsQuery, CreateTeamCommand,
UpdateTeamCommand,
useCreateTeamMutation, useCreateTeamMutation,
useDeleteTeamByIdMutation, useDeleteTeamByIdMutation,
useListTeamsRolesQuery,
CreateTeamCommand,
useSetTeamRolesMutation,
useGetTeamByIdQuery, useGetTeamByIdQuery,
useSearchTeamsQuery as useLegacySearchTeamsQuery,
useListTeamsRolesQuery,
useSetTeamRolesMutation,
useUpdateTeamMutation, useUpdateTeamMutation,
UpdateTeamCommand,
} from 'app/api/clients/legacy'; } from 'app/api/clients/legacy';
import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils';
import { updateNavIndex } from 'app/core/reducers/navModel'; import { updateNavIndex } from 'app/core/reducers/navModel';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { addFilteredDisplayName } from 'app/core/utils/roles';
import { AccessControlAction, Role } from 'app/types/accessControl'; import { AccessControlAction, Role } from 'app/types/accessControl';
import { useDispatch } from 'app/types/store'; import { useDispatch } from 'app/types/store';