diff --git a/pkg/api/api.go b/pkg/api/api.go index a1a689978cd..41cadf05fb9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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")) diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index ba32c38c9b4..79d39b287ca 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -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) diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 748c0c7ecff..3a68ecb7a56 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -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", diff --git a/public/app/features/connections/Connections.tsx b/public/app/features/connections/Connections.tsx index b2f4c682d57..b6877dadfb1 100644 --- a/public/app/features/connections/Connections.tsx +++ b/public/app/features/connections/Connections.tsx @@ -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 ( - { - if (canAdminPlugins) { - return ; - } - - return ; - }} - /> + } /> ({ `, }); +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, item: CardGridItem) => void; } -export const CardGrid: FC = ({ items }) => { +export const CardGrid: FC = ({ items, onClickItem }) => { const styles = useStyles2(getStyles); return (
    {items.map((item) => ( - + { + if (onClickItem) { + onClickItem(e, item); + } + }} + >
    {item.logo && ( diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx index a585ab8d1fb..f726b80774a 100644 --- a/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx +++ b/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx @@ -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(); + }); }); diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx index f9fcc45fcca..64090c47740 100644 --- a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx +++ b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx @@ -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(null); const styles = useStyles2(getStyles); + const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); const handleSearchChange = (e: React.FormEvent) => { 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, 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 && } {/* We need this extra spacing when there are no filters */}
    @@ -55,7 +89,7 @@ export function ConnectData() { ) : !!error ? (

    Error: {error.message}

    ) : ( - + )} {showNoResults && } diff --git a/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx b/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx new file mode 100644 index 00000000000..4e8b8580dac --- /dev/null +++ b/public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx @@ -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 ( + } + isOpen={isOpen} + onDismiss={onDismiss} + > +
    +
    + {item.description &&
    {item.description}
    } +
    + Links +
    + + {item.name} + +
    +
    +
    +
    + +
    +
    +

    + Editors cannot add new connections. You may check to see if it is already configured in{' '} + Your Connections. +

    +

    To add a new connection, contact your Grafana admin.

    +
    +
    +
    + +
    +
    +
    + ); +} + +export function NoAccessModalHeader({ item }: { item: CardGridItem }) { + const styles = useStyles2(getStyles); + return ( +
    +
    + {item.logo && {`logo} +

    {item.name}

    +
    +
    + ); +} diff --git a/public/app/features/connections/tabs/ConnectData/NoAccessModal/index.tsx b/public/app/features/connections/tabs/ConnectData/NoAccessModal/index.tsx new file mode 100644 index 00000000000..562cce192d4 --- /dev/null +++ b/public/app/features/connections/tabs/ConnectData/NoAccessModal/index.tsx @@ -0,0 +1 @@ +export * from './NoAccessModal';