Admin: Combine org and admin user pages (#59365)

* Admin: Add unified users page

* Admin: Combine admin and org components

* Admin: Add combined route

* Admin: Show combined page in nav

* Admin: Update translation

* Admin: Update description

* Admin: Update description on backend

* Admin: Update translations

* Admin: Use dynamic imports
This commit is contained in:
Alex Khomenko 2022-11-30 15:24:53 +02:00 committed by GitHub
parent 32a498e04f
commit c3d13a0e2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 207 additions and 128 deletions

View File

@ -103,7 +103,12 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
r.Get("/admin", reqGrafanaAdmin, hs.Index)
r.Get("/admin/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Index)
r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index)
// Show the combined users page for org admins if topnav is enabled
if hs.Features.IsEnabled(featuremgmt.FlagTopnav) {
r.Get("/admin/users", authorize(reqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))), hs.Index)
} else {
r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index)
}
r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index)
r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index)
r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)

View File

@ -198,9 +198,8 @@ func ApplyAdminIA(root *NavTreeRoot) {
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app"))
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries")) // enterprise only
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("users"))
if globalUsers := root.FindById("global-users"); globalUsers != nil {
globalUsers.Text = "Users (All orgs)"
globalUsers.Text = "Users"
accessNodeLinks = append(accessNodeLinks, globalUsers)
}
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams"))

View File

@ -36,14 +36,16 @@ func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, e
})
}
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Users",
Id: "users",
SubTitle: "Invite and assign roles to users",
Icon: "user",
Url: s.cfg.AppSubURL + "/org/users",
})
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Users",
Id: "users",
SubTitle: "Invite and assign roles to users",
Icon: "user",
Url: s.cfg.AppSubURL + "/org/users",
})
}
}
if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) {
@ -123,10 +125,18 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
adminNavLinks := []*navtree.NavLink{}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
} else {
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
}
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {

View File

@ -97,7 +97,7 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.admin.title', 'Server admin');
case 'global-users':
return config.featureToggles.topnav
? t('nav.global-users.title', 'Users (All orgs)')
? t('nav.global-users.title', 'Users')
: t('nav.global-users.titleBeforeTopnav', 'Users');
case 'global-orgs':
return t('nav.global-orgs.title', 'Organizations');
@ -184,7 +184,7 @@ export function getNavSubTitle(navId: string | undefined) {
case 'serviceaccounts':
return t('nav.service-accounts.subtitle', 'Use service accounts to run automated workloads in Grafana');
case 'global-users':
return t('nav.global-users.subtitle', 'Manage and create users across the whole Grafana server');
return t('nav.global-users.subtitle', 'Manage users in Grafana');
case 'global-orgs':
return t('nav.global-orgs.subtitle', 'Isolated instances of Grafana running on the same server');
case 'server-settings':

View File

@ -77,98 +77,105 @@ const UserListAdminPageUnConnected = ({
const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]);
return (
<Page navId="global-users">
<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: false },
{ label: 'Active last 30 days', value: true },
]}
onChange={(value) => changeFilter({ name: 'activeLast30Days', value })}
value={filters.find((f) => f.name === 'activeLast30Days')?.value}
className={styles.filter}
/>
{extraFilters.map((FilterComponent, index) => (
<FilterComponent key={index} filters={filters} onChange={changeFilter} className={styles.filter} />
))}
</div>
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary">
New user
</LinkButton>
)}
<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: false },
{ label: 'Active last 30 days', value: true },
]}
onChange={(value) => changeFilter({ name: 'activeLast30Days', value })}
value={filters.find((f) => f.name === 'activeLast30Days')?.value}
className={styles.filter}
/>
{extraFilters.map((FilterComponent, index) => (
<FilterComponent key={index} filters={filters} onChange={changeFilter} className={styles.filter} />
))}
</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>
)}
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
<LinkButton href="admin/users/create" variant="primary">
New user
</LinkButton>
)}
</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>
Last active&nbsp;
<Tooltip placement="top" content="Time since user was seen using Grafana">
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 style={{ width: '1%' }}></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} />}
</>
)}
</Page.Contents>
</Page>
)}
<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%' }}></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} />}
</>
)}
</Page.Contents>
);
};
export const UserListAdminPageContent = connector(UserListAdminPageUnConnected);
export function UserListAdminPage() {
return (
<Page navId="global-users">
<UserListAdminPageContent />
</Page>
);
}
const getUsersAriaLabel = (name: string) => {
return `Edit user's ${name} details`;
};
@ -349,4 +356,4 @@ const getStyles = (theme: GrafanaTheme2) => {
};
};
export default connector(UserListAdminPageUnConnected);
export default UserListAdminPage;

View File

