diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8c28d3bab..87036142214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ The types of changes are: - Added preliminary privacy notice page [#2995](https://github.com/ethyca/fides/pull/2995) - Table for privacy notices [#3001](https://github.com/ethyca/fides/pull/3001) - Query params on connection type endpoint to filter by supported action type [#2996](https://github.com/ethyca/fides/pull/2996) +- Scope restrictions for privacy notice table in the UI [#3007](https://github.com/ethyca/fides/pull/3007) +- Toggle for enabling/disabling privacy notices in the UI [#3010](https://github.com/ethyca/fides/pull/3010) - Add endpoint to retrieve privacy notices grouped by their associated data uses [#2956](https://github.com/ethyca/fides/pull/2956) - Support for uploading custom connector templates via the UI [#2997](https://github.com/ethyca/fides/pull/2997) diff --git a/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts b/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts index a5326bf417e..faca4306434 100644 --- a/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts +++ b/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts @@ -3,10 +3,13 @@ import { stubPlus } from "cypress/support/stubs"; import { PRIVACY_NOTICES_ROUTE } from "~/features/common/nav/v2/routes"; import { RoleRegistryEnum } from "~/types/api"; +const DATA_SALES_NOTICE_ID = "pri_afd25287-cce4-487a-a6b4-b7647b068990"; +const ESSENTIAL_NOTICE_ID = "pri_e76cbe20-6ffa-46b4-9a91-b1ae3412dd49"; + describe("Privacy notices", () => { beforeEach(() => { cy.login(); - cy.intercept("GET", "/api/v1/privacy-notice/*", { + cy.intercept("GET", "/api/v1/privacy-notice*", { fixture: "privacy-notices/list.json", }).as("getNotices"); stubPlus(true); @@ -31,11 +34,53 @@ describe("Privacy notices", () => { cy.getByTestId("privacy-notices-page"); }); }); + + it("viewers and approvers cannot click into a notice to edit", () => { + [RoleRegistryEnum.VIEWER, RoleRegistryEnum.VIEWER_AND_APPROVER].forEach( + (role) => { + cy.assumeRole(role); + cy.visit(PRIVACY_NOTICES_ROUTE); + cy.wait("@getNotices"); + cy.getByTestId("row-Essential").click(); + // we should still be on the same page + cy.getByTestId("privacy-notice-detail-page").should("not.exist"); + cy.getByTestId("privacy-notices-page"); + } + ); + }); + + it("viewers and approvers cannot toggle the enable toggle", () => { + [RoleRegistryEnum.VIEWER, RoleRegistryEnum.VIEWER_AND_APPROVER].forEach( + (role) => { + cy.assumeRole(role); + cy.visit(PRIVACY_NOTICES_ROUTE); + cy.wait("@getNotices"); + cy.getByTestId("toggle-Enable") + .first() + .within(() => { + cy.get("span").should("have.attr", "data-disabled"); + }); + } + ); + }); + + it("viewers and approvers cannot add notices", () => { + [RoleRegistryEnum.VIEWER, RoleRegistryEnum.VIEWER_AND_APPROVER].forEach( + (role) => { + cy.assumeRole(role); + cy.visit(PRIVACY_NOTICES_ROUTE); + cy.wait("@getNotices"); + cy.getByTestId("privacy-notices-page"); + cy.getByTestId("add-privacy-notice-btn").should("not.exist"); + } + ); + }); }); describe("table", () => { beforeEach(() => { cy.visit(PRIVACY_NOTICES_ROUTE); + cy.wait("@getNotices"); }); it("should render a row for each privacy notice", () => { @@ -64,7 +109,49 @@ describe("Privacy notices", () => { it("can click a row to go to the notice page", () => { cy.getByTestId("row-Essential").click(); cy.getByTestId("privacy-notice-detail-page"); - cy.url().should("contain", "pri_e76cbe20-6ffa-46b4-9a91-b1ae3412dd49"); + cy.url().should("contain", ESSENTIAL_NOTICE_ID); + }); + + describe("enabling and disabling", () => { + beforeEach(() => { + cy.intercept("PATCH", "/api/v1/privacy-notice*", { + fixture: "privacy-notices/list.json", + }).as("patchNotices"); + }); + + it("can enable a notice", () => { + cy.getByTestId("row-Data Sales").within(() => { + cy.getByTestId("toggle-Enable").within(() => { + cy.get("span").should("not.have.attr", "data-checked"); + }); + cy.getByTestId("toggle-Enable").click(); + }); + + cy.wait("@patchNotices").then((interception) => { + const { body } = interception.request; + expect(body).to.eql([{ id: DATA_SALES_NOTICE_ID, disabled: false }]); + }); + // redux should requery after invalidation + cy.wait("@getNotices"); + }); + + it("can disable a notice with a warning", () => { + cy.getByTestId("row-Essential").within(() => { + cy.getByTestId("toggle-Enable").within(() => { + cy.get("span").should("have.attr", "data-checked"); + }); + cy.getByTestId("toggle-Enable").click(); + }); + + cy.getByTestId("confirmation-modal"); + cy.getByTestId("continue-btn").click(); + cy.wait("@patchNotices").then((interception) => { + const { body } = interception.request; + expect(body).to.eql([{ id: ESSENTIAL_NOTICE_ID, disabled: true }]); + }); + // redux should requery after invalidation + cy.wait("@getNotices"); + }); }); }); }); diff --git a/clients/admin-ui/cypress/fixtures/privacy-notices/list.json b/clients/admin-ui/cypress/fixtures/privacy-notices/list.json index 5222059bd24..2cb6fc6f1c0 100644 --- a/clients/admin-ui/cypress/fixtures/privacy-notices/list.json +++ b/clients/admin-ui/cypress/fixtures/privacy-notices/list.json @@ -8,7 +8,7 @@ "consent_mechanism": "opt_out", "data_uses": ["advertising.third_party.personalized"], "enforcement_level": "frontend", - "disabled": false, + "disabled": true, "has_gpc_flag": false, "displayed_in_privacy_center": true, "displayed_in_privacy_modal": true, diff --git a/clients/admin-ui/src/features/common/ConfirmationModal.tsx b/clients/admin-ui/src/features/common/ConfirmationModal.tsx index da5611d33c1..e5c713f27ce 100644 --- a/clients/admin-ui/src/features/common/ConfirmationModal.tsx +++ b/clients/admin-ui/src/features/common/ConfirmationModal.tsx @@ -2,6 +2,7 @@ import { ThemingProps } from "@chakra-ui/system"; import { Button, + Center, Modal, ModalBody, ModalContent, @@ -26,6 +27,7 @@ interface Props { returnFocusOnClose?: boolean; isCentered?: boolean; testId?: string; + icon?: ReactNode; } const ConfirmationModal = ({ isOpen, @@ -41,6 +43,7 @@ const ConfirmationModal = ({ returnFocusOnClose, isCentered, testId = "confirmation-modal", + icon, }: Props) => ( - - {title ? {title} : null} + + {icon ?
{icon}
: null} + {title ? ( + + {title} + + ) : null} {message ? {message} : null} diff --git a/clients/admin-ui/src/features/privacy-notices/PrivacyNoticesTable.tsx b/clients/admin-ui/src/features/privacy-notices/PrivacyNoticesTable.tsx index faae3720de6..0f84ca47b2c 100644 --- a/clients/admin-ui/src/features/privacy-notices/PrivacyNoticesTable.tsx +++ b/clients/admin-ui/src/features/privacy-notices/PrivacyNoticesTable.tsx @@ -13,19 +13,20 @@ import { Tr, } from "@fidesui/react"; import { useRouter } from "next/router"; -import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { ReactNode, useMemo } from "react"; import { Column, useSortBy, useTable } from "react-table"; import { useAppSelector } from "~/app/hooks"; -import { PrivacyNoticeResponse } from "~/types/api"; +import { PRIVACY_NOTICES_ROUTE } from "~/features/common/nav/v2/routes"; +import Restrict, { useHasPermission } from "~/features/common/Restrict"; +import { PrivacyNoticeResponse, ScopeRegistryEnum } from "~/types/api"; -import { PRIVACY_NOTICES_ROUTE } from "../common/nav/v2/routes"; import { DateCell, + EnablePrivacyNoticeCell, MechanismCell, MultiTagCell, TitleCell, - ToggleCell, WrappedCell, } from "./cells"; import { @@ -35,14 +36,6 @@ import { useGetAllPrivacyNoticesQuery, } from "./privacy-notices.slice"; -/** - * We add an 'enabled' flag so that the UI can toggle the 'enabled' state - * without directly modifying the PrivacyNoticeResponse data - */ -interface PrivacyNoticeTableData extends PrivacyNoticeResponse { - enabled: boolean; -} - const PrivacyNoticesTable = () => { const router = useRouter(); // Subscribe to get all privacy notices @@ -52,34 +45,12 @@ const PrivacyNoticesTable = () => { const privacyNotices = useAppSelector(selectAllPrivacyNotices); - // Sync up our enabled state with the initial privacy notice enabled state - const [enabledNoticeIds, setEnabledNoticeIds] = useState([]); - useEffect(() => { - setEnabledNoticeIds( - privacyNotices.filter((pn) => !pn.disabled).map((pn) => pn.id) - ); - }, [privacyNotices]); - - const handleToggle = useCallback( - (privacyNoticeId: PrivacyNoticeTableData["id"]) => { - const isEnabled = !!enabledNoticeIds.find( - (enabledId) => enabledId === privacyNoticeId - ); - - if (isEnabled) { - setEnabledNoticeIds( - enabledNoticeIds.filter( - (enabledNoticeId) => enabledNoticeId !== privacyNoticeId - ) - ); - } else { - setEnabledNoticeIds([...enabledNoticeIds, privacyNoticeId]); - } - }, - [enabledNoticeIds] - ); + // Permissions + const userCanUpdate = useHasPermission([ + ScopeRegistryEnum.PRIVACY_NOTICE_UPDATE, + ]); - const columns: Column[] = useMemo( + const columns: Column[] = useMemo( () => [ { Header: "Title", @@ -101,27 +72,15 @@ const PrivacyNoticesTable = () => { { Header: "Last update", accessor: "updated_at", Cell: DateCell }, { Header: "Enable", - accessor: "enabled", - onToggle: handleToggle, - Cell: ToggleCell, + accessor: "disabled", + disabled: !userCanUpdate, + Cell: EnablePrivacyNoticeCell, }, ], - [handleToggle] - ); - - // Create the data object as the PrivacyNoticeResponse + a UI only "enabled" field - // which the UI can control the state of - const data: PrivacyNoticeTableData[] = useMemo( - () => - privacyNotices.map((pn) => { - const isEnabled = - enabledNoticeIds.filter((noticeId) => noticeId === pn.id).length > 0; - return { ...pn, enabled: isEnabled }; - }), - [privacyNotices, enabledNoticeIds] + [userCanUpdate] ); - const tableInstance = useTable({ columns, data }, useSortBy); + const tableInstance = useTable({ columns, data: privacyNotices }, useSortBy); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = tableInstance; @@ -187,13 +146,19 @@ const PrivacyNoticesTable = () => { prepareRow(row); const { key: rowKey, ...rowProps } = row.getRowProps(); const onClick = () => { - router.push(`${PRIVACY_NOTICES_ROUTE}/${row.original.id}`); + if (userCanUpdate) { + router.push(`${PRIVACY_NOTICES_ROUTE}/${row.original.id}`); + } }; return ( {row.cells.map((cell) => { @@ -219,9 +184,15 @@ const PrivacyNoticesTable = () => { - + + + diff --git a/clients/admin-ui/src/features/privacy-notices/cells.tsx b/clients/admin-ui/src/features/privacy-notices/cells.tsx index f45d0db942a..df317a00d76 100644 --- a/clients/admin-ui/src/features/privacy-notices/cells.tsx +++ b/clients/admin-ui/src/features/privacy-notices/cells.tsx @@ -1,9 +1,19 @@ -import { Box, Switch, Tag, Text } from "@fidesui/react"; +import { + Box, + Switch, + Tag, + Text, + useDisclosure, + WarningIcon, +} from "@fidesui/react"; +import { ChangeEvent } from "react"; import { CellProps } from "react-table"; +import ConfirmationModal from "~/features/common/ConfirmationModal"; import { PrivacyNoticeResponse } from "~/types/api"; import { MECHANISM_MAP } from "./constants"; +import { usePatchPrivacyNoticesMutation } from "./privacy-notices.slice"; export const TitleCell = ({ value, @@ -56,24 +66,65 @@ export const MultiTagCell = ({ ); }; -export const ToggleCell = ({ +export const EnablePrivacyNoticeCell = ({ value, column, row, -}: CellProps) => ( - { - /** - * It's difficult to use a custom column in react-table 7 since we'd have to modify - * the declaration file. However, that modifies the type globally, so our datamap table - * would also have issues. Ignoring the type for now, but should potentially revisit - * if we update to react-table 8 - * https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/59837 - */ - // @ts-ignore - column.onToggle(row.original.id); - }} - /> -); +}: CellProps) => { + const modal = useDisclosure(); + const [patchNoticeMutationTrigger] = usePatchPrivacyNoticesMutation(); + + const handlePatch = async ({ enable }: { enable: boolean }) => { + await patchNoticeMutationTrigger([ + { id: row.original.id, disabled: !enable }, + ]); + }; + + const handleToggle = async (event: ChangeEvent) => { + const { checked } = event.target; + if (checked) { + await handlePatch({ enable: true }); + } else { + modal.onOpen(); + } + }; + + return ( + <> + + { + handlePatch({ enable: false }); + modal.onClose(); + }} + title="Disable privacy notice" + message={ + + Are you sure you want to disable this privacy notice? Disabling this + notice means your users will no longer see this explanation about + your data uses which is necessary to ensure compliance. + + } + continueButtonText="Confirm" + isCentered + icon={} + /> + + ); +}; diff --git a/clients/admin-ui/src/features/privacy-notices/privacy-notices.slice.ts b/clients/admin-ui/src/features/privacy-notices/privacy-notices.slice.ts index 29040a5d45b..73e3543a384 100644 --- a/clients/admin-ui/src/features/privacy-notices/privacy-notices.slice.ts +++ b/clients/admin-ui/src/features/privacy-notices/privacy-notices.slice.ts @@ -39,10 +39,22 @@ const privacyNoticesApi = baseApi.injectEndpoints({ }), providesTags: () => ["PrivacyNotices"], }), + patchPrivacyNotices: build.mutation< + PrivacyNoticeResponse[], + Partial[] + >({ + query: (payload) => ({ + method: "PATCH", + url: `privacy-notice/`, + body: payload, + }), + invalidatesTags: () => ["PrivacyNotices"], + }), }), }); -export const { useGetAllPrivacyNoticesQuery } = privacyNoticesApi; +export const { useGetAllPrivacyNoticesQuery, usePatchPrivacyNoticesMutation } = + privacyNoticesApi; export const privacyNoticesSlice = createSlice({ name: "privacyNotices", diff --git a/clients/admin-ui/src/features/system/system.slice.ts b/clients/admin-ui/src/features/system/system.slice.ts index b1d89f3644f..2cf46d16a16 100644 --- a/clients/admin-ui/src/features/system/system.slice.ts +++ b/clients/admin-ui/src/features/system/system.slice.ts @@ -61,7 +61,7 @@ const systemApi = baseApi.injectEndpoints({ method: "PUT", body: patch, }), - invalidatesTags: ["Datamap", "System"], + invalidatesTags: ["Datamap", "System", "PrivacyNotices"], }), }), });