From c3d13a0e2f66bb023cef6f45e9156384a95bfd02 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 30 Nov 2022 15:24:53 +0200 Subject: [PATCH] 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 --- pkg/api/api.go | 7 +- pkg/services/navtree/models.go | 3 +- pkg/services/navtree/navtreeimpl/admin.go | 34 ++-- .../NavBar/navBarItem-translations.ts | 4 +- .../app/features/admin/UserListAdminPage.tsx | 177 +++++++++--------- public/app/features/admin/UserListPage.tsx | 52 +++++ .../app/features/users/UsersListPage.test.tsx | 4 +- public/app/features/users/UsersListPage.tsx | 30 +-- public/app/routes/routes.tsx | 8 +- public/locales/en-US/grafana.json | 4 +- public/locales/fr-FR/grafana.json | 8 +- public/locales/pseudo-LOCALE/grafana.json | 4 +- 12 files changed, 207 insertions(+), 128 deletions(-) create mode 100644 public/app/features/admin/UserListPage.tsx diff --git a/pkg/api/api.go b/pkg/api/api.go index 75524df2003..7b5988351de 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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) diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index e73929d123a..276318892c2 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -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")) diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 7bf7a95ec46..0429cd01276 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -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) { diff --git a/public/app/core/components/NavBar/navBarItem-translations.ts b/public/app/core/components/NavBar/navBarItem-translations.ts index ea674073cf7..9fc0820c0e0 100644 --- a/public/app/core/components/NavBar/navBarItem-translations.ts +++ b/public/app/core/components/NavBar/navBarItem-translations.ts @@ -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': diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 308c6841427..a1119b509d9 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -77,98 +77,105 @@ const UserListAdminPageUnConnected = ({ const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]); return ( - - -
-
- - changeFilter({ name: 'activeLast30Days', value })} - value={filters.find((f) => f.name === 'activeLast30Days')?.value} - className={styles.filter} - /> - {extraFilters.map((FilterComponent, index) => ( - - ))} -
- {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( - - New user - - )} + +
+
+ + changeFilter({ name: 'activeLast30Days', value })} + value={filters.find((f) => f.name === 'activeLast30Days')?.value} + className={styles.filter} + /> + {extraFilters.map((FilterComponent, index) => ( + + ))}
- {isLoading ? ( - - ) : ( - <> -
- - - - - - - - - {showLicensedRole && ( - - )} + {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( + + New user + + )} + + {isLoading ? ( + + ) : ( + <> +
+
LoginEmailNameBelongs to - Licensed role{' '} - - Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their - dashboard/folder permissions.{' '} - - Learn more - - - } - > - - -
+ + + + + + + + {showLicensedRole && ( - - - - - {users.map((user) => ( - - ))} - -
LoginEmailNameBelongs to - Last active  - + Licensed role{' '} + + Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their + dashboard/folder permissions.{' '} + + Learn more + + + } + >
-
- {showPaging && } - - )} - - + )} + + Last active  + + + + + + + + + {users.map((user) => ( + + ))} + + +
+ {showPaging && } + + )} +
); }; +export const UserListAdminPageContent = connector(UserListAdminPageUnConnected); +export function UserListAdminPage() { + return ( + + + + ); +} + 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; diff --git a/public/app/features/admin/UserListPage.tsx b/public/app/features/admin/UserListPage.tsx new file mode 100644 index 00000000000..fa1cb2ebaeb --- /dev/null +++ b/public/app/features/admin/UserListPage.tsx @@ -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 ( + + {showToggle && ( + + + + )} + {view === 'admin' ? : } + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + margin: ${theme.spacing(2, 0)}; + `, + }; +}; diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index cb999f5dd5b..f4e8c2d3b8d 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -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( - + ); }; diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 7cab296441d..c30b74326d8 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -48,7 +48,7 @@ export interface State { const pageLimit = 30; -export class UsersListPage extends PureComponent { +export class UsersListPageUnconnected extends PureComponent { declare externalUserMngInfoHtml: string; constructor(props: Props) { @@ -127,19 +127,23 @@ export class UsersListPage extends PureComponent { const externalUserMngInfoHtml = this.externalUserMngInfoHtml; return ( - - - <> - - {externalUserMngInfoHtml && ( -
- )} - {hasFetched && this.renderTable()} - - - + + + {externalUserMngInfoHtml && ( +
+ )} + {hasFetched && this.renderTable()} + ); } } -export default connector(UsersListPage); +export const UsersListPageContent = connector(UsersListPageUnconnected); + +export default function UsersListPage() { + return ( + + + + ); +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index bb3db15c368..37f90b009c4 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 99188ce13ff..a5d68e18d78 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7230a71d11d..8d2c31949a2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -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 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.", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index abce1243e4c..14a3869cc61 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -197,8 +197,8 @@ "title": "Øřģäʼnįžäŧįőʼnş" }, "global-users": { - "subtitle": "Mäʼnäģę äʼnđ čřęäŧę ūşęřş äčřőşş ŧĥę ŵĥőľę Ğřäƒäʼnä şęřvęř", - "title": "Ůşęřş (Åľľ őřģş)", + "subtitle": "Mäʼnäģę ūşęřş įʼn Ğřäƒäʼnä", + "title": "Ůşęřş", "titleBeforeTopnav": "Ůşęřş" }, "help": {