mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 05:11:50 +08:00
Connections: Show a "No access" modal if the user has no permissions (#61397)
* feat: add a new modal for displaying no-access info * feat(CardGrid): add an onClick handler for items * feat: open a no-access modal when clicking on a connection in the catlog * feat: update permissions Open a "No access" modal when the user clicks a connection type but has no permissions creating a datasource out of it * test: add tests for opening the No Access modal * test: fix the user permissions in tests * Wip * Revert "Wip" This reverts commit 7f080c7f772e22f59573f60bea31418080ffd6c7.
This commit is contained in:
@ -136,9 +136,10 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/connections/your-connections/datasources", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
r.Get("/connections/your-connections/datasources", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
||||||
r.Get("/connections/your-connections/datasources/new", authorize(reqOrgAdmin, datasources.NewPageAccess), hs.Index)
|
r.Get("/connections/your-connections/datasources/new", authorize(reqOrgAdmin, datasources.NewPageAccess), hs.Index)
|
||||||
r.Get("/connections/your-connections/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index)
|
r.Get("/connections/your-connections/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index)
|
||||||
r.Get("/connections/connect-data", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
|
r.Get("/connections", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
||||||
r.Get("/connections/connect-data/datasources/:id", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
|
r.Get("/connections/connect-data", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
||||||
r.Get("/connections/connect-data/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
|
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
|
||||||
|
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
|
||||||
|
|
||||||
// App Root Page
|
// App Root Page
|
||||||
appPluginIDScope := plugins.ScopeProvider.GetResourceScope(ac.Parameter(":id"))
|
appPluginIDScope := plugins.ScopeProvider.GetResourceScope(ac.Parameter(":id"))
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/navtree"
|
"github.com/grafana/grafana/pkg/services/navtree"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||||
@ -26,6 +27,8 @@ func TestAddAppLinks(t *testing.T) {
|
|||||||
permissions := []ac.Permission{
|
permissions := []ac.Permission{
|
||||||
{Action: plugins.ActionAppAccess, Scope: "*"},
|
{Action: plugins.ActionAppAccess, Scope: "*"},
|
||||||
{Action: plugins.ActionInstall, Scope: "*"},
|
{Action: plugins.ActionInstall, Scope: "*"},
|
||||||
|
{Action: datasources.ActionCreate, Scope: "*"},
|
||||||
|
{Action: datasources.ActionRead, Scope: "*"},
|
||||||
}
|
}
|
||||||
|
|
||||||
testApp1 := plugins.PluginDTO{
|
testApp1 := plugins.PluginDTO{
|
||||||
@ -287,19 +290,22 @@ func TestAddAppLinks(t *testing.T) {
|
|||||||
"/connections/connect-data": {SectionID: "connections"},
|
"/connections/connect-data": {SectionID: "connections"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build nav-tree and check if the "Connections" page is there
|
||||||
treeRoot := navtree.NavTreeRoot{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
treeRoot.AddSection(service.buildDataConnectionsNavLink(reqCtx))
|
treeRoot.AddSection(service.buildDataConnectionsNavLink(reqCtx))
|
||||||
connectionsNode := treeRoot.FindById("connections")
|
connectionsNode := treeRoot.FindById("connections")
|
||||||
|
require.NotNil(t, connectionsNode)
|
||||||
require.Equal(t, "Connections", connectionsNode.Text)
|
require.Equal(t, "Connections", connectionsNode.Text)
|
||||||
|
|
||||||
|
// Check if the original "Connect data" page (served by core) is there until we add the standalone plugin page
|
||||||
connectDataNode := connectionsNode.Children[0]
|
connectDataNode := connectionsNode.Children[0]
|
||||||
require.Equal(t, "Connect data", connectDataNode.Text)
|
require.Equal(t, "Connect data", connectDataNode.Text)
|
||||||
require.Equal(t, "connections-connect-data", connectDataNode.Id) // Original "Connect data" page
|
require.Equal(t, "connections-connect-data", connectDataNode.Id)
|
||||||
require.Equal(t, "", connectDataNode.PluginID)
|
require.Equal(t, "", connectDataNode.PluginID)
|
||||||
|
|
||||||
|
// Check if the standalone plugin page appears under the section where we registered it and if it overrides the original page
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
|
|
||||||
// Check if the standalone plugin page appears under the section where we registered it
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "Connections", connectionsNode.Text)
|
require.Equal(t, "Connections", connectionsNode.Text)
|
||||||
require.Equal(t, "Connect data", connectDataNode.Text)
|
require.Equal(t, "Connect data", connectDataNode.Text)
|
||||||
|
@ -569,9 +569,8 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
|
|||||||
|
|
||||||
baseUrl := s.cfg.AppSubURL + "/connections"
|
baseUrl := s.cfg.AppSubURL + "/connections"
|
||||||
|
|
||||||
// Connect data
|
if hasAccess(ac.ReqOrgAdmin, datasources.ConfigurationPageAccess) {
|
||||||
// FIXME: while we don't have a permissions for listing plugins the legacy check has to stay as a default
|
// Connect data
|
||||||
if plugins.ReqCanAdminPlugins(s.cfg)(c) || hasAccess(plugins.ReqCanAdminPlugins(s.cfg), plugins.AdminAccessEvaluator) {
|
|
||||||
children = append(children, &navtree.NavLink{
|
children = append(children, &navtree.NavLink{
|
||||||
Id: "connections-connect-data",
|
Id: "connections-connect-data",
|
||||||
Text: "Connect data",
|
Text: "Connect data",
|
||||||
@ -580,9 +579,7 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
|
|||||||
Url: s.cfg.AppSubURL + "/connections/connect-data",
|
Url: s.cfg.AppSubURL + "/connections/connect-data",
|
||||||
Children: []*navtree.NavLink{},
|
Children: []*navtree.NavLink{},
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if hasAccess(ac.ReqOrgAdmin, datasources.ConfigurationPageAccess) {
|
|
||||||
// Your connections
|
// Your connections
|
||||||
children = append(children, &navtree.NavLink{
|
children = append(children, &navtree.NavLink{
|
||||||
Id: "connections-your-connections",
|
Id: "connections-your-connections",
|
||||||
|
@ -2,9 +2,8 @@ import * as React from 'react';
|
|||||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||||
|
|
||||||
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
|
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
|
||||||
import { contextSrv } from 'app/core/core';
|
|
||||||
import { DataSourcesRoutesContext } from 'app/features/datasources/state';
|
import { DataSourcesRoutesContext } from 'app/features/datasources/state';
|
||||||
import { AccessControlAction, StoreState, useSelector } from 'app/types';
|
import { StoreState, useSelector } from 'app/types';
|
||||||
|
|
||||||
import { ROUTES } from './constants';
|
import { ROUTES } from './constants';
|
||||||
import {
|
import {
|
||||||
@ -18,7 +17,6 @@ import {
|
|||||||
export default function Connections() {
|
export default function Connections() {
|
||||||
const navIndex = useSelector((state: StoreState) => state.navIndex);
|
const navIndex = useSelector((state: StoreState) => state.navIndex);
|
||||||
const isConnectDataPageOverriden = Boolean(navIndex['standalone-plugin-page-/connections/connect-data']);
|
const isConnectDataPageOverriden = Boolean(navIndex['standalone-plugin-page-/connections/connect-data']);
|
||||||
const canAdminPlugins = contextSrv.hasPermission(AccessControlAction.PluginsInstall);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataSourcesRoutesContext.Provider
|
<DataSourcesRoutesContext.Provider
|
||||||
@ -30,17 +28,7 @@ export default function Connections() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route exact path={ROUTES.Base} component={() => <Redirect to={ROUTES.ConnectData} />} />
|
||||||
exact
|
|
||||||
path={ROUTES.Base}
|
|
||||||
component={() => {
|
|
||||||
if (canAdminPlugins) {
|
|
||||||
return <Redirect to={ROUTES.ConnectData} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Redirect to={ROUTES.DataSources} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={ROUTES.YourConnections}
|
path={ROUTES.YourConnections}
|
||||||
|
@ -39,17 +39,28 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type CardGridItem = { id: string; name: string; description: string; url: string; logo?: string };
|
||||||
export interface CardGridProps {
|
export interface CardGridProps {
|
||||||
items: Array<{ id: string; name: string; url: string; logo?: string }>;
|
items: CardGridItem[];
|
||||||
|
onClickItem?: (e: React.MouseEvent<HTMLElement>, item: CardGridItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardGrid: FC<CardGridProps> = ({ items }) => {
|
export const CardGrid: FC<CardGridProps> = ({ items, onClickItem }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={styles.sourcesList}>
|
<ul className={styles.sourcesList}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.id} className={styles.card} href={item.url}>
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className={styles.card}
|
||||||
|
href={item.url}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (onClickItem) {
|
||||||
|
onClickItem(e, item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card.Heading>
|
<Card.Heading>
|
||||||
<div className={styles.cardContent}>
|
<div className={styles.cardContent}>
|
||||||
{item.logo && (
|
{item.logo && (
|
||||||
|
@ -3,9 +3,11 @@ import React from 'react';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { PluginType } from '@grafana/data';
|
import { PluginType } from '@grafana/data';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__';
|
import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__';
|
||||||
import { CatalogPlugin } from 'app/features/plugins/admin/types';
|
import { CatalogPlugin } from 'app/features/plugins/admin/types';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
import { ConnectData } from './ConnectData';
|
import { ConnectData } from './ConnectData';
|
||||||
|
|
||||||
@ -28,7 +30,13 @@ const mockCatalogDataSourcePlugin = getCatalogPluginMock({
|
|||||||
id: 'sample-data-source',
|
id: 'sample-data-source',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const originalHasPermission = contextSrv.hasPermission;
|
||||||
|
|
||||||
describe('Connect Data', () => {
|
describe('Connect Data', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
contextSrv.hasPermission = originalHasPermission;
|
||||||
|
});
|
||||||
|
|
||||||
test('renders no results if the plugins list is empty', async () => {
|
test('renders no results if the plugins list is empty', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@ -57,4 +65,38 @@ describe('Connect Data', () => {
|
|||||||
fireEvent.change(searchField, { target: { value: 'cramp' } });
|
fireEvent.change(searchField, { target: { value: 'cramp' } });
|
||||||
expect(screen.queryByText('No results matching your query were found.')).toBeInTheDocument();
|
expect(screen.queryByText('No results matching your query were found.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows a "No access" modal if the user does not have permissions to create datasources', async () => {
|
||||||
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockImplementation((permission: string) => {
|
||||||
|
if (permission === AccessControlAction.DataSourcesCreate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]);
|
||||||
|
const exampleSentenceInModal = 'Editors cannot add new connections.';
|
||||||
|
|
||||||
|
// Should not show the modal by default
|
||||||
|
expect(screen.queryByText(new RegExp(exampleSentenceInModal))).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show the modal if the user has no permissions
|
||||||
|
fireEvent.click(await screen.findByText('Sample data source'));
|
||||||
|
expect(screen.queryByText(new RegExp(exampleSentenceInModal))).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show a "No access" modal but displays the details page if the user has the right permissions', async () => {
|
||||||
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]);
|
||||||
|
const exampleSentenceInModal = 'Editors cannot add new connections.';
|
||||||
|
|
||||||
|
// Should not show the modal by default
|
||||||
|
expect(screen.queryByText(new RegExp(exampleSentenceInModal))).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not show the modal when clicking a card
|
||||||
|
fireEvent.click(await screen.findByText('Sample data source'));
|
||||||
|
expect(screen.queryByText(new RegExp(exampleSentenceInModal))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,12 +3,15 @@ import React, { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { PluginType } from '@grafana/data';
|
import { PluginType } from '@grafana/data';
|
||||||
import { useStyles2, LoadingPlaceholder } from '@grafana/ui';
|
import { useStyles2, LoadingPlaceholder } from '@grafana/ui';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
import { useGetAllWithFilters } from 'app/features/plugins/admin/state/hooks';
|
import { useGetAllWithFilters } from 'app/features/plugins/admin/state/hooks';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
import { ROUTES } from '../../constants';
|
import { ROUTES } from '../../constants';
|
||||||
|
|
||||||
import { CardGrid } from './CardGrid';
|
import { CardGrid, type CardGridItem } from './CardGrid';
|
||||||
import { CategoryHeader } from './CategoryHeader';
|
import { CategoryHeader } from './CategoryHeader';
|
||||||
|
import { NoAccessModal } from './NoAccessModal';
|
||||||
import { NoResults } from './NoResults';
|
import { NoResults } from './NoResults';
|
||||||
import { Search } from './Search';
|
import { Search } from './Search';
|
||||||
|
|
||||||
@ -16,11 +19,20 @@ const getStyles = () => ({
|
|||||||
spacer: css`
|
spacer: css`
|
||||||
height: 16px;
|
height: 16px;
|
||||||
`,
|
`,
|
||||||
|
modal: css`
|
||||||
|
width: 500px;
|
||||||
|
`,
|
||||||
|
modalContent: css`
|
||||||
|
overflow: visible;
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ConnectData() {
|
export function ConnectData() {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [isNoAccessModalOpen, setIsNoAccessModalOpen] = useState(false);
|
||||||
|
const [focusedItem, setFocusedItem] = useState<CardGridItem | null>(null);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
||||||
|
|
||||||
const handleSearchChange = (e: React.FormEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
setSearchTerm(e.currentTarget.value.toLowerCase());
|
setSearchTerm(e.currentTarget.value.toLowerCase());
|
||||||
@ -37,15 +49,37 @@ export function ConnectData() {
|
|||||||
plugins.map((plugin) => ({
|
plugins.map((plugin) => ({
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
logo: plugin.info.logos.small,
|
logo: plugin.info.logos.small,
|
||||||
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
|
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
|
||||||
})),
|
})),
|
||||||
[plugins]
|
[plugins]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onClickCardGridItem = (e: React.MouseEvent<HTMLElement>, item: CardGridItem) => {
|
||||||
|
if (!canCreateDataSources) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
openModal(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = (item: CardGridItem) => {
|
||||||
|
setIsNoAccessModalOpen(true);
|
||||||
|
setFocusedItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsNoAccessModalOpen(false);
|
||||||
|
setFocusedItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
const showNoResults = useMemo(() => !isLoading && !error && plugins.length < 1, [isLoading, error, plugins]);
|
const showNoResults = useMemo(() => !isLoading && !error && plugins.length < 1, [isLoading, error, plugins]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{focusedItem && <NoAccessModal item={focusedItem} isOpen={isNoAccessModalOpen} onDismiss={closeModal} />}
|
||||||
<Search onChange={handleSearchChange} />
|
<Search onChange={handleSearchChange} />
|
||||||
{/* We need this extra spacing when there are no filters */}
|
{/* We need this extra spacing when there are no filters */}
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
@ -55,7 +89,7 @@ export function ConnectData() {
|
|||||||
) : !!error ? (
|
) : !!error ? (
|
||||||
<p>Error: {error.message}</p>
|
<p>Error: {error.message}</p>
|
||||||
) : (
|
) : (
|
||||||
<CardGrid items={cardGridItems} />
|
<CardGrid items={cardGridItems} onClickItem={onClickCardGridItem} />
|
||||||
)}
|
)}
|
||||||
{showNoResults && <NoResults />}
|
{showNoResults && <NoResults />}
|
||||||
</>
|
</>
|
||||||
|
@ -0,0 +1,117 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { useStyles2, Modal, Icon, Button } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { type CardGridItem } from '../CardGrid';
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
modal: css`
|
||||||
|
width: 500px;
|
||||||
|
`,
|
||||||
|
modalContent: css`
|
||||||
|
overflow: visible;
|
||||||
|
color: ${theme.colors.text.secondary};
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: ${theme.colors.text.link};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
description: css`
|
||||||
|
margin-bottom: ${theme.spacing.gridSize * 2}px;
|
||||||
|
`,
|
||||||
|
bottomSection: css`
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid ${theme.colors.border.weak};
|
||||||
|
padding-top: ${theme.spacing.gridSize * 3}px;
|
||||||
|
margin-top: ${theme.spacing.gridSize * 3}px;
|
||||||
|
`,
|
||||||
|
actionsSection: css`
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
margin-top: ${theme.spacing.gridSize * 3}px;
|
||||||
|
`,
|
||||||
|
warningIcon: css`
|
||||||
|
color: ${theme.colors.warning.main};
|
||||||
|
padding-right: ${theme.spacing.gridSize}px;
|
||||||
|
margin-top: ${theme.spacing.gridSize / 4}px;
|
||||||
|
`,
|
||||||
|
header: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`,
|
||||||
|
headerTitle: css`
|
||||||
|
margin: 0;
|
||||||
|
`,
|
||||||
|
headerLogo: css`
|
||||||
|
margin-right: ${theme.spacing.gridSize * 2}px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NoAccessModalProps = {
|
||||||
|
item: CardGridItem;
|
||||||
|
isOpen: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NoAccessModal({ item, isOpen, onDismiss }: NoAccessModalProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className={styles.modal}
|
||||||
|
contentClassName={styles.modalContent}
|
||||||
|
title={<NoAccessModalHeader item={item} />}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{item.description && <div className={styles.description}>{item.description}</div>}
|
||||||
|
<div>
|
||||||
|
Links
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={`https://grafana.com/grafana/plugins/${item.id}`}
|
||||||
|
title={`${item.name} on Grafana.com`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.bottomSection}>
|
||||||
|
<div className={styles.warningIcon}>
|
||||||
|
<Icon name="exclamation-triangle" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Editors cannot add new connections. You may check to see if it is already configured in{' '}
|
||||||
|
<a href="/connections/your-connections">Your Connections</a>.
|
||||||
|
</p>
|
||||||
|
<p>To add a new connection, contact your Grafana admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actionsSection}>
|
||||||
|
<Button onClick={onDismiss}>Okay</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoAccessModalHeader({ item }: { item: CardGridItem }) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.header}>
|
||||||
|
{item.logo && <img className={styles.headerLogo} src={item.logo} alt={`logo of ${item.name}`} />}
|
||||||
|
<h4 className={styles.headerTitle}>{item.name}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export * from './NoAccessModal';
|
Reference in New Issue
Block a user