Published on

Display Consents on Application Startup

Authors

Running some level of startup routine that includes application pre-render checking to find the right path through the app is a common practice for many applications. Doing a preliminary check on startup can be used for a number of things, such as:

Set a Display Name on First Login Before the application loads, you can do a check to see if it is the user's first login once the startup data is received and allow the user to set their display name for the application. As you can imagine, this is particularly useful for social media apps or applications that implement content sharing. To take it one step further, the user could be encouraged to complete their profile the next time they login.

Check for Authentication State You can use it to check the authenticated status of the user and redirect to login prompt if false. This is particularly useful here since the application has not rendered yet when the PreliminaryCheck code runs. If the application uses an auth service context provider like the one I describe here, it can be abstracted into the Preliminary Check container to keep things clean.

Prompt for Password Update For applications that require password reset at certain intervals to maintain security compliance, a check can be run to see when the user's password will expire and prompt accordingly.

Display Targeted Content If a user has not logged in for a while, a modal window can be displayed showing targeted content that includes things that might be important to the user to help them get caught up or suggest a starting point.

Check for Consents to be Signed Prior to Application Use In order to maintain legal compliance, many applications require one or more consents to be signed prior to using the application. This is almost always the case with internal-facing third party apps provided by employers. This may include terms and conditions of use, a privacy policy required by the application itself, and/or consents required by the employer that is providing the service. This is the scenario that I will describe in this article, since it is a common requirement for web applications.

The code that corresponds to this post can be found in the targeted-consent repo on Github.

Setup

To run the application, clone the repo, run npm install and npm start. The api calls are mocked, and the app is configured to use the mock service worker when running locally.

The components of the project that we are most interested in for the purposes of this article are the PreliminaryCheck, TargetedConsent, and TargetedConsentForm.

Consents Page

TargetedConsentForm

This component is responsible for displaying the consent that must be signed by the user.

UI

The UI for the consent form includes the following:

  • Header: displays the title of the consent using consentName, which comes from the consent object, along with a short description asking the user to please review the information and indicate that they have accepted the terms by checking the box under the consent content.
  • Content: the HTML content of the consent is set inside the consent section div using the dangerouslySetInnerHTML tag. This content comes from the consentHtml param of the consent object.
  • Footer: This contains the checkbox for accepting the terms, along with a continue button that is disabled unless the box is checked.

Functionality

As you might expect, there is little functionality contained in the consent form component. The parent component keeps track of which consent is currently being displayed and simply just tells the TargetedConsentForm UI what to display by passing in the consentName and consentHtml. The third prop is the onContinue, which as you might expect, is called when the continue button is pressed.

There's one peice of state that we must keep track of internally, which is the state of the checkbox that we're tracking with isTermsAccepted, since the button must be disabled if it is not checked. However, we have to be sure to call setIsTermsAccepted(false) in the handleClick so the the checkbox becomes unchecked when the next consent is passed in if there is more than one consent to be signed.

TargetedConsentForm.tsx

import React, { useState } from "react";
import styles from "./styles.module.css";

type TTargetedConsentFormProps = {
  consentName: string;
  consentHtml: string;
  onContinue: () => void;
};

export const TargetedConsentForm: React.FC<TTargetedConsentFormProps> = ({
  consentName,
  consentHtml,
  onContinue,
}) => {
  const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);

  const handleClick = () => {
    onContinue();
    setIsTermsAccepted(false);
  };

  const handleCheckboxChange = () => {
    setIsTermsAccepted(!isTermsAccepted);
  };

  return (
    <div>
      <header>
        <div className={styles.consentHeader}>
          {consentName ? <h1>{consentName}</h1> : null}
          <p>
            Please review the information below. If you consent to participate
            in the program, please indicate by checking the box below to
            proceed.
          </p>
        </div>
      </header>
      <div className={styles.contentWrapper}>
        <div
          data-testid="consentContent"
          className={styles.consentContent}
          dangerouslySetInnerHTML={{
            __html: consentHtml,
          }}
        />
      </div>
      <footer>
        <div className={styles.targetedConsentFooter}>
          <div className={styles.checkboxContainer}>
            <input
              type="checkbox"
              id="consentCheck"
              name="consentCheck"
              checked={isTermsAccepted}
              onChange={handleCheckboxChange}
            />
            <label className={styles.checkboxLabel} htmlFor="consentCheck">
              {consentName
                ? `I agree to the terms and conditions of ${consentName}`
                : "I agree to the terms and conditions"}
            </label>
          </div>
          <button
            className={styles.consentButton}
            onClick={handleClick}
            disabled={!isTermsAccepted}
          >
            Continue
          </button>
        </div>
      </footer>
    </div>
  );
};

