diff --git a/src/components/AddNewCheckButton/AddNewCheckButton.tsx b/src/components/AddNewCheckButton/AddNewCheckButton.tsx index 91726af41..7134d065d 100644 --- a/src/components/AddNewCheckButton/AddNewCheckButton.tsx +++ b/src/components/AddNewCheckButton/AddNewCheckButton.tsx @@ -2,19 +2,20 @@ import React from 'react'; import { Button } from '@grafana/ui'; import { ROUTES } from 'routing/types'; -import { useCanWriteSM } from 'hooks/useDSPermission'; +import { getUserPermissions } from 'data/permissions'; import { useNavigation } from 'hooks/useNavigation'; export function AddNewCheckButton() { const navigate = useNavigation(); - const canEdit = useCanWriteSM(); - - if (!canEdit) { - return null; - } + const { canWriteChecks } = getUserPermissions(); return ( - ); diff --git a/src/components/AppInitializer.tsx b/src/components/AppInitializer.tsx index c4d894708..26e801119 100644 --- a/src/components/AppInitializer.tsx +++ b/src/components/AppInitializer.tsx @@ -6,9 +6,11 @@ import { DataTestIds } from 'test/dataTestIds'; import { hasGlobalPermission } from 'utils'; import { ROUTES } from 'routing/types'; +import { getUserPermissions } from 'data/permissions'; import { useAppInitializer } from 'hooks/useAppInitializer'; import { useMeta } from 'hooks/useMeta'; import { MismatchedDatasourceModal } from 'components/MismatchedDatasourceModal'; +import { ContactAdminAlert } from 'page/ContactAdminAlert'; interface Props { redirectTo?: ROUTES; @@ -19,7 +21,10 @@ interface Props { export const AppInitializer = ({ redirectTo, buttonText }: PropsWithChildren) => { const { jsonData } = useMeta(); const styles = useStyles2(getStyles); - const canInitialize = hasGlobalPermission(`datasources:create`); + const { canWritePlugin } = getUserPermissions(); + + const canReadDs = hasGlobalPermission(`datasources:read`); + const canInitialize = canWritePlugin && hasGlobalPermission(`datasources:create`); const { error, @@ -35,11 +40,13 @@ export const AppInitializer = ({ redirectTo, buttonText }: PropsWithChildren; + } + if (!canInitialize) { return ( - - Contact your administrator to get you started. - + ); } diff --git a/src/components/CheckForm/CheckForm.tsx b/src/components/CheckForm/CheckForm.tsx index 96e16914d..71f8cddb8 100644 --- a/src/components/CheckForm/CheckForm.tsx +++ b/src/components/CheckForm/CheckForm.tsx @@ -12,9 +12,10 @@ import { createNavModel } from 'utils'; import { ROUTES } from 'routing/types'; import { generateRoutePath } from 'routing/utils'; import { AdHocCheckResponse } from 'datasource/responses.types'; +import { getUserPermissions } from 'data/permissions'; import { useCheckTypeGroupOption } from 'hooks/useCheckTypeGroupOptions'; import { useCheckTypeOptions } from 'hooks/useCheckTypeOptions'; -import { useCanReadLogs, useCanWriteSM } from 'hooks/useDSPermission'; +import { useCanReadLogs } from 'hooks/useDSPermission'; import { useLimits } from 'hooks/useLimits'; import { toFormValues } from 'components/CheckEditor/checkFormTransformations'; import { CheckJobName } from 'components/CheckEditor/FormComponents/CheckJobName'; @@ -72,7 +73,7 @@ type CheckFormProps = { }; export const CheckForm = ({ check, disabled }: CheckFormProps) => { - const canEdit = useCanWriteSM(); + const { canWriteChecks } = getUserPermissions(); const canReadLogs = useCanReadLogs(); const [openTestCheckModal, setOpenTestCheckModal] = useState(false); const [adhocTestData, setAdhocTestData] = useState(); @@ -90,7 +91,7 @@ export const CheckForm = ({ check, disabled }: CheckFormProps) => { isOverCheckLimit || (checkType === CheckType.Browser && isOverBrowserLimit) || ([CheckType.MULTI_HTTP, CheckType.Scripted].includes(checkType) && isOverScriptedLimit); - const isDisabled = disabled || !canEdit || getLimitDisabled({ isExistingCheck, isLoading, overLimit }); + const isDisabled = disabled || !canWriteChecks || getLimitDisabled({ isExistingCheck, isLoading, overLimit }); const formMethods = useForm({ defaultValues: toFormValues(initialCheck, checkType), diff --git a/src/components/ConfigActions.test.tsx b/src/components/ConfigActions.test.tsx deleted file mode 100644 index 514290f93..000000000 --- a/src/components/ConfigActions.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import { render } from 'test/render'; - -import { hasGlobalPermission } from 'utils'; -import { ConfigActions } from 'components/ConfigActions'; - -jest.mock('utils', () => { - return { - ...jest.requireActual('utils'), - hasGlobalPermission: jest.fn().mockReturnValue(true), - }; -}); - -it('shows disable option when activated', async () => { - render(); - - const disableButton = await screen.findByText('Disable synthetic monitoring'); - expect(disableButton).toBeInTheDocument(); -}); - -it('shows enable action when disabled', async () => { - render(, { - meta: { - enabled: false, - }, - }); - - const enableButton = await screen.findByText('Enable plugin'); - expect(enableButton).toBeInTheDocument(); -}); - -it('shows setup action when not intialized', async () => { - render(); - const setupButton = await screen.findByText('Setup'); - expect(setupButton).toBeInTheDocument(); -}); - -it(`doesn't show any config actions when the user doesn't have write permissions`, async () => { - jest.mocked(hasGlobalPermission).mockReturnValue(false); - - render(); - - expect(screen.queryByText('Disable synthetic monitoring')).not.toBeInTheDocument(); - expect(screen.queryByText('Enable plugin')).not.toBeInTheDocument(); - expect(screen.queryByText('Setup')).not.toBeInTheDocument(); -}); - -it(`doesn't show any config actions when the user doesn't have write permissions and meta enabled is false`, async () => { - jest.mocked(hasGlobalPermission).mockReturnValue(false); - - render(, { - meta: { - enabled: false, - }, - }); - - expect(screen.queryByText('Disable synthetic monitoring')).not.toBeInTheDocument(); - expect(screen.queryByText('Enable plugin')).not.toBeInTheDocument(); - expect(screen.queryByText('Setup')).not.toBeInTheDocument(); -}); diff --git a/src/components/ConfigActions.tsx b/src/components/ConfigActions.tsx deleted file mode 100644 index 17ad11ad2..000000000 --- a/src/components/ConfigActions.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useState } from 'react'; -import { getBackendSrv } from '@grafana/runtime'; -import { Button, LinkButton } from '@grafana/ui'; - -import { hasGlobalPermission } from 'utils'; -import { ROUTES } from 'routing/types'; -import { getRoute } from 'routing/utils'; -import { useMeta } from 'hooks/useMeta'; - -import { DisablePluginModal } from './DisablePluginModal'; - -export const ConfigActions = ({ initialized }: { initialized?: boolean }) => { - const [showDisableModal, setShowDisableModal] = useState(false); - const meta = useMeta(); - const canEdit = hasGlobalPermission(`plugins:write`); - - const handleEnable = async () => { - await getBackendSrv() - .fetch({ - url: `/api/plugins/${meta.id}/settings`, - method: 'POST', - data: { - enabled: true, - pinned: true, - }, - }) - .toPromise(); - window.location.reload(); - }; - - if (!canEdit) { - return null; - } - - if (!meta.enabled) { - return ( - - ); - } - - if (initialized) { - return ( - <> - - setShowDisableModal(false)} /> - - ); - } - - return ( - - Setup - - ); -}; diff --git a/src/components/DeleteProbeButton/DeleteProbeButton.tsx b/src/components/DeleteProbeButton/DeleteProbeButton.tsx index 2910201b5..13e930873 100644 --- a/src/components/DeleteProbeButton/DeleteProbeButton.tsx +++ b/src/components/DeleteProbeButton/DeleteProbeButton.tsx @@ -19,8 +19,10 @@ export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }: }, [_onDeleteSuccess]); const { mutateAsync: deleteProbe, isPending } = useDeleteProbe({ onSuccess: onDeleteSuccess }); - const canEdit = useCanEditProbe(probe); - const canDelete = canEdit && !probe.checks.length; + + const { canDeleteProbes } = useCanEditProbe(); + + const canDelete = canDeleteProbes && !probe.checks.length; const styles = getStyles(); const [error, setError] = useState(); @@ -37,7 +39,7 @@ export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }: }; if (!canDelete) { - const tooltipContent = canEdit ? ( + const tooltipContent = canDeleteProbes ? ( <> Unable to delete the probe because it is currently in use.
@@ -53,7 +55,7 @@ export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }: // Both tooltip component and button prob is used for accessibility reasons return ( - + diff --git a/src/components/LinkedDatasourceView.tsx b/src/components/LinkedDatasourceView.tsx index e9b32b381..0d2d7e803 100644 --- a/src/components/LinkedDatasourceView.tsx +++ b/src/components/LinkedDatasourceView.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Alert, Card, Tag } from '@grafana/ui'; -import { useCanWriteLogs, useCanWriteMetrics, useCanWriteSM } from 'hooks/useDSPermission'; +import { getUserPermissions } from 'data/permissions'; +import { useCanWriteLogs, useCanWriteMetrics } from 'hooks/useDSPermission'; import { useLogsDS } from 'hooks/useLogsDS'; import { useMetricsDS } from 'hooks/useMetricsDS'; import { useSMDS } from 'hooks/useSMDS'; @@ -15,14 +16,14 @@ export const LinkedDatasourceView = ({ type }: LinkedDatasourceViewProps) => { const logsDS = useLogsDS(); const smDS = useSMDS(); - const canEditSM = useCanWriteSM(); + const { canWriteSM } = getUserPermissions(); const canEditLogs = useCanWriteLogs(); const canEditMetrics = useCanWriteMetrics(); const canEditMap = { prometheus: canEditMetrics, loki: canEditLogs, - 'synthetic-monitoring-datasource': canEditSM, + 'synthetic-monitoring-datasource': canWriteSM, }; const dsMap = { diff --git a/src/components/ProbeCard/ProbeCard.test.tsx b/src/components/ProbeCard/ProbeCard.test.tsx index 7e7c818fb..5e9aabd39 100644 --- a/src/components/ProbeCard/ProbeCard.test.tsx +++ b/src/components/ProbeCard/ProbeCard.test.tsx @@ -6,7 +6,7 @@ import { userEvent } from '@testing-library/user-event'; import { DataTestIds } from 'test/dataTestIds'; import { OFFLINE_PROBE, ONLINE_PROBE, PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes'; import { render } from 'test/render'; -import { probeToExtendedProbe, runTestAsViewer } from 'test/utils'; +import { probeToExtendedProbe, runTestAsRBACReader, runTestAsViewer } from 'test/utils'; import { type ExtendedProbe } from 'types'; import { ROUTES } from 'routing/types'; @@ -92,6 +92,18 @@ it(`Displays the correct information for a private probe as a viewer`, async () expect(button).toHaveTextContent('View'); }); +it(`Displays the correct information for a private probe as a RBAC viewer`, async () => { + runTestAsRBACReader(); + const probe = probeToExtendedProbe(PRIVATE_PROBE); + + render(); + await screen.findByText(probe.name, { exact: false }); + + const button = screen.getByTestId('probe-card-action-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('View'); +}); + it(`Displays the correct information for a public probe`, async () => { const probe = probeToExtendedProbe(PUBLIC_PROBE); diff --git a/src/components/ProbeCard/ProbeCard.tsx b/src/components/ProbeCard/ProbeCard.tsx index 0e81c3de1..6824d5561 100644 --- a/src/components/ProbeCard/ProbeCard.tsx +++ b/src/components/ProbeCard/ProbeCard.tsx @@ -15,8 +15,8 @@ import { ProbeLabels } from './ProbeLabels'; import { ProbeStatus } from './ProbeStatus'; export const ProbeCard = ({ probe }: { probe: ExtendedProbe }) => { - const canEdit = useCanEditProbe(probe); - const probeEditHref = generateRoutePath(canEdit ? ROUTES.EditProbe : ROUTES.ViewProbe, { id: probe.id! }); + const { canWriteProbes } = useCanEditProbe(probe); + const probeEditHref = generateRoutePath(canWriteProbes ? ROUTES.EditProbe : ROUTES.ViewProbe, { id: probe.id! }); const labelsString = labelsToString(probe.labels); const styles = useStyles2(getStyles2); @@ -55,7 +55,7 @@ export const ProbeCard = ({ probe }: { probe: ExtendedProbe }) => { - {canEdit ? ( + {canWriteProbes ? ( <> { await assertUneditable(); }); +it('the form is uneditable when logged in as a RBAC viewer', async () => { + runTestAsRBACReader(); + await renderProbeEditor(); + await assertUneditable(); +}); + it('the form actions are unavailable when viewing a public probe', async () => { await renderProbeEditor({ probe: PUBLIC_PROBE }); await assertNoActions(); @@ -124,6 +130,12 @@ it('should render the form in read mode when passing `forceReadMode`', async () await assertUneditable(); }); +it('the form actions are unavailable as a RBAC viewer', async () => { + runTestAsRBACReader(); + await renderProbeEditor(); + await assertNoActions(); +}); + async function assertUneditable() { const nameInput = await screen.findByLabelText('Probe Name', { exact: false }); expect(nameInput).toBeDisabled(); diff --git a/src/components/ProbeEditor/ProbeEditor.tsx b/src/components/ProbeEditor/ProbeEditor.tsx index 4a91e7508..a13e41874 100644 --- a/src/components/ProbeEditor/ProbeEditor.tsx +++ b/src/components/ProbeEditor/ProbeEditor.tsx @@ -36,8 +36,8 @@ export const ProbeEditor = ({ forceViewMode, // When true, the form is in view mode }: ProbeEditorProps) => { const styles = useStyles2(getStyles); - const canEdit = useCanEditProbe(probe); - const writeMode = canEdit && !forceViewMode; + const { canWriteProbes } = useCanEditProbe(probe); + const writeMode = canWriteProbes && !forceViewMode; const form = useForm({ defaultValues: probe, resolver: zodResolver(ProbeSchema) }); const { latitude, longitude } = form.watch(); const handleSubmit = form.handleSubmit((formValues: Probe) => onSubmit(formValues)); @@ -164,7 +164,7 @@ export const ProbeEditor = ({ /> - {canEdit && disabled={!writeMode} labelDestination={'probe'} />} + {canWriteProbes && disabled={!writeMode} labelDestination={'probe'} />}
Capabilities
- {canEdit && ( + {canWriteProbes && ( <>
- {canEdit && ( + {canWriteProbes && ( @@ -109,12 +118,17 @@ const AlertingPageContent = () => { key={`${alertRule.alert}-${index}`} rule={alertRule} onSubmit={getUpdateRules(index)} - canEdit={canEdit} + canEdit={canWriteAlerts} /> ))} {Boolean(alertRules?.length) ? ( - @@ -142,14 +156,6 @@ const AlertingPageContent = () => { ); }; -const InsufficientPermissions = () => { - return ( - - You do not have the appropriate permissions to read the alert rules. To request access contact your administrator. - - ); -}; - const getStyles = (theme: GrafanaTheme2) => ({ emptyCard: css({ backgroundColor: theme.colors.background.secondary, diff --git a/src/page/CheckList/components/BulkActions.tsx b/src/page/CheckList/components/BulkActions.tsx index 804995e32..b2490971a 100644 --- a/src/page/CheckList/components/BulkActions.tsx +++ b/src/page/CheckList/components/BulkActions.tsx @@ -4,8 +4,8 @@ import { Button, ButtonCascader, ConfirmModal, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { Check } from 'types'; +import { getUserPermissions } from 'data/permissions'; import { useBulkDeleteChecks, useBulkUpdateChecks } from 'data/useChecks'; -import { useCanWriteSM } from 'hooks/useDSPermission'; import { BulkActionsModal } from 'page/CheckList/components/BulkActionsModal'; interface BulkActionsProps { @@ -19,7 +19,7 @@ enum BulkAction { } export const BulkActions = ({ checks, onResolved }: BulkActionsProps) => { - const canEdit = useCanWriteSM(); + const { canWriteChecks, canDeleteChecks } = getUserPermissions(); const styles = useStyles2(getStyles); const [bulkEditAction, setBulkEditAction] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -62,7 +62,7 @@ export const BulkActions = ({ checks, onResolved }: BulkActionsProps) => { value: BulkAction.Remove, }, ]} - disabled={!canEdit} + disabled={!canWriteChecks} onChange={(value: string[]) => { const action = value[0] as BulkAction; setBulkEditAction(action); @@ -71,10 +71,22 @@ export const BulkActions = ({ checks, onResolved }: BulkActionsProps) => { Bulk Edit Probes )} - - @@ -83,7 +95,7 @@ export const BulkActions = ({ checks, onResolved }: BulkActionsProps) => { variant="destructive" fill="text" onClick={() => setShowDeleteModal(true)} - disabled={!canEdit} + disabled={!canDeleteChecks} > Delete diff --git a/src/page/CheckList/components/CheckItemActionButtons.tsx b/src/page/CheckList/components/CheckItemActionButtons.tsx index 9075f78e7..99b780091 100644 --- a/src/page/CheckList/components/CheckItemActionButtons.tsx +++ b/src/page/CheckList/components/CheckItemActionButtons.tsx @@ -6,8 +6,8 @@ import { css } from '@emotion/css'; import { Check } from 'types'; import { ROUTES } from 'routing/types'; import { generateRoutePath, getRoute } from 'routing/utils'; +import { getUserPermissions } from 'data/permissions'; import { useDeleteCheck } from 'data/useChecks'; -import { useCanReadMetrics, useCanWriteSM } from 'hooks/useDSPermission'; interface CheckItemActionButtonsProps { check: Check; @@ -15,8 +15,7 @@ interface CheckItemActionButtonsProps { } export const CheckItemActionButtons = ({ check, viewDashboardAsIcon }: CheckItemActionButtonsProps) => { - const canEdit = useCanWriteSM(); - const canReadMetrics = useCanReadMetrics(); + const { canReadChecks, canWriteChecks, canDeleteChecks } = getUserPermissions(); const styles = useStyles2(getStyles); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -24,7 +23,7 @@ export const CheckItemActionButtons = ({ check, viewDashboardAsIcon }: CheckItem return (
- {canReadMetrics && ( + {canReadChecks && ( <> {viewDashboardAsIcon ? ( @@ -54,7 +53,7 @@ export const CheckItemActionButtons = ({ check, viewDashboardAsIcon }: CheckItem tooltip="Delete check" name="trash-alt" onClick={() => setShowDeleteModal(true)} - disabled={!canEdit} + disabled={!canDeleteChecks} /> { - const canEdit = useCanWriteSM(); + const { canWriteChecks, canWriteThresholds } = getUserPermissions(); + const styles = useStyles2(getStyles); const [showThresholdModal, setShowThresholdModal] = useState(false); const hasChecks = checks.length > 0; @@ -95,14 +96,13 @@ export const CheckListHeader = ({ checkFilters={checkFilters} onChange={onFilterChange} /> - {canEdit && ( - <> - - - + {canWriteThresholds && ( + )} + + {canWriteChecks && }
diff --git a/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx b/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx index d6d2ed31d..8a23ed514 100644 --- a/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx +++ b/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx @@ -2,14 +2,15 @@ import React, { useState } from 'react'; import { Alert, Button, Modal, Space, TextLink } from '@grafana/ui'; import { FaroEvent, reportError, reportEvent } from 'faro'; -import { useCanWriteSM } from 'hooks/useDSPermission'; +import { getUserPermissions } from 'data/permissions'; import { useSMDS } from 'hooks/useSMDS'; import { Clipboard } from 'components/Clipboard'; +import { ContactAdminAlert } from 'page/ContactAdminAlert'; import { ConfigContent } from '../ConfigContent'; export function AccessTokensTab() { - const canCreateAccessToken = useCanWriteSM(); + const { canWriteTokens } = getUserPermissions(); const smDS = useSMDS(); const [showModal, setShowModal] = useState(false); const [error, setError] = useState(); @@ -30,6 +31,13 @@ export function AccessTokensTab() { return ( + {!canWriteTokens && ( + + )} + You can use an SM access token to authenticate with the synthetic monitoring api. Check out the{' '} @@ -42,8 +50,8 @@ export function AccessTokensTab() { documentation to learn more about how to interact with the synthetic monitoring API.