mirror of
https://github.com/grafana/grafana.git
synced 2025-02-10 23:55:47 -06: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 7f080c7f77
.
This commit is contained in:
parent
58a86133af
commit
4ef82dc73f
@ -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"))
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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 && (
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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 />}
|
||||
</>
|
||||
|
@ -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';
|
Loading…
Reference in New Issue
Block a user