Admin: Use InteractiveTable for user and team tables (#74821)

* Admin: Use InteractiveTable

* Admin: Fix pagination

* Admin: Use CellWrapper

* Admin: Split components

* Admin: Separate OrgUsersTable

* Admin: Remove UsersTable

* Admin: Use the new table for TeamList

* Admin: Cleanup TeamList page

* Admin: Add edit team action

* Admin: Use explicit edit action instead of a link wrapper

* Admin: Fix responsive styles

* Cleanup

* Remove redundant sort

* Add item key

* Fix icon styles

* Set loading by default

* Use separate pagination component

* Use default sorting functionality

* Fix merge conflicts

* Update betterer

* Move pagination inside OrgUsersTable.tsx

* Disable sort if results have more than 1 page

* Update betterer results

* Use CSS objects

* More style fixes

* Update betterer
This commit is contained in:
Alex Khomenko 2023-09-27 08:55:57 +03:00 committed by GitHub
parent 32ed3e8009
commit 87ced17060
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 715 additions and 624 deletions

View File

@ -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"]
],

View File

@ -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) => {
<Legend>Organization users</Legend>
{!canReadUsers && renderMissingPermissionMessage()}
{canReadUsers && !!users.length && (
<VerticalGroup spacing="md">
<UsersTable users={users} orgId={orgId} onRoleChange={onRoleChange} onRemoveUser={onRemoveUser} />
<HorizontalGroup justify="flex-end">
<Pagination
onNavigate={onPageChange}
currentPage={page}
numberOfPages={totalPages}
hideWhenSinglePage={true}
/>
</HorizontalGroup>
</VerticalGroup>
<OrgUsersTable
users={users}
orgId={orgId}
onRoleChange={onRoleChange}
onRemoveUser={onRemoveUser}
changePage={onPageChange}
page={page}
totalPages={totalPages}
/>
)}
</div>
</>

View File

@ -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 (
<Page.Contents>
<div className="page-action-bar" data-testid={selectors.container}>
<>
<InlineField grow>
<FilterInput
placeholder="Search user by login, email, or name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
</InlineField>
<div className={styles.actionBar} data-testid={selectors.container}>
<div className={styles.row}>
<FilterInput
placeholder="Search user by login, email, or name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
<RadioButtonGroup
options={[
{ label: 'All users', value: false },
@ -104,76 +90,30 @@ const UserListAdminPageUnConnected = ({
{extraFilters.map((FilterComponent, index) => (
<FilterComponent key={index} filters={filters} onChange={changeFilter} className={styles.filter} />
))}
</>
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary">
New user
</LinkButton>
)}
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary">
New user
</LinkButton>
)}
</div>
</div>
{isLoading ? (
<PageLoader />
) : (
<>
<div className={cx(styles.table, 'admin-list-table')}>
<table className="filter-table form-inline filter-table--hover">
<thead>
<tr>
<th></th>
<th>Login</th>
<th>Email</th>
<th>Name</th>
<th>Belongs to</th>
{showLicensedRole && (
<th>
Licensed role{' '}
<Tooltip
placement="top"
content={
<>
Licensed role is based on a user&apos;s Org role (i.e. Viewer, Editor, Admin) and their
dashboard/folder permissions.{' '}
<a
className={styles.link}
target="_blank"
rel="noreferrer noopener"
href={
'https://grafana.com/docs/grafana/next/enterprise/license/license-restrictions/#active-users-limit'
}
>
Learn more
</a>
</>
}
>
<Icon name="question-circle" />
</Tooltip>
</th>
)}
<th>
Last active&nbsp;
<Tooltip placement="top" content="Time since user was seen using Grafana">
<Icon name="question-circle" />
</Tooltip>
</th>
<th style={{ width: '1%' }}>Origin</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<UserListItem user={user} showLicensedRole={showLicensedRole} key={user.id} />
))}
</tbody>
</table>
</div>
{showPaging && <Pagination numberOfPages={totalPages} currentPage={page} onNavigate={changePage} />}
</>
<UsersTable
users={users}
showPaging={showPaging}
totalPages={totalPages}
onChangePage={changePage}
currentPage={page}
/>
)}
</Page.Contents>
);
};
export const UserListAdminPageContent = connector(UserListAdminPageUnConnected);
export function UserListAdminPage() {
return (
<Page navId="global-users">
@ -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 (
<tr key={user.id}>
<td className="width-4 text-center link-td">
<a href={editUrl} aria-label={`Edit user's ${user.name} details`}>
<img className="filter-table__avatar" src={user.avatarUrl} alt={`Avatar for user ${user.name}`} />
</a>
</td>
<td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.login} aria-label={getUsersAriaLabel(user.name)}>
{user.login}
</a>
</td>
<td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.email} aria-label={getUsersAriaLabel(user.name)}>
{user.email}
</a>
</td>
<td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.name} aria-label={getUsersAriaLabel(user.name)}>
{user.name}
</a>
</td>
<td
className={styles.row}
title={
user.orgs?.length
? `The user is a member of the following organizations: ${user.orgs.map((org) => org.name).join(',')}`
: undefined
}
>
<OrgUnits units={user.orgs} icon={'building'} />
{user.isAdmin && (
<a href={editUrl} aria-label={getUsersAriaLabel(user.name)}>
<Tooltip placement="top" content="Grafana Admin">
<Icon name="shield" />
</Tooltip>
</a>
)}
</td>
{showLicensedRole && (
<td className={cx('link-td', styles.iconRow)}>
<a className="ellipsis" href={editUrl} title={user.name} aria-label={getUsersAriaLabel(user.name)}>
{user.licensedRole === 'None' ? (
<span className={styles.disabled}>
Not assigned{' '}
<Tooltip placement="top" content="A licensed role will be assigned when this user signs in">
<Icon name="question-circle" />
</Tooltip>
</span>
) : (
user.licensedRole
)}
</a>
</td>
)}
<td className="link-td">
{user.lastSeenAtAge && (
<a
href={editUrl}
aria-label={`Last seen at ${user.lastSeenAtAge}. Follow to edit user's ${user.name} details.`}
>
{user.lastSeenAtAge === '10 years' ? <span className={styles.disabled}>Never</span> : user.lastSeenAtAge}
</a>
)}
</td>
<td className="text-right">
{Array.isArray(user.authLabels) && user.authLabels.length > 0 && (
<TagBadge label={user.authLabels[0]} removeIcon={false} count={0} />
)}
</td>
<td className="text-right">
{user.isDisabled && <span className="label label-tag label-tag--gray">Disabled</span>}
</td>
</tr>
);
});
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 ? (
<Tooltip
placement={'top'}
content={
<div className={styles.unitTooltip}>
{units?.map((unit) => (
<a
href={unit.url}
className={styles.link}
title={unit.name}
key={unit.name}
aria-label={`Edit ${unit.name}`}
>
{unit.name}
</a>
))}
</div>
}
>
<div className={styles.unitItem}>
<Icon name={icon} /> <span>{units.length}</span>
</div>
</Tooltip>
) : (
<a
href={units[0].url}
className={styles.unitItem}
title={units[0].name}
key={units[0].name}
aria-label={`Edit ${units[0].name}`}
>
<Icon name={icon} /> {units[0].name}
</a>
);
};
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%',
},
}),
};
};

