Admin: Show licensed roles and unit membership in UI (#39773)

* Extend User type

* Render licensed roles and org units

* Combine admin icon with units

* Extract search users to a new service

* Fix wire provider

* remove mock data

* Fix icon margin

* Fix common_test and remove RouteRegister

* Remove old endpoints

* Fix test

* Add indexes to dashboards and orgs tables

* Fix lint

* Revert docs changes

* undo docs formatting

* Change order of input and filters

* Abstract aria-label into a function

* Add accessible info about user's membership

* UI tweaks

Co-authored-by: spinillos <selenepinillos@gmail.com>
This commit is contained in:
Alex Khomenko 2021-09-29 18:26:50 +03:00 committed by GitHub
parent 0f34ae4fbb
commit 62dc10829a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 179 additions and 19 deletions

View File

@ -28,6 +28,7 @@ export const getAvailableIcons = () =>
'book-open',
'brackets-curly',
'bug',
'building',
'calculator-alt',
'calendar-alt',
'camera',

View File

@ -1,14 +1,23 @@
import React, { useEffect } from 'react';
import React, { useEffect, useMemo, memo } from 'react';
import { css, cx } from '@emotion/css';
import { connect, ConnectedProps } from 'react-redux';
import { Pagination, Tooltip, LinkButton, Icon, RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui';
import {
Icon,
IconName,
LinkButton,
Pagination,
RadioButtonGroup,
Tooltip,
useStyles2,
FilterInput,
} from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import Page from 'app/core/components/Page/Page';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { contextSrv } from 'app/core/core';
import { getNavModel } from '../../core/selectors/navModel';
import { AccessControlAction, StoreState, UserDTO } from '../../types';
import { fetchUsers, changeQuery, changePage, changeFilter } from './state/actions';
import { AccessControlAction, StoreState, Unit, UserDTO } from '../../types';
import { changeFilter, changePage, changeQuery, fetchUsers } from './state/actions';
import PageLoader from '../../core/components/PageLoader/PageLoader';
const mapDispatchToProps = {
@ -55,11 +64,19 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
fetchUsers();
}, [fetchUsers]);
const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]);
return (
<Page navModel={navModel}>
<Page.Contents>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
placeholder="Search user by login, email, or name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
<RadioButtonGroup
options={[
{ label: 'All users', value: 'all' },
@ -69,12 +86,6 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
value={filter}
className={styles.filter}
/>
<FilterInput
placeholder="Search user by login, email, or name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
</div>
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary">
@ -94,7 +105,31 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
<th>Login</th>
<th>Email</th>
<th>Name</th>
<th>Server admin</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}
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">
@ -104,7 +139,11 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
<th style={{ width: '1%' }}></th>
</tr>
</thead>
<tbody>{users.map(renderUser)}</tbody>
<tbody>
{users.map((user) => (
<UserListItem user={user} showLicensedRole={showLicensedRole} key={user.id} />
))}
</tbody>
</table>
</div>
{showPaging && <Pagination numberOfPages={totalPages} currentPage={page} onNavigate={changePage} />}
@ -115,7 +154,17 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
);
};
const renderUser = (user: UserDTO) => {
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 (
@ -126,36 +175,61 @@ const renderUser = (user: UserDTO) => {
</a>
</td>
<td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.login} aria-label={`Edit user's ${user.name} details`}>
<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={`Edit user's ${user.name} details`}>
<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={`Edit user's ${user.name} details`}>
<a className="ellipsis" href={editUrl} title={user.name} aria-label={getUsersAriaLabel(user.name)}>
{user.name}
</a>
</td>
<td className="link-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={`Edit user's ${user.name} details`}>
<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}
{user.lastSeenAtAge === '10 years' ? <span className={styles.disabled}>Never</span> : user.lastSeenAtAge}
</a>
)}
</td>
@ -169,6 +243,53 @@ const renderUser = (user: UserDTO) => {
</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) => {
@ -177,8 +298,40 @@ const getStyles = (theme: GrafanaTheme2) => {
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;
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: ${theme.colors.text.link};
:hover {
text-decoration: underline;
}
`,
};
};

View File

@ -22,6 +22,8 @@ export interface User {
orgId?: number;
}
export type Unit = { name: string; url: string };
export interface UserDTO {
id: number;
login: string;
@ -37,6 +39,10 @@ export interface UserDTO {
avatarUrl?: string;
orgId?: number;
lastSeenAtAge?: string;
licensedRole?: string;
permissions?: string[];
teams?: Unit[];
orgs?: Unit[];
}
export interface Invitee {