mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
0f34ae4fbb
commit
62dc10829a
@ -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',
|
||||||
|
@ -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'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
|
Last active
|
||||||
<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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user