Repository: ethyca/fidesops (language: python)

Issue:
Send Identity Verification Email [#1010]
# Purpose

If subject identity verification is turned on, have the user verify their identity before queuing the request for execution.

# Changes
- Update existing `create_privacy_request` endpoint to check if identity verification is on
  - If yes, set privacy request status to `identity_unverified`
  -  Caches a 6 digit code in Redis that lasts for ten minutes
  -  Emails that code to the user
  - Suspends further processing of that request.
- To resume where we left off, send a POST to /privacy-request/{{privacy_request_id}}/verify added here https://github.com/ethyca/fidesops/pull/1115

# Checklist
- [x] Update [`CHANGELOG.md`](https://github.com/ethyca/fidesops/blob/main/CHANGELOG.md) file
  - [x] Merge in main so the most recent `CHANGELOG.md` file is being appended to
  - [x] Add description within the `Unreleased` section in an appropriate category. Add a new category from the list at the top of the file if the needed one isn't already there.
  - [x] Add a link to this PR at the end of the description with the PR number as the text. example: [#1](https://github.com/ethyca/fidesops/pull/1)
- [x] Applicable documentation updated (guides, quickstart, postman collections, tutorial, fidesdemo, [database diagram](https://github.com/ethyca/fidesops/blob/main/docs/fidesops/docs/development/update_erd_diagram.md).
- If docs updated (select one):
  - [ ] documentation complete, or draft/outline provided (tag docs-team to complete/review on this branch)
  - [ ] documentation issue created (tag docs-team to complete issue separately)
- [x] Good unit test/integration test coverage
- [ ] This PR contains a DB migration. If checked, the reviewer should confirm with the author that the [down_revision correctly references the previous migration](https://ethyca.github.io/fidesops/development/contributing_details/#alembic-migrations) before merging
- [ ] The `Run Unsafe PR Checks` label has been applied, and checks have passed, if this PR touches any external services

# Ticket

Fixes #1010 
 

Frontend - Privacy center to accept an ID verification code
### Description
The purpose of this ticket is to update the UI

### Assumptions
* verification should already exist as part of another ticket, this ticket is to expose it in the FE

### Use Case

1. As a data subject, I need the ability to submit my email address so that I can verify my identity and reduce the risk of a malevolent actor from acquiring or deleting my data.
2. As a Fides admin, I want my users to verify their identity so I can ensure that I am processing a privacy request for the actual person making the request.

### Acceptance Criteria
1. Demonstrate that a user can submit an ID verification code to submit a privacy request
2. Confirm that the email inputs are validated and scrubbed

### Mockups
[Figma](https://www.figma.com/file/p3AJEdylT9S8JQGD3VE0Rv/Privacy-Center?node-id=0%3A1)


Discussion / hints:
Thank you for the review @seanpreston!
Verified I received new email with Andrew's templating merge with:

```
Your privacy request verification code is 442512. Please return to the Privacy Center and enter the code to continue. This code will expire in 10 minutes
```


^ Note this code is just for my local environment, nothing secure is pasted here.
@adriaaaa to reach out to design team for unhappy path mocks.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a69c1badb..238c3cc66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@ The types of changes are:
 * Adds users and owners configuration for Hubspot connector [#1091](https://github.com/ethyca/fidesops/pull/1091)
 * Foundations for a new email connector type [#1142](https://github.com/ethyca/fidesops/pull/1142)
 * Adds endpoint for GET identity verification config [#1221](https://github.com/ethyca/fidesops/pull/1221)
+* Add user identification flow to privacy center [#1231](https://github.com/ethyca/fidesops/pull/1231)
 * Access support for Shopify [#1220](https://github.com/ethyca/fidesops/pull/1220)
 
 ## [1.7.1](https://github.com/ethyca/fidesops/compare/1.7.0...1.7.1)
diff --git a/clients/ops/privacy-center/.eslintignore b/clients/ops/privacy-center/.eslintignore
new file mode 100644
index 000000000..acaeb30a9
--- /dev/null
+++ b/clients/ops/privacy-center/.eslintignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+.eslintrc.json
+next.config.js
+jest.config.js
\ No newline at end of file
diff --git a/clients/ops/privacy-center/common/CommonHeaders.ts b/clients/ops/privacy-center/common/CommonHeaders.ts
new file mode 100644
index 000000000..c72511eee
--- /dev/null
+++ b/clients/ops/privacy-center/common/CommonHeaders.ts
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+
+/**
+ * Adds common headers to all api calls to fidesops
+ */
+export function addCommonHeaders(headers: Headers, token: string | null) {
+  headers.set("Access-Control-Allow-Origin", "*");
+  headers.set("X-Fides-Source", "fidesops-privacy-center");
+  headers.set("Accept", "application/json");
+  headers.set("Content-Type", "application/json");
+  if (token) {
+    headers.set("authorization", `Bearer ${token}`);
+  }
+  return headers;
+}
+
+
diff --git a/clients/ops/privacy-center/components/PrivacyCard.tsx b/clients/ops/privacy-center/components/PrivacyCard.tsx
new file mode 100644
index 000000000..2d670d251
--- /dev/null
+++ b/clients/ops/privacy-center/components/PrivacyCard.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+import { Heading, Text, Stack, Box, Image, HStack } from "@fidesui/react";
+
+type PrivacyCardProps = {
+  title: string;
+  policyKey: string;
+  iconPath: string;
+  description: string;
+  onOpen: (policyKey: string) => void;
+};
+
+const PrivacyCard: React.FC<PrivacyCardProps> = ({
+  title,
+  policyKey,
+  iconPath,
+  description,
+  onOpen,
+}) => (
+  <Box
+    as="button"
+    key={title}
+    bg="white"
+    py={8}
+    px={6}
+    borderRadius={4}
+    boxShadow="base"
+    maxWidth={["100%", "100%", "100%", 304]}
+    transition="box-shadow 50ms"
+    cursor="pointer"
+    userSelect="none"
+    m={2}
+    _hover={{
+      boxShadow: "complimentary-2xl",
+    }}
+    _focus={{
+      outline: "none",
+      boxShadow: "complimentary-2xl",
+    }}
+    onClick={() => onOpen(policyKey)}
+  >
+    <Stack spacing={7}>
+      <HStack justifyContent="center">
+        <Image src={iconPath} alt={description} width="54px" height="54px" />
+      </HStack>
+
+      <Stack spacing={1} textAlign="center">
+        <Heading
+          fontSize="large"
+          fontWeight="semibold"
+          lineHeight="28px"
+          color="gray.600"
+        >
+          {title}
+        </Heading>
+        <Text fontSize="xs" color="gray.600">
+          {description}
+        </Text>
+      </Stack>
+    </Stack>
+  </Box>
+);
+
+export default PrivacyCard;
diff --git a/clients/ops/privacy-center/components/RequestModal.tsx b/clients/ops/privacy-center/components/RequestModal.tsx
deleted file mode 100644
index f6ef10229..000000000
--- a/clients/ops/privacy-center/components/RequestModal.tsx
+++ /dev/null
@@ -1,289 +0,0 @@
-import React, { useEffect, useState } from "react";
-import {
-  Modal,
-  ModalOverlay,
-  ModalContent,
-  ModalHeader,
-  ModalFooter,
-  ModalBody,
-  Text,
-  Button,
-  chakra,
-  Stack,
-  FormControl,
-  Input,
-  FormErrorMessage,
-} from "@fidesui/react";
-
-import { useFormik } from "formik";
-
-import type { AlertState } from "../types/AlertState";
-
-import config from "../config/config.json";
-
-export const useRequestModal = () => {
-  const [isOpen, setIsOpen] = useState(false);
-  const [openAction, setOpenAction] = useState<string | null>(null);
-
-  const onOpen = (action: string) => {
-    setOpenAction(action);
-    setIsOpen(true);
-  };
-
-  const onClose = () => {
-    setIsOpen(false);
-    setOpenAction(null);
-  };
-
-  return { isOpen, onClose, onOpen, openAction };
-};
-
-const useRequestForm = ({
-  onClose,
-  action,
-  setAlert,
-}: {
-  onClose: () => void;
-  action: typeof config.actions[0] | null;
-  setAlert: (state: AlertState) => void;
-}) => {
-  const [isLoading, setIsLoading] = useState(false);
-  const formik = useFormik({
-    initialValues: {
-      name: "",
-      email: "",
-      phone: "",
-    },
-    onSubmit: async (values) => {
-      if (!action) {
-        // somehow we've reached a broken state, return
-        return;
-      }
-
-      setIsLoading(true);
-      const host =
-        process.env.NODE_ENV === "development"
-          ? config.fidesops_host_development
-          : config.fidesops_host_production;
-      const body = [
-        {
-          identity: {
-            email: values.email,
-            phone_number: values.phone,
-            // enable this when name field is supported on the server
-            // name: values.name
-          },
-          policy_key: action.policy_key,
-        },
-      ];
-
-      const handleError = () => {
-        setAlert({
-          status: "error",
-          description: "Your request has failed. Please try again.",
-        });
-        onClose();
-      };
-
-      try {
-        const response = await fetch(`${host}/privacy-request`, {
-          method: "POST",
-          headers: {
-            Accept: "application/json",
-            "Content-Type": "application/json",
-            "X-Fides-Source": "fidesops-privacy-center",
-          },
-          body: JSON.stringify(body),
-        });
-
-        if (!response.ok) {
-          handleError();
-          return;
-        }
-
-        const data = await response.json();
-
-        if (data.succeeded.length) {
-          setAlert({
-            status: "success",
-            description:
-              "Your request was successful, please await further instructions.",
-          });
-        } else {
-          handleError();
-        }
-      } catch (error) {
-        handleError();
-        return;
-      }
-
-      onClose();
-    },
-    validate: (values) => {
-      if (!action) return {};
-      const errors: {
-        name?: string;
-        email?: string;
-        phone?: string;
-      } = {};
-
-      if (!values.email && action.identity_inputs.email === "required") {
-        errors.email = "Required";
-      }
-
-      if (!values.name && action.identity_inputs.name === "required") {
-        errors.name = "Required";
-      }
-
-      if (!values.phone && action.identity_inputs.phone === "required") {
-        errors.phone = "Required";
-      }
-
-      return errors;
-    },
-  });
-
-  return { ...formik, isLoading };
-};
-
-export const RequestModal: React.FC<{
-  isOpen: boolean;
-  onClose: () => void;
-  openAction: string | null;
-  setAlert: (state: AlertState) => void;
-}> = ({ isOpen, onClose, openAction, setAlert }) => {
-  const action = openAction
-    ? config.actions.filter(({ policy_key }) => policy_key === openAction)[0]
-    : null;
-
-  const {
-    errors,
-    handleBlur,
-    handleChange,
-    handleSubmit,
-    touched,
-    values,
-    isValid,
-    dirty,
-    resetForm,
-  } = useRequestForm({ onClose, action, setAlert });
-
-  useEffect(() => resetForm(), [isOpen, resetForm]);
-
-  if (!action) return null;
-
-  return (
-    <Modal isOpen={isOpen} onClose={onClose}>
-      <ModalOverlay />
-      <ModalContent top={[0, "205px"]} maxWidth="464px" mx={5} my={3}>
-        <ModalHeader pt={6} pb={0}>
-          {action.title}
-        </ModalHeader>
-        <chakra.form onSubmit={handleSubmit}>
-          <ModalBody>
-            <Text fontSize="sm" color="gray.500" mb={4}>
-              {action.description}
-            </Text>
-            <Stack spacing={3}>
-              {action.identity_inputs.name ? (
-                <FormControl
-                  id="name"
-                  isInvalid={touched.name && Boolean(errors.name)}
-                >
-                  <Input
-                    id="name"
-                    name="name"
-                    focusBorderColor="primary.500"
-                    placeholder={
-                      action.identity_inputs.name === "required"
-                        ? "Name*"
-                        : "Name"
-                    }
-                    isRequired={action.identity_inputs.name === "required"}
-                    onChange={handleChange}
-                    onBlur={handleBlur}
-                    value={values.name}
-                    isInvalid={touched.name && Boolean(errors.name)}
-                  />
-                  <FormErrorMessage>{errors.name}</FormErrorMessage>
-                </FormControl>
-              ) : null}
-              {action.identity_inputs.email ? (
-                <FormControl
-                  id="email"
-                  isInvalid={touched.email && Boolean(errors.email)}
-                >
-                  <Input
-                    id="email"
-                    name="email"
-                    type="email"
-                    focusBorderColor="primary.500"
-                    placeholder={
-                      action.identity_inputs.email === "required"
-                        ? "Email*"
-                        : "Email"
-                    }
-                    isRequired={action.identity_inputs.email === "required"}
-                    onChange={handleChange}
-                    onBlur={handleBlur}
-                    value={values.email}
-                    isInvalid={touched.email && Boolean(errors.email)}
-                  />
-                  <FormErrorMessage>{errors.email}</FormErrorMessage>
-                </FormControl>
-              ) : null}
-              {action.identity_inputs.phone ? (
-                <FormControl
-                  id="phone"
-                  isInvalid={touched.phone && Boolean(errors.phone)}
-                >
-                  <Input
-                    id="phone"
-                    name="phone"
-                    type="phone"
-                    focusBorderColor="primary.500"
-                    placeholder={
-                      action.identity_inputs.phone === "required"
-                        ? "Phone*"
-                        : "Phone"
-                    }
-                    isRequired={action.identity_inputs.phone === "required"}
-                    onChange={handleChange}
-                    onBlur={handleBlur}
-                    value={values.phone}
-                    isInvalid={touched.phone && Boolean(errors.phone)}
-                  />
-                  <FormErrorMessage>{errors.phone}</FormErrorMessage>
-                </FormControl>
-              ) : null}
-            </Stack>
-          </ModalBody>
-
-          <ModalFooter pb={6}>
-            <Button
-              variant="outline"
-              flex="1"
-              mr={3}
-              size="sm"
-              onClick={onClose}
-            >
-              Cancel
-            </Button>
-            <Button
-              type="submit"
-              flex="1"
-              bg="primary.800"
-              _hover={{ bg: "primary.400" }}
-              _active={{ bg: "primary.500" }}
-              colorScheme="primary"
-              disabled={!(isValid && dirty)}
-              size="sm"
-            >
-              Continue
-            </Button>
-          </ModalFooter>
-        </chakra.form>
-      </ModalContent>
-    </Modal>
-  );
-};
diff --git a/clients/ops/privacy-center/components/modals/PrivacyRequestForm.tsx b/clients/ops/privacy-center/components/modals/PrivacyRequestForm.tsx
new file mode 100644
index 000000000..f951c250f
--- /dev/null
+++ b/clients/ops/privacy-center/components/modals/PrivacyRequestForm.tsx
@@ -0,0 +1,299 @@
+import React, { useEffect, useState } from "react";
+import {
+  Button,
+  chakra,
+  FormControl,
+  FormErrorMessage,
+  Input,
+  ModalBody,
+  ModalFooter,
+  ModalHeader,
+  Stack,
+  Text,
+} from "@fidesui/react";
+
+import { useFormik } from "formik";
+
+import { Headers } from "headers-polyfill";
+import type { AlertState } from "../../types/AlertState";
+import { PrivacyRequestStatus } from "../../types";
+import { addCommonHeaders } from "../../common/CommonHeaders";
+
+import config from "../../config/config.json";
+
+import { ModalViews } from "./types";
+import { hostUrl } from "../../constants";
+
+const usePrivacyRequestForm = ({
+  onClose,
+  action,
+  setAlert,
+  setCurrentView,
+  setPrivacyRequestId,
+  isVerificationRequired,
+}: {
+  onClose: () => void;
+  action: typeof config.actions[0] | null;
+  setAlert: (state: AlertState) => void;
+  setCurrentView: (view: ModalViews) => void;
+  setPrivacyRequestId: (id: string) => void;
+  isVerificationRequired: boolean;
+}) => {
+  const [isLoading, setIsLoading] = useState(false);
+  const formik = useFormik({
+    initialValues: {
+      name: "",
+      email: "",
+      phone: "",
+    },
+    onSubmit: async (values) => {
+      if (!action) {
+        // somehow we've reached a broken state, return
+        return;
+      }
+
+      setIsLoading(true);
+
+      const body = [
+        {
+          identity: {
+            email: values.email,
+            phone_number: values.phone,
+            // enable this when name field is supported on the server
+            // name: values.name
+          },
+          policy_key: action.policy_key,
+        },
+      ];
+
+      const handleError = () => {
+        setAlert({
+          status: "error",
+          description: "Your request has failed. Please try again.",
+        });
+        onClose();
+      };
+
+      try {
+        const headers: Headers = new Headers();
+        addCommonHeaders(headers, null);
+
+        const response = await fetch(`${hostUrl}/privacy-request`, {
+          method: "POST",
+          headers,
+          body: JSON.stringify(body),
+        });
+
+        if (!response.ok) {
+          handleError();
+          return;
+        }
+
+        const data = await response.json();
+
+        if (!isVerificationRequired && data.succeeded.length) {
+          setAlert({
+            status: "success",
+            description:
+              "Your request was successful, please await further instructions.",
+          });
+        } else if (
+          isVerificationRequired &&
+          data.succeeded.length &&
+          data.succeeded[0].status === PrivacyRequestStatus.IDENTITY_UNVERIFIED
+        ) {
+          setPrivacyRequestId(data.succeeded[0].id);
+          setCurrentView(ModalViews.IdentityVerification);
+        } else {
+          handleError();
+        }
+      } catch (error) {
+        handleError();
+        return;
+      }
+
+      if (!isVerificationRequired) {
+        onClose();
+      }
+    },
+    validate: (values) => {
+      if (!action) return {};
+      const errors: {
+        name?: string;
+        email?: string;
+        phone?: string;
+      } = {};
+
+      if (!values.email && action.identity_inputs.email === "required") {
+        errors.email = "Required";
+      }
+
+      if (!values.name && action.identity_inputs.name === "required") {
+        errors.name = "Required";
+      }
+
+      if (!values.phone && action.identity_inputs.phone === "required") {
+        errors.phone = "Required";
+      }
+
+      return errors;
+    },
+  });
+
+  return { ...formik, isLoading };
+};
+
+type PrivacyRequestFormProps = {
+  isOpen: boolean;
+  onClose: () => void;
+  openAction: string | null;
+  setAlert: (state: AlertState) => void;
+  setCurrentView: (view: ModalViews) => void;
+  setPrivacyRequestId: (id: string) => void;
+  isVerificationRequired: boolean;
+};
+
+const PrivacyRequestForm: React.FC<PrivacyRequestFormProps> = ({
+  isOpen,
+  onClose,
+  openAction,
+  setAlert,
+  setCurrentView,
+  setPrivacyRequestId,
+  isVerificationRequired,
+}) => {
+  const action = openAction
+    ? config.actions.filter(({ policy_key }) => policy_key === openAction)[0]
+    : null;
+
+  const {
+    errors,
+    handleBlur,
+    handleChange,
+    handleSubmit,
+    touched,
+    values,
+    isValid,
+    dirty,
+    resetForm,
+  } = usePrivacyRequestForm({
+    onClose,
+    action,
+    setAlert,
+    setCurrentView,
+    setPrivacyRequestId,
+    isVerificationRequired,
+  });
+
+  useEffect(() => resetForm(), [isOpen, resetForm]);
+
+  if (!action) return null;
+
+  return (
+    <>
+      <ModalHeader pt={6} pb={0}>
+        {action.title}
+      </ModalHeader>
+      <chakra.form onSubmit={handleSubmit}>
+        <ModalBody>
+          <Text fontSize="sm" color="gray.500" mb={4}>
+            {action.description}
+          </Text>
+          <Stack spacing={3}>
+            {action.identity_inputs.name ? (
+              <FormControl
+                id="name"
+                isInvalid={touched.name && Boolean(errors.name)}
+              >
+                <Input
+                  id="name"
+                  name="name"
+                  focusBorderColor="primary.500"
+                  placeholder={
+                    action.identity_inputs.name === "required"
+                      ? "Name*"
+                      : "Name"
+                  }
+                  isRequired={action.identity_inputs.name === "required"}
+                  onChange={handleChange}
+                  onBlur={handleBlur}
+                  value={values.name}
+                  isInvalid={touched.name && Boolean(errors.name)}
+                />
+                <FormErrorMessage>{errors.name}</FormErrorMessage>
+              </FormControl>
+            ) : null}
+            {action.identity_inputs.email ? (
+              <FormControl
+                id="email"
+                isInvalid={touched.email && Boolean(errors.email)}
+              >
+                <Input
+                  id="email"
+                  name="email"
+                  type="email"
+                  focusBorderColor="primary.500"
+                  placeholder={
+                    action.identity_inputs.email === "required"
+                      ? "Email*"
+                      : "Email"
+                  }
+                  isRequired={action.identity_inputs.email === "required"}
+                  onChange={handleChange}
+                  onBlur={handleBlur}
+                  value={values.email}
+                  isInvalid={touched.email && Boolean(errors.email)}
+                />
+                <FormErrorMessage>{errors.email}</FormErrorMessage>
+              </FormControl>
+            ) : null}
+            {action.identity_inputs.phone ? (
+              <FormControl
+                id="phone"
+                isInvalid={touched.phone && Boolean(errors.phone)}
+              >
+                <Input
+                  id="phone"
+                  name="phone"
+                  type="phone"
+                  focusBorderColor="primary.500"
+                  placeholder={
+                    action.identity_inputs.phone === "required"
+                      ? "Phone*"
+                      : "Phone"
+                  }
+                  isRequired={action.identity_inputs.phone === "required"}
+                  onChange={handleChange}
+                  onBlur={handleBlur}
+                  value={values.phone}
+                  isInvalid={touched.phone && Boolean(errors.phone)}
+                />
+                <FormErrorMessage>{errors.phone}</FormErrorMessage>
+              </FormControl>
+            ) : null}
+          </Stack>
+        </ModalBody>
+
+        <ModalFooter pb={6}>
+          <Button variant="outline" flex="1" mr={3} size="sm" onClick={onClose}>
+            Cancel
+          </Button>
+          <Button
+            type="submit"
+            flex="1"
+            bg="primary.800"
+            _hover={{ bg: "primary.400" }}
+            _active={{ bg: "primary.500" }}
+            colorScheme="primary"
+            disabled={!(isValid && dirty)}
+            size="sm"
+          >
+            Continue
+          </Button>
+        </ModalFooter>
+      </chakra.form>
+    </>
+  );
+};
+
+export default PrivacyRequestForm;
diff --git a/clients/ops/privacy-center/components/modals/RequestModal.tsx b/clients/ops/privacy-center/components/modals/RequestModal.tsx
new file mode 100644
index 000000000..93a5ac28f
--- /dev/null
+++ b/clients/ops/privacy-center/components/modals/RequestModal.tsx
@@ -0,0 +1,115 @@
+import React, { useState } from "react";
+import { Modal, ModalContent, ModalOverlay } from "@fidesui/react";
+
+import type { AlertState } from "../../types/AlertState";
+
+import config from "../../config/config.json";
+
+import { ModalViews } from "./types";
+import PrivacyRequestForm from "./PrivacyRequestForm";
+import VerificationForm from "./VerificationForm";
+import RequestSubmitted from "./RequestSubmitted";
+
+export const useRequestModal = () => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [openAction, setOpenAction] = useState<string | null>(null);
+  const [currentView, setCurrentView] = useState<ModalViews>(
+    ModalViews.PrivacyRequest
+  );
+  const [privacyRequestId, setPrivacyRequestId] = useState<string>("");
+
+  const onOpen = (action: string) => {
+    setOpenAction(action);
+    setIsOpen(true);
+  };
+
+  const onClose = () => {
+    setIsOpen(false);
+    setOpenAction(null);
+    setCurrentView(ModalViews.PrivacyRequest);
+    setPrivacyRequestId("");
+  };
+
+  return {
+    isOpen,
+    onClose,
+    onOpen,
+    openAction,
+    currentView,
+    setCurrentView,
+    privacyRequestId,
+    setPrivacyRequestId,
+  };
+};
+
+export type RequestModalProps = {
+  isOpen: boolean;
+  onClose: () => void;
+  openAction: string | null;
+  setAlert: (state: AlertState) => void;
+  currentView: ModalViews;
+  setCurrentView: (view: ModalViews) => void;
+  privacyRequestId: string;
+  setPrivacyRequestId: (id: string) => void;
+  isVerificationRequired: boolean;
+};
+
+export const RequestModal: React.FC<RequestModalProps> = ({
+  isOpen,
+  onClose,
+  openAction,
+  setAlert,
+  currentView,
+  setCurrentView,
+  privacyRequestId,
+  setPrivacyRequestId,
+  isVerificationRequired,
+}) => {
+  const action = openAction
+    ? config.actions.filter(({ policy_key }) => policy_key === openAction)[0]
+    : null;
+
+  if (!action) return null;
+
+  let form = null;
+
+  if (currentView === ModalViews.PrivacyRequest) {
+    form = (
+      <PrivacyRequestForm
+        isOpen={isOpen}
+        onClose={onClose}
+        openAction={openAction}
+        setAlert={setAlert}
+        setCurrentView={setCurrentView}
+        setPrivacyRequestId={setPrivacyRequestId}
+        isVerificationRequired={isVerificationRequired}
+      />
+    );
+  }
+
+  if (currentView === ModalViews.IdentityVerification) {
+    form = (
+      <VerificationForm
+        isOpen={isOpen}
+        onClose={onClose}
+        openAction={openAction}
+        setAlert={setAlert}
+        privacyRequestId={privacyRequestId}
+        setCurrentView={setCurrentView}
+      />
+    );
+  }
+
+  if (currentView === ModalViews.RequestSubmitted) {
+    form = <RequestSubmitted onClose={onClose} action={action} />;
+  }
+
+  return (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <ModalOverlay />
+      <ModalContent top={[0, "205px"]} maxWidth="464px" mx={5} my={3}>
+        {form}
+      </ModalContent>
+    </Modal>
+  );
+};
diff --git a/clients/ops/privacy-center/components/modals/RequestSubmitted.tsx b/clients/ops/privacy-center/components/modals/RequestSubmitted.tsx
new file mode 100644
index 000000000..d4cd9be72
--- /dev/null
+++ b/clients/ops/privacy-center/components/modals/RequestSubmitted.tsx
@@ -0,0 +1,58 @@
+import React from "react";
+import {
+  ModalHeader,
+  ModalFooter,
+  ModalBody,
+  Text,
+  Button,
+  Image,
+  HStack,
+} from "@fidesui/react";
+
+type RequestSubmittedProps = {
+  onClose: () => void;
+  action: any;
+};
+
+const RequestSubmitted: React.FC<RequestSubmittedProps> = ({
+  onClose,
+  action,
+}) => (
+  <>
+    <HStack justifyContent="center">
+      <Image
+        mt="24px"
+        src="/green-check.svg"
+        alt="green-checkmark"
+        width="48px"
+        height="48px"
+      />
+    </HStack>
+    <ModalHeader pt={6} pb={0} textAlign="center">
+      Request submitted
+    </ModalHeader>
+
+    <ModalBody>
+      <Text fontSize="sm" color="gray.500" mb={4}>
+        Thanks for your {action.policy_key} Request. A member of our team will
+        review and be in contact with you shortly.
+      </Text>
+    </ModalBody>
+
+    <ModalFooter pb={6}>
+      <Button
+        flex="1"
+        bg="primary.800"
+        _hover={{ bg: "primary.400" }}
+        _active={{ bg: "primary.500" }}
+        colorScheme="primary"
+        size="sm"
+        onClick={onClose}
+      >
+        Continue
+      </Button>
+    </ModalFooter>
+  </>
+);
+
+export default RequestSubmitted;
diff --git a/clients/ops/privacy-center/components/modals/VerificationForm.tsx b/clients/ops/privacy-center/components/modals/VerificationForm.tsx
new file mode 100644
index 000000000..3f686e242
--- /dev/null
+++ b/clients/ops/privacy-center/components/modals/VerificationForm.tsx
@@ -0,0 +1,237 @@
+import React, { useCallback, useEffect, useState } from "react";
+import {
+  Button,
+  chakra,
+  FormControl,
+  FormErrorMessage,
+  HStack,
+  Input,
+  ModalBody,
+  ModalFooter,
+  ModalHeader,
+  Stack,
+  Text,
+  VStack,
+} from "@fidesui/react";
+
+import { useFormik } from "formik";
+
+import { Headers } from "headers-polyfill";
+import type { AlertState } from "../../types/AlertState";
+import { ModalViews } from "./types";
+import { addCommonHeaders } from "../../common/CommonHeaders";
+
+import config from "../../config/config.json";
+import { hostUrl } from "../../constants";
+
+const useVerificationForm = ({
+  onClose,
+  action,
+  setAlert,
+  privacyRequestId,
+  setCurrentView,
+}: {
+  onClose: () => void;
+  action: typeof config.actions[0] | null;
+  setAlert: (state: AlertState) => void;
+  privacyRequestId: string;
+  setCurrentView: (view: ModalViews) => void;
+}) => {
+  const [isLoading, setIsLoading] = useState(false);
+  const resetVerificationProcess = useCallback(() => {
+    setCurrentView(ModalViews.PrivacyRequest);
+  }, [setCurrentView]);
+
+  const formik = useFormik({
+    initialValues: {
+      code: "",
+    },
+    onSubmit: async (values) => {
+      if (!action) {
+        // somehow we've reached a broken state, return
+        return;
+      }
+
+      setIsLoading(true);
+
+      const body = {
+        code: values.code,
+      };
+
+      const handleError = (detail: string | undefined) => {
+        const fallbackMessage =
+          "An error occured while verifying your request.";
+        setAlert({
+          status: "error",
+          description: detail || fallbackMessage,
+        });
+        onClose();
+      };
+      try {
+        const headers: Headers = new Headers();
+        addCommonHeaders(headers, null);
+
+        const response = await fetch(
+          `${hostUrl}/privacy-request/${privacyRequestId}/verify`,
+          {
+            method: "POST",
+            headers,
+            body: JSON.stringify(body),
+          }
+        );
+        const data = await response.json();
+
+        if (!response.ok) {
+          handleError(data?.detail);
+          return;
+        }
+
+        setCurrentView(ModalViews.RequestSubmitted);
+      } catch (error) {
+        handleError("");
+      }
+    },
+    validate: (values) => {
+      const errors: {
+        code?: string;
+      } = {};
+
+      if (!values.code) {
+        errors.code = "Required";
+        return errors;
+      }
+      if (!values.code.match(/^\d+$/g)) {
+        errors.code = "Verification code must be all numbers";
+      }
+
+      return errors;
+    },
+  });
+
+  return { ...formik, isLoading, resetVerificationProcess };
+};
+
+type VerificationFormProps = {
+  isOpen: boolean;
+  onClose: () => void;
+  openAction: string | null;
+  setAlert: (state: AlertState) => void;
+  privacyRequestId: string;
+  setCurrentView: (view: ModalViews) => void;
+};
+
+const VerificationForm: React.FC<VerificationFormProps> = ({
+  isOpen,
+  onClose,
+  openAction,
+  setAlert,
+  privacyRequestId,
+  setCurrentView,
+}) => {
+  const action = openAction
+    ? config.actions.filter(({ policy_key }) => policy_key === openAction)[0]
+    : null;
+
+  const {
+    errors,
+    handleBlur,
+    handleChange,
+    handleSubmit,
+    touched,
+    values,
+    isValid,
+    dirty,
+    resetForm,
+    resetVerificationProcess,
+  } = useVerificationForm({
+    onClose,
+    action,
+    setAlert,
+    privacyRequestId,
+    setCurrentView,
+  });
+
+  useEffect(() => resetForm(), [isOpen, resetForm]);
+
+  if (!action) return null;
+
+  return (
+    <>
+      <ModalHeader pt={6} pb={0}>
+        Enter verification code
+      </ModalHeader>
+      <chakra.form onSubmit={handleSubmit}>
+        <ModalBody>
+          <Text fontSize="sm" color="gray.500" mb={4}>
+            We have sent a verification code to your email address. Please check
+            your email, then return to this window and the code below.
+          </Text>
+          <Stack spacing={3}>
+            {action.identity_inputs.name ? (
+              <FormControl
+                id="code"
+                isInvalid={touched.code && Boolean(errors.code)}
+              >
+                <Input
+                  id="code"
+                  name="code"
+                  focusBorderColor="primary.500"
+                  placeholder="Verification Code"
+                  isRequired
+                  onChange={handleChange}
+                  onBlur={handleBlur}
+                  value={values.code}
+                  isInvalid={touched.code && Boolean(errors.code)}
+                />
+                <FormErrorMessage>{errors.code}</FormErrorMessage>
+              </FormControl>
+            ) : null}
+          </Stack>
+        </ModalBody>
+
+        <ModalFooter pb={6}>
+          <VStack id="test" width="100%">
+            <HStack width="100%">
+              <Button
+                type="submit"
+                flex="1"
+                bg="primary.800"
+                _hover={{ bg: "primary.400" }}
+                _active={{ bg: "primary.500" }}
+                colorScheme="primary"
+                disabled={!(isValid && dirty)}
+                size="sm"
+              >
+                Submit code
+              </Button>
+            </HStack>
+            <HStack pt="8px" width="100%">
+              <Text
+                fontSize="sm"
+                fontWeight="normal"
+                lineHeight={5}
+                color="gray.500"
+              >
+                Didn&apos;t receive a code?
+              </Text>{" "}
+              <Text
+                fontSize="sm"
+                fontWeight="normal"
+                lineHeight={5}
+                color="primary.700"
+                textDecoration="underline"
+                cursor="pointer"
+                _active={{ bg: "primary.500" }}
+                onClick={resetVerificationProcess}
+              >
+                Click here to try again
+              </Text>
+            </HStack>
+          </VStack>
+        </ModalFooter>
+      </chakra.form>
+    </>
+  );
+};
+
+export default VerificationForm;
diff --git a/clients/ops/privacy-center/components/modals/types.ts b/clients/ops/privacy-center/components/modals/types.ts
new file mode 100644
index 000000000..ba0f62542
--- /dev/null
+++ b/clients/ops/privacy-center/components/modals/types.ts
@@ -0,0 +1,7 @@
+/* eslint-disable import/prefer-default-export */
+
+export enum ModalViews {
+  PrivacyRequest = "privacyRequest",
+  IdentityVerification = "identityVerification",
+  RequestSubmitted = "requestSubmitted",
+}
diff --git a/clients/ops/privacy-center/constants/index.ts b/clients/ops/privacy-center/constants/index.ts
new file mode 100644
index 000000000..589a23915
--- /dev/null
+++ b/clients/ops/privacy-center/constants/index.ts
@@ -0,0 +1,9 @@
+/* eslint-disable import/prefer-default-export */
+
+
+import config from "../config/config.json";
+
+export const hostUrl =
+  process.env.NODE_ENV === "development"
+    ? config.fidesops_host_development
+    : config.fidesops_host_production;
\ No newline at end of file
diff --git a/clients/ops/privacy-center/package-lock.json b/clients/ops/privacy-center/package-lock.json
index 8b6cf4a40..c5f4b5df9 100644
--- a/clients/ops/privacy-center/package-lock.json
+++ b/clients/ops/privacy-center/package-lock.json
@@ -14,6 +14,7 @@
         "@fontsource/inter": "^4.5.4",
         "formik": "^2.2.9",
         "framer-motion": "^5",
+        "headers-polyfill": "^3.0.10",
         "next": "12.1.0",
         "next-auth": "^4.10.3",
         "react": "^17.0.2",
@@ -6870,10 +6871,9 @@
       }
     },
     "node_modules/headers-polyfill": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.0.4.tgz",
-      "integrity": "sha512-I1DOM1EdWYntdrnCvqQtcKwSSuiTzoqOExy4v1mdcFixFZABlWP4IPHdmoLtPda0abMHqDOY4H9svhQ10DFR4w==",
-      "dev": true
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.0.10.tgz",
+      "integrity": "sha512-lOhQU7iG3AMcjmb8NIWCa+KwfJw5bY44BoWPtrj5A4iDbSD3ylGf5QcYr0ZyQnhkKQ2GgWNLdF2rfrXtXlF3nQ=="
     },
     "node_modules/hey-listen": {
       "version": "1.0.8",
@@ -16138,10 +16138,9 @@
       }
     },
     "headers-polyfill": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.0.4.tgz",
-      "integrity": "sha512-I1DOM1EdWYntdrnCvqQtcKwSSuiTzoqOExy4v1mdcFixFZABlWP4IPHdmoLtPda0abMHqDOY4H9svhQ10DFR4w==",
-      "dev": true
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.0.10.tgz",
+      "integrity": "sha512-lOhQU7iG3AMcjmb8NIWCa+KwfJw5bY44BoWPtrj5A4iDbSD3ylGf5QcYr0ZyQnhkKQ2GgWNLdF2rfrXtXlF3nQ=="
     },
     "hey-listen": {
       "version": "1.0.8",
diff --git a/clients/ops/privacy-center/package.json b/clients/ops/privacy-center/package.json
index 8d69b32f5..f78471191 100644
--- a/clients/ops/privacy-center/package.json
+++ b/clients/ops/privacy-center/package.json
@@ -5,7 +5,8 @@
     "dev": "next dev",
     "build": "next build",
     "start": "next start",
-    "lint": "next lint",
+    "lint": "eslint . --ext .ts,.tsx",
+    "lint:fix": "eslint . --fix --ext .ts,.tsx",
     "format": "prettier --write types/ theme/ pages/ config/ components/ __tests__/",
     "format:ci": "prettier --check types/ theme/ pages/ config/ components/ __tests__/",
     "test": "jest --watch",
@@ -24,6 +25,7 @@
     "@fontsource/inter": "^4.5.4",
     "formik": "^2.2.9",
     "framer-motion": "^5",
+    "headers-polyfill": "^3.0.10",
     "next": "12.1.0",
     "next-auth": "^4.10.3",
     "react": "^17.0.2",
@@ -52,8 +54,8 @@
     "identity-obj-proxy": "^3.0.0",
     "jest": "^27.5.1",
     "msw": "^0.39.0",
+    "prettier": "^2.6.2",
     "typescript": "4.5.5",
-    "whatwg-fetch": "^3.6.2",
-    "prettier": "^2.6.2"
+    "whatwg-fetch": "^3.6.2"
   }
 }
diff --git a/clients/ops/privacy-center/pages/index.tsx b/clients/ops/privacy-center/pages/index.tsx
index eb876f768..121405e68 100644
--- a/clients/ops/privacy-center/pages/index.tsx
+++ b/clients/ops/privacy-center/pages/index.tsx
@@ -6,7 +6,6 @@ import {
   Heading,
   Text,
   Stack,
-  Box,
   Alert,
   AlertIcon,
   AlertDescription,
@@ -14,14 +13,30 @@ import {
   Image,
 } from "@fidesui/react";
 
-import { useRequestModal, RequestModal } from "../components/RequestModal";
+import {
+  useRequestModal,
+  RequestModal,
+} from "../components/modals/RequestModal";
+import PrivacyCard from "../components/PrivacyCard";
 import type { AlertState } from "../types/AlertState";
 
 import config from "../config/config.json";
+import { hostUrl } from "../constants";
 
 const Home: NextPage = () => {
   const [alert, setAlert] = useState<AlertState | null>(null);
-  const { isOpen, onClose, onOpen, openAction } = useRequestModal();
+  const [isVerificationRequired, setIsVerificationRequired] =
+    useState<boolean>(false);
+  const {
+    isOpen,
+    onClose,
+    onOpen,
+    openAction,
+    currentView,
+    setCurrentView,
+    privacyRequestId,
+    setPrivacyRequestId,
+  } = useRequestModal();
 
   useEffect(() => {
     if (alert?.status) {
@@ -31,6 +46,19 @@ const Home: NextPage = () => {
     return () => false;
   }, [alert]);
 
+  useEffect(() => {
+    const getConfig = async () => {
+      const response = await fetch(`${hostUrl}/id-verification/config`, {
+        headers: {
+          "X-Fides-Source": "fidesops-privacy-center",
+        },
+      });
+      const data = await response.json();
+      setIsVerificationRequired(data.identity_verification_required);
+    };
+    getConfig();
+  }, [setIsVerificationRequired]);
+
   return (
     <div>
       <Head>
@@ -97,50 +125,14 @@ const Home: NextPage = () => {
           </Stack>
           <Flex m={-2} flexDirection={["column", "column", "row"]}>
             {config.actions.map((action) => (
-              <Box
-                as="button"
+              <PrivacyCard
                 key={action.title}
-                bg="white"
-                py={8}
-                px={6}
-                borderRadius={4}
-                boxShadow="base"
-                maxWidth={["100%", "100%", "100%", 304]}
-                transition="box-shadow 50ms"
-                cursor="pointer"
-                userSelect="none"
-                m={2}
-                _hover={{
-                  boxShadow: "complimentary-2xl",
-                }}
-                _focus={{
-                  outline: "none",
-                  boxShadow: "complimentary-2xl",
-                }}
-                onClick={() => onOpen(action.policy_key)}
-              >
-                <Stack spacing={7}>
-                  <Image
-                    src={action.icon_path}
-                    alt={action.description}
-                    width="54px"
-                    height="54px"
-                  />
-                  <Stack spacing={1} textAlign="center">
-                    <Heading
-                      fontSize="large"
-                      fontWeight="semibold"
-                      lineHeight="28px"
-                      color="gray.600"
-                    >
-                      {action.title}
-                    </Heading>
-                    <Text fontSize="xs" color="gray.600">
-                      {action.description}
-                    </Text>
-                  </Stack>
-                </Stack>
-              </Box>
+                title={action.title}
+                policyKey={action.policy_key}
+                iconPath={action.icon_path}
+                description={action.description}
+                onOpen={onOpen}
+              />
             ))}
           </Flex>
         </Stack>
@@ -149,6 +141,11 @@ const Home: NextPage = () => {
           onClose={onClose}
           openAction={openAction}
           setAlert={setAlert}
+          currentView={currentView}
+          setCurrentView={setCurrentView}
+          privacyRequestId={privacyRequestId}
+          setPrivacyRequestId={setPrivacyRequestId}
+          isVerificationRequired={isVerificationRequired}
         />
       </main>
     </div>
diff --git a/clients/ops/privacy-center/public/green-check.svg b/clients/ops/privacy-center/public/green-check.svg
new file mode 100644
index 000000000..30db47950
--- /dev/null
+++ b/clients/ops/privacy-center/public/green-check.svg
@@ -0,0 +1,4 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="48" height="48" rx="24" fill="#C6F6D5"/>
+<path d="M21.9997 27.1715L31.1917 17.9785L32.6067 19.3925L21.9997 29.9995L15.6357 23.6355L17.0497 22.2215L21.9997 27.1715Z" fill="#25855A"/>
+</svg>
diff --git a/clients/ops/privacy-center/types/index.ts b/clients/ops/privacy-center/types/index.ts
new file mode 100644
index 000000000..0597a4df0
--- /dev/null
+++ b/clients/ops/privacy-center/types/index.ts
@@ -0,0 +1,13 @@
+/* eslint-disable import/prefer-default-export */
+
+export enum PrivacyRequestStatus {
+  APPROVED = "approved",
+  COMPLETE = "complete",
+  DENIED = "denied",
+  ERROR = "error",
+  IN_PROCESSING = "in_processing",
+  PAUSED = "paused",
+  CANCELED = "canceled",
+  PENDING = "pending",
+  IDENTITY_UNVERIFIED = "identity_unverified",
+}


Test patch:
diff --git a/clients/ops/privacy-center/__tests__/RequestModal.test.tsx b/clients/ops/privacy-center/__tests__/RequestModal.test.tsx
index 982abfe60..52fafa587 100644
--- a/clients/ops/privacy-center/__tests__/RequestModal.test.tsx
+++ b/clients/ops/privacy-center/__tests__/RequestModal.test.tsx
@@ -11,23 +11,42 @@ import { act } from "react-dom/test-utils";
 import { rest } from "msw";
 import { setupServer } from "msw/node";
 
-import { RequestModal } from "../components/RequestModal";
+import {
+  RequestModal,
+  RequestModalProps,
+} from "../components/modals/RequestModal";
 import IndexPage from "../pages/index";
 
 import mockConfig from "../config/__mocks__/config.json";
+import { hostUrl } from "../constants";
+import { ModalViews } from "../components/modals/types";
 
 jest.mock("../config/config.json");
 
-const server = setupServer();
+const server = setupServer(
+  rest.get(`${hostUrl}/id-verification/config`, (req, res, ctx) =>
+    res(
+      ctx.json({
+        identity_verification_required: false,
+        valid_email_config_exists: false,
+      })
+    )
+  )
+);
 beforeAll(() => server.listen());
 afterEach(() => server.resetHandlers());
 afterAll(() => server.close());
 
-const defaultModalProperties = {
+const defaultModalProperties: RequestModalProps = {
   isOpen: true,
   onClose: () => {},
   openAction: mockConfig.actions[0].policy_key,
   setAlert: () => {},
+  currentView: ModalViews.PrivacyRequest,
+  setCurrentView: () => {},
+  privacyRequestId: "",
+  setPrivacyRequestId: () => {},
+  isVerificationRequired: false,
 };
 
 describe("RequestModal", () => {