mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
32ed3e8009
commit
87ced17060
@ -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"]
|
||||
],
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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'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
|
||||
<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%',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
28
public/app/features/admin/Users/Avatar.tsx
Normal file
28
public/app/features/admin/Users/Avatar.tsx
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
55
public/app/features/admin/Users/OrgUnits.tsx
Normal file
55
public/app/features/admin/Users/OrgUnits.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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', () => {
|
230
public/app/features/admin/Users/OrgUsersTable.tsx
Normal file
230
public/app/features/admin/Users/OrgUsersTable.tsx
Normal 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),
|
||||
}),
|
||||
});
|
177
public/app/features/admin/Users/UsersTable.tsx
Normal file
177
public/app/features/admin/Users/UsersTable.tsx
Normal 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',
|
||||
}),
|
||||
};
|
||||
};
|
@ -131,7 +131,7 @@ const initialUserListAdminState: UserListAdminState = {
|
||||
totalPages: 1,
|
||||
showPaging: false,
|
||||
filters: [{ name: 'activeLast30Days', value: false }],
|
||||
isLoading: false,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
interface UsersFetched {
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -42,6 +42,7 @@ export interface UserDTO extends WithAccessControlMetadata {
|
||||
theme?: string;
|
||||
avatarUrl?: string;
|
||||
orgId?: number;
|
||||
lastSeenAt?: string;
|
||||
lastSeenAtAge?: string;
|
||||
licensedRole?: string;
|
||||
permissions?: string[];
|
||||
|
Loading…
Reference in New Issue
Block a user