@ -0,0 +1,52 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { RadioButtonGroup, Field, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { Page } from '../../core/components/Page/Page';
import { AccessControlAction } from '../../types';
import { UsersListPageContent } from '../users/UsersListPage';
import { UserListAdminPageContent } from './UserListAdminPage';
const views = [
{ value: 'admin', label: 'All organisations' },
{ value: 'org', label: 'This organisation' },
];
export default function UserListPage() {
const hasAccessToAdminUsers = contextSrv.hasAccess(AccessControlAction.UsersRead, contextSrv.isGrafanaAdmin);
const hasAccessToOrgUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
const styles = useStyles2(getStyles);
const [view, setView] = useState(() => {
if (hasAccessToAdminUsers) {
return 'admin';
} else if (hasAccessToOrgUsers) {
return 'org';
}
return null;
});
const showToggle = hasAccessToOrgUsers && hasAccessToAdminUsers;
return (
<Page navId={'global-users'}>
{showToggle && (
<Field label={'Display list of users for'} className={styles.container}>
<RadioButtonGroup options={views} onChange={setView} value={view} />
</Field>
)}
{view === 'admin' ? <UserListAdminPageContent /> : <UsersListPageContent />}
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
margin: ${theme.spacing(2, 0)};
`,
};
};

View File

@ -6,7 +6,7 @@ import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { configureStore } from 'app/store/configureStore';
import { Invitee, OrgUser } from 'app/types';
import { Props, UsersListPage } from './UsersListPage';
import { Props, UsersListPageUnconnected } from './UsersListPage';
import { setUsersSearchPage, setUsersSearchQuery } from './state/reducers';
jest.mock('../../core/app_events', () => ({
@ -42,7 +42,7 @@ const setup = (propOverrides?: object) => {
render(
<Provider store={store}>
<UsersListPage {...props} />
<UsersListPageUnconnected {...props} />
</Provider>
);
};

View File

@ -48,7 +48,7 @@ export interface State {
const pageLimit = 30;
export class UsersListPage extends PureComponent<Props, State> {
export class UsersListPageUnconnected extends PureComponent<Props, State> {
declare externalUserMngInfoHtml: string;
constructor(props: Props) {
@ -127,19 +127,23 @@ export class UsersListPage extends PureComponent<Props, State> {
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
return (
<Page navId="users">
<Page.Contents isLoading={!hasFetched}>
<>
<UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} />
{externalUserMngInfoHtml && (
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
)}
{hasFetched && this.renderTable()}
</>
</Page.Contents>
</Page>
<Page.Contents isLoading={!hasFetched}>
<UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} />
{externalUserMngInfoHtml && (
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
)}
{hasFetched && this.renderTable()}
</Page.Contents>
);
}
}
export default connector(UsersListPage);
export const UsersListPageContent = connector(UsersListPageUnconnected);
export default function UsersListPage() {
return (
<Page navId="users">
<UsersListPageContent />
</Page>
);
}

View File

@ -330,9 +330,11 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/admin/users',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage')
),
component: config.featureToggles.topnav
? SafeDynamicImport(() => import(/* webpackChunkName: "UserListPage" */ 'app/features/admin/UserListPage'))
: SafeDynamicImport(
() => import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage')
),
},
{
path: '/admin/users/create',

View File

@ -197,8 +197,8 @@
"title": "Organizations"
},
"global-users": {
"subtitle": "Manage and create users across the whole Grafana server",
"title": "Users (All orgs)",
"subtitle": "Manage users in Grafana",
"title": "Users",
"titleBeforeTopnav": "Users"
},
"help": {

View File

@ -21,7 +21,7 @@
"query-tab": "Requête",
"stats-tab": "Statistiques",
"subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}",
"title": "Inspecter : {{panelTitle}}"
"title": "Inspecter\u00a0: {{panelTitle}}"
},
"inspect-data": {
"data-options": "Options de données",
@ -51,7 +51,7 @@
"panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.",
"panel-json-label": "Panneau JSON",
"select-source": "Sélectionner la source",
"unknown": "Objet inconnu : {{show}}"
"unknown": "Objet inconnu\u00a0: {{show}}"
},
"inspect-meta": {
"no-inspector": "Pas d'inspecteur de métadonnées"
@ -95,7 +95,7 @@
},
"library-panels": {
"save": {
"error": "Erreur lors de l'enregistrement du panneau de bibliothèque : \"{{errorMsg}}\"",
"error": "Erreur lors de l'enregistrement du panneau de bibliothèque\u00a0: \"{{errorMsg}}\"",
"success": "Panneau de bibliothèque enregistré"
}
},
@ -403,7 +403,7 @@
"info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.",
"info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne</1> qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.",
"local-button": "Instantané local",
"mistake-message": "Avez-vous commis une erreur ? ",
"mistake-message": "Avez-vous commis une erreur\u00a0? ",
"name": "Nom de l'instantané",
"timeout": "Délai dexpiration (secondes)",
"timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.",

View File

@ -197,8 +197,8 @@
"title": "Øřģäʼnįžäŧįőʼnş"
},
"global-users": {
"subtitle": "Mäʼnäģę äʼnđ čřęäŧę ūşęřş äčřőşş ŧĥę ŵĥőľę Ğřäƒäʼnä şęřvęř",
"title": "Ůşęřş (Åľľ őřģş)",
"subtitle": "Mäʼnäģę ūşęřş įʼn Ğřäƒäʼnä",
"title": "Ůşęřş",
"titleBeforeTopnav": "Ůşęřş"
},
"help": {