View File

@ -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 <img className={styles.image} src={src} alt={alt} />;
};
const getStyles = (theme: GrafanaTheme2) => {
return {
image: css({
width: theme.spacing(3),
height: theme.spacing(3),
borderRadius: theme.shape.radius.circle,
}),
};
};

View File

@ -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 ? (
<Tooltip
placement={'top'}
content={
<div className={styles.unitTooltip}>{units?.map((unit) => <span key={unit.name}>{unit.name}</span>)}</div>
}
>
<div className={styles.unitItem}>
<Icon name={icon} /> <span>{units.length}</span>
</div>
</Tooltip>
) : (
<span className={styles.unitItem}>
<Icon name={icon} /> {units[0].name}
</span>
);
};
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;
`,
};
};

View File

@ -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(<UsersTable {...props} />);
render(<OrgUsersTable {...props} />);
};
describe('Render', () => {

View File

@ -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<T extends keyof OrgUser = keyof OrgUser> = CellProps<OrgUser, OrgUser[T]>;
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<OrgUser | null>(null);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
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<Column<OrgUser>> = useMemo(
() => [
{
id: 'avatarUrl',
header: '',
cell: ({ cell: { value } }: Cell<'avatarUrl'>) => <Avatar src={value} alt="User avatar" />,
},
{
id: 'login',
header: 'Login',
cell: ({ cell: { value } }: Cell<'login'>) => <div>{value}</div>,
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() ? (
<UserRolePicker
userId={original.userId}
orgId={orgId}
roleOptions={roleOptions}
basicRole={value}
onBasicRoleChange={(newRole) => onRoleChange(newRole, original)}
basicRoleDisabled={basicRoleDisabled}
basicRoleDisabledMessage={disabledRoleMessage}
/>
) : (
<OrgRolePicker
aria-label="Role"
value={value}
disabled={basicRoleDisabled}
onChange={(newRole) => onRoleChange(newRole, original)}
/>
);
},
},
{
id: 'info',
header: '',
cell: InfoCell,
},
{
id: 'authLabels',
header: 'Origin',
cell: ({ cell: { value } }: Cell<'authLabels'>) => (
<>{Array.isArray(value) && value.length > 0 && <TagBadge label={value[0]} removeIcon={false} count={0} />}</>
),
},
{
id: 'isDisabled',
header: '',
cell: ({ cell: { value } }: Cell<'isDisabled'>) => <>{value && <Tag colorIndex={9} name={'Disabled'} />}</>,
},
{
id: 'delete',
header: '',
cell: ({ row: { original } }: Cell) => {
return (
contextSrv.hasPermissionInMetadata(AccessControlAction.OrgUsersRemove, original) && (
<Button
size="sm"
variant="destructive"
onClick={() => {
setUserToRemove(original);
}}
icon="times"
aria-label={`Delete user ${original.name}`}
/>
)
);
},
},
],
[orgId, roleOptions, onRoleChange, enableSort]
);
return (
<VerticalGroup spacing="md" data-testid={selectors.container}>
<InteractiveTable columns={columns} data={users} getRowId={(user) => String(user.userId)} />
<HorizontalGroup justify="flex-end">
<Pagination onNavigate={changePage} currentPage={page} numberOfPages={totalPages} hideWhenSinglePage={true} />
</HorizontalGroup>
{Boolean(userToRemove) && (
<ConfirmModal
body={`Are you sure you want to delete user ${userToRemove?.login}?`}
confirmText="Delete"
title="Delete"
onDismiss={() => {
setUserToRemove(null);
}}
isOpen={true}
onConfirm={() => {
if (!userToRemove) {
return;
}
onRemoveUser(userToRemove);
setUserToRemove(null);
}}
/>
)}
</VerticalGroup>
);
};
const InfoCell = ({ row: { original } }: Cell) => {
const styles = useStyles2(getStyles);
const basicRoleDisabled = getBasicRoleDisabled(original);
return (
basicRoleDisabled && (
<div className={styles.row}>
<Tooltip content={disabledRoleMessage}>
<Icon name="question-circle" className={styles.icon} />
</Tooltip>
</div>
)
);
};
const getStyles = (theme: GrafanaTheme2) => ({
row: css({
display: 'flex',
alignItems: 'center',
}),
icon: css({
marginLeft: theme.spacing(1),
}),
});

View File

@ -0,0 +1,177 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
InteractiveTable,
CellProps,
Tooltip,
Icon,
useStyles2,
Tag,
Pagination,
Column,
VerticalGroup,
HorizontalGroup,
} from '@grafana/ui';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { UserDTO } from 'app/types';
import { Avatar } from './Avatar';
import { OrgUnits } from './OrgUnits';
type Cell<T extends keyof UserDTO = keyof UserDTO> = CellProps<UserDTO, UserDTO[T]>;
interface UsersTableProps {
users: UserDTO[];
showPaging?: boolean;
totalPages: number;
onChangePage: (page: number) => void;
currentPage: number;
}
export const UsersTable = ({ users, showPaging, totalPages, onChangePage, currentPage }: UsersTableProps) => {
const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]);
const enableSort = totalPages === 1;
const columns: Array<Column<UserDTO>> = useMemo(
() => [
{
id: 'avatarUrl',
header: '',
cell: ({ cell: { value } }: Cell<'avatarUrl'>) => <Avatar src={value} alt={'User avatar'} />,
},
{
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: 'orgs',
header: 'Belongs to',
cell: OrgUnitsCell,
sortType: enableSort ? (a, b) => (a.original.orgs?.length || 0) - (b.original.orgs?.length || 0) : undefined,
},
...(showLicensedRole
? [
{
id: 'licensedRole',
header: 'Licensed role',
cell: LicensedRoleCell,
// Needs the assertion here, the types are not inferred correctly due to the conditional assignment
sortType: enableSort ? ('string' as const) : undefined,
},
]
: []),
{
id: 'lastSeenAtAge',
header: 'Last active',
headerTooltip: {
content: 'Time since user was seen using Grafana',
iconName: 'question-circle',
},
cell: LastSeenAtCell,
sortType: enableSort
? (a, b) => new Date(a.original.lastSeenAt!).getTime() - new Date(b.original.lastSeenAt!).getTime()
: undefined,
},
{
id: 'authLabels',
header: 'Origin',
cell: ({ cell: { value } }: Cell<'authLabels'>) => (
<>{Array.isArray(value) && value.length > 0 && <TagBadge label={value[0]} removeIcon={false} count={0} />}</>
),
},
{
id: 'isDisabled',
header: '',
cell: ({ cell: { value } }: Cell<'isDisabled'>) => <>{value && <Tag colorIndex={9} name={'Disabled'} />}</>,
},
{
id: 'edit',
header: '',
cell: ({ row: { original } }: Cell) => {
return (
<a href={`admin/users/edit/${original.id}`} aria-label={`Edit team ${original.name}`}>
<Tooltip content={'Edit user'}>
<Icon name={'pen'} />
</Tooltip>
</a>
);
},
},
],
[showLicensedRole, enableSort]
);
return (
<VerticalGroup spacing={'md'}>
<InteractiveTable columns={columns} data={users} getRowId={(user) => String(user.id)} />
{showPaging && (
<HorizontalGroup justify={'flex-end'}>
<Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} />
</HorizontalGroup>
)}
</VerticalGroup>
);
};
const OrgUnitsCell = ({ cell: { value, row } }: Cell<'orgs'>) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.row}>
<OrgUnits units={value} icon={'building'} />
{row.original.isAdmin && (
<Tooltip placement="top" content="Grafana Admin">
<Icon name="shield" />
</Tooltip>
)}
</div>
);
};
const LicensedRoleCell = ({ cell: { value } }: Cell<'licensedRole'>) => {
const styles = useStyles2(getStyles);
return (
<>
{value === 'None' ? (
<span className={styles.disabled}>
Not assigned{' '}
<Tooltip placement="top" content="A licensed role will be assigned when this user signs in">
<Icon name="question-circle" />
</Tooltip>
</span>
) : (
value
)}
</>
);
};
const LastSeenAtCell = ({ cell: { value } }: Cell<'lastSeenAtAge'>) => {
const styles = useStyles2(getStyles);
return <>{value && <>{value === '10 years' ? <span className={styles.disabled}>Never</span> : value}</>}</>;
};
const getStyles = (theme: GrafanaTheme2) => {
return {
disabled: css({ color: theme.colors.text.disabled }),
row: css({
display: 'flex',
alignItems: 'center',
}),
};
};

View File

@ -131,7 +131,7 @@ const initialUserListAdminState: UserListAdminState = {
totalPages: 1,
showPaging: false,
filters: [{ name: 'activeLast30Days', value: false }],
isLoading: false,
isLoading: true,
};
interface UsersFetched {

View File

@ -26,10 +26,11 @@ const setup = (propOverrides?: object) => {
changePage: jest.fn(),
changeQuery: jest.fn(),
query: '',
page: 1,
totalPages: 0,
page: 0,
hasFetched: false,
editorsCanAdmin: false,
perPage: 10,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,

View File

@ -1,34 +1,35 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination, InlineField } from '@grafana/ui';
import {
LinkButton,
FilterInput,
InlineField,
CellProps,
DeleteButton,
InteractiveTable,
Icon,
Tooltip,
Column,
HorizontalGroup,
Pagination,
VerticalGroup,
} from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Page } from 'app/core/components/Page/Page';
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { config } from 'app/core/config';
import { contextSrv, User } from 'app/core/services/context_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, Role, StoreState, Team } from 'app/types';
import { connectWithCleanUp } from '../../core/components/connectWithCleanUp';
import { TeamRolePicker } from '../../core/components/RolePicker/TeamRolePicker';
import { Avatar } from '../admin/Users/Avatar';
import { TeamListRow } from './TeamListRow';
import { deleteTeam, loadTeams, changePage, changeQuery } from './state/actions';
import { initialTeamsState } from './state/reducers';
import { isPermissionTeamAdmin } from './state/selectors';
export interface Props {
teams: Team[];
page: number;
query: string;
noTeams: boolean;
totalPages: number;
hasFetched: boolean;
loadTeams: typeof loadTeams;
deleteTeam: typeof deleteTeam;
changePage: typeof changePage;
changeQuery: typeof changeQuery;
editorsCanAdmin: boolean;
signedInUser: User;
}
type Cell<T extends keyof Team = keyof Team> = CellProps<Team, Team[T]>;
export interface OwnProps {}
export interface State {
roleOptions: Role[];
@ -36,19 +37,20 @@ export interface State {
export const TeamList = ({
teams,
page,
query,
noTeams,
totalPages,
hasFetched,
loadTeams,
deleteTeam,
changeQuery,
changePage,
totalPages,
signedInUser,
editorsCanAdmin,
page,
changePage,
}: Props) => {
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const enableSort = totalPages === 1;
useEffect(() => {
loadTeams(true);
@ -63,6 +65,99 @@ export const TeamList = ({
const canCreate = contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate);
const displayRolePicker = shouldDisplayRolePicker();
const columns: Array<Column<Team>> = useMemo(
() => [
{
id: 'avatarUrl',
header: '',
cell: ({ cell: { value } }: Cell<'avatarUrl'>) => <Avatar src={value} alt="User avatar" />,
},
{
id: 'name',
header: 'Name',
cell: ({ cell: { value } }: Cell<'name'>) => value,
sortType: enableSort ? 'string' : undefined,
},
{
id: 'email',
header: 'Email',
cell: ({ cell: { value } }: Cell<'email'>) => value,
sortType: enableSort ? 'string' : undefined,
},
{
id: 'memberCount',
header: 'Members',
cell: ({ cell: { value } }: Cell<'memberCount'>) => value,
sortType: enableSort ? 'number' : undefined,
},
...(displayRolePicker
? [
{
id: 'role',
header: 'Role',
cell: ({ cell: { value }, row: { original } }: Cell<'memberCount'>) => {
const canSeeTeamRoles = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsRolesList,
original,
false
);
return canSeeTeamRoles && <TeamRolePicker teamId={original.id} roleOptions={roleOptions} />;
},
},
]
: []),
{
id: 'edit',
header: '',
cell: ({ row: { original } }: Cell) => {
const isTeamAdmin = isPermissionTeamAdmin({
permission: original.permission,
editorsCanAdmin,
signedInUser,
});
const canReadTeam = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsRead,
original,
isTeamAdmin
);
return canReadTeam ? (
<a href={`org/teams/edit/${original.id}`} aria-label={`Edit team ${original.name}`}>
<Tooltip content={'Edit team'}>
<Icon name={'pen'} />
</Tooltip>
</a>
) : null;
},
},
{
id: 'delete',
header: '',
cell: ({ row: { original } }: Cell) => {
const isTeamAdmin = isPermissionTeamAdmin({
permission: original.permission,
editorsCanAdmin,
signedInUser,
});
const canDelete = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsDelete,
original,
isTeamAdmin
);
return (
<DeleteButton
aria-label={`Delete team ${original.name}`}
size="sm"
disabled={!canDelete}
onConfirm={() => deleteTeam(original.id)}
/>
);
},
},
],
[displayRolePicker, editorsCanAdmin, roleOptions, signedInUser, deleteTeam, enableSort]
);
return (
<Page navId="teams">
<Page.Contents isLoading={!hasFetched}>
@ -89,47 +184,12 @@ export const TeamList = ({
New Team
</LinkButton>
</div>
<div className="admin-list-table">
<VerticalGroup spacing="md">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th />
<th>Name</th>
<th>Email</th>
<th>Members</th>
{displayRolePicker && <th>Roles</th>}
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>
{teams.map((team) => (
<TeamListRow
key={team.id}
team={team}
roleOptions={roleOptions}
displayRolePicker={displayRolePicker}
isTeamAdmin={isPermissionTeamAdmin({
permission: team.permission,
editorsCanAdmin,
signedInUser,
})}
onDelete={deleteTeam}
/>
))}
</tbody>
</table>
<HorizontalGroup justify="flex-end">
<Pagination
hideWhenSinglePage
currentPage={page}
numberOfPages={totalPages}
onNavigate={changePage}
/>
</HorizontalGroup>
</VerticalGroup>
</div>
<VerticalGroup spacing={'md'}>
<InteractiveTable columns={columns} data={teams} getRowId={(team) => String(team.id)} />
<HorizontalGroup justify="flex-end">
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={totalPages} onNavigate={changePage} />
</HorizontalGroup>
</VerticalGroup>
</>
)}
</Page.Contents>
@ -148,9 +208,9 @@ function shouldDisplayRolePicker(): boolean {
function mapStateToProps(state: StoreState) {
return {
teams: state.teams.teams,
page: state.teams.page,
query: state.teams.query,
perPage: state.teams.perPage,
page: state.teams.page,
noTeams: state.teams.noTeams,
totalPages: state.teams.totalPages,
hasFetched: state.teams.hasFetched,
@ -166,8 +226,6 @@ const mapDispatchToProps = {
changeQuery,
};
export default connectWithCleanUp(
mapStateToProps,
mapDispatchToProps,
(state) => (state.teams = initialTeamsState)
)(TeamList);
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export default connector(TeamList);

View File

@ -1,65 +0,0 @@
import React from 'react';
import { DeleteButton } from '@grafana/ui';
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, Role, Team } from 'app/types';
type Props = {
team: Team;
roleOptions: Role[];
isTeamAdmin: boolean;
displayRolePicker: boolean;
onDelete: (id: number) => void;
};
export const TeamListRow = ({ team, roleOptions, isTeamAdmin, displayRolePicker, onDelete }: Props) => {
const teamUrl = `org/teams/edit/${team.id}`;
const canDelete = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsDelete, team, isTeamAdmin);
const canReadTeam = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRead, team, isTeamAdmin);
const canSeeTeamRoles = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRolesList, team, false);
return (
<tr key={team.id}>
<td className="width-4 text-center link-td">
{canReadTeam ? (
<a href={teamUrl}>
<img className="filter-table__avatar" src={team.avatarUrl} alt="Team avatar" />
</a>
) : (
<img className="filter-table__avatar" src={team.avatarUrl} alt="Team avatar" />
)}
</td>
<td className="link-td">
{canReadTeam ? <a href={teamUrl}>{team.name}</a> : <div style={{ padding: '0px 8px' }}>{team.name}</div>}
</td>
<td className="link-td">
{canReadTeam ? (
<a href={teamUrl} aria-label={team.email || 'Empty email cell'}>
{team.email}
</a>
) : (
<div style={{ padding: '0px 8px' }} aria-label={team.email || 'Empty email cell'}>
{team.email}
</div>
)}
</td>
<td className="link-td">
{canReadTeam ? (
<a href={teamUrl}>{team.memberCount}</a>
) : (
<div style={{ padding: '0px 8px' }}>{team.memberCount}</div>
)}
</td>
{displayRolePicker && <td>{canSeeTeamRoles && <TeamRolePicker teamId={team.id} roleOptions={roleOptions} />}</td>}
<td className="text-right">
<DeleteButton
aria-label={`Delete team ${team.name}`}
size="sm"
disabled={!canDelete}
onConfirm={() => onDelete(team.id)}
/>
</td>
</tr>
);
};

View File

@ -2,18 +2,16 @@ import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { renderMarkdown } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { OrgUser, OrgRole, StoreState } from 'app/types';
import { OrgUsersTable } from '../admin/Users/OrgUsersTable';
import InviteesTable from '../invites/InviteesTable';
import { fetchInvitees } from '../invites/state/actions';
import { selectInvitesMatchingQuery } from '../invites/state/selectors';
import { UsersActionBar } from './UsersActionBar';
import { UsersTable } from './UsersTable';
import { loadUsers, removeUser, updateUser, changePage } from './state/actions';
import { getUsers, getUsersSearchQuery } from './state/selectors';
@ -47,8 +45,6 @@ export interface State {
showInvites: boolean;
}
const selectors = e2eSelectors.pages.UserListPage.UsersListPage;
export const UsersListPageUnconnected = ({
users,
page,
@ -61,7 +57,7 @@ export const UsersListPageUnconnected = ({
changePage,
updateUser,
removeUser,
}: Props): JSX.Element => {
}: Props) => {
const [showInvites, setShowInvites] = useState(false);
const externalUserMngInfoHtml = externalUserMngInfo ? renderMarkdown(externalUserMngInfo) : '';
@ -74,6 +70,8 @@ export const UsersListPageUnconnected = ({
updateUser({ ...user, role: role });
};
const onRemoveUser = (user: OrgUser) => removeUser(user.userId);
const onShowInvites = () => {
setShowInvites(!showInvites);
};
@ -83,22 +81,15 @@ export const UsersListPageUnconnected = ({
return <InviteesTable invitees={invitees} />;
} else {
return (
<VerticalGroup spacing="md" data-testid={selectors.container}>
<UsersTable
users={users}
orgId={contextSrv.user.orgId}
onRoleChange={(role, user) => onRoleChange(role, user)}
onRemoveUser={(user) => removeUser(user.userId)}
/>
<HorizontalGroup justify="flex-end">
<Pagination
onNavigate={changePage}
currentPage={page}
numberOfPages={totalPages}
hideWhenSinglePage={true}
/>
</HorizontalGroup>
</VerticalGroup>
<OrgUsersTable
users={users}
orgId={contextSrv.user.orgId}
onRoleChange={onRoleChange}
onRemoveUser={onRemoveUser}
changePage={changePage}
page={page}
totalPages={totalPages}
/>
);
}
};

View File

@ -1,174 +0,0 @@
import React, { useEffect, useState } from 'react';
import { OrgRole } from '@grafana/data';
import { Button, ConfirmModal, Icon, Tooltip } 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 '../admin/OrgRolePicker';
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.`;
export interface Props {
users: OrgUser[];
orgId?: number;
onRoleChange: (role: OrgRole, user: OrgUser) => void;
onRemoveUser: (user: OrgUser) => void;
}
export const UsersTable = ({ users, orgId, onRoleChange, onRemoveUser }: Props) => {
const [userToRemove, setUserToRemove] = useState<OrgUser | null>(null);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
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]);
return (
<>
<table className="filter-table form-inline">
<thead>
<tr>
<th />
<th>Login</th>
<th>Email</th>
<th>Name</th>
<th>Seen</th>
<th>Role</th>
<th />
<th style={{ width: '34px' }} />
<th>Origin</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((user, index) => {
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 (
<tr key={`${user.userId}-${index}`}>
<td className="width-2 text-center">
<img className="filter-table__avatar" src={user.avatarUrl} alt="User avatar" />
</td>
<td className="max-width-6">
<span className="ellipsis" title={user.login}>
{user.login}
</span>
</td>
<td className="max-width-5">
<span className="ellipsis" title={user.email}>
{user.email}
</span>
</td>
<td className="max-width-5">
<span className="ellipsis" title={user.name}>
{user.name}
</span>
</td>
<td className="width-1">{user.lastSeenAtAge}</td>
<td className="width-8">
{contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={user.userId}
orgId={orgId}
roleOptions={roleOptions}
basicRole={user.role}
onBasicRoleChange={(newRole) => onRoleChange(newRole, user)}
basicRoleDisabled={basicRoleDisabled}
basicRoleDisabledMessage={disabledRoleMessage}
/>
) : (
<OrgRolePicker
aria-label="Role"
value={user.role}
disabled={basicRoleDisabled}
onChange={(newRole) => onRoleChange(newRole, user)}
/>
)}
</td>
<td>
{basicRoleDisabled && (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Tooltip content={disabledRoleMessage}>
<Icon name="question-circle" style={{ marginLeft: '8px' }} />
</Tooltip>
</div>
)}
</td>
<td className="width-1 text-center">
{user.isDisabled && <span className="label label-tag label-tag--gray">Disabled</span>}
</td>
<td className="width-1">
{Array.isArray(user.authLabels) && user.authLabels.length > 0 && (
<TagBadge label={user.authLabels[0]} removeIcon={false} count={0} />
)}
</td>
{contextSrv.hasPermissionInMetadata(AccessControlAction.OrgUsersRemove, user) && (
<td className="text-right">
<Button
size="sm"
variant="destructive"
onClick={() => {
setUserToRemove(user);
}}
icon="times"
aria-label="Delete user"
/>
</td>
)}
</tr>
);
})}
</tbody>
</table>
{Boolean(userToRemove) && (
<ConfirmModal
body={`Are you sure you want to delete user ${userToRemove?.login}?`}
confirmText="Delete"
title="Delete"
onDismiss={() => {
setUserToRemove(null);
}}
isOpen={true}
onConfirm={() => {
if (!userToRemove) {
return;
}
onRemoveUser(userToRemove);
setUserToRemove(null);
}}
/>
)}
</>
);
};

View File

@ -42,6 +42,7 @@ export interface UserDTO extends WithAccessControlMetadata {
theme?: string;
avatarUrl?: string;
orgId?: number;
lastSeenAt?: string;
lastSeenAtAge?: string;
licensedRole?: string;
permissions?: string[];