Skip to content

Instantly share code, notes, and snippets.

@cerebrl
Last active October 2, 2023 23:36
Show Gist options
  • Save cerebrl/2108815d33233e23e7b2356b6da52cd6 to your computer and use it in GitHub Desktop.
Save cerebrl/2108815d33233e23e7b2356b6da52cd6 to your computer and use it in GitHub Desktop.

Login Widget with React JS

Requirements

  1. Node 18+
  2. NPM 8+

Configure Your ForgeRock Server

  1. Ensure CORS is enabled
  2. Create an OAuth client (Native/SPA client) with the following values:
    • Client ID: WebLoginWidgetClient
    • Scopes: openid email
    • Sign in URLs: http://localhost:5173/ (port number may vary)
    • Enable Implied Consent
    • Ensure Token Endpoint Authentication Method is set to 'none'
    • Grant Types contains Authorization Code
  3. Ensure you have the default "Login" journey/tree

Create a Vite App

  1. Create Vite app with npm create vite (more info can be found on Vite's docs)
  2. Follow prompts, choosing React as your desired library
  3. Open the newly created directory in your IDE of choice
  4. With your terminal/bash, install the dependencies: npm install (or, simply npm i)
  5. Run the app in developer mode: npm run dev
  6. Copy the URL printed in the console to see the rendered app (usually http://localhost:5173)

Pro tip: using a different browser for development testing than the one you use to log into the ForgeRock platform. This is a good idea as admin user and test user sessions can collide causing odd authentication failures.

Install the Login Widget

Install the Login Widget via your open terminal: npm install @forgerock/login-widget@beta. The Widget is currently in beta and requires the use of the beta tag.

Prepare the HTML & CSS

Return back to the project in your IDE and look for the index.html file. Since we will start with the Modal type component of the Widget, create a root element on which the Widget will mount by adding <div id="widget"></div> towards the bottom of the <body> element, but before the <script> tag:

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
  </head>
  <body>
    <div id="root"></div>
    <div id="widget"></div>
    <!-- new element -->
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Before we get to the JavaScript, we have to do one more thing. Wrap the app's CSS in a @layer. This helps control the CSS cascade and simply solves styling issues with very little effort. Open index.css and App.css and wrap them both with the below:

@layer app {
  /* your app's css */
}

Import & Configure the Widget

Open the main application file, usually called App.jsx in your IDE. Import the Widget class, the configuration module, and the Widget's CSS.

import Widget, { configuration } from '@forgerock/login-widget';
import '@forgerock/login-widget/widget.css';

For now, just call the configuration method within your App function component and save off the return value to config variable for later use. This internally prepares the Widget for use.

function App() {
  const [count, setCount] = useState(0);

  // Initiate all the Widget modules
  const config = configuration();

  // ...

Instantiate & Mount the Widget

Before we can do much, we need to import the useEffect from the React library. This is to control the execution of a few statements we will write next. After importing useEffect let's write it into our component with an empty dependency array.

import React, { useEffect, useState } from 'react';

// ...

function App() {

  // ...

  useEffect(() => {}, []);

  // ...

Note: The empty dependency array is to tell React this has no dependencies at this point and should only run once (there's some nuance here that I'm going to ignore).

Now that we have the useEffect written, add the following inside of it:

  1. Instantiate the Widget class within this useEffect
  2. Pass an object as an argument with a target property containing the selected DOM element we created in a previous step
  3. Assign its return value to a widget variable
  4. Return a function that calls widget.$destroy()

Your useEffect should now look like this:

useEffect(() => {
  const widget = new Widget({ target: document.getElementById('widget') });

  return () => {
    widget.$destroy();
  };
}, []);

Note: The reason for the returned function is for proper clean up when the React component unmounts. If it remounts, we won't get two widgets added to the DOM.

If you revisit your browser, you'll notice that the app doesn't look any different. This is because the Widget, by default, will be invisible at startup. To ensure it's working as expected, inspect the DOM in the browser developer tools. If you open the <div id="widget"> element in the DOM, you should see the Login Widget mounted within it.

Controlling the Component

An invisible Widget isn't all that useful, so our next task is to pull in the component module to manage the component's events.

  1. Add the component module to our import from the @forgerock/login-widget
  2. Call the component function just under the configuration function
  3. Assign its return value to a componentEvents variable:
// ...

function App() {
  // ...

  const config = configuration();
  const componentEvents = component();

   // ...

Now that we have a reference to the component events observable, we can not only trigger an event (like open), but we can also listen for events. Before we call the open method, let's repurpose the existing button within the App component.

  1. Within the button on click handler, change the setCount function to componentEvents.open
  2. Change the button text to read "Login":
<button
  onClick={() => {
    componentEvents.open();
  }}>
  Login
</button>

You can now revisit your test browser and click the "Login" button. The modal should open and have a "spinner" animating on repeat. This is expected. The Widget is currently waiting on information for it to render.

Now, click the button in the top-right to close the modal. The modal should be dismissed as expected.

Now that we have the modal mounted and functional, let's prepare to call the ForgeRock platform to get our login data.

Calling the ForgeRock Platform

Before the Widget can connect with the ForgeRock platform, we need to use that config variable we created earlier. Call its set method within the exiting useEffect, and provide the configuration values for your ForgeRock server:

useEffect(() => {
  config.set({
    forgerock: {
      serverConfig: {
        baseUrl: 'https://example.forgeblocks.com/am',
        timeout: 3000,
      },
    },
  });

  const widget = new Widget({ target: document.getElementById('widget')});

  // ...

Now that we have the Widget configured for calling ForgeRock, let's import the journey module to start our authentication flow:

import Widget, {
  component,
  configuration,
  journey,
} from '@forgerock/login-widget';

Execute the journey function and assign its returned value to a journeyEvents variable. This can be done just underneath the other "event" variables:

// …

function App() {
  // …

  const config = configuration();
  const componentEvents = component();
  const journeyEvents = journey();

This new events observable will provide access to journey events. Within the Login button's on click handler add the start method. Now, when we open the modal, we'll also call start to request the user's first authentication step.

<button onClick={() => {
  journeyEvents.start();
  componentEvents.open();
}>
  Login
</button>

You are now capable of authenticating a user. With an existing user in your ForgeRock system, log that user in and see what happens. If successful, you'll notice the modal will dismiss itself, but your app is not capturing anything from this action. Let's now capture this data.

Authenticating a User

There are multiple ways to capture the event of a successful login and accessing the user information. Let's start with using the journeyEvents observable we created previously.

Within the existing useEffect function:

  1. Call the subscribe method and assign its return value to a unsubscribe variable
  2. Pass in a function that just logs the event being emitted
  3. Call the unsubscribe function within the useEffect's return function
// ...

useEffect(() => {
  // ...

  const widget = new Widget({ target: document.getElementById('widget') });

  const journeyEventsUnsub = journeyEvents.subscribe((event) => {
    console.log(event);
  });

  return () => {
    widget.$destroy();
    journeyEventsUnsub();
  };
}, []);

Note: Unsubscribing from the observable is important to avoid memory leaks if the component mounts and unmounts frequently.

Revisit your app in the test browser, but remove all of the browser's cookies and Web Storage to ensure we have a fresh start. In Chromium browsers, you can find it under the "Application" tab of the browser tools. In Firefox and Safari, you can find it under the "Storage" tab.

Once you have deleted all the cookies and storage, refresh the browser and try to login your test user. Notice in the console that there's a lot of events being emitted. You are welcome to browse through the objects. Initially, you may not have much need for all this data, but over time, this information will likely become more valuable to you.

To narrow down all of this information, let's capture just one piece of the event: the user response after successfully logging in. To do that, let's add a simple conditional.

Add an if condition within the subscribe callback function that tests for the existence of the user response.

const journeyEventsUnsub = journeyEvents.subscribe((event) => {
  if (event.user.response) {
    console.log(event.user.response);
  }
});

With the above condition, we only log out the user information when it's truthy. This helps us narrow down only the information that's useful to us right now.

Remove all the cookies, Web Storage and refresh the page. Try logging in again, and you should see only one log of the user information when it's available.

Finally, let's repurpose the useState hook that's already used in the component to save the user information.

  1. Change the zeroth index of the returned value from count to userInfo
  2. Change the first index of the returned value from setCount to setUserInfo
  3. Change the default value passed into the useState from 0 to null
  4. Change the condition from just truthy to userInfo !== event.user.response
  5. Replace the console.log with the setUserInfo function
  6. Add the userInfo variable in the dependency array of the useEffect

This is what the top part of your App function component should look like thus far:

function App() {
  const [userInfo, setUserInfo] = useState(null);

  // Initiate all the Widget modules
  const config = configuration();
  const componentEvents = component();
  const journeyEvents = journey();

  useEffect(() => {
    // Set the Widget's configuration
    config.set({
      forgerock: {
        serverConfig: {
          baseUrl: 'https://example.forgeblocks.com/am',
          timeout: 3000,
        }
      }
    });

    // Instantiate the Widget and assign it to a variable
    const widget = new Widget({ target: document.getElementById('widget-modal') });

    // Subscribe to journey observable and assign unsubscribe function to variable
    const journeyEventsUnsub = journeyEvents.subscribe((event) => {
      if (userInfo !== event.user.response) {
        setUserInfo(event.user.response);
      }
    });

    // Return a function that destroys the Widget and unsubscribes from the journey observable
    return () => {
      widget.$destroy();
      journeyEventsUnsub();
    };
  }, [userInfo]);

  // ...

Note: The condition comparing userInfo to event.user.response just reduces the number of times the setUserInfo is called as it will now only be called if what's set in the hook is different than what's emitted from the Widget.

Now that we have the user data set into our React component, let's print it out into the DOM.

  1. Replace the paragraph tag containing the text "Edit src/App.jsx and save to test HMR" with a <pre> tag
  2. Within the <pre> tag, write a pair of braces: {}
  3. Within these braces, use the JSON.stringify method to serialize the userInfo value

Your JSX should look like this:

<pre>{JSON.stringify(userInfo, null, ' ')}</pre>

Note: the null and ' ' (literal space character) help format the JSON a bit.

After clearing the browser data, try logging the user in and observe the user info get rendered onto the page after success.

Logging a User Out

Our final action is to log the user out, clearing all the user-related cookies, storage and cache. To do this, we need one final module that we haven't imported yet: user. Let's import that in.

import Widget, {
  configuration,
  component,
  journey,
  user,
} from '@forgerock/login-widget';

We're going to make our Login button a bit smarter, and have it as a Login button when the user is logged out, and a Logout button when the user is logged in.

  1. Wrap the button element with braces containing a ternary using the "falsiness" of the userInfo as the condition
  2. When no userInfo exists (the user is logged out), render the Login button
  3. Write a Logout button with an on click handler to run the user.logout function

The resulting JSX should look like this:

{
  !userInfo ? (
    <button
      onClick={() => {
        journeyEvents.start();
        componentEvents.open();
      }}>
      Login
    </button>
  ) : (
    <button
      onClick={() => {
        user.logout();
      }}>
      Logout
    </button>
  )
}

Note, we don't have to worry about resetting the userInfo with the setUserInfo function because we are already "listening" to events emitted from the journeyEvents subscription with the user object nested within it.

If your app is already reacting to the presence of user info, it should be rendering the Logout button already. Click it and observe the application reacting. You should now be able to log a user in, and log a user out, all while the app is reacting to this change in state.

Repo reflecting completion of tutorial

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment