diff --git a/.betterer.results b/.betterer.results index dc17bc5297c..92718adbf97 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4688,9 +4688,6 @@ exports[`better eslint`] = { "public/app/features/search/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], "public/app/features/serviceaccounts/ServiceAccountPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], diff --git a/public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx b/public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx new file mode 100644 index 00000000000..076ad470c36 --- /dev/null +++ b/public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { OrgRole } from '@grafana/data'; + +import { RolePickerBadges } from './RolePickerBadges'; + +const props = { + disabled: false, + user: { + login: 'admin', + email: 'email@example.com', + avatarUrl: 'avatarURL', + lastSeenAt: 'lastSeenAt', + lastSeenAtAge: 'lastSeenAtAge', + name: 'administrator', + orgId: 1, + role: OrgRole.Admin, + roles: [ + { + uid: 'uid', + name: 'admin', + displayName: 'Admin', + description: 'description', + group: 'group', + global: true, + version: 1, + created: 'created', + updated: 'updated', + }, + ], + userId: 1, + isDisabled: false, + }, +}; + +describe('RolePickerBadges', () => { + it('should render', async () => { + render(); + + expect(screen.getByText(/\+1/i)).toBeInTheDocument(); + + expect(screen.getByText(/Admin/i)).toBeInTheDocument(); + await userEvent.click(screen.getByText('Admin')); + }); +}); diff --git a/public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx b/public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx new file mode 100644 index 00000000000..7c5eeec4444 --- /dev/null +++ b/public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx @@ -0,0 +1,63 @@ +import { css } from '@emotion/css'; +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Badge, Stack } from '@grafana/ui'; +import { OrgUser } from 'app/types'; + +import { RolePickerDrawer } from './RolePickerDrawer'; + +export interface Props { + disabled?: boolean; + user: OrgUser; +} + +export const RolePickerBadges = ({ disabled, user }: Props) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const { badge, badgeDisabled } = useStyles2(getStyles); + const badgeStyle = disabled ? badgeDisabled : badge; + + const methods = useForm({ + defaultValues: { + name: user.name, + role: user.role, + roles: user.roles, + }, + }); + const { watch } = methods; + + const drawerControl = () => { + if (!disabled) { + setIsDrawerOpen(true); + } + }; + + return ( + <> + + + {user.roles && user.roles.length > 0 && ( + + )} + + {isDrawerOpen && ( + + setIsDrawerOpen(false)} /> + + )} + + ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + badge: css({ + cursor: 'pointer', + }), + badgeDisabled: css({ + cursor: 'not-allowed', + }), + }; +} diff --git a/public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx new file mode 100644 index 00000000000..20a26a159c6 --- /dev/null +++ b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { RolePickerDrawer } from './RolePickerDrawer'; + +const props = { + onClose: () => {}, +}; + +describe('RolePickerDrawer', () => { + interface WrapperProps { + children: React.ReactNode; + } + + const Wrapper = (props: WrapperProps) => { + const formMethods = useForm({ + defaultValues: { + name: 'service-account-name', + }, + }); + return {props.children}; + }; + + it('should render', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: 'service-account-name' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'documentation' })).toBeInTheDocument(); + + expect(screen.getByRole('radio', { name: 'None' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Viewer' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Editor' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Admin' })).toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx new file mode 100644 index 00000000000..5192291b787 --- /dev/null +++ b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx @@ -0,0 +1,52 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { toOption } from '@grafana/data'; +import { Drawer, Field, RadioButtonGroup, TextLink } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { OrgRole } from 'app/types'; + +const roleOptions = Object.keys(OrgRole).map(toOption); + +const drawerSubtitle = ( + + Assign roles to users to ensure granular control over access to Grafana‘s features and resources. Find out + more in our{' '} + + documentation + + . + +); + +export interface Props { + onClose: () => void; +} + +export const RolePickerDrawer = ({ onClose }: Props) => { + const methods = useFormContext(); + const { control, getValues, setValue } = methods; + const [name, roles] = getValues(['name', 'roles']); + return ( + + + ( + { + setValue('roleCollection', [v, ...roles]); + onChange(v); + }} + /> + )} + /> + + + ); +}; diff --git a/public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx b/public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx new file mode 100644 index 00000000000..1fd1278e0d8 --- /dev/null +++ b/public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { RolePickerSelect } from './RolePickerSelect'; + +describe('RolePickerSelect', () => { + interface WrapperProps { + children: React.ReactNode; + } + + const Wrapper = (props: WrapperProps) => { + const formMethods = useForm({}); + return {props.children}; + }; + + it('should render', async () => { + const props = {}; + render( + + + + ); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx b/public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx new file mode 100644 index 00000000000..6f8a91cbec1 --- /dev/null +++ b/public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { toOption } from '@grafana/data'; +import { MultiSelect } from '@grafana/ui'; + +import { RolePickerDrawer } from './RolePickerDrawer'; + +export interface Props {} + +export const RolePickerSelect = ({}: Props) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { control } = useFormContext(); + + const toggleDrawer = () => setIsDrawerOpen(!isDrawerOpen); + + return ( + <> + ( + { + // TODO cannnot remove basic roles + // TODO open drawer instead + }} + value={value?.map(toOption)} + /> + )} + /> + {isDrawerOpen && } + + ); +}; diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 98fc52462ac..607d732bb12 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { OrgRole } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { Avatar, Box, @@ -20,6 +21,7 @@ import { } from '@grafana/ui'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api'; +import { RolePickerBadges } from 'app/core/components/RolePickerDrawer/RolePickerBadges'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { contextSrv } from 'app/core/core'; import { AccessControlAction, OrgUser, Role } from 'app/types'; @@ -127,6 +129,10 @@ export const OrgUsersTable = ({ } }; + if (config.featureToggles.rolePickerDrawer) { + return ; + } + return contextSrv.licensedAccessControlEnabled() ? ( { const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) => getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa); +const defaultServiceAccount = { + id: 0, + orgId: contextSrv.user.orgId, + role: contextSrv.licensedAccessControlEnabled() ? OrgRole.None : OrgRole.Viewer, + tokens: 0, + name: '', + login: '', + isDisabled: false, + createdAt: '', + teams: [], +}; + export const ServiceAccountCreatePage = ({}: Props): JSX.Element => { const [roleOptions, setRoleOptions] = useState([]); const [pendingRoles, setPendingRoles] = useState([]); - const currentOrgId = contextSrv.user.orgId; - const [serviceAccount, setServiceAccount] = useState({ - id: 0, - orgId: contextSrv.user.orgId, - role: contextSrv.licensedAccessControlEnabled() ? OrgRole.None : OrgRole.Viewer, - tokens: 0, - name: '', - login: '', - isDisabled: false, - createdAt: '', - teams: [], + const methods = useForm({ + defaultValues: { + name: '', + role: defaultServiceAccount.role, + roleCollection: [defaultServiceAccount.role], + roles: [], + }, }); + const { + formState: { errors }, + register, + } = methods; + + const currentOrgId = contextSrv.user.orgId; + const [serviceAccount, setServiceAccount] = useState(defaultServiceAccount); useEffect(() => { async function fetchOptions() { @@ -47,7 +65,7 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => { setRoleOptions(options); } } catch (e) { - console.error('Error loading options', e); + console.error('Error loading options', e); // TODO: handle error } } if (contextSrv.licensedAccessControlEnabled()) { @@ -79,7 +97,7 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => { await updateUserRoles(pendingRoles, newAccount.id, newAccount.orgId); } } catch (e) { - console.error(e); + console.error(e); // TODO: handle error } locationService.push(`/org/serviceaccounts/${response.id}`); }, @@ -99,44 +117,85 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => { }; return ( - + -
- {({ register, errors }) => { - return ( - <> -
- - - - - {contextSrv.licensedAccessControlEnabled() ? ( - - ) : ( - - )} - -
- - - ); - }} -
+ {config.featureToggles.rolePickerDrawer && ( + +
+
+ + + + + + +
+ +
+
+ )} + {!config.featureToggles.rolePickerDrawer && ( +
+ {({ register, errors }) => { + return ( + <> +
+ + + + + {contextSrv.licensedAccessControlEnabled() ? ( + + ) : ( + + )} + +
+ + + ); + }} +
+ )}
); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a8433824dec..858f7357e31 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2455,6 +2455,16 @@ }, "dismissable-button": "Close" }, + "role-picker": { + "title": { + "description": "Assign roles to users to ensure granular control over access to Grafana‘s features and resources. Find out more in our <2>documentation." + } + }, + "role-picker-drawer": { + "basic-roles": { + "label": "Basic Roles" + } + }, "save-dashboards": { "name-exists": { "message-info": "A dashboard with the same name in the selected folder already exists, including recently deleted dashboards.", @@ -2534,6 +2544,21 @@ "selected-count": "Selected " } }, + "service-account-create-page": { + "create": { + "button": "Create" + }, + "name": { + "label": "Display name", + "required-error": "Display name is required" + }, + "page-nav": { + "label": "Create service account" + }, + "role": { + "label": "Role" + } + }, "service-accounts": { "empty-state": { "button-title": "Add service account", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 573b334e572..31f557cdd18 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2455,6 +2455,16 @@ }, "dismissable-button": "Cľőşę" }, + "role-picker": { + "title": { + "description": "Åşşįģʼn řőľęş ŧő ūşęřş ŧő ęʼnşūřę ģřäʼnūľäř čőʼnŧřőľ ővęř äččęşş ŧő Ğřäƒäʼnä&ľşqūő;ş ƒęäŧūřęş äʼnđ řęşőūřčęş. Fįʼnđ őūŧ mőřę įʼn őūř <2>đőčūmęʼnŧäŧįőʼn." + } + }, + "role-picker-drawer": { + "basic-roles": { + "label": "ßäşįč Ŗőľęş" + } + }, "save-dashboards": { "name-exists": { "message-info": "Å đäşĥþőäřđ ŵįŧĥ ŧĥę şämę ʼnämę įʼn ŧĥę şęľęčŧęđ ƒőľđęř äľřęäđy ęχįşŧş, įʼnčľūđįʼnģ řęčęʼnŧľy đęľęŧęđ đäşĥþőäřđş.", @@ -2534,6 +2544,21 @@ "selected-count": "Ŝęľęčŧęđ " } }, + "service-account-create-page": { + "create": { + "button": "Cřęäŧę" + }, + "name": { + "label": "Đįşpľäy ʼnämę", + "required-error": "Đįşpľäy ʼnämę įş řęqūįřęđ" + }, + "page-nav": { + "label": "Cřęäŧę şęřvįčę äččőūʼnŧ" + }, + "role": { + "label": "Ŗőľę" + } + }, "service-accounts": { "empty-state": { "button-title": "Åđđ şęřvįčę äččőūʼnŧ",