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 7f080c7f77.
This commit is contained in:
Levente Balogh 2023-01-18 15:34:23 +01:00 committed by GitHub
parent 58a86133af
commit 4ef82dc73f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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/new", authorize(reqOrgAdmin, datasources.NewPageAccess), 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/connect-data/datasources/:id", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
r.Get("/connections/connect-data/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
r.Get("/connections", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/connect-data", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), 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
appPluginIDScope := plugins.ScopeProvider.GetResourceScope(ac.Parameter(":id"))

View File

@ -11,6 +11,7 @@ import (
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
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/navtree"
"github.com/grafana/grafana/pkg/services/pluginsettings"
@ -26,6 +27,8 @@ func TestAddAppLinks(t *testing.T) {
permissions := []ac.Permission{
{Action: plugins.ActionAppAccess, Scope: "*"},
{Action: plugins.ActionInstall, Scope: "*"},
{Action: datasources.ActionCreate, Scope: "*"},
{Action: datasources.ActionRead, Scope: "*"},
}
testApp1 := plugins.PluginDTO{
@ -287,19 +290,22 @@ func TestAddAppLinks(t *testing.T) {
"/connections/connect-data": {SectionID: "connections"},
}
// Build nav-tree and check if the "Connections" page is there
treeRoot := navtree.NavTreeRoot{}
treeRoot.AddSection(service.buildDataConnectionsNavLink(reqCtx))
connectionsNode := treeRoot.FindById("connections")
require.NotNil(t, connectionsNode)
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]
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)
// 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)
// Check if the standalone plugin page appears under the section where we registered it
require.NoError(t, err)
require.Equal(t, "Connections", connectionsNode.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"
// Connect data
// FIXME: while we don't have a permissions for listing plugins the legacy check has to stay as a default
if plugins.ReqCanAdminPlugins(s.cfg)(c) || hasAccess(plugins.ReqCanAdminPlugins(s.cfg), plugins.AdminAccessEvaluator) {
if hasAccess(ac.ReqOrgAdmin, datasources.ConfigurationPageAccess) {
// Connect data
children = append(children, &navtree.NavLink{
Id: "connections-connect-data",
Text: "Connect data",
@ -580,9 +579,7 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
Url: s.cfg.AppSubURL + "/connections/connect-data",
Children: []*navtree.NavLink{},
})
}
if hasAccess(ac.ReqOrgAdmin, datasources.ConfigurationPageAccess) {
// Your connections
children = append(children, &navtree.NavLink{
Id: "connections-your-connections",

View File

@ -2,9 +2,8 @@ import * as React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
import { contextSrv } from 'app/core/core';
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 {
@ -18,7 +17,6 @@ import {
export default function Connections() {
const navIndex = useSelector((state: StoreState) => state.navIndex);
const isConnectDataPageOverriden = Boolean(navIndex['standalone-plugin-page-/connections/connect-data']);
const canAdminPlugins = contextSrv.hasPermission(AccessControlAction.PluginsInstall);
return (
<DataSourcesRoutesContext.Provider
@ -30,17 +28,7 @@ export default function Connections() {
}}
>
<Switch>
<Route
exact
path={ROUTES.Base}
component={() => {
if (canAdminPlugins) {
return <Redirect to={ROUTES.ConnectData} />;
}
return <Redirect to={ROUTES.DataSources} />;
}}
/>
<Route exact path={ROUTES.Base} component={() => <Redirect to={ROUTES.ConnectData} />} />
<Route
exact
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 {
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);
return (
<ul className={styles.sourcesList}>
{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>
<div className={styles.cardContent}>
{item.logo && (

View File

@ -3,9 +3,11 @@ import React from 'react';
import { Provider } from 'react-redux';
import { PluginType } from '@grafana/data';
import { contextSrv } from 'app/core/core';
import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__';
import { CatalogPlugin } from 'app/features/plugins/admin/types';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { ConnectData } from './ConnectData';
@ -28,7 +30,13 @@ const mockCatalogDataSourcePlugin = getCatalogPluginMock({
id: 'sample-data-source',
});
const originalHasPermission = contextSrv.hasPermission;
describe('Connect Data', () => {
beforeEach(() => {
contextSrv.hasPermission = originalHasPermission;
});
test('renders no results if the plugins list is empty', async () => {
renderPage();
@ -57,4 +65,38 @@ describe('Connect Data', () => {
fireEvent.change(searchField, { target: { value: 'cramp' } });
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 { useStyles2, LoadingPlaceholder } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { useGetAllWithFilters } from 'app/features/plugins/admin/state/hooks';
import { AccessControlAction } from 'app/types';
import { ROUTES } from '../../constants';
import { CardGrid } from './CardGrid';
import { CardGrid, type CardGridItem } from './CardGrid';
import { CategoryHeader } from './CategoryHeader';
import { NoAccessModal } from './NoAccessModal';
import { NoResults } from './NoResults';
import { Search } from './Search';
@ -16,11 +19,20 @@ const getStyles = () => ({
spacer: css`
height: 16px;
`,
modal: css`
width: 500px;
`,
modalContent: css`
overflow: visible;
`,
});
export function ConnectData() {
const [searchTerm, setSearchTerm] = useState('');
const [isNoAccessModalOpen, setIsNoAccessModalOpen] = useState(false);
const [focusedItem, setFocusedItem] = useState<CardGridItem | null>(null);
const styles = useStyles2(getStyles);
const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
const handleSearchChange = (e: React.FormEvent<HTMLInputElement>) => {
setSearchTerm(e.currentTarget.value.toLowerCase());
@ -37,15 +49,37 @@ export function ConnectData() {
plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
logo: plugin.info.logos.small,
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
})),
[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]);
return (
<>
{focusedItem && <NoAccessModal item={focusedItem} isOpen={isNoAccessModalOpen} onDismiss={closeModal} />}
<Search onChange={handleSearchChange} />
{/* We need this extra spacing when there are no filters */}
<div className={styles.spacer} />
@ -55,7 +89,7 @@ export function ConnectData() {
) : !!error ? (
<p>Error: {error.message}</p>
) : (
<CardGrid items={cardGridItems} />
<CardGrid items={cardGridItems} onClickItem={onClickCardGridItem} />
)}
{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';