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;
}