Add new role picker to admin/users page (#40631)

* Very simple role picker

* Style radio button

* Separate component for the built-in roles selector

* Custom component instead of Select

* refactor

* Custom input for role picker

* Refactor

* Able to select built-in role

* Add checkboxes for role selector

* Filter out fixed and internal roles

* Add action buttons

* Implement role search

* Fix selecting roles

* Pass custom roles to update

* User role picker

* Some UX work on role picker

* Clear search query on close

* Blur input when closed

* Add roles counter

* Refactor

* Add disabled state for picker

* Adjust disabled styles

* Replace ChangeOrgButton with role picker on admin/users page

* Remove unused code

* Apply suggestions from code review

Suggestions from the @Clarity-89

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Refactor: fix some errors after applying review suggestions

* Show fixed roles in the picker

* Show applied fixed roles

* Fix role counter

* Fix checkbox selection

* Use specific Role type for menu options

* Fix menu when roles list is empty

* Fix radio button name

* Make fixed roles from built-in role disabled

* Make whole menu scrollable

* Add BuiltInRole type

* Simplify appliedRoles

* Simplify options and props

* Do not select and disable inherited fixed roles

* Enable selecting fixed role

* Add description tooltip

* Fix role param name

* Export common input styles from grafana/ui

* Add ValueContainer

* Use value container

* Refactor appliedRoles logic

* Optimise role rendering

* Display selected roles

* Fix tooltip position

* Use OrgRole type

* Optimise role rendering

* Use radio button from grafana UI

* Submenu WIP

* Role picker submenu WIP

* Hide role description

* Tweak styles

* Implement submenu selection

* Disable role selection if it's inherited

* Show new role picker only in Enterprise

* Fix types

* Use orgid when fetching/updating roles

* Use orgId in all access control requests

* Styles for partially checked checkbox

* Tweak group option styles

* Role picker menu: refactor

* Reorganize roles in menu

* Fix input behaviour

* Hide groups on search

* Remove unused components

* Refactor

* Fix group selection

* Remove icons from role tags

* Add spacing for menu sections

* Rename clear all to clear in submenu

* Tweak menu width

* Show changes in the input when selecting roles

* Exclude inherited roles from selection

* Increase menu height

* Change built-in role in input on select

* Include inherited roles to the built-in role selection

* refcator import

* Refactor role picker to be able to pass roles and builtin roles getters

* Add role picker to the org users page

* Show inherited builtin roles in the popup

* Filter out managed roles

* Fix displaying initial builtin roles

* Show tooltip only for non-builtin roles

* Set min width for focused input

* Do not disable inherited roles (by design)

* Only show picker if access control enabled

* Fix tests

* Only close menu on click outside or on indicator click

* Open submenu on hover

* Don't search on empty query

* Do not open/close menu on click

* Refactor

* Apply suggestions from code review

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Fix formatting

* Apply suggestions

* Add more space for close menu sign

* Tune tooltip styles

* Move tooltip to the right side of option

* Use info sign instead of question

Co-authored-by: Clarity-89 <homes89@ukr.net>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Alexander Zobnin 2021-11-17 18:22:40 +03:00 committed by GitHub
parent 78888158ca
commit 757463bd27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1184 additions and 35 deletions

View File

@ -25,7 +25,7 @@ export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'siz
interface StyleDeps {
theme: GrafanaTheme2;
invalid: boolean;
invalid?: boolean;
width?: number;
}

View File

@ -176,6 +176,7 @@ export { MultiSelectValueEditor } from './OptionsUI/multiSelect';
// Next-gen forms
export { Form } from './Forms/Form';
export { sharedInputStyle } from './Forms/commonStyles';
export { InputControl } from './InputControl';
export { Button, LinkButton, ButtonVariant, ToolbarButton, ButtonGroup, ToolbarButtonRow } from './Button';
export { ValuePicker } from './ValuePicker/ValuePicker';
@ -194,15 +195,18 @@ export { InlineLabel } from './Forms/InlineLabel';
export { InlineFieldRow } from './Forms/InlineFieldRow';
export { FieldArray } from './Forms/FieldArray';
// Select
export { default as resetSelectStyles } from './Select/resetSelectStyles';
export { selectOptionInTest } from './Select/test-utils';
export * from './Select/Select';
export { DropdownIndicator } from './Select/DropdownIndicator';
export { getSelectStyles } from './Select/getSelectStyles';
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { Badge, BadgeColor, BadgeProps } from './Badge/Badge';
export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup';
export { Input } from './Input/Input';
export { Input, getInputStyles } from './Input/Input';
export { FilterInput } from './FilterInput/FilterInput';
export { FormInputSize } from './Forms/types';
@ -217,7 +221,6 @@ export { RelativeTimeRangePicker } from './DateTimePickers/RelativeTimeRangePick
export { Card, Props as CardProps, getCardStyles } from './Card/Card';
export { CardContainer, CardContainerProps } from './Card/CardContainer';
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';
export { ButtonSelect } from './Dropdown/ButtonSelect';
export { PluginSignatureBadge, PluginSignatureBadgeProps } from './PluginSignatureBadge/PluginSignatureBadge';

View File

@ -0,0 +1,141 @@
import React, { FormEvent, useCallback, useEffect, useState } from 'react';
import { ClickOutsideWrapper } from '@grafana/ui';
import { RolePickerMenu } from './RolePickerMenu';
import { RolePickerInput } from './RolePickerInput';
import { Role, OrgRole } from 'app/types';
export interface Props {
builtInRole: OrgRole;
getRoles: () => Promise<Role[]>;
getRoleOptions: () => Promise<Role[]>;
getBuiltinRoles: () => Promise<Record<string, Role[]>>;
onRolesChange: (newRoles: string[]) => void;
onBuiltinRoleChange: (newRole: OrgRole) => void;
disabled?: boolean;
}
export const RolePicker = ({
builtInRole,
getRoles,
getRoleOptions,
getBuiltinRoles,
onRolesChange,
onBuiltinRoleChange,
disabled,
}: Props): JSX.Element | null => {
const [isOpen, setOpen] = useState(false);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [appliedRoles, setAppliedRoles] = useState<Role[]>([]);
const [selectedRoles, setSelectedRoles] = useState<Role[]>([]);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole>(builtInRole);
const [builtInRoles, setBuiltinRoles] = useState<Record<string, Role[]>>({});
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchOptions() {
try {
let options = await getRoleOptions();
setRoleOptions(options.filter((option) => !option.name?.startsWith('managed:')));
const builtInRoles = await getBuiltinRoles();
setBuiltinRoles(builtInRoles);
const userRoles = await getRoles();
setAppliedRoles(userRoles);
setSelectedRoles(userRoles);
} catch (e) {
// TODO handle error
console.error('Error loading options');
} finally {
setIsLoading(false);
}
}
fetchOptions();
}, [getRoles, getRoleOptions, getBuiltinRoles, builtInRole]);
const onOpen = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!disabled) {
event.preventDefault();
event.stopPropagation();
setOpen(true);
}
},
[setOpen, disabled]
);
const onClose = useCallback(() => {
setOpen(false);
setQuery('');
setSelectedRoles(appliedRoles);
setSelectedBuiltInRole(builtInRole);
}, [appliedRoles, builtInRole]);
// Only call onClose if menu is open. Prevent unnecessary calls for multiple pickers on the page.
const onClickOutside = () => isOpen && onClose();
const onInputChange = (query?: string) => {
if (query) {
setQuery(query);
} else {
setQuery('');
}
};
const onSelect = (roles: Role[]) => {
setSelectedRoles(roles);
};
const onBuiltInRoleSelect = (role: OrgRole) => {
setSelectedBuiltInRole(role);
};
const onUpdate = (newBuiltInRole: OrgRole, newRoles: string[]) => {
onBuiltinRoleChange(newBuiltInRole);
onRolesChange(newRoles);
setOpen(false);
setQuery('');
};
const getOptions = () => {
if (query && query.trim() !== '') {
return roleOptions.filter((option) => option.name?.toLowerCase().includes(query.toLowerCase()));
}
return roleOptions;
};
if (isLoading) {
return null;
}
return (
<div data-testid="role-picker" style={{ position: 'relative' }}>
<ClickOutsideWrapper onClick={onClickOutside}>
<RolePickerInput
builtInRole={selectedBuiltInRole}
appliedRoles={selectedRoles}
query={query}
onQueryChange={onInputChange}
onOpen={onOpen}
onClose={onClose}
isFocused={isOpen}
disabled={disabled}
/>
{isOpen && (
<RolePickerMenu
options={getOptions()}
builtInRole={selectedBuiltInRole}
builtInRoles={builtInRoles}
appliedRoles={appliedRoles}
onBuiltInRoleSelect={onBuiltInRoleSelect}
onSelect={onSelect}
onUpdate={onUpdate}
showGroups={query.length === 0 || query.trim() === ''}
/>
)}
</ClickOutsideWrapper>
</div>
);
};