export default TargetedConsentForm;

Looking at the code, you may have noticed that I am using TypeScript here. I believe that this is becoming the standard and rightfully so, and I wish more blog posts, how-to's, and tutorials would use it.

Logic for the UI: the TargetedConsent Component

The parent of the TargetedConsentForm has a bit more going on. It recieves an array of consents from the result of the GetUserConsents api call that comes from the PreliminaryCheck component and is responsible for the following:

  1. Display the current consent, beginning with the first index in the array.
  2. Display the next consent when a consent has been signed.
  3. Create an array of signed consents, adding each consent as they are signed.
  4. When all consents have been signed, call onConsentComplete with the array of signed consents.

TargetedConsent.tsx

import React, { useEffect, useState } from "react";
import { TConsent } from "../../types";
import TargetedConsentForm from "./TargetedConsentForm";
import styles from "./styles.module.css";

type TTargetedConsentProps = {
  onConsentComplete: (signedConsents: TConsent[]) => void;
  consents: TConsent[];
};

const TargetedConsent = (props: TTargetedConsentProps) => {
  const { onConsentComplete, consents } = props;
  const [currentIndex, setCurrentIndex] = useState<number>(0);
  const [currentConsent, setCurrentConsent] = useState<TConsent>(
    consents[currentIndex]
  );
  const [signedConsents, setSignedConsents] = useState<TConsent[]>([]);

  const handleContinue = () => {
    setSignedConsents([...signedConsents, currentConsent]);
    setCurrentIndex(currentIndex + 1);
    setCurrentConsent(consents[currentIndex + 1]);
  };

  useEffect(() => {
    if (signedConsents.length === consents.length) {
      onConsentComplete(signedConsents);
    }
  }, [signedConsents, consents, onConsentComplete]);

  return (
    <>
      {currentConsent && (
        <div className={styles.consentWrapper}>
          <div className={styles.consentContainer}>
            <TargetedConsentForm
              consentName={currentConsent.name}
              consentHtml={currentConsent.consentHtml}
              onContinue={handleContinue}
            />
          </div>
        </div>
      )}
    </>
  );
};

export default TargetedConsent;

Let's look at how we accomplish each of these 4 tasks in a little more detail:

1. Display the current consent, beginning with the first index in the array. First, we use the useState hook to define the current consent in local state, which is immediately set with the first consent. Note that the initial value of currentIndex is set to 0.

const [currentIndex, setCurrentIndex] = useState<number>(0);
const [currentConsent, setCurrentConsent] = useState<TConsent>(consents[currentIndex]);

2. Display the next consent when a consent has been signed. Then, when the continue button is clicked, we set the currentConsent to the next index in the array. We have to do currentIndex + 1 instead of simply currentIndex even though we are setting the currentIndex to +1 already because React hasn't yet performed a state update.

setCurrentIndex(currentIndex + 1)
setCurrentConsent(consents[currentIndex + 1])

3. Create an array of signed consents, adding each consent as they are signed.

setSignedConsents([...signedConsents, currentConsent])

When handleContinue is called, we also add the current consent to the signedConsents array, which will be sent back to the server in the api call to SignUserConsents when all consents are signed.

4. When all consents have been signed, call onConsentComplete with the array of signed consents. For this we are using the useEffect hook to check if the length of the signedConsents array is equal to the length of the original consents array. When this happens we can safely assert that all consents have been signed.

useEffect(() => {
  if (signedConsents.length === consents.length) {
    onConsentComplete(signedConsents)
  }
}, [signedConsents, consents, onConsentComplete])

Add the Preliminary Check to Run at Application Startup

We're finally ready to add the PreliminaryCheck component that will wrap the routes and make sure that the app is not loaded unless all user consents have been signed. Before we create the component though, let's go ahead and add it to the App component so you can see how it will be implemented.

import React from 'react'
import { Route, Routes, BrowserRouter } from 'react-router-dom'
import { Home } from './Home'
import { Discover } from './Discover'
import { PreliminaryCheck } from './PreliminaryCheck'

function App() {
  return (
    <BrowserRouter>
      <PreliminaryCheck>
        <Routes>
          <Route path='/' element={<Home />} />
          <Route path='/discover' element={<Discover />} />
        </Routes>
      </PreliminaryCheck>
    </BrowserRouter>
  )
}

export default App

Note that I am using v6 of react-router-dom, which replaces Switch with Routes, removes the need for exact from Route attributes, and replaces component with element.

Wrapping the application's routes with the PreliminaryCheck component allows us to conditionally render the application according to the results of the preliminary check routine.

For this project, the PreliminaryCheck is responsible for the following:

  1. Make the api call to get consents when the component is rendered.
  2. If there are consents to be signed, block the app from loading and display the consents, otherwise load the app.
  3. When all consents have been signed, make the respective api call with the array of signed consents and load the app.

PreliminaryCheck.tsx

import React, { useState, useEffect } from "react";
import {
  TargetedConsent,
  LoadingSpinner,
  PageContainer,
  ErrorState,
} from "../../components";
import { TConsent, TConsents } from "../../types";
import Axios from "axios";
import { useNavigate } from "react-router-dom";

type TPreliminaryCheckProps = {
  children: React.ReactNode;
};

export default function PreliminaryCheck({ children }: TPreliminaryCheckProps) {
  const [consentsComplete, setConsentsComplete] = useState<boolean>(false);
  const [consentsData, setConsentsData] = useState<TConsents>();
  const [isLoading, setIsLoading] = useState<boolean>(true);

  const navigate = useNavigate();

  useEffect(() => {
    if (!consentsData) {
      Axios("/user/consents/GetUserConsents")
        .then((response) => {
          setConsentsData(response.data);
        })
        .catch((error) => {
          return (
            <ErrorState
              onContinue={() => navigate("/")}
              errorMessage={error.message}
            />
          );
        })
        .finally(() => setIsLoading(false));
    }
  }, [consentsData, navigate]);

  async function signConsents(signedConsents: TConsent[]) {
    setIsLoading(true);
    await Axios.post("/user/consents/SignUserConsents", signedConsents)
      .then((response) => {
        setConsentsData(response.data);
      })
      .catch((error) => {
        return (
          <ErrorState
            onContinue={() => navigate("/")}
            errorMessage={error.message}
          />
        );
      })
      .finally(() => {
        setConsentsComplete(true);
        setIsLoading(false);
      });
  }

  const handleConsentsComplete = (signedConsents: TConsent[]) => {
    if (!consentsComplete) {
      signConsents(signedConsents);
    }
  };

  if (isLoading) {
    return (
      <PageContainer>
        <LoadingSpinner />
      </PageContainer>
    );
  }
  
  if (consentsData && consentsData.count > 0 && !consentsComplete) {
    return (
      <PageContainer>
        <TargetedConsent
          onConsentComplete={handleConsentsComplete}
          consents={consentsData.consents}
        />
      </PageContainer>
    );
  }
  return <>{children}</>;
}

Let's take a look at these four responsibilities in further detail:

