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', 'book-open',
'brackets-curly', 'brackets-curly',
'bug', 'bug',
'building',
'calculator-alt', 'calculator-alt',
'calendar-alt', 'calendar-alt',
'camera', '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 { css, cx } from '@emotion/css';
import { connect, ConnectedProps } from 'react-redux'; 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 { GrafanaTheme2 } from '@grafana/data';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { getNavModel } from '../../core/selectors/navModel'; import { getNavModel } from '../../core/selectors/navModel';
import { AccessControlAction, StoreState, UserDTO } from '../../types'; import { AccessControlAction, StoreState, Unit, UserDTO } from '../../types';
import { fetchUsers, changeQuery, changePage, changeFilter } from './state/actions'; import { changeFilter, changePage, changeQuery, fetchUsers } from './state/actions';
import PageLoader from '../../core/components/PageLoader/PageLoader'; import PageLoader from '../../core/components/PageLoader/PageLoader';
const mapDispatchToProps = { const mapDispatchToProps = {
@ -55,11 +64,19 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
fetchUsers(); fetchUsers();
}, [fetchUsers]); }, [fetchUsers]);
const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]);
return ( return (
<Page navModel={navModel}> <Page navModel={navModel}>
<Page.Contents> <Page.Contents>
<div className="page-action-bar"> <div className="page-action-bar">
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<FilterInput
placeholder="Search user by login, email, or name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
<RadioButtonGroup <RadioButtonGroup
options={[ options={[
{ label: 'All users', value: 'all' }, { label: 'All users', value: 'all' },
@ -69,12 +86,6 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
value={filter} value={filter}
className={styles.filter} className={styles.filter}
/> />
<FilterInput
placeholder="Search user by login, email, or name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
</div> </div>
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( {contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary"> <LinkButton href="admin/users/create" variant="primary">
@ -94,7 +105,31 @@ const UserListAdminPageUnConnected: React.FC<Props> = ({
<th>Login</th> <th>Login</th>
<th>Email</th> <th>Email</th>
<th>Name</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> <th>
Last active&nbsp; Last active&nbsp;
<Tooltip placement="top" content="Time since user was seen using Grafana"> <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> <th style={{ width: '1%' }}></th>
</tr> </tr>
</thead> </thead>
<tbody>{users.map(renderUser)}</tbody> <tbody>
{users.map((user) => (
<UserListItem user={user} showLicensedRole={showLicensedRole} key={user.id} />
))}
</tbody>
</table> </table>
</div> </div>
{showPaging && <Pagination numberOfPages={totalPages} currentPage={page} onNavigate={changePage} />} {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}`; const editUrl = `admin/users/edit/${user.id}`;
return ( return (
@ -126,36 +175,61 @@ const renderUser = (user: UserDTO) => {
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <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} {user.login}
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <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} {user.email}
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <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} {user.name}
</a> </a>
</td> </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 && ( {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"> <Tooltip placement="top" content="Grafana Admin">
<Icon name="shield" /> <Icon name="shield" />
</Tooltip> </Tooltip>
</a> </a>
)} )}
</td> </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"> <td className="link-td">
{user.lastSeenAtAge && ( {user.lastSeenAtAge && (
<a <a
href={editUrl} href={editUrl}
aria-label={`Last seen at ${user.lastSeenAtAge}. Follow to edit user's ${user.name} details.`} 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> </a>
)} )}
</td> </td>
@ -169,6 +243,53 @@ const renderUser = (user: UserDTO) => {
</td> </td>
</tr> </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) => { const getStyles = (theme: GrafanaTheme2) => {
@ -177,8 +298,40 @@ const getStyles = (theme: GrafanaTheme2) => {
margin-top: ${theme.spacing(3)}; margin-top: ${theme.spacing(3)};
`, `,
filter: css` 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)}; 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; orgId?: number;
} }
export type Unit = { name: string; url: string };
export interface UserDTO { export interface UserDTO {
id: number; id: number;
login: string; login: string;
@ -37,6 +39,10 @@ export interface UserDTO {
avatarUrl?: string; avatarUrl?: string;
orgId?: number; orgId?: number;
lastSeenAtAge?: string; lastSeenAtAge?: string;
licensedRole?: string;
permissions?: string[];
teams?: Unit[];
orgs?: Unit[];
} }
export interface Invitee { export interface Invitee {