View File

@ -0,0 +1,156 @@
import React, { FormEvent, HTMLProps, MutableRefObject, useEffect, useRef } from 'react';
import { css, cx } from '@emotion/css';
import { useStyles2, getInputStyles, sharedInputStyle, styleMixins, Tooltip, Icon } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { ValueContainer } from './ValueContainer';
import { Role } from '../../../types';
const stopPropagation = (event: React.MouseEvent<HTMLDivElement>) => event.stopPropagation();
interface InputProps extends HTMLProps<HTMLInputElement> {
appliedRoles: Role[];
builtInRole: string;
query: string;
isFocused?: boolean;
disabled?: boolean;
onQueryChange: (query?: string) => void;
onOpen: (event: FormEvent<HTMLElement>) => void;
onClose: () => void;
}
export const RolePickerInput = ({
appliedRoles,
builtInRole,
disabled,
isFocused,
query,
onOpen,
onClose,
onQueryChange,
...rest
}: InputProps): JSX.Element => {
const styles = useStyles2((theme) => getRolePickerInputStyles(theme, false, !!isFocused, !!disabled, false));
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isFocused) {
(inputRef as MutableRefObject<HTMLInputElement>).current?.focus();
}
});
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target?.value;
onQueryChange(query);
};
const numberOfRoles = appliedRoles.length;
return !isFocused ? (
<div className={styles.selectedRoles} onMouseDown={onOpen}>
<ValueContainer>{builtInRole}</ValueContainer>
{!!numberOfRoles && (
<Tooltip
content={
<div className={styles.tooltip}>
{appliedRoles?.map((role) => (
<p key={role.uid}>{role.displayName}</p>
))}
</div>
}
>
<div>
<ValueContainer>{`+${numberOfRoles} role${numberOfRoles > 1 ? 's' : ''}`}</ValueContainer>
</div>
</Tooltip>
)}
</div>
) : (
<div className={styles.wrapper}>
<ValueContainer>{builtInRole}</ValueContainer>
{appliedRoles.map((role) => (
<ValueContainer key={role.uid}>{role.displayName}</ValueContainer>
))}
{!disabled && (
<input
{...rest}
className={styles.input}
ref={inputRef}
onMouseDown={stopPropagation}
onChange={onInputChange}
data-testid="role-picker-input"
placeholder={isFocused ? 'Select role' : ''}
value={query}
/>
)}
<div className={styles.suffix}>
<Icon name="angle-up" className={styles.dropdownIndicator} onMouseDown={onClose} />
</div>
</div>
);
};
RolePickerInput.displayName = 'RolePickerInput';
const getRolePickerInputStyles = (
theme: GrafanaTheme2,
invalid: boolean,
focused: boolean,
disabled: boolean,
withPrefix: boolean
) => {
const styles = getInputStyles({ theme, invalid });
return {
wrapper: cx(
styles.wrapper,
sharedInputStyle(theme, invalid),
focused &&
css`
${styleMixins.focusCss(theme.v1)}
`,
disabled && styles.inputDisabled,
css`
min-width: 260px;
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;
`,
withPrefix &&
css`
padding-left: 0;
`
),
input: cx(
sharedInputStyle(theme, invalid),
css`
max-width: 120px;
border: none;
cursor: ${focused ? 'default' : 'pointer'};
`
),
suffix: styles.suffix,
dropdownIndicator: css`
cursor: pointer;
`,
selectedRoles: css`
display: flex;
align-items: center;
cursor: ${disabled ? 'not-allowed' : 'pointer'};
`,
tooltip: css`
p {
margin-bottom: ${theme.spacing(0.5)};
}
`,
};
};

