diff --git a/pkg/api/api.go b/pkg/api/api.go index 94f1efc0359..94c3c1af96d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -287,12 +287,10 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Any("/plugins/:pluginId/resources/*", hs.CallResource) apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList)) - if hs.Cfg.PluginAdminEnabled { - apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { - pluginRoute.Post("/:pluginId/install", bind(dtos.InstallPluginCommand{}), routing.Wrap(hs.InstallPlugin)) - pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin)) - }, reqGrafanaAdmin) - } + apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { + pluginRoute.Post("/:pluginId/install", bind(dtos.InstallPluginCommand{}), routing.Wrap(hs.InstallPlugin)) + pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin)) + }, reqGrafanaAdmin) apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { pluginRoute.Get("/:pluginId/dashboards/", routing.Wrap(hs.GetPluginDashboards)) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 4738157ffef..3b3c533679f 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -247,7 +247,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "http2Enabled": hs.Cfg.Protocol == setting.HTTP2Scheme, "sentry": hs.Cfg.Sentry, "pluginCatalogURL": hs.Cfg.PluginCatalogURL, - "pluginAdminEnabled": (c.IsGrafanaAdmin || hs.Cfg.PluginAdminExternalManageEnabled) && hs.Cfg.PluginAdminEnabled, + "pluginAdminEnabled": hs.Cfg.PluginAdminEnabled, "pluginAdminExternalManageEnabled": hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled, "expressionsEnabled": hs.Cfg.ExpressionsEnabled, "awsAllowedAuthProviders": hs.Cfg.AWSAllowedAuthProviders, diff --git a/pkg/api/index.go b/pkg/api/index.go index 3941af970ff..51ce5a63325 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -388,6 +388,12 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { }) } + if hs.Cfg.PluginAdminEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.ActionPluginsManage) { + adminNavLinks = append(adminNavLinks, &dtos.NavLink{ + Text: "Plugins", Id: "admin-plugins", Url: hs.Cfg.AppSubURL + "/admin/plugins", Icon: "plug", + }) + } + return adminNavLinks } diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 2dea4614816..e5e06d166a3 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -88,6 +88,9 @@ const ( // Datasources actions ActionDatasourcesExplore = "datasources:explore" + // Plugin actions + ActionPluginsManage = "plugins:manage" + // Global Scopes ScopeGlobalUsersAll = "global:users:*" diff --git a/public/app/features/plugins/admin/components/InstallControls.tsx b/public/app/features/plugins/admin/components/InstallControls.tsx index a71a67e6f07..63ce949cade 100644 --- a/public/app/features/plugins/admin/components/InstallControls.tsx +++ b/public/app/features/plugins/admin/components/InstallControls.tsx @@ -94,6 +94,11 @@ export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, ha ); } + if (!hasPermission) { + const message = `You need server admin privileges to ${isInstalled ? 'uninstall' : 'install'} this plugin.`; + return
{message}
; + } + if (isInstalled) { return ( @@ -122,7 +127,6 @@ export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, ha Please refresh your browser window before using this plugin. )} - {!hasPermission &&
You need admin privileges to manage this plugin.
} )}
@@ -149,7 +153,6 @@ export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, ha - {!hasPermission &&
You need admin privileges to install this plugin.
} )} diff --git a/public/app/features/plugins/admin/components/PluginList.tsx b/public/app/features/plugins/admin/components/PluginList.tsx index 1c64a8b8f1c..225fd2f6a4c 100644 --- a/public/app/features/plugins/admin/components/PluginList.tsx +++ b/public/app/features/plugins/admin/components/PluginList.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; +import { useLocation } from 'react-router-dom'; import { Card } from '../components/Card'; import { Grid } from '../components/Grid'; @@ -14,6 +15,7 @@ interface Props { export const PluginList = ({ plugins }: Props) => { const styles = useStyles2(getStyles); + const location = useLocation(); return ( @@ -23,7 +25,7 @@ export const PluginList = ({ plugins }: Props) => { return ( { export const usePluginDetails = (id: string) => { const [state, dispatch] = useReducer(reducer, initialState); + const userCanConfigurePlugins = isOrgAdmin(); useEffect(() => { const fetchPlugin = async () => { @@ -125,7 +125,7 @@ export const usePluginDetails = (id: string) => { const pluginConfig = state.pluginConfig; const tabs: Array<{ label: string }> = [...defaultTabs]; - if (pluginConfig && state.isAdmin) { + if (pluginConfig && userCanConfigurePlugins) { if (pluginConfig.meta.type === PluginType.app) { if (pluginConfig.angularConfigCtrl) { tabs.push({ @@ -149,7 +149,7 @@ export const usePluginDetails = (id: string) => { } } dispatch({ type: ActionTypes.UPDATE_TABS, payload: tabs }); - }, [state.isAdmin, state.pluginConfig, id]); + }, [userCanConfigurePlugins, state.pluginConfig, id]); return { state, dispatch }; }; diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index f2010ef068f..b2887f6c4b4 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { Router } from 'react-router-dom'; import { render, RenderResult, waitFor } from '@testing-library/react'; -import BrowsePage from './Browse'; -import { locationService } from '@grafana/runtime'; -import { configureStore } from 'app/store/configureStore'; import { Provider } from 'react-redux'; -import { LocalPlugin, Plugin } from '../types'; +import { locationService } from '@grafana/runtime'; +import BrowsePage from './Browse'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { configureStore } from 'app/store/configureStore'; +import { LocalPlugin, Plugin, PluginAdminRoutes } from '../types'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; jest.mock('@grafana/runtime', () => ({ @@ -27,11 +28,14 @@ jest.mock('@grafana/runtime', () => ({ function setup(path = '/plugins'): RenderResult { const store = configureStore(); locationService.push(path); + const props = getRouteComponentProps({ + route: { routeName: PluginAdminRoutes.Home } as any, + }); return render( - + ); diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index bdcbce39c5d..2579bc6ea16 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -4,11 +4,11 @@ import { SelectableValue, dateTimeParse, GrafanaTheme2 } from '@grafana/data'; import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { useLocation } from 'react-router-dom'; import { locationSearchToObject } from '@grafana/runtime'; - +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { PluginList } from '../components/PluginList'; import { SearchField } from '../components/SearchField'; import { useHistory } from '../hooks/useHistory'; -import { CatalogPlugin } from '../types'; +import { CatalogPlugin, PluginAdminRoutes } from '../types'; import { Page as PluginPage } from '../components/Page'; import { HorizontalGroup } from '../components/HorizontalGroup'; import { Page } from 'app/core/components/Page/Page'; @@ -17,10 +17,11 @@ import { useSelector } from 'react-redux'; import { StoreState } from 'app/types/store'; import { getNavModel } from 'app/core/selectors/navModel'; -export default function Browse(): ReactElement | null { +export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null { const location = useLocation(); const query = locationSearchToObject(location.search); - const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, 'plugins')); + const navModelId = getNavModelId(route.routeName); + const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, navModelId)); const styles = useStyles2(getStyles); const q = (query.q as string) ?? ''; @@ -127,6 +128,16 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, }); +// Because the component is used under multiple paths (/plugins and /admin/plugins) we need to get +// the correct navModel from the store +const getNavModelId = (routeName?: string) => { + if (routeName === PluginAdminRoutes.HomeAdmin || routeName === PluginAdminRoutes.BrowseAdmin) { + return 'admin-plugins'; + } + + return 'plugins'; +}; + const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = { name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name), updated: (a: CatalogPlugin, b: CatalogPlugin) => diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index 609ffc0769a..d4bc3e712b9 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -31,6 +31,13 @@ jest.mock('@grafana/runtime', () => { }), config: { ...original.config, + bootData: { + ...original.config.bootData, + user: { + ...original.config.bootData.user, + isGrafanaAdmin: true, + }, + }, buildInfo: { ...original.config.buildInfo, version: 'v7.5.0', diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index b07842588db..51016c1d78d 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { css } from '@emotion/css'; - import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, TabsBar, TabContent, Tab, Icon, Alert } from '@grafana/ui'; @@ -34,6 +33,7 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen } = state; const tab = tabs[activeTab]; const styles = useStyles2(getStyles); + const breadcrumbHref = match.url.substring(0, match.url.lastIndexOf('/')); if (loading) { return ( @@ -66,13 +66,13 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen className={css` text-decoration: underline; `} - href={'/plugins'} + href={breadcrumbHref} > Plugins
  • - + {plugin.name}
  • diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index eb92796a1dd..1ef12f125d7 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -1,5 +1,15 @@ import { GrafanaPlugin, PluginMeta } from '@grafana/data'; export type PluginTypeCode = 'app' | 'panel' | 'datasource'; + +export enum PluginAdminRoutes { + Home = 'plugins-home', + Browse = 'plugins-browse', + Details = 'plugins-details', + HomeAdmin = 'plugins-home-admin', + BrowseAdmin = 'plugins-browse-admin', + DetailsAdmin = 'plugins-details-admin', +} + export interface CatalogPlugin { description: string; downloads: number; @@ -137,7 +147,6 @@ export interface Org { export interface PluginDetailsState { hasInstalledPanel: boolean; hasUpdate: boolean; - isAdmin: boolean; isInstalled: boolean; isInflight: boolean; loading: boolean; diff --git a/public/app/features/plugins/routes.ts b/public/app/features/plugins/routes.ts index 02352b28cb1..01cf002f6d6 100644 --- a/public/app/features/plugins/routes.ts +++ b/public/app/features/plugins/routes.ts @@ -1,6 +1,26 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; +import { isGrafanaAdmin } from './admin/helpers'; +import { PluginAdminRoutes } from './admin/types'; + +const pluginAdminRoutes = [ + { + path: '/plugins', + routeName: PluginAdminRoutes.Home, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), + }, + { + path: '/plugins/browse', + routeName: PluginAdminRoutes.Browse, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), + }, + { + path: '/plugins/:pluginId/', + routeName: PluginAdminRoutes.Details, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')), + }, +]; export function getPluginsAdminRoutes(cfg = config): RouteDescriptor[] { if (!cfg.pluginAdminEnabled) { @@ -22,18 +42,26 @@ export function getPluginsAdminRoutes(cfg = config): RouteDescriptor[] { ]; } - return [ - { - path: '/plugins', - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), - }, - { - path: '/plugins/browse', - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), - }, - { - path: '/plugins/:pluginId/', - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')), - }, - ]; + if (isGrafanaAdmin()) { + return [ + ...pluginAdminRoutes, + { + path: '/admin/plugins', + routeName: PluginAdminRoutes.HomeAdmin, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), + }, + { + path: '/admin/plugins/browse', + routeName: PluginAdminRoutes.BrowseAdmin, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), + }, + { + path: '/admin/plugins/:pluginId/', + routeName: PluginAdminRoutes.DetailsAdmin, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')), + }, + ]; + } + + return pluginAdminRoutes; }