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:
Levente Balogh
2023-01-18 15:34:23 +01:00
committed by GitHub
parent 58a86133af
commit 4ef82dc73f
9 changed files with 226 additions and 29 deletions

View File

@ -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"))

View File

@ -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)

View File

@ -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",

View File

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

View File

@ -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 && (

View File

@ -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();
});
}); });

View File

@ -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 />}
</> </>

View File

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

View File

@ -0,0 +1 @@
export * from './NoAccessModal';