View File

@ -0,0 +1,610 @@
import React, { FormEvent, useEffect, useRef, useState } from 'react';
import { css, cx } from '@emotion/css';
import {
Button,
Checkbox,
CustomScrollbar,
HorizontalGroup,
Icon,
Portal,
RadioButtonGroup,
Tooltip,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles';
import { OrgRole, Role } from 'app/types';
type BuiltInRoles = Record<string, Role[]>;
const BuiltinRoles = Object.values(OrgRole);
const BuiltinRoleOption: Array<SelectableValue<OrgRole>> = BuiltinRoles.map((r) => ({
label: r,
value: r,
}));
const fixedRoleGroupNames: Record<string, string> = {
ldap: 'LDAP',
current: 'Current org',
};
interface RolePickerMenuProps {
builtInRole: OrgRole;
builtInRoles: BuiltInRoles;
options: Role[];
appliedRoles: Role[];
showGroups?: boolean;
onSelect: (roles: Role[]) => void;
onBuiltInRoleSelect?: (role: OrgRole) => void;
onUpdate: (newBuiltInRole: OrgRole, newRoles: string[]) => void;
onClear?: () => void;
}
export const RolePickerMenu = ({
builtInRole,
builtInRoles,
options,
appliedRoles,
showGroups,
onSelect,
onBuiltInRoleSelect,
onUpdate,
onClear,
}: RolePickerMenuProps): JSX.Element => {
const [selectedOptions, setSelectedOptions] = useState<Role[]>(appliedRoles);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole>(builtInRole);
const [showSubMenu, setShowSubMenu] = useState(false);
const [openedMenuGroup, setOpenedMenuGroup] = useState('');
const [subMenuOptions, setSubMenuOptions] = useState<Role[]>([]);
const subMenuNode = useRef<HTMLDivElement | null>(null);
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles);
// Call onSelect() on every selectedOptions change
useEffect(() => {
onSelect(selectedOptions);
}, [selectedOptions, onSelect]);
useEffect(() => {
if (onBuiltInRoleSelect) {
onBuiltInRoleSelect(selectedBuiltInRole);
}
}, [selectedBuiltInRole, onBuiltInRoleSelect]);
const customRoles = options.filter(filterCustomRoles).sort(sortRolesByName);
const fixedRoles = options.filter(filterFixedRoles).sort(sortRolesByName);
const optionGroups = getOptionGroups(options);
const getSelectedGroupOptions = (group: string) => {
const selectedGroupOptions = [];
for (const role of selectedOptions) {
if (getRoleGroup(role) === group) {
selectedGroupOptions.push(role);
}
}
return selectedGroupOptions;
};
const groupSelected = (group: string) => {
const selectedGroupOptions = getSelectedGroupOptions(group);
const groupOptions = optionGroups.find((g) => g.value === group);
return selectedGroupOptions.length > 0 && selectedGroupOptions.length >= groupOptions!.options.length;
};
const groupPartiallySelected = (group: string) => {
const selectedGroupOptions = getSelectedGroupOptions(group);
const groupOptions = optionGroups.find((g) => g.value === group);
return selectedGroupOptions.length > 0 && selectedGroupOptions.length < groupOptions!.options.length;
};
const onChange = (option: Role) => {
if (selectedOptions.find((role) => role.uid === option.uid)) {
setSelectedOptions(selectedOptions.filter((role) => role.uid !== option.uid));
} else {
setSelectedOptions([...selectedOptions, option]);
}
};
const onGroupChange = (value: string) => {
const group = optionGroups.find((g) => {
return g.value === value;
});
if (groupSelected(value)) {
if (group) {
setSelectedOptions(selectedOptions.filter((role) => !group.options.find((option) => role.uid === option.uid)));
}
} else {
if (group) {
const restOptions = selectedOptions.filter((role) => !group.options.find((option) => role.uid === option.uid));
setSelectedOptions([...restOptions, ...group.options]);
}
}
};
const onOpenSubMenu = (value: string) => {
setOpenedMenuGroup(value);
setShowSubMenu(true);
const group = optionGroups.find((g) => {
return g.value === value;
});
if (group) {
setSubMenuOptions(group.options);
}
};
const onCloseSubMenu = (value: string) => {
setShowSubMenu(false);
setOpenedMenuGroup('');
setSubMenuOptions([]);
};
const onSelectedBuiltinRoleChange = (newRole: OrgRole) => {
setSelectedBuiltInRole(newRole);
};
const onClearInternal = async () => {
if (onClear) {
onClear();
}
setSelectedOptions([]);
};
const onClearSubMenu = () => {
const options = selectedOptions.filter((role) => {
const groupName = getRoleGroup(role);
return groupName !== openedMenuGroup;
});
setSelectedOptions(options);
};
const onUpdateInternal = () => {
const selectedCustomRoles: string[] = [];
for (const key in selectedOptions) {
const roleUID = selectedOptions[key]?.uid;
selectedCustomRoles.push(roleUID);
}
onUpdate(selectedBuiltInRole, selectedCustomRoles);
};
return (
<div className={cx(styles.menu, customStyles.menuWrapper)}>
<div className={customStyles.menu} aria-label="Role picker menu">
<CustomScrollbar autoHide={false} autoHeightMax="300px" hideHorizontalTrack hideVerticalTrack>
<div className={customStyles.menuSection}>
<div className={customStyles.groupHeader}>Built-in roles</div>
<RadioButtonGroup
className={customStyles.builtInRoleSelector}
options={BuiltinRoleOption}
value={selectedBuiltInRole}
onChange={onSelectedBuiltinRoleChange}
fullWidth={true}
/>
</div>
{!!fixedRoles.length &&
(showGroups && !!optionGroups.length ? (
<div className={customStyles.menuSection}>
<div className={customStyles.groupHeader}>Fixed roles</div>
<div className={styles.optionBody}>
{optionGroups.map((option, i) => (
<RoleMenuGroupOption
data={option}
key={i}
isSelected={groupSelected(option.value) || groupPartiallySelected(option.value)}
partiallySelected={groupPartiallySelected(option.value)}
onChange={onGroupChange}
onOpenSubMenu={onOpenSubMenu}
onCloseSubMenu={onCloseSubMenu}
root={subMenuNode?.current!}
isFocused={showSubMenu && openedMenuGroup === option.value}
>
{showSubMenu && openedMenuGroup === option.value && (
<RolePickerSubMenu
options={subMenuOptions}
selectedOptions={selectedOptions}
onSelect={onChange}
onClear={onClearSubMenu}
/>
)}
</RoleMenuGroupOption>
))}
</div>
</div>
) : (
<div className={customStyles.menuSection}>
<div className={customStyles.groupHeader}>Fixed roles</div>
<div className={styles.optionBody}>
{fixedRoles.map((option, i) => (
<RoleMenuOption
data={option}
key={i}
isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))}
onChange={onChange}
hideDescription
/>
))}
</div>
</div>
))}
{!!customRoles.length && (
<div>
<div className={customStyles.groupHeader}>Custom roles</div>
<div className={styles.optionBody}>
{customRoles.map((option, i) => (
<RoleMenuOption
data={option}
key={i}
isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))}
onChange={onChange}
hideDescription
/>
))}
</div>
</div>
)}
</CustomScrollbar>
<div className={customStyles.menuButtonRow}>
<HorizontalGroup justify="flex-end">
<Button size="sm" fill="text" onClick={onClearInternal}>
Clear all
</Button>
<Button size="sm" onClick={onUpdateInternal}>
Update
</Button>
</HorizontalGroup>
</div>
</div>
<div ref={subMenuNode}></div>
</div>
);
};
const filterCustomRoles = (option: Role) => !option.name?.startsWith('fixed:');
const filterFixedRoles = (option: Role) => option.name?.startsWith('fixed:');
const getOptionGroups = (options: Role[]) => {
const groupsMap: { [key: string]: Role[] } = {};
options.forEach((role) => {
if (role.name.startsWith('fixed:')) {
const groupName = getRoleGroup(role);
if (groupsMap[groupName]) {
groupsMap[groupName].push(role);
} else {
groupsMap[groupName] = [role];
}
}
});
const groups = [];
for (const groupName of Object.keys(groupsMap)) {
const groupOptions = groupsMap[groupName].sort(sortRolesByName);
groups.push({
name: fixedRoleGroupNames[groupName] || capitalize(groupName),
value: groupName,
options: groupOptions,
});
}
return groups.sort((a, b) => a.name.localeCompare(b.name));
};
interface RolePickerSubMenuProps {
options: Role[];
selectedOptions: Role[];
disabledOptions?: Role[];
onSelect: (option: Role) => void;
onClear?: () => void;
}
export const RolePickerSubMenu = ({
options,
selectedOptions,
disabledOptions,
onSelect,
onClear,
}: RolePickerSubMenuProps): JSX.Element => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles);
const onClearInternal = async () => {
if (onClear) {
onClear();
}
};
return (
<div className={customStyles.subMenu} aria-label="Role picker submenu">
<CustomScrollbar autoHide={false} autoHeightMax="300px" hideHorizontalTrack>
<div className={styles.optionBody}>
{options.map((option, i) => (
<RoleMenuOption
data={option}
key={i}
isSelected={
!!(
option.uid &&
(!!selectedOptions.find((opt) => opt.uid === option.uid) ||
disabledOptions?.find((opt) => opt.uid === option.uid))
)
}
disabled={!!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid))}
onChange={onSelect}
hideDescription
/>
))}
</div>
</CustomScrollbar>
<div className={customStyles.subMenuButtonRow}>
<HorizontalGroup justify="flex-end">
<Button size="sm" fill="text" onClick={onClearInternal}>
Clear
</Button>
</HorizontalGroup>
</div>
</div>
);
};
interface RoleMenuOptionProps<T> {
data: Role;
onChange: (value: Role) => void;
isSelected?: boolean;
isFocused?: boolean;
disabled?: boolean;
hideDescription?: boolean;
}
export const RoleMenuOption = React.forwardRef<HTMLDivElement, React.PropsWithChildren<RoleMenuOptionProps<any>>>(
({ data, isFocused, isSelected, disabled, onChange, hideDescription }, ref) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles);
const wrapperClassName = cx(
styles.option,
isFocused && styles.optionFocused,
disabled && customStyles.menuOptionDisabled
);
const onChangeInternal = (event: FormEvent<HTMLElement>) => {
if (disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
onChange(data);
};
return (
<div ref={ref} className={wrapperClassName} aria-label="Role picker option" onClick={onChangeInternal}>
<Checkbox
value={isSelected}
className={customStyles.menuOptionCheckbox}
onChange={onChangeInternal}
disabled={disabled}
/>
<div className={cx(styles.optionBody, customStyles.menuOptionBody)}>
<span>{data.displayName || data.name}</span>
{!hideDescription && data.description && <div className={styles.optionDescription}>{data.description}</div>}
</div>
{data.description && (
<Tooltip content={data.description}>
<Icon name="info-circle" className={customStyles.menuOptionInfoSign} />
</Tooltip>
)}
</div>
);
}
);
RoleMenuOption.displayName = 'RoleMenuOption';
interface RoleMenuGroupsOptionProps {
data: SelectableValue<string>;
onChange: (value: string) => void;
onClick?: (value: string) => void;
onOpenSubMenu?: (value: string) => void;
onCloseSubMenu?: (value: string) => void;
isSelected?: boolean;
partiallySelected?: boolean;
isFocused?: boolean;
disabled?: boolean;
children?: React.ReactNode;
root?: HTMLElement;
}
export const RoleMenuGroupOption = React.forwardRef<HTMLDivElement, RoleMenuGroupsOptionProps>(
(
{
data,
isFocused,
isSelected,
partiallySelected,
disabled,
onChange,
onClick,
onOpenSubMenu,
onCloseSubMenu,
children,
root,
},
ref
) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles);
const wrapperClassName = cx(
styles.option,
isFocused && styles.optionFocused,
disabled && customStyles.menuOptionDisabled
);
const onChangeInternal = (event: FormEvent<HTMLElement>) => {
if (disabled) {
return;
}
if (data.value) {
onChange(data.value);
}
};
const onClickInternal = (event: FormEvent<HTMLElement>) => {
if (onClick) {
onClick(data.value!);
}
};
const onMouseEnter = () => {
if (onOpenSubMenu) {
onOpenSubMenu(data.value!);
}
};
const onMouseLeave = () => {
if (onCloseSubMenu) {
onCloseSubMenu(data.value!);
}
};
return (
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<div ref={ref} className={wrapperClassName} aria-label="Role picker option" onClick={onClickInternal}>
<Checkbox
value={isSelected}
className={cx(customStyles.menuOptionCheckbox, {
[customStyles.checkboxPartiallyChecked]: partiallySelected,
})}
onChange={onChangeInternal}
disabled={disabled}
/>
<div className={cx(styles.optionBody, customStyles.menuOptionBody)}>
<span>{data.displayName || data.name}</span>
<span className={customStyles.menuOptionExpand}></span>
</div>
{root && children && (
<Portal className={customStyles.subMenuPortal} root={root}>
{children}
</Portal>
)}
</div>
</div>
);
}
);
RoleMenuGroupOption.displayName = 'RoleMenuGroupOption';
const getRoleGroup = (role: Role) => {
const parts = role.name.split(':');
return parts.length > 1 ? parts[1] : '';
};
const capitalize = (s: string): string => {
return s.slice(0, 1).toUpperCase() + s.slice(1);
};
const sortRolesByName = (a: Role, b: Role) => a.name.localeCompare(b.name);
export const getStyles = (theme: GrafanaTheme2) => {
return {
menuWrapper: css`
display: flex;
max-height: 650px;
position: absolute;
z-index: ${theme.zIndex.dropdown};
overflow: hidden;
min-width: auto;
`,
menu: css`
min-width: 260px;
& > div {
padding-top: ${theme.spacing(1)};
}
`,
subMenu: css`
height: 100%;
min-width: 260px;
display: flex;
flex-direction: column;
border-left-style: solid;
border-left-width: 1px;
border-left-color: ${theme.components.input.borderColor};
& > div {
padding-top: ${theme.spacing(1)};
}
`,
groupHeader: css`
padding: ${theme.spacing(0, 4)};
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.borderRadius(1)};
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, 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};
`,
builtInRoleSelector: css`
margin: ${theme.spacing(1, 1.25, 1, 1)};
`,
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);
}
}
}
`,
};
};

View File

@ -0,0 +1,79 @@
import React, { FC } from 'react';
import { getBackendSrv } from '@grafana/runtime';
import { Role, OrgRole } from 'app/types';
import { RolePicker } from './RolePicker';
export interface Props {
builtInRole: OrgRole;
userId: number;
orgId?: number;
onBuiltinRoleChange: (newRole: OrgRole) => void;
getRoleOptions?: () => Promise<Role[]>;
getBuiltinRoles?: () => Promise<{ [key: string]: Role[] }>;
disabled?: boolean;
}
export const UserRolePicker: FC<Props> = ({
builtInRole,
userId,
orgId,
onBuiltinRoleChange,
getRoleOptions,
getBuiltinRoles,
disabled,
}) => {
return (
<RolePicker
builtInRole={builtInRole}
onRolesChange={(roles) => updateUserRoles(roles, userId, orgId)}
onBuiltinRoleChange={onBuiltinRoleChange}
getRoleOptions={() => (getRoleOptions ? getRoleOptions() : fetchRoleOptions(orgId))}
getRoles={() => fetchUserRoles(userId, orgId)}
getBuiltinRoles={() => (getBuiltinRoles ? getBuiltinRoles() : fetchBuiltinRoles(orgId))}
disabled={disabled}
/>
);
};
export const fetchRoleOptions = async (orgId?: number, query?: string): Promise<Role[]> => {
let rolesUrl = '/api/access-control/roles';
if (orgId) {
rolesUrl += `?targetOrgId=${orgId}`;
}
const roles = await getBackendSrv().get(rolesUrl);
if (!roles || !roles.length) {
return [];
}
return roles;
};
export const fetchBuiltinRoles = (orgId?: number): Promise<{ [key: string]: Role[] }> => {
let builtinRolesUrl = '/api/access-control/builtin-roles';
if (orgId) {
builtinRolesUrl += `?targetOrgId=${orgId}`;
}
return getBackendSrv().get(builtinRolesUrl);
};
export const fetchUserRoles = async (userId: number, orgId?: number): Promise<Role[]> => {
let userRolesUrl = `/api/access-control/users/${userId}/roles`;
if (orgId) {
userRolesUrl += `?targetOrgId=${orgId}`;
}
const roles = await getBackendSrv().get(userRolesUrl);
if (!roles || !roles.length) {
return [];
}
return roles;
};
export const updateUserRoles = (roleUids: string[], userId: number, orgId?: number) => {
let userRolesUrl = `/api/access-control/users/${userId}/roles`;
if (orgId) {
userRolesUrl += `?targetOrgId=${orgId}`;
}
return getBackendSrv().put(userRolesUrl, {
orgId,
roleUids,
});
};

View File

@ -0,0 +1,37 @@
import React, { ReactNode } from 'react';
import { css, cx } from '@emotion/css';
import { getInputStyles, Icon, IconName, useStyles2, getSelectStyles } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
export interface Props {
children: ReactNode;
iconName?: IconName;
}
export const ValueContainer = ({ children, iconName }: Props) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
{iconName && <Icon name={iconName} size="xs" />}
{children}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
const { prefix } = getInputStyles({ theme });
const { multiValueContainer } = getSelectStyles(theme);
return {
container: cx(
prefix,
multiValueContainer,
css`
position: relative;
padding: ${theme.spacing(0.5, 1, 0.5, 1)};
svg {
margin-right: ${theme.spacing(0.5)};
}
`
),
};
};

View File

@ -77,6 +77,10 @@ export class ContextSrv {
return this.user.orgRole === role;
}
accessControlEnabled(): boolean {
return config.licenseInfo.hasLicense && config.featureToggles['accesscontrol'];
}
// Checks whether user has required permission
hasPermission(action: AccessControlAction | string): boolean {
// Fallback if access control disabled

View File

@ -128,6 +128,7 @@ export class UserAdminPage extends PureComponent<Props> {
{orgs && (
<UserOrgs
user={user}
orgs={orgs}
isExternalUser={user?.isExternal}
onOrgRemove={this.onOrgRemove}

View File

@ -11,16 +11,19 @@ import {
Themeable,
Tooltip,
useStyles2,
useTheme,
withTheme,
} from '@grafana/ui';
import { GrafanaTheme, GrafanaTheme2 } from '@grafana/data';
import { AccessControlAction, Organization, OrgRole, UserOrg } from 'app/types';
import { AccessControlAction, Organization, OrgRole, UserDTO, UserOrg } from 'app/types';
import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker';
import { OrgRolePicker } from './OrgRolePicker';
import { contextSrv } from 'app/core/core';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
interface Props {
orgs: UserOrg[];
user?: UserDTO;
isExternalUser?: boolean;
onOrgRemove: (orgId: number) => void;
@ -49,7 +52,7 @@ export class UserOrgs extends PureComponent<Props, State> {
};
render() {
const { orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props;
const { user, orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props;
const { showAddOrgModal } = this.state;
const addToOrgContainerClass = css`
margin-top: 0.8rem;
@ -66,6 +69,7 @@ export class UserOrgs extends PureComponent<Props, State> {
<OrgRow
key={`${org.orgId}-${index}`}
isExternalUser={isExternalUser}
user={user}
org={org}
onOrgRoleChange={onOrgRoleChange}
onOrgRemove={onOrgRemove}
@ -107,22 +111,25 @@ const getOrgRowStyles = stylesFactory((theme: GrafanaTheme) => {
tooltipItemLink: css`
color: ${theme.palette.blue95};
`,
rolePickerWrapper: css`
display: flex;
`,
rolePicker: css`
flex: auto;
margin-right: ${theme.spacing.sm};
`,
};
});
interface OrgRowProps extends Themeable {
user?: UserDTO;
org: UserOrg;
isExternalUser?: boolean;
onOrgRemove: (orgId: number) => void;
onOrgRoleChange: (orgId: number, newRole: OrgRole) => void;
}
interface OrgRowState {
currentRole: OrgRole;
isChangingRole: boolean;
}
class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
class UnThemedOrgRow extends PureComponent<OrgRowProps> {
state = {
currentRole: this.props.org.role,
isChangingRole: false,
@ -150,13 +157,18 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
this.setState({ isChangingRole: false });
};
onBuiltinRoleChange = (newRole: OrgRole) => {
this.props.onOrgRoleChange(this.props.org.orgId, newRole);
};
render() {
const { org, isExternalUser, theme } = this.props;
const { user, org, isExternalUser, theme } = this.props;
const { currentRole, isChangingRole } = this.state;
const styles = getOrgRowStyles(theme);
const labelClass = cx('width-16', styles.label);
const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate);
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove);
const rolePickerDisabled = isExternalUser || !canChangeRole;
const inputId = `${org.name}-input`;
return (
@ -164,25 +176,44 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
<td className={labelClass}>
<label htmlFor={inputId}>{org.name}</label>
</td>
{isChangingRole ? (
{contextSrv.accessControlEnabled() ? (
<td>
<OrgRolePicker inputId={inputId} value={currentRole} onChange={this.onOrgRoleChange} autoFocus />
<div className={styles.rolePickerWrapper}>
<div className={styles.rolePicker}>
<UserRolePicker
userId={user?.id || 0}
orgId={org.orgId}
builtInRole={org.role}
onBuiltinRoleChange={this.onBuiltinRoleChange}
disabled={rolePickerDisabled}
/>
</div>
{isExternalUser && <ExternalUserTooltip />}
</div>
</td>
) : (
<td className="width-25">{org.role}</td>
)}
<td colSpan={1}>
<div className="pull-right">
{canChangeRole && (
<ChangeOrgButton
isExternalUser={isExternalUser}
onChangeRoleClick={this.onChangeRoleClick}
onCancelClick={this.onCancelClick}
onOrgRoleSave={this.onOrgRoleSave}
/>
<>
{isChangingRole ? (
<td>
<OrgRolePicker inputId={inputId} value={currentRole} onChange={this.onOrgRoleChange} autoFocus />
</td>
) : (
<td className="width-25">{org.role}</td>
)}
</div>
</td>
<td colSpan={1}>
<div className="pull-right">
{canChangeRole && (
<ChangeOrgButton
isExternalUser={isExternalUser}
onChangeRoleClick={this.onChangeRoleClick}
onCancelClick={this.onCancelClick}
onOrgRoleSave={this.onOrgRoleSave}
/>
)}
</div>
</td>
</>
)}
<td colSpan={1}>
<div className="pull-right">
{canRemoveFromOrg && (
@ -219,7 +250,9 @@ const getAddToOrgModalStyles = stylesFactory(() => ({
interface AddToOrgModalProps {
isOpen: boolean;
onOrgAdd(orgId: number, role: string): void;
onDismiss?(): void;
}
@ -347,3 +380,41 @@ export function ChangeOrgButton({
</div>
);
}
const ExternalUserTooltip: React.FC = () => {
const theme = useTheme();
const styles = getTooltipStyles(theme);
return (
<div className={styles.disabledTooltip}>
<Tooltip
placement="right-end"
content={
<div>
This user&apos;s role is not editable because it is synchronized from your auth provider. Refer to the&nbsp;
<a
className={styles.tooltipItemLink}
href={'https://grafana.com/docs/grafana/latest/auth'}
rel="noreferrer noopener"
target="_blank"
>
Grafana authentication docs
</a>
&nbsp;for details.
</div>
}
>
<Icon name="question-circle" />
</Tooltip>
</div>
);
};
const getTooltipStyles = stylesFactory((theme: GrafanaTheme) => ({
disabledTooltip: css`
display: flex;
`,
tooltipItemLink: css`
color: ${theme.palette.blue95};
`,
}));

View File

@ -8,6 +8,7 @@ import { ConfirmModal } from '@grafana/ui';
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermission: () => true,
accessControlEnabled: () => false,
},
}));

View File

@ -1,9 +1,10 @@
import React, { FC, useState } from 'react';
import { AccessControlAction, OrgUser } from 'app/types';
import React, { FC, useEffect, useState } from 'react';
import { AccessControlAction, OrgUser, Role } from 'app/types';
import { OrgRolePicker } from '../admin/OrgRolePicker';
import { Button, ConfirmModal } from '@grafana/ui';
import { OrgRole } from '@grafana/data';
import { contextSrv } from 'app/core/core';
import { fetchBuiltinRoles, fetchRoleOptions, UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
export interface Props {
users: OrgUser[];
@ -15,8 +16,31 @@ const UsersTable: FC<Props> = (props) => {
const { users, onRoleChange, onRemoveUser } = props;
const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate);
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove);
const rolePickerDisabled = !canUpdateRole;
const [showRemoveModal, setShowRemoveModal] = useState<string | boolean>(false);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({});
useEffect(() => {
async function fetchOptions() {
try {
let options = await fetchRoleOptions();
setRoleOptions(options);
const builtInRoles = await fetchBuiltinRoles();
setBuiltinRoles(builtInRoles);
} catch (e) {
console.error('Error loading options');
}
}
if (contextSrv.accessControlEnabled()) {
fetchOptions();
}
}, []);
const getRoleOptions = async () => roleOptions;
const getBuiltinRoles = async () => builtinRoles;
return (
<table className="filter-table form-inline">
<thead>
@ -56,12 +80,23 @@ const UsersTable: FC<Props> = (props) => {
<td className="width-1">{user.lastSeenAtAge}</td>
<td className="width-8">
<OrgRolePicker
aria-label="Role"
value={user.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, user)}
/>
{contextSrv.accessControlEnabled() ? (
<UserRolePicker
userId={user.userId}
builtInRole={user.role}
onBuiltinRoleChange={(newRole) => onRoleChange(newRole, user)}
getRoleOptions={getRoleOptions}
getBuiltinRoles={getBuiltinRoles}
disabled={rolePickerDisabled}
/>
) : (
<OrgRolePicker
aria-label="Role"
value={user.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, user)}
/>
)}
</td>
{canRemoveFromOrg && (

View File

@ -43,3 +43,14 @@ export enum AccessControlAction {
ActionServerStatsRead = 'server.stats:read',
}
export interface Role {
uid: string;
name: string;
displayName: string;
description: string;
global: boolean;
version: number;
created: string;
updated: string;
}