mirror of
https://github.com/grafana/grafana.git
synced 2024-12-29 10:21:41 -06:00
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:
parent
32a498e04f
commit
c3d13a0e2f
@ -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)
|
||||
|
@ -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"))
|
||||
|
@ -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) {
|
||||
|
@ -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':
|
||||
|
@ -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'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
|
||||
<Tooltip placement="top" content="Time since user was seen using Grafana">
|
||||
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 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
|
||||
<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;
|
||||
|
52
public/app/features/admin/UserListPage.tsx
Normal file
52
public/app/features/admin/UserListPage.tsx
Normal 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)};
|
||||
`,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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": {
|
||||
|
@ -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 d’expiration (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.",
|
||||
|
@ -197,8 +197,8 @@
|
||||
"title": "Øřģäʼnįžäŧįőʼnş"
|
||||
},
|
||||
"global-users": {
|
||||
"subtitle": "Mäʼnäģę äʼnđ čřęäŧę ūşęřş äčřőşş ŧĥę ŵĥőľę Ğřäƒäʼnä şęřvęř",
|
||||
"title": "Ůşęřş (Åľľ őřģş)",
|
||||
"subtitle": "Mäʼnäģę ūşęřş įʼn Ğřäƒäʼnä",
|
||||
"title": "Ůşęřş",
|
||||
"titleBeforeTopnav": "Ůşęřş"
|
||||
},
|
||||
"help": {
|
||||
|
Loading…
Reference in New Issue
Block a user