1. Make the api call to get consents when the component is rendered. We take care of this with React's useEffect hook. If we don't have consentsData yet, then we make the call with Axios.

  useEffect(() => {
    if (!consentsData) {
      Axios("/user/consents/GetUserConsents")
        .then((response) => {
          setConsentsData(response.data);
        })
        .catch((error) => {
          return (
            <ErrorState
              onContinue={() => navigate("/")}
              errorMessage={error.message}
            />
          );
        })
        .finally(() => setIsLoading(false));
    }
  }, [consentsData, navigate]);

When we get a response from the api call, we call setConsentsData with the response data to save the consents in state. If we get an error, we return our ErrorState component, which takes two parameters: onContinue for when the continue button is clicked, and the error message to be displayed. In this scenario I'm telling it to reload the application with navigate("/"), which will try to make the api call again when it loads. Chances are though, it will just produce the same result if it failed the first time. In most cases, if there is an error retrieving the consents, the best thing to do would be to call a logout function onContinue. If the user cannot review and accept the consents, they should be logged out immediately as anything less could be a legal liability. Lastly, we set the loading state to false to get rid of the loading spinner.

If you pull down this project from the repo and run it, you'll notice that we are using a mock service worker to intercept the api call from Axios and provide us with the mock data contained in src/mocks/mocks.ts. This way you can simply replace MSW with you're own database connection and modify the consent data object to meet the needs of your application.

It is worth mentioning that in a production application we wouldn't be making these api calls directly from within the PreliminaryCheck component. Instead, we would abstract our services into a data layer, such as a separate data provider package that can be called into from anywhere in the application. My personal favorite is to use react-query for data synchronization in the data provider.

2. If there are consents to be signed, block the app from loading and display the consents, otherwise load the app.

if (consentsData && consentsData.count > 0 && !consentsComplete) {
  return (
    <PageContainer>
      <TargetedConsent
        onConsentComplete={handleConsentsComplete}
        consents={consentsData.consents}
      />
    </PageContainer>
  )
}
return <>{children}</>

This one is pretty simple. We're just checking to se if the value for count on the consents object is greater than 0 and that consent signing has not been completed and if so, we render the targeted consent flow. If the condition does not meet these criteria, then children is rendered, which are the routes being wrapped by the PreliminaryCheck.

3. When all consents have been signed, make the respective api call with the array of signed consents and load the app. If you remember, the TargetedConsent component includes a prop for onConsentsComplete. This is called when the last consent has been signed and the signedConsents array length is equal to that of the original consents array. When this is triggered, it calls the signConsents async function, which makes the SignUserConsents api request:

async function signConsents(signedConsents: TConsent[]) {
  setIsLoading(true)
  await Axios.post('/user/consents/SignUserConsents', signedConsents)
    .then(response => {
      setConsentsData(response.data)
    })
    .catch(error => {
      return (
        <ErrorState
          onContinue={() => navigate('/')}
          errorMessage={error.message}
        />
      )
    })
    .finally(() => {
      setConsentsComplete(true)
      setIsLoading(false)
    })
}

const handleConsentsComplete = (signedConsents: TConsent[]) => {
  if (!consentsComplete) {
    signConsents(signedConsents)
  }
}

Notice that we are once again calling setConsentsData with the data we get from the response of the api, the same way as we did when we made the get request to retrieve the initial set of consents. Why would we do that? In our scenario, the backend takes the consents that it recieves from us and sets the accepted value for each of those consents accordingly, then does another check to see if there are any consents left to be signed. If it finds none, it send back a consents object with a count of 0. The reason for this is in the off chance that a consent was added while the user was going through the signing process, we want to make sure that consent still gets displayed before the app loads. However, as long as the response we get back includes a count of 0, the application will load.

Although we're only showing how to do consent-checking here, additional routines could be added to this process. After consents are signed, you could prompt for a password update, then have the user set their display name, then display a "while you were away" type of page to show what they've missed since their last login, for example. This method is a simple enough solution where it can be easily extended without adding a high degree of complexity.