mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 10:10:24 +08: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:
@ -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)
|
||||
// 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,6 +36,7 @@ func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, e
|
||||
})
|
||||
}
|
||||
|
||||
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
|
||||
configNodes = append(configNodes, &navtree.NavLink{
|
||||
Text: "Users",
|
||||
@ -45,6 +46,7 @@ func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, e
|
||||
Url: s.cfg.AppSubURL + "/org/users",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) {
|
||||
configNodes = append(configNodes, &navtree.NavLink{
|
||||
@ -123,11 +125,19 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink
|
||||
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
|
||||
adminNavLinks := []*navtree.NavLink{}
|
||||
|
||||
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) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
|
@ -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,7 +77,6 @@ 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">
|
||||
@ -165,10 +164,18 @@ const UserListAdminPageUnConnected = ({
|
||||
</>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connector(UsersListPage);
|
||||
export const UsersListPageContent = connector(UsersListPageUnconnected);
|
||||
|
||||
export default function UsersListPage() {
|
||||
return (
|
||||
<Page navId="users">
|
||||
<UsersListPageContent />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
@ -330,7 +330,9 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
component: SafeDynamicImport(
|
||||
component: config.featureToggles.topnav
|
||||
? SafeDynamicImport(() => import(/* webpackChunkName: "UserListPage" */ 'app/features/admin/UserListPage'))
|
||||
: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage')
|
||||
),
|
||||
},
|
||||
|
@ -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": {
|
||||
|
Reference in New Issue
Block a user