mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
78888158ca
commit
757463bd27
@ -25,7 +25,7 @@ export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'siz
|
||||
|
||||
interface StyleDeps {
|
||||
theme: GrafanaTheme2;
|
||||
invalid: boolean;
|
||||
invalid?: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
141
public/app/core/components/RolePicker/RolePicker.tsx
Normal file
141
public/app/core/components/RolePicker/RolePicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
156
public/app/core/components/RolePicker/RolePickerInput.tsx
Normal file
156
public/app/core/components/RolePicker/RolePickerInput.tsx
Normal 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)};
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
610
public/app/core/components/RolePicker/RolePickerMenu.tsx
Normal file
610
public/app/core/components/RolePicker/RolePickerMenu.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
79
public/app/core/components/RolePicker/UserRolePicker.tsx
Normal file
79
public/app/core/components/RolePicker/UserRolePicker.tsx
Normal 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,
|
||||
});
|
||||
};
|
37
public/app/core/components/RolePicker/ValueContainer.tsx
Normal file
37
public/app/core/components/RolePicker/ValueContainer.tsx
Normal 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)};
|
||||
}
|
||||
`
|
||||
),
|
||||
};
|
||||
};
|
@ -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
|
||||
|
@ -128,6 +128,7 @@ export class UserAdminPage extends PureComponent<Props> {
|
||||
|
||||
{orgs && (
|
||||
<UserOrgs
|
||||
user={user}
|
||||
orgs={orgs}
|
||||
isExternalUser={user?.isExternal}
|
||||
onOrgRemove={this.onOrgRemove}
|
||||
|
@ -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's role is not editable because it is synchronized from your auth provider. Refer to the
|
||||
<a
|
||||
className={styles.tooltipItemLink}
|
||||
href={'https://grafana.com/docs/grafana/latest/auth'}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Grafana authentication docs
|
||||
</a>
|
||||
for details.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon name="question-circle" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getTooltipStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
disabledTooltip: css`
|
||||
display: flex;
|
||||
`,
|
||||
tooltipItemLink: css`
|
||||
color: ${theme.palette.blue95};
|
||||
`,
|
||||
}));
|
||||
|
@ -8,6 +8,7 @@ import { ConfirmModal } from '@grafana/ui';
|
||||
jest.mock('app/core/core', () => ({
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
accessControlEnabled: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
|
@ -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 && (
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user