diff --git a/.betterer.results b/.betterer.results index ff015aeab35..2ccb99f1535 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1714,16 +1714,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"] ], - "public/app/features/admin/UserListAdminPage.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] - ], "public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -1770,6 +1760,11 @@ exports[`better eslint`] = { "public/app/features/admin/UserSessions.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], + "public/app/features/admin/Users/OrgUnits.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Styles should be written using objects.", "1"], + [0, 0, 0, "Styles should be written using objects.", "2"] + ], "public/app/features/admin/ldap/LdapPage.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/admin/AdminEditOrgPage.tsx b/public/app/features/admin/AdminEditOrgPage.tsx index 0c071fac6b3..7c4400acc66 100644 --- a/public/app/features/admin/AdminEditOrgPage.tsx +++ b/public/app/features/admin/AdminEditOrgPage.tsx @@ -3,14 +3,14 @@ import { useAsyncFn } from 'react-use'; import { NavModelItem, UrlQueryValue } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import { Form, Field, Input, Button, Legend, Alert, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui'; +import { Form, Field, Input, Button, Legend, Alert } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { OrgUser, AccessControlAction, OrgRole } from 'app/types'; -import { UsersTable } from '../users/UsersTable'; +import { OrgUsersTable } from './Users/OrgUsersTable'; const perPage = 30; @@ -120,17 +120,15 @@ const AdminEditOrgPage = ({ match }: Props) => { Organization users {!canReadUsers && renderMissingPermissionMessage()} {canReadUsers && !!users.length && ( - - - - - - + )} diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 74ae31c0c59..f1fecda0671 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -1,27 +1,17 @@ -import { css, cx } from '@emotion/css'; -import React, { ComponentType, useEffect, useMemo, memo } from 'react'; +import { css } from '@emotion/css'; +import React, { ComponentType, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; -import { - Icon, - IconName, - LinkButton, - Pagination, - RadioButtonGroup, - Tooltip, - useStyles2, - FilterInput, - InlineField, -} from '@grafana/ui'; +import { LinkButton, RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { contextSrv } from 'app/core/core'; import PageLoader from '../../core/components/PageLoader/PageLoader'; -import { AccessControlAction, StoreState, Unit, UserDTO, UserFilter } from '../../types'; +import { AccessControlAction, StoreState, UserFilter } from '../../types'; +import { UsersTable } from './Users/UsersTable'; import { changeFilter, changePage, changeQuery, fetchUsers } from './state/actions'; export interface FilterProps { @@ -65,12 +55,12 @@ const UserListAdminPageUnConnected = ({ changeQuery, users, showPaging, - totalPages, - page, - changePage, changeFilter, filters, isLoading, + totalPages, + page, + changePage, }: Props) => { const styles = useStyles2(getStyles); @@ -78,20 +68,16 @@ const UserListAdminPageUnConnected = ({ fetchUsers(); }, [fetchUsers]); - const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]); - return ( -
- <> - - - +
+
+ ( ))} - - {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( - - New user - - )} + {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( + + New user + + )} +
{isLoading ? ( ) : ( - <> -
- - - - - - - - - {showLicensedRole && ( - - )} - - - - - - {users.map((user) => ( - - ))} - -
LoginEmailNameBelongs to - Licensed role{' '} - - Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their - dashboard/folder permissions.{' '} - - Learn more - - - } - > - - - - Last active  - - - - Origin
-
- {showPaging && } - + )} ); }; export const UserListAdminPageContent = connector(UserListAdminPageUnConnected); + export function UserListAdminPage() { return ( @@ -182,183 +122,36 @@ export function UserListAdminPage() { ); } -const getUsersAriaLabel = (name: string) => { - return `Edit user's ${name} details`; -}; - -type UserListItemProps = { - user: UserDTO; - showLicensedRole: boolean; -}; - -const UserListItem = memo(({ user, showLicensedRole }: UserListItemProps) => { - const styles = useStyles2(getStyles); - const editUrl = `admin/users/edit/${user.id}`; - - return ( - - - - {`Avatar - - - - - {user.login} - - - - - {user.email} - - - - - {user.name} - - - - org.name).join(',')}` - : undefined - } - > - - {user.isAdmin && ( - - - - - - )} - - {showLicensedRole && ( - - - {user.licensedRole === 'None' ? ( - - Not assigned{' '} - - - - - ) : ( - user.licensedRole - )} - - - )} - - {user.lastSeenAtAge && ( - - {user.lastSeenAtAge === '10 years' ? Never : user.lastSeenAtAge} - - )} - - - {Array.isArray(user.authLabels) && user.authLabels.length > 0 && ( - - )} - - - {user.isDisabled && Disabled} - - - ); -}); - -UserListItem.displayName = 'UserListItem'; - -type OrgUnitProps = { units?: Unit[]; icon: IconName }; - -const OrgUnits = ({ units, icon }: OrgUnitProps) => { - const styles = useStyles2(getStyles); - - if (!units?.length) { - return null; - } - - return units.length > 1 ? ( - - {units?.map((unit) => ( - - {unit.name} - - ))} -
- } - > -
- {units.length} -
- - ) : ( - - {units[0].name} - - ); -}; - const getStyles = (theme: GrafanaTheme2) => { return { - table: css` - margin-top: ${theme.spacing(3)}; - `, - filter: css` - margin: 0 ${theme.spacing(1)}; - `, - iconRow: css` - svg { - margin-left: ${theme.spacing(0.5)}; - } - `, - row: css` - display: flex; - align-items: center; - height: 100% !important; + filter: css({ + margin: theme.spacing(0, 1), + [theme.breakpoints.down('sm')]: { + margin: 0, + }, + }), + actionBar: css({ + marginBottom: theme.spacing(2), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, + }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + textAlign: 'left', + marginBottom: theme.spacing(0.5), + flexGrow: 1, - a { - padding: ${theme.spacing(0.5)} 0 !important; - } - `, - unitTooltip: css` - display: flex; - flex-direction: column; - `, - unitItem: css` - cursor: pointer; - padding: ${theme.spacing(0.5)} 0; - margin-right: ${theme.spacing(1)}; - `, - disabled: css` - color: ${theme.colors.text.disabled}; - `, - link: css` - color: inherit; - cursor: pointer; - text-decoration: underline; - `, + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + gap: theme.spacing(2), + width: '100%', + }, + }), }; }; diff --git a/public/app/features/admin/Users/Avatar.tsx b/public/app/features/admin/Users/Avatar.tsx new file mode 100644 index 00000000000..40f5a0bf3ea --- /dev/null +++ b/public/app/features/admin/Users/Avatar.tsx @@ -0,0 +1,28 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +export interface AvatarProps { + src?: string; + alt: string; +} +export const Avatar = ({ src, alt }: AvatarProps) => { + const styles = useStyles2(getStyles); + + if (!src) { + return null; + } + return {alt}; +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + image: css({ + width: theme.spacing(3), + height: theme.spacing(3), + borderRadius: theme.shape.radius.circle, + }), + }; +}; diff --git a/public/app/features/admin/Users/OrgUnits.tsx b/public/app/features/admin/Users/OrgUnits.tsx new file mode 100644 index 00000000000..d3ed21c87c4 --- /dev/null +++ b/public/app/features/admin/Users/OrgUnits.tsx @@ -0,0 +1,55 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, IconName } from '@grafana/data'; +import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { Unit } from 'app/types'; + +type OrgUnitProps = { units?: Unit[]; icon: IconName }; + +export const OrgUnits = ({ units, icon }: OrgUnitProps) => { + const styles = useStyles2(getStyles); + + if (!units?.length) { + return null; + } + + return units.length > 1 ? ( + {units?.map((unit) => {unit.name})} + } + > +
+ {units.length} +
+
+ ) : ( + + {units[0].name} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + unitTooltip: css` + display: flex; + flex-direction: column; + `, + unitItem: css` + padding: ${theme.spacing(0.5)} 0; + margin-right: ${theme.spacing(1)}; + + svg { + margin-bottom: ${theme.spacing(0.25)}; + } + `, + link: css` + color: inherit; + cursor: pointer; + text-decoration: underline; + `, + }; +}; diff --git a/public/app/features/users/UsersTable.test.tsx b/public/app/features/admin/Users/OrgUsersTable.test.tsx similarity index 86% rename from public/app/features/users/UsersTable.test.tsx rename to public/app/features/admin/Users/OrgUsersTable.test.tsx index 0db1c7b1511..73f04d628f0 100644 --- a/public/app/features/users/UsersTable.test.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.test.tsx @@ -2,10 +2,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { OrgUser } from 'app/types'; +import { OrgUser } from '../../../types'; +import { getMockUsers } from '../../users/__mocks__/userMocks'; -import { UsersTable, Props } from './UsersTable'; -import { getMockUsers } from './__mocks__/userMocks'; +import { OrgUsersTable, Props } from './OrgUsersTable'; jest.mock('app/core/core', () => ({ contextSrv: { @@ -20,11 +20,14 @@ const setup = (propOverrides?: object) => { users: [] as OrgUser[], onRoleChange: jest.fn(), onRemoveUser: jest.fn(), + changePage: jest.fn(), + page: 0, + totalPages: 1, }; Object.assign(props, propOverrides); - render(); + render(); }; describe('Render', () => { diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx new file mode 100644 index 00000000000..1ec0a5b6d24 --- /dev/null +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -0,0 +1,230 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { GrafanaTheme2, OrgRole } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { + Button, + ConfirmModal, + Icon, + Tooltip, + CellProps, + useStyles2, + Tag, + InteractiveTable, + Column, + Pagination, + HorizontalGroup, + VerticalGroup, +} from '@grafana/ui'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; +import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; +import config from 'app/core/config'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction, OrgUser, Role } from 'app/types'; + +import { OrgRolePicker } from '../OrgRolePicker'; + +import { Avatar } from './Avatar'; + +type Cell = CellProps; + +const disabledRoleMessage = `This user's role is not editable because it is synchronized from your auth provider. + Refer to the Grafana authentication docs for details.`; + +const getBasicRoleDisabled = (user: OrgUser) => { + let basicRoleDisabled = !contextSrv.hasPermissionInMetadata(AccessControlAction.OrgUsersWrite, user); + let authLabel = Array.isArray(user.authLabels) && user.authLabels.length > 0 ? user.authLabels[0] : ''; + // A GCom specific feature toggle for role locking has been introduced, as the previous implementation had a bug with locking down external users synced through GCom (https://github.com/grafana/grafana/pull/72044) + // Remove this conditional once FlagGcomOnlyExternalOrgRoleSync feature toggle has been removed + if (authLabel !== 'grafana.com' || config.featureToggles.gcomOnlyExternalOrgRoleSync) { + const isUserSynced = user?.isExternallySynced; + basicRoleDisabled = isUserSynced || basicRoleDisabled; + } + + return basicRoleDisabled; +}; + +const selectors = e2eSelectors.pages.UserListPage.UsersListPage; + +export interface Props { + users: OrgUser[]; + orgId?: number; + onRoleChange: (role: OrgRole, user: OrgUser) => void; + onRemoveUser: (user: OrgUser) => void; + changePage: (page: number) => void; + page: number; + totalPages: number; +} + +export const OrgUsersTable = ({ users, orgId, onRoleChange, onRemoveUser, changePage, page, totalPages }: Props) => { + const [userToRemove, setUserToRemove] = useState(null); + const [roleOptions, setRoleOptions] = useState([]); + const enableSort = totalPages === 1; + + useEffect(() => { + async function fetchOptions() { + try { + if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { + let options = await fetchRoleOptions(orgId); + setRoleOptions(options); + } + } catch (e) { + console.error('Error loading options'); + } + } + if (contextSrv.licensedAccessControlEnabled()) { + fetchOptions(); + } + }, [orgId]); + + const columns: Array> = useMemo( + () => [ + { + id: 'avatarUrl', + header: '', + cell: ({ cell: { value } }: Cell<'avatarUrl'>) => , + }, + { + id: 'login', + header: 'Login', + cell: ({ cell: { value } }: Cell<'login'>) =>
{value}
, + sortType: enableSort ? 'string' : undefined, + }, + { + id: 'email', + header: 'Email', + cell: ({ cell: { value } }: Cell<'email'>) => value, + sortType: enableSort ? 'string' : undefined, + }, + { + id: 'name', + header: 'Name', + cell: ({ cell: { value } }: Cell<'name'>) => value, + sortType: enableSort ? 'string' : undefined, + }, + { + id: 'lastSeenAtAge', + header: 'Last active', + cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => value, + sortType: enableSort + ? (a, b) => new Date(a.original.lastSeenAt).getTime() - new Date(b.original.lastSeenAt).getTime() + : undefined, + }, + { + id: 'role', + header: 'Role', + cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => { + const basicRoleDisabled = getBasicRoleDisabled(original); + return contextSrv.licensedAccessControlEnabled() ? ( + onRoleChange(newRole, original)} + basicRoleDisabled={basicRoleDisabled} + basicRoleDisabledMessage={disabledRoleMessage} + /> + ) : ( + onRoleChange(newRole, original)} + /> + ); + }, + }, + { + id: 'info', + header: '', + cell: InfoCell, + }, + { + id: 'authLabels', + header: 'Origin', + cell: ({ cell: { value } }: Cell<'authLabels'>) => ( + <>{Array.isArray(value) && value.length > 0 && } + ), + }, + { + id: 'isDisabled', + header: '', + cell: ({ cell: { value } }: Cell<'isDisabled'>) => <>{value && }, + }, + { + id: 'delete', + header: '', + cell: ({ row: { original } }: Cell) => { + return ( + contextSrv.hasPermissionInMetadata(AccessControlAction.OrgUsersRemove, original) && ( +