GAS: Show mapped roles in role picker (#96681)

* add group mapping UID returned mapped roles

* request mapped roles from the frontend, but don't attempt to update mapped roles

* lock mapped roles and show a pop-up message about why a role is locked

* update role selectors to not allow deselecting a mapped role

* swagger gen

* simplify and set mapped as bool instead of mapping UID array

* swagger gen
This commit is contained in:
Ieva 2024-11-20 17:37:12 +00:00 committed by GitHub
parent e06ad2a6ef
commit a60953c8f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 72 additions and 15 deletions

View File

@ -81,6 +81,7 @@ type RoleDTO struct {
Group string `xorm:"group_name" json:"group"`
Permissions []Permission `json:"permissions,omitempty"`
Delegatable *bool `json:"delegatable,omitempty"`
Mapped bool `json:"mapped,omitempty"`
Hidden bool `json:"hidden,omitempty"`
ID int64 `json:"-" xorm:"pk autoincr 'id'"`

View File

@ -7123,6 +7123,9 @@
"hidden": {
"type": "boolean"
},
"mapped": {
"type": "boolean"
},
"name": {
"type": "string"
},

View File

@ -19793,6 +19793,9 @@
"hidden": {
"type": "boolean"
},
"mapped": {
"type": "boolean"
},
"name": {
"type": "string"
},

View File

@ -78,7 +78,10 @@ export const RoleMenuGroupsSection = forwardRef<HTMLDivElement, RoleMenuGroupsSe
value={groupOption.value}
isSelected={groupSelected(groupOption.value) || groupPartiallySelected(groupOption.value)}
partiallySelected={groupPartiallySelected(groupOption.value)}
disabled={groupOption.options?.every(isNotDelegatable)}
disabled={groupOption.options?.every(
(option) =>
isNotDelegatable(option) || selectedOptions.find((opt) => opt.uid === option.uid && opt.mapped)
)}
onChange={onGroupChange}
onOpenSubMenu={onOpenSubMenu}
onCloseSubMenu={onCloseSubMenu}
@ -102,6 +105,7 @@ export const RoleMenuGroupsSection = forwardRef<HTMLDivElement, RoleMenuGroupsSe
key={option.uid}
isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))}
disabled={isNotDelegatable(option)}
mapped={!!(option.uid && selectedOptions.find((opt) => opt.uid === option.uid && opt.mapped))}
onChange={onRoleChange}
hideDescription
/>

View File

@ -13,14 +13,23 @@ interface RoleMenuOptionProps {
isSelected?: boolean;
isFocused?: boolean;
disabled?: boolean;
mapped?: boolean;
hideDescription?: boolean;
}
export const RoleMenuOption = forwardRef<HTMLDivElement, React.PropsWithChildren<RoleMenuOptionProps>>(
({ data, isFocused, isSelected, disabled, onChange, hideDescription }, ref) => {
({ data, isFocused, isSelected, disabled, mapped, onChange, hideDescription }, ref) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles);
disabled = disabled || mapped;
let disabledMessage = '';
if (disabled) {
disabledMessage = 'You do not have permissions to assign this role.';
if (mapped) {
disabledMessage = 'Role assignment cannot be removed because the role is mapped through group sync.';
}
}
const wrapperClassName = cx(
styles.option,
@ -51,6 +60,11 @@ export const RoleMenuOption = forwardRef<HTMLDivElement, React.PropsWithChildren
<span>{data.displayName || data.name}</span>
{!hideDescription && data.description && <div className={styles.optionDescription}>{data.description}</div>}
</div>
{disabledMessage && (
<Tooltip content={disabledMessage}>
<Icon name="lock" />
</Tooltip>
)}
{data.description && (
<Tooltip content={data.description}>
<Icon name="info-circle" className={customStyles.menuOptionInfoSign} />

View File

@ -157,8 +157,15 @@ export const RolePickerMenu = ({
return selectedGroupOptions.length > 0 && selectedGroupOptions.length < groupOptions!.options.length;
};
const changeableGroupRolesSelected = (groupType: GroupType, group: string) => {
const selectedGroupOptions = getSelectedGroupOptions(group);
const changeableGroupOptions = selectedGroupOptions.filter((role) => role.delegatable && !role.mapped);
const groupOptions = rolesCollection[groupType]?.optionGroup.find((g) => g.value === group);
return changeableGroupOptions.length > 0 && changeableGroupOptions.length < groupOptions!.options.length;
};
const onChange = (option: Role) => {
if (selectedOptions.find((role) => role.uid === option.uid)) {
if (selectedOptions.find((role) => role.uid === option.uid && !role.mapped)) {
setSelectedOptions(selectedOptions.filter((role) => role.uid !== option.uid));
} else {
setSelectedOptions([...selectedOptions, option]);
@ -174,12 +181,21 @@ export const RolePickerMenu = ({
return;
}
if (groupSelected(groupType, value) || groupPartiallySelected(groupType, value)) {
setSelectedOptions(selectedOptions.filter((role) => !group.options.find((option) => role.uid === option.uid)));
} else {
const groupOptions = group.options.filter((role) => role.delegatable);
if (groupSelected(groupType, value) || changeableGroupRolesSelected(groupType, value)) {
const mappedGroupOptions = selectedOptions.filter((option) =>
group.options.find((role) => role.uid === option.uid && option.mapped)
);
const restOptions = selectedOptions.filter((role) => !group.options.find((option) => role.uid === option.uid));
setSelectedOptions([...restOptions, ...groupOptions]);
setSelectedOptions([...restOptions, ...mappedGroupOptions]);
} else {
const mappedGroupOptions = selectedOptions.filter((option) =>
group.options.find((role) => role.uid === option.uid && role.delegatable)
);
const groupOptions = group.options.filter(
(role) => role.delegatable && !selectedOptions.find((option) => role.uid === option.uid && option.mapped)
);
const restOptions = selectedOptions.filter((role) => !group.options.find((option) => role.uid === option.uid));
setSelectedOptions([...restOptions, ...groupOptions, ...mappedGroupOptions]);
}
};
@ -188,13 +204,17 @@ export const RolePickerMenu = ({
};
const onClearInternal = async () => {
setSelectedOptions([]);
const mappedRoles = selectedOptions.filter((role) => role.mapped);
const nonDelegatableRoles = options.filter((role) =>
selectedOptions.find((option) => role.uid === option.uid && !role.delegatable)
);
setSelectedOptions([...mappedRoles, ...nonDelegatableRoles]);
};
const onClearSubMenu = (group: string) => {
const options = selectedOptions.filter((role) => {
const roleGroup = getRoleGroup(role);
return roleGroup !== group;
return roleGroup !== group || role.mapped;
});
setSelectedOptions(options);
};

View File

@ -57,6 +57,7 @@ export const RolePickerSubMenu = ({
disabled={
!!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid)) || isNotDelegatable(option)
}
mapped={!!(option.uid && selectedOptions.find((opt) => opt.uid === option.uid && opt.mapped))}
onChange={onSelect}
hideDescription
/>

View File

@ -16,9 +16,9 @@ export const fetchRoleOptions = async (orgId?: number): Promise<Role[]> => {
};
export const fetchUserRoles = async (userId: number, orgId?: number): Promise<Role[]> => {
let userRolesUrl = `/api/access-control/users/${userId}/roles`;
let userRolesUrl = `/api/access-control/users/${userId}/roles?includeMapped=true`;
if (orgId) {
userRolesUrl += `?targetOrgId=${orgId}`;
userRolesUrl += `&targetOrgId=${orgId}`;
}
try {
const roles = await getBackendSrv().get(userRolesUrl);
@ -39,7 +39,8 @@ export const updateUserRoles = (roles: Role[], userId: number, orgId?: number) =
if (orgId) {
userRolesUrl += `?targetOrgId=${orgId}`;
}
const roleUids = roles.flatMap((x) => x.uid);
const filteredRoles = roles.filter((role) => !role.mapped);
const roleUids = filteredRoles.flatMap((x) => x.uid);
return getBackendSrv().put(userRolesUrl, {
orgId,
roleUids,

View File

@ -19,7 +19,10 @@ export const getOrgUsers = async (orgId: UrlQueryValue, page: number) => {
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 });
const roles = await getBackendSrv().post(`/api/access-control/users/roles/search?includeMapped=true`, {
userIds,
orgId,
});
users.forEach((u) => {
u.roles = roles ? roles[u.userId] || [] : [];
});

View File

@ -36,7 +36,10 @@ export function loadUsers(): ThunkResult<void> {
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 });
const roles = await getBackendSrv().post(`/api/access-control/users/roles/search?includeMapped=true`, {
userIds,
orgId,
});
users.orgUsers.forEach((u: OrgUser) => {
u.roles = roles ? roles[u.userId] || [] : [];
});

View File

@ -167,6 +167,7 @@ export interface Role {
group: string;
global: boolean;
delegatable?: boolean;
mapped?: boolean;
version: number;
created: string;
updated: string;

View File

@ -9751,6 +9751,9 @@
"hidden": {
"type": "boolean"
},
"mapped": {
"type": "boolean"
},
"name": {
"type": "string"
},