diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ffee48a20..96994e9d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ The types of changes are: ## [Unreleased](https://github.com/ethyca/fides/compare/2.2.2...main) +### Added +* Send email alerts on privacy request failures once the specified threshold is reached. [#1793](https://github.com/ethyca/fides/pull/1793) +* DSR Notifications (toast) [#1895](https://github.com/ethyca/fides/pull/1895) +* DSR configure alerts btn [#1895](https://github.com/ethyca/fides/pull/1895) +* DSR configure alters (FE) [#1895](https://github.com/ethyca/fides/pull/1895) + ### Changed * Updated to use `loguru` logging library throughout codebase [#2031](https://github.com/ethyca/fides/pull/2031) @@ -68,6 +74,7 @@ The types of changes are: * Enable the onboarding flow [#1836](https://github.com/ethyca/fides/pull/1836) * Access and erasure support for Fullstory API [#1821](https://github.com/ethyca/fides/pull/1821) * Add function to poll privacy request for completion [#1860](https://github.com/ethyca/fides/pull/1860) +* Added rescan flow for the data flow scanner [#1844](https://github.com/ethyca/fides/pull/1844) * Add rescan flow for the data flow scanner [#1844](https://github.com/ethyca/fides/pull/1844) * Add Fides connector to support parent-child Fides deployments [#1861](https://github.com/ethyca/fides/pull/1861) * Classification UI now polls for updates to classifications [#1908](https://github.com/ethyca/fides/pull/1908) @@ -96,7 +103,7 @@ The types of changes are: ### Added * Classification flow for system data flows - * Classification is now triggered as part of data flow scanning +* Classification is now triggered as part of data flow scanning * Include `ingress` and `egress` fields on system export and `datamap/` endpoint [#1740](https://github.com/ethyca/fides/pull/1740) * Repeatable unique identifier for dataset fides_keys and metadata [#1786](https://github.com/ethyca/fides/pull/1786) * Adds SMS support for identity verification notifications [#1726](https://github.com/ethyca/fides/pull/1726) diff --git a/clients/admin-ui/src/features/common/hooks/useAlert.tsx b/clients/admin-ui/src/features/common/hooks/useAlert.tsx index e2b7806c00..346947d81f 100644 --- a/clients/admin-ui/src/features/common/hooks/useAlert.tsx +++ b/clients/admin-ui/src/features/common/hooks/useAlert.tsx @@ -8,6 +8,7 @@ import { useToast, UseToastOptions, } from "@fidesui/react"; +import { MouseEventHandler } from "react"; /** * Custom hook for toast notifications @@ -24,12 +25,16 @@ export const useAlert = () => { const errorAlert = ( description: string | JSX.Element, title?: string, - options?: UseToastOptions + addedOptions?: UseToastOptions ) => { - toast({ - ...options, - position: options?.position || DEFAULT_POSITION, - render: ({ onClose }) => ( + const options = { + ...addedOptions, + position: addedOptions?.position || DEFAULT_POSITION, + render: ({ + onClose, + }: { + onClose: MouseEventHandler | undefined; + }) => ( @@ -45,7 +50,13 @@ export const useAlert = () => { /> ), - }); + }; + + if (addedOptions?.id && toast.isActive(addedOptions.id)) { + toast.update(addedOptions.id, options); + } else { + toast(options); + } }; /** @@ -55,12 +66,16 @@ export const useAlert = () => { const successAlert = ( description: string, title?: string, - options?: UseToastOptions + addedOptions?: UseToastOptions ) => { - toast({ - ...options, - position: options?.position || DEFAULT_POSITION, - render: ({ onClose }) => ( + const options = { + ...addedOptions, + position: addedOptions?.position || DEFAULT_POSITION, + render: ({ + onClose, + }: { + onClose: MouseEventHandler | undefined; + }) => ( @@ -76,7 +91,12 @@ export const useAlert = () => { /> ), - }); + }; + if (addedOptions?.id && toast.isActive(addedOptions.id)) { + toast.update(addedOptions.id, options); + } else { + toast(options); + } }; return { errorAlert, successAlert }; diff --git a/clients/admin-ui/src/features/privacy-requests/EmailChipList.tsx b/clients/admin-ui/src/features/privacy-requests/EmailChipList.tsx new file mode 100644 index 0000000000..76b6716cb7 --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/EmailChipList.tsx @@ -0,0 +1,113 @@ +import { + FormControl, + FormErrorMessage, + FormLabel, + forwardRef, + Input, + Tag, + TagCloseButton, + TagLabel, + VStack, + Wrap, +} from "@fidesui/react"; +import { FieldArrayRenderProps } from "formik"; +import React, { useState } from "react"; + +const EMAIL_REGEXP = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i; +const isValidEmail = (email: string) => EMAIL_REGEXP.test(email); + +type EmailChipListProps = { + isRequired: boolean; +}; + +const EmailChipList = forwardRef( + ( + { + isRequired = false, + ...props + }: FieldArrayRenderProps & EmailChipListProps, + ref + ) => { + const { emails }: { emails: string[] } = props.form.values; + const [inputValue, setInputValue] = useState(""); + + const emailChipExists = (email: string) => emails.includes(email); + + const addEmails = (emailsToAdd: string[]) => { + const validatedEmails = emailsToAdd + .map((e) => e.trim()) + .filter((email) => isValidEmail(email) && !emailChipExists(email)); + validatedEmails.forEach((email) => props.push(email)); + setInputValue(""); + }; + + const handleChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (["Enter", "Tab", ","].includes(event.key)) { + event.preventDefault(); + addEmails([inputValue]); + } + }; + + const handlePaste = (event: React.ClipboardEvent) => { + event.preventDefault(); + const pastedData = event.clipboardData.getData("text"); + const pastedEmails = pastedData.split(","); + addEmails(pastedEmails); + }; + + return ( + + + Email + + + {/* @ts-ignore */} + + {props.form.errors[props.name]} + {emails.length > 0 && ( + + {emails.map((email, index) => ( + + {email} + { + props.remove(index); + }} + /> + + ))} + + )} + + + ); + } +); + +export default EmailChipList; diff --git a/clients/admin-ui/src/features/privacy-requests/RequestTable.tsx b/clients/admin-ui/src/features/privacy-requests/RequestTable.tsx index ca83056508..5baf7f2a53 100644 --- a/clients/admin-ui/src/features/privacy-requests/RequestTable.tsx +++ b/clients/admin-ui/src/features/privacy-requests/RequestTable.tsx @@ -1,6 +1,5 @@ import { Checkbox, Flex, Table, Tbody, Th, Thead, Tr } from "@fidesui/react"; -import { debounce } from "common/utils"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect } from "react"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; @@ -14,26 +13,13 @@ import { } from "./privacy-requests.slice"; import RequestRow from "./RequestRow"; import SortRequestButton from "./SortRequestButton"; -import { PrivacyRequest, PrivacyRequestParams } from "./types"; +import { PrivacyRequest } from "./types"; -type RequestTableProps = { - requests?: PrivacyRequest[]; -}; - -const useRequestTable = () => { +const RequestTable: React.FC = () => { const dispatch = useAppDispatch(); const filters = useAppSelector(selectPrivacyRequestFilters); - const [cachedFilters, setCachedFilters] = useState(filters); - const updateCachedFilters = useRef( - debounce( - (updatedFilters: React.SetStateAction) => - setCachedFilters(updatedFilters), - 250 - ) - ); - const { checkAll, errorRequests } = useAppSelector(selectRetryRequests); - const { data, isFetching } = useGetAllPrivacyRequestsQuery(cachedFilters); + const { data, isFetching } = useGetAllPrivacyRequestsQuery(filters); const { items: requests, total } = data || { items: [], total: 0 }; const getErrorRequests = useCallback( @@ -75,40 +61,10 @@ const useRequestTable = () => { }; useEffect(() => { - updateCachedFilters.current(filters); if (isFetching && filters.status?.includes("error")) { dispatch(setRetryRequests({ checkAll: false, errorRequests: [] })); } - }, [dispatch, filters, isFetching]); - - return { - ...filters, - checkAll, - errorRequests, - handleCheckChange, - handleNextPage, - handlePreviousPage, - handleCheckAll, - isFetching, - requests, - total, - }; -}; - -const RequestTable: React.FC = () => { - const { - checkAll, - errorRequests, - handleCheckChange, - handleNextPage, - handlePreviousPage, - handleCheckAll, - isFetching, - page, - requests, - size, - total, - } = useRequestTable(); + }, [dispatch, filters.status, isFetching]); return ( <> @@ -153,8 +109,8 @@ const RequestTable: React.FC = () => { = () => { ); }; -RequestTable.defaultProps = { - requests: [], -}; - export default RequestTable; diff --git a/clients/admin-ui/src/features/privacy-requests/buttons/ActionButtons.tsx b/clients/admin-ui/src/features/privacy-requests/buttons/ActionButtons.tsx index a749e55814..9d3c3f455b 100644 --- a/clients/admin-ui/src/features/privacy-requests/buttons/ActionButtons.tsx +++ b/clients/admin-ui/src/features/privacy-requests/buttons/ActionButtons.tsx @@ -4,16 +4,18 @@ import React from "react"; import { useAppSelector } from "~/app/hooks"; import { selectRetryRequests } from "../privacy-requests.slice"; +import MoreButton from "./MoreButton"; import ReprocessButton from "./ReprocessButton"; const ActionButtons: React.FC = () => { const { errorRequests } = useAppSelector(selectRetryRequests); - return errorRequests?.length > 0 ? ( + return ( - + {errorRequests?.length > 0 && } + - ) : null; + ); }; export default ActionButtons; diff --git a/clients/admin-ui/src/features/privacy-requests/buttons/MoreButton.tsx b/clients/admin-ui/src/features/privacy-requests/buttons/MoreButton.tsx new file mode 100644 index 0000000000..dd8c866919 --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/buttons/MoreButton.tsx @@ -0,0 +1,46 @@ +import { + Box, + Button, + Menu, + MenuButton, + MenuList, + MenuProps, +} from "@fidesui/react"; + +import { ArrowDownLineIcon } from "~/features/common/Icon"; + +import ConfigureAlerts from "../drawers/ConfigureAlerts"; + +type MoreButtonProps = { + menuProps?: MenuProps; +}; + +const MoreButton: React.FC = ({ + menuProps, +}: MoreButtonProps) => ( + + + } + size="sm" + variant="outline" + _active={{ + bg: "none", + }} + _hover={{ + bg: "none", + }} + > + More + + + {/* MenuItems are not rendered unless Menu is open */} + + + + +); + +export default MoreButton; diff --git a/clients/admin-ui/src/features/privacy-requests/drawers/ConfigureAlerts.tsx b/clients/admin-ui/src/features/privacy-requests/drawers/ConfigureAlerts.tsx new file mode 100644 index 0000000000..4f211b2cec --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/drawers/ConfigureAlerts.tsx @@ -0,0 +1,296 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + Box, + Button, + ButtonGroup, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + FormControl, + FormLabel, + HStack, + MenuItem, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + Switch, + Text, + useDisclosure, + VStack, +} from "@fidesui/react"; +import { + Field, + FieldArray, + FieldInputProps, + FieldMetaProps, + Form, + Formik, + FormikHelpers, + FormikProps, +} from "formik"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; +import * as Yup from "yup"; + +import { getErrorMessage } from "~/features/common/helpers"; +import { useAlert } from "~/features/common/hooks"; + +import EmailChipList from "../EmailChipList"; +import { + useGetNotificationQuery, + useSaveNotificationMutation, +} from "../privacy-requests.slice"; + +const DEFAULT_MIN_ERROR_COUNT = 1; + +const validationSchema = Yup.object().shape({ + emails: Yup.array(Yup.string()).when(["notify"], { + is: true, + then: Yup.array(Yup.string()) + .min(1, "Must enter at least one valid email") + .label("Email"), + }), + notify: Yup.boolean(), + minErrorCount: Yup.number().required(), +}); + +const ConfigureAlerts = () => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const [formValues, setFormValues] = useState({ + emails: [] as string[], + notify: false, + minErrorCount: DEFAULT_MIN_ERROR_COUNT, + }); + const firstField = useRef(null); + const { errorAlert, successAlert } = useAlert(); + const [skip, setSkip] = useState(true); + + const { data } = useGetNotificationQuery(undefined, { skip }); + const [saveNotification] = useSaveNotificationMutation(); + + const handleSubmit = async ( + values: typeof formValues, + helpers: FormikHelpers + ) => { + helpers.setSubmitting(true); + const payload = await saveNotification({ + email_addresses: values.emails, + notify_after_failures: values.notify ? values.minErrorCount : 0, + }); + if ("error" in payload) { + errorAlert( + getErrorMessage(payload.error), + `Configure alerts and notifications has failed to save due to the following:` + ); + } else { + successAlert(`Configure alerts and notifications saved successfully.`); + } + helpers.setSubmitting(false); + onClose(); + }; + + useEffect(() => { + if (isOpen) { + setSkip(false); + } + if (data) { + setFormValues({ + emails: data.email_addresses, + notify: data.notify_after_failures !== 0, + minErrorCount: data.notify_after_failures, + }); + } + }, [data, isOpen]); + + return ( + <> + + Configure alerts + + + {(props: FormikProps) => ( + { + props.resetForm(); + onClose(); + }} + size="lg" + > + + + + + + Configure alerts and notifications + + + + Setup your alerts to send you a notification when there are + any processing failures. You can also setup a threshold for + connector failures for Fides to notify you after X amount of + failures have occurred. + + + + +
+ Contact details + + + ( + + )} + /> + + + + {({ field }: { field: FieldInputProps }) => ( + + + Notify me immediately if there are any DSR + processing errors + + + ) => { + field.onChange(event); + props.setFieldValue( + "minErrorCount", + DEFAULT_MIN_ERROR_COUNT + ); + if (!event.target.checked) { + setTimeout(() => { + props.setFieldTouched("emails", false); + }, 0); + } + }} + /> + + )} + + + + + If selected, then Fides will notify you by your chosen + method of communication every time the system encounters a + data subject request processing error. You can turn this off + anytime and setup a more suitable notification method below + if you wish. + + {props.values.notify && ( + + + {({ + field, + meta, + }: { + field: FieldInputProps; + meta: FieldMetaProps; + }) => ( + + Notify me after + { + props.setFieldValue( + "minErrorCount", + valueAsNumber + ); + }} + size="sm" + w="80px" + > + + + + + + + DSR processing errors + + )} + + + )} +
+
+ + + + + + +
+
+ )} +
+ + ); +}; + +export default ConfigureAlerts; diff --git a/clients/admin-ui/src/features/privacy-requests/hooks/useDSRErrorAlert.tsx b/clients/admin-ui/src/features/privacy-requests/hooks/useDSRErrorAlert.tsx new file mode 100644 index 0000000000..5177696d15 --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/hooks/useDSRErrorAlert.tsx @@ -0,0 +1,85 @@ +import { Box, Text } from "@fidesui/react"; +import { useEffect, useState } from "react"; + +import { useAlert } from "~/features/common/hooks"; + +import { + useGetAllPrivacyRequestsQuery, + useGetNotificationQuery, +} from "../privacy-requests.slice"; + +type Requests = { + /** + * Number of new requests + */ + count: number; + /** + * Total number of requests + */ + total: number; +}; + +export const useDSRErrorAlert = () => { + const { errorAlert } = useAlert(); + const [hasAlert, setHasAlert] = useState(false); + const [requests, setRequests] = useState({ + count: 0, + total: 0, + }); + const [skip, setSkip] = useState(true); + const TOAST_ID = "dsrErrorAlert"; + const DEFAULT_POLLING_INTERVAL = 15000; + const STATUS = "error"; + + const { data: notification } = useGetNotificationQuery(); + const { data } = useGetAllPrivacyRequestsQuery( + { + status: [STATUS], + }, + { + pollingInterval: DEFAULT_POLLING_INTERVAL, + skip, + } + ); + + useEffect(() => { + setSkip(!(notification && notification.notify_after_failures > 0)); + }, [notification]); + + useEffect(() => { + const total = data?.total || 0; + + if ( + total >= (notification?.notify_after_failures || 0) && + total > requests.total + ) { + setRequests({ count: total - requests.total, total }); + setHasAlert(true); + } else { + setHasAlert(false); + } + }, [data?.total, notification?.notify_after_failures, requests.total]); + + const processing = () => { + if (!hasAlert) { + return; + } + errorAlert( + + DSR automation has failed for{" "} + + {requests.count} + {" "} + privacy request(s). Please review the event log for further details. + , + undefined, + { + containerStyle: { maxWidth: "max-content" }, + duration: null, + id: TOAST_ID, + } + ); + }; + + return { processing }; +}; diff --git a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts index ada3fc04fb..970f6255d1 100644 --- a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts +++ b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts @@ -2,7 +2,10 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import { addCommonHeaders } from "common/CommonHeaders"; -import { BulkPostPrivacyRequests } from "~/types/api"; +import { + BulkPostPrivacyRequests, + PrivacyRequestNotificationInfo, +} from "~/types/api"; import type { RootState } from "../../app/store"; import { BASE_URL } from "../../constants"; @@ -249,7 +252,7 @@ export const privacyRequestApi = createApi({ return headers; }, }), - tagTypes: ["Request"], + tagTypes: ["Request", "Notification"], endpoints: (build) => ({ approveRequest: build.mutation< PrivacyRequest, @@ -302,6 +305,21 @@ export const privacyRequestApi = createApi({ }); }, }), + getNotification: build.query({ + query: () => ({ + url: `privacy-request/notification`, + }), + providesTags: ["Notification"], + transformResponse: (response: PrivacyRequestNotificationInfo) => { + const cloneResponse = { ...response }; + if (cloneResponse.email_addresses?.length > 0) { + cloneResponse.email_addresses = cloneResponse.email_addresses.filter( + (item) => item !== "" + ); + } + return cloneResponse; + }, + }), getUploadedManualWebhookData: build.query< any, GetUpdloadedManualWebhookDataRequest @@ -324,6 +342,14 @@ export const privacyRequestApi = createApi({ }), invalidatesTags: ["Request"], }), + saveNotification: build.mutation({ + query: (params) => ({ + url: `privacy-request/notification`, + method: "PUT", + body: params, + }), + invalidatesTags: ["Notification"], + }), uploadManualWebhookData: build.mutation< any, PatchUploadManualWebhookDataRequest @@ -342,8 +368,10 @@ export const { useBulkRetryMutation, useDenyRequestMutation, useGetAllPrivacyRequestsQuery, + useGetNotificationQuery, useGetUploadedManualWebhookDataQuery, useResumePrivacyRequestFromRequiresInputMutation, useRetryMutation, + useSaveNotificationMutation, useUploadManualWebhookDataMutation, } = privacyRequestApi; diff --git a/clients/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx b/clients/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx index 8f68391175..42ffa56ab6 100644 --- a/clients/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx +++ b/clients/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx @@ -40,7 +40,7 @@ const ManualProcessingDetail: React.FC = ({ onSaveClick, }) => { const { isOpen, onOpen, onClose } = useDisclosure(); - const firstField = useRef(); + const firstField = useRef(null); const handleSubmit = async (values: any, _actions: any) => { const params: PatchUploadManualWebhookDataRequest = { @@ -93,7 +93,6 @@ const ManualProcessingDetail: React.FC = ({ ( - - - - Privacy Requests - - - - - - - +const ActionButtons = dynamic( + () => import("~/features/privacy-requests/buttons/ActionButtons"), + { loading: () =>
Loading...
} ); +const Home: NextPage = () => { + const { processing } = useDSRErrorAlert(); + + useEffect(() => { + processing(); + }, [processing]); + + return ( + + + + Privacy Requests + + + + + + + + ); +}; + export default Home; diff --git a/clients/admin-ui/src/pages/subject-request/[id].tsx b/clients/admin-ui/src/pages/subject-request/[id].tsx index 6f22f7054b..a872829513 100644 --- a/clients/admin-ui/src/pages/subject-request/[id].tsx +++ b/clients/admin-ui/src/pages/subject-request/[id].tsx @@ -12,7 +12,6 @@ import type { NextPage } from "next"; import NextLink from "next/link"; import { useRouter } from "next/router"; import { useGetAllPrivacyRequestsQuery } from "privacy-requests/index"; -import React from "react"; import SubjectRequest from "subject-request/SubjectRequest"; import Layout from "~/features/common/Layout"; @@ -60,7 +59,7 @@ const SubjectRequestDetails: NextPage = () => { - Privacy Request + Privacy Requests diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index 7246a60248..baa5e4c86f 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -68,6 +68,7 @@ export type { PolicyMaskingSpecResponse } from "./models/PolicyMaskingSpecRespon export type { PolicyResponse } from "./models/PolicyResponse"; export type { PolicyRule } from "./models/PolicyRule"; export type { PrivacyDeclaration } from "./models/PrivacyDeclaration"; +export type { PrivacyRequestNotificationInfo } from "./models/PrivacyRequestNotificationInfo"; export type { PrivacyRequestResponse } from "./models/PrivacyRequestResponse"; export type { PrivacyRequestReviewer } from "./models/PrivacyRequestReviewer"; export type { PrivacyRequestStatus } from "./models/PrivacyRequestStatus"; diff --git a/clients/admin-ui/src/types/api/models/PrivacyRequestNotificationInfo.ts b/clients/admin-ui/src/types/api/models/PrivacyRequestNotificationInfo.ts new file mode 100644 index 0000000000..a71e632971 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/PrivacyRequestNotificationInfo.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * A base template for all other Fidesops Schemas to inherit from. + */ +export type PrivacyRequestNotificationInfo = { + email_addresses: Array; + notify_after_failures: number; +};