From 757463bd27ff1d69a7399d0682a507c6e060fcd4 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 17 Nov 2021 18:22:40 +0300 Subject: [PATCH] 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 * 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 * 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 Co-authored-by: Alex Khomenko --- .../grafana-ui/src/components/Input/Input.tsx | 2 +- packages/grafana-ui/src/components/index.ts | 7 +- .../core/components/RolePicker/RolePicker.tsx | 141 ++++ .../components/RolePicker/RolePickerInput.tsx | 156 +++++ .../components/RolePicker/RolePickerMenu.tsx | 610 ++++++++++++++++++ .../components/RolePicker/UserRolePicker.tsx | 79 +++ .../components/RolePicker/ValueContainer.tsx | 37 ++ public/app/core/services/context_srv.ts | 4 + public/app/features/admin/UserAdminPage.tsx | 1 + public/app/features/admin/UserOrgs.tsx | 119 +++- public/app/features/users/UsersTable.test.tsx | 1 + public/app/features/users/UsersTable.tsx | 51 +- public/app/types/accessControl.ts | 11 + 13 files changed, 1184 insertions(+), 35 deletions(-) create mode 100644 public/app/core/components/RolePicker/RolePicker.tsx create mode 100644 public/app/core/components/RolePicker/RolePickerInput.tsx create mode 100644 public/app/core/components/RolePicker/RolePickerMenu.tsx create mode 100644 public/app/core/components/RolePicker/UserRolePicker.tsx create mode 100644 public/app/core/components/RolePicker/ValueContainer.tsx diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 1fbd49fa6bb..2830cb114cb 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -25,7 +25,7 @@ export interface Props extends Omit, 'prefix' | 'siz interface StyleDeps { theme: GrafanaTheme2; - invalid: boolean; + invalid?: boolean; width?: number; } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 3fc0ae51f63..2aa052b4a89 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx new file mode 100644 index 00000000000..10d2ec9b276 --- /dev/null +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -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; + getRoleOptions: () => Promise; + getBuiltinRoles: () => Promise>; + 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([]); + const [appliedRoles, setAppliedRoles] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + const [selectedBuiltInRole, setSelectedBuiltInRole] = useState(builtInRole); + const [builtInRoles, setBuiltinRoles] = useState>({}); + 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) => { + 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 ( +
+ + + {isOpen && ( + + )} + +
+ ); +}; diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx new file mode 100644 index 00000000000..1fa19ca2d05 --- /dev/null +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -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) => event.stopPropagation(); + +interface InputProps extends HTMLProps { + appliedRoles: Role[]; + builtInRole: string; + query: string; + isFocused?: boolean; + disabled?: boolean; + onQueryChange: (query?: string) => void; + onOpen: (event: FormEvent) => 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(null); + + useEffect(() => { + if (isFocused) { + (inputRef as MutableRefObject).current?.focus(); + } + }); + + const onInputChange = (event: React.ChangeEvent) => { + const query = event.target?.value; + onQueryChange(query); + }; + + const numberOfRoles = appliedRoles.length; + + return !isFocused ? ( +
+ {builtInRole} + {!!numberOfRoles && ( + + {appliedRoles?.map((role) => ( +

{role.displayName}

+ ))} +
+ } + > +
+ {`+${numberOfRoles} role${numberOfRoles > 1 ? 's' : ''}`} +
+ + )} + + ) : ( +
+ {builtInRole} + {appliedRoles.map((role) => ( + {role.displayName} + ))} + + {!disabled && ( + + )} +
+ +
+
+ ); +}; + +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)}; + } + `, + }; +}; diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx new file mode 100644 index 00000000000..2742b13488c --- /dev/null +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -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; + +const BuiltinRoles = Object.values(OrgRole); +const BuiltinRoleOption: Array> = BuiltinRoles.map((r) => ({ + label: r, + value: r, +})); + +const fixedRoleGroupNames: Record = { + 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(appliedRoles); + const [selectedBuiltInRole, setSelectedBuiltInRole] = useState(builtInRole); + const [showSubMenu, setShowSubMenu] = useState(false); + const [openedMenuGroup, setOpenedMenuGroup] = useState(''); + const [subMenuOptions, setSubMenuOptions] = useState([]); + const subMenuNode = useRef(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 ( +
+
+ +
+
Built-in roles
+ +
+ {!!fixedRoles.length && + (showGroups && !!optionGroups.length ? ( +
+
Fixed roles
+
+ {optionGroups.map((option, i) => ( + + {showSubMenu && openedMenuGroup === option.value && ( + + )} + + ))} +
+
+ ) : ( +
+
Fixed roles
+
+ {fixedRoles.map((option, i) => ( + opt.uid === option.uid))} + onChange={onChange} + hideDescription + /> + ))} +
+
+ ))} + {!!customRoles.length && ( +
+
Custom roles
+
+ {customRoles.map((option, i) => ( + opt.uid === option.uid))} + onChange={onChange} + hideDescription + /> + ))} +
+
+ )} +
+
+ + + + +
+
+
+
+ ); +}; + +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 ( +
+ +
+ {options.map((option, i) => ( + opt.uid === option.uid) || + disabledOptions?.find((opt) => opt.uid === option.uid)) + ) + } + disabled={!!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid))} + onChange={onSelect} + hideDescription + /> + ))} +
+
+
+ + + +
+
+ ); +}; + +interface RoleMenuOptionProps { + data: Role; + onChange: (value: Role) => void; + isSelected?: boolean; + isFocused?: boolean; + disabled?: boolean; + hideDescription?: boolean; +} + +export const RoleMenuOption = React.forwardRef>>( + ({ 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) => { + if (disabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onChange(data); + }; + + return ( +
+ +
+ {data.displayName || data.name} + {!hideDescription && data.description &&
{data.description}
} +
+ {data.description && ( + + + + )} +
+ ); + } +); + +RoleMenuOption.displayName = 'RoleMenuOption'; + +interface RoleMenuGroupsOptionProps { + data: SelectableValue; + 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( + ( + { + 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) => { + if (disabled) { + return; + } + if (data.value) { + onChange(data.value); + } + }; + + const onClickInternal = (event: FormEvent) => { + if (onClick) { + onClick(data.value!); + } + }; + + const onMouseEnter = () => { + if (onOpenSubMenu) { + onOpenSubMenu(data.value!); + } + }; + + const onMouseLeave = () => { + if (onCloseSubMenu) { + onCloseSubMenu(data.value!); + } + }; + + return ( +
+
+ +
+ {data.displayName || data.name} + +
+ {root && children && ( + + {children} + + )} +
+
+ ); + } +); + +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); + } + } + } + `, + }; +}; diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx new file mode 100644 index 00000000000..ffb152e414b --- /dev/null +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -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; + getBuiltinRoles?: () => Promise<{ [key: string]: Role[] }>; + disabled?: boolean; +} + +export const UserRolePicker: FC = ({ + builtInRole, + userId, + orgId, + onBuiltinRoleChange, + getRoleOptions, + getBuiltinRoles, + disabled, +}) => { + return ( + 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 => { + 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 => { + 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, + }); +}; diff --git a/public/app/core/components/RolePicker/ValueContainer.tsx b/public/app/core/components/RolePicker/ValueContainer.tsx new file mode 100644 index 00000000000..844819364e0 --- /dev/null +++ b/public/app/core/components/RolePicker/ValueContainer.tsx @@ -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 ( +
+ {iconName && } + {children} +
+ ); +}; + +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)}; + } + ` + ), + }; +}; diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index bdc9d10f6df..09e8ce1c731 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -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 diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index ec5fa2eb9ec..e2dac144d4a 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -128,6 +128,7 @@ export class UserAdminPage extends PureComponent { {orgs && ( void; @@ -49,7 +52,7 @@ export class UserOrgs extends PureComponent { }; 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 { { 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 { +class UnThemedOrgRow extends PureComponent { state = { currentRole: this.props.org.role, isChangingRole: false, @@ -150,13 +157,18 @@ class UnThemedOrgRow extends PureComponent { 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 { - {isChangingRole ? ( + {contextSrv.accessControlEnabled() ? ( - +
+
+ +
+ {isExternalUser && } +
) : ( - {org.role} - )} - -
- {canChangeRole && ( - + <> + {isChangingRole ? ( + + + + ) : ( + {org.role} )} -
- + +
+ {canChangeRole && ( + + )} +
+ + + )}
{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({
); } + +const ExternalUserTooltip: React.FC = () => { + const theme = useTheme(); + const styles = getTooltipStyles(theme); + + return ( +
+ + This user's role is not editable because it is synchronized from your auth provider. Refer to the  + + Grafana authentication docs + +  for details. +
+ } + > + + + + ); +}; + +const getTooltipStyles = stylesFactory((theme: GrafanaTheme) => ({ + disabledTooltip: css` + display: flex; + `, + tooltipItemLink: css` + color: ${theme.palette.blue95}; + `, +})); diff --git a/public/app/features/users/UsersTable.test.tsx b/public/app/features/users/UsersTable.test.tsx index e5127b26042..c37d7729b75 100644 --- a/public/app/features/users/UsersTable.test.tsx +++ b/public/app/features/users/UsersTable.test.tsx @@ -8,6 +8,7 @@ import { ConfirmModal } from '@grafana/ui'; jest.mock('app/core/core', () => ({ contextSrv: { hasPermission: () => true, + accessControlEnabled: () => false, }, })); diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx index cac71231311..8bdd8a78c13 100644 --- a/public/app/features/users/UsersTable.tsx +++ b/public/app/features/users/UsersTable.tsx @@ -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) => { const { users, onRoleChange, onRemoveUser } = props; const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate); const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove); + const rolePickerDisabled = !canUpdateRole; const [showRemoveModal, setShowRemoveModal] = useState(false); + const [roleOptions, setRoleOptions] = useState([]); + 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 ( @@ -56,12 +80,23 @@ const UsersTable: FC = (props) => { {canRemoveFromOrg && ( diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index b036281d6aa..c285b8a7a9e 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -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; +}
{user.lastSeenAtAge} - onRoleChange(newRole, user)} - /> + {contextSrv.accessControlEnabled() ? ( + onRoleChange(newRole, user)} + getRoleOptions={getRoleOptions} + getBuiltinRoles={getBuiltinRoles} + disabled={rolePickerDisabled} + /> + ) : ( + onRoleChange(newRole, user)} + /> + )}