Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moody-queens-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/login-widget': patch
---

Add dedicated MFA enrollment and recovery codes stages for multi-factor setup journeys, including a new `mfa-enrollment.svelte` stage that handles the MFA setup text-output callback script and a `recovery-codes.svelte` stage that displays one-time recovery codes after MFA registration.
2 changes: 1 addition & 1 deletion apps/login-app/src/routes/(app)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
query.gotoOnFail = data.redirectParams?.gotoOnFail;
journeyStore.start({
journey: journeyParam || authIndexValue || 'Login',
journey: journeyParam || authIndexValue || '',
query,
// recaptchaAction: 'MyTestAction',
});
Expand Down
37 changes: 37 additions & 0 deletions apps/login-app/src/routes/+error.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!--

Copyright © 2026 Ping Identity Corporation. All right reserved.

This software may be modified and distributed under the terms
of the MIT license. See the LICENSE file for details.

-->

<script lang="ts">
import '../app.css';
import { page } from '$app/stores';

function getErrorMessage(err: unknown): string {
if (typeof err === 'string') {
return err;
}

const maybe = err as { message?: unknown; body?: { message?: unknown } } | null | undefined;
const message = maybe?.body?.message ?? maybe?.message;
return typeof message === 'string' ? message : 'An unexpected error occurred.';
}

$: message = getErrorMessage($page.error);
</script>

<div
class="tw_bg-body-light dark:tw_bg-body-dark tw_min-h-screen tw_flex tw_items-center tw_justify-center tw_p-6"
>
<div
class="tw_containing-box dark:tw_containing-box_dark md:tw_containing-box_medium tw_flex tw_flex-col tw_items-center tw_text-center tw_gap-4"
>
<h1 class="tw_primary-header dark:tw_primary-header_dark">Configuration error</h1>
<p class="tw_text-secondary-dark dark:tw_text-secondary-light">{message}</p>
<p class="tw_text-secondary-dark dark:tw_text-secondary-light">Status: {$page.status}</p>
</div>
</div>
1 change: 1 addition & 0 deletions core/_utilities/i18n.utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export function interpolate(
messageDirty = externalText;
}
const messageClean = sanitize(messageDirty, {
whiteList: { a: ['target', 'href', 'title', 'rel', 'class'], b: [], em: [] },
/**
* Allow `?` as first char in `href` value for anchor tags.
* To preserve original behavior in addition to this one exception,
Expand Down
7 changes: 5 additions & 2 deletions core/journey/_utilities/data-analysis.utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@

import { callbackType } from '@forgerock/journey-client';

import type { BaseCallback } from '@forgerock/journey-client/types';
import type { ConfirmationCallback, SelectIdPCallback } from '@forgerock/journey-client/types';
import type {
BaseCallback,
ConfirmationCallback,
SelectIdPCallback,
} from '@forgerock/journey-client/types';

import type { CallbackMetadata } from '$journey/journey.interfaces';

Expand Down
29 changes: 28 additions & 1 deletion core/journey/_utilities/map-stage.utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ vi.mock('./custom-registry', () => ({
import { createJourneyStep } from '$journey/_utilities/step.mock';
import Generic from '$journey/stages/generic.svelte';
import Login from '$journey/stages/login.svelte';
import { createMixedLoginWebAuthnStep } from '$journey/stages/mfa-stages.mock';
import MfaEnrollment from '$journey/stages/mfa-enrollment.svelte';
import {
createMixedLoginWebAuthnStep,
getAuthenticatorAppLinksStep,
getAuthenticatorAppStep,
mfaEnrollmentStep,
} from '$journey/stages/mfa-stages.mock';
import { mapStepToStage } from './map-stage.utilities';
import { step1, step3 } from './step.mock';

Expand All @@ -45,4 +51,25 @@ describe('Test mapping of step to stage', () => {
const result = mapStepToStage(mixedWithoutStage);
expect(result).toStrictEqual(Login);
});

it('maps steps with HiddenValueCallback id starting with skip- to MfaEnrollment', () => {
const stepWithSkipHidden = createJourneyStep(mfaEnrollmentStep as Step);

const result = mapStepToStage(stepWithSkipHidden);
expect(result).toStrictEqual(MfaEnrollment);
});

it('maps steps with HiddenValueCallback id starting with getapp- to MfaEnrollment', () => {
const stepWithGetAppHidden = createJourneyStep(getAuthenticatorAppStep as Step);

const result = mapStepToStage(stepWithGetAppHidden);
expect(result).toStrictEqual(MfaEnrollment);
});

it('maps steps with type-4 app-links script to MfaEnrollment', () => {
const stepWithAppLinksScript = createJourneyStep(getAuthenticatorAppLinksStep as Step);

const result = mapStepToStage(stepWithAppLinksScript);
expect(result).toStrictEqual(MfaEnrollment);
});
});
29 changes: 28 additions & 1 deletion core/journey/_utilities/map-stage.utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { WebAuthn } from '@forgerock/journey-client/webauthn';
import EmailSuspend from '$journey/stages/email-suspend.svelte';
import Generic from '$journey/stages/generic.svelte';
import Login from '$journey/stages/login.svelte';
import MfaEnrollment from '$journey/stages/mfa-enrollment.svelte';
import OneTimePassword from '$journey/stages/one-time-password.svelte';
import QrCode from '$journey/stages/qr-code.svelte';
import RecoveryCodesStage from '$journey/stages/recovery-codes.svelte';
Expand All @@ -23,7 +24,11 @@ import WebAuthnStage from '$journey/stages/webauthn.svelte';
import { isMixedLoginWebAuthnStep } from '../stages/_utilities/webauthn.utilities';
import { customStageRegistry } from './custom-registry';

import type { SuspendedTextOutputCallback } from '@forgerock/journey-client/types';
import type {
HiddenValueCallback,
SuspendedTextOutputCallback,
TextOutputCallback,
} from '@forgerock/journey-client/types';
import type { Component } from 'svelte';

import type { StepTypes } from '$journey/journey.interfaces';
Expand All @@ -33,9 +38,11 @@ type StageTypes =
| typeof Registration
| typeof Login
| typeof Generic
| typeof MfaEnrollment
Comment thread
vatsalparikh marked this conversation as resolved.
| typeof QrCode
| typeof EmailSuspend
| typeof RecoveryCodesStage;

/**
* @function mapStepToStage - Maps the current step to the proper stage component.
* @param {object} currentStep - The current step to check
Expand Down Expand Up @@ -95,5 +102,25 @@ export function mapStepToStage(currentStep: StepTypes): StageTypes | Component {
return EmailSuspend;
}

const hiddenValueCallbacks = currentStep.getCallbacksOfType(
callbackType.HiddenValueCallback,
) as HiddenValueCallback[];
Comment thread
vatsalparikh marked this conversation as resolved.
const hasMfaHidden = hiddenValueCallbacks.some((cb) => {
const id = cb.getOutputByName('id', '') as string;
return id.startsWith('skip-') || id.startsWith('getapp-');
});
if (hasMfaHidden) return MfaEnrollment;

// recognize app links screen based on the presence of app store URL links and message type 4 text output callback
const textOutputCallbacks = currentStep.getCallbacksOfType(
callbackType.TextOutputCallback,
) as TextOutputCallback[];
const hasAppLinksScript = textOutputCallbacks.some(
(cb) =>
cb.getMessageType() === '4' &&
(cb.getMessage().includes('itunes.apple.com') || cb.getMessage().includes('play.google.com')),
);
if (hasAppLinksScript) return MfaEnrollment;
Comment thread
vatsalparikh marked this conversation as resolved.

return Generic;
}
202 changes: 202 additions & 0 deletions core/journey/stages/mfa-enrollment.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
<!--

Copyright © 2025-2026 Ping Identity Corporation. All right reserved.

This software may be modified and distributed under the terms
of the MIT license. See the LICENSE file for details.

-->

<script lang="ts">
import { callbackType } from '@forgerock/journey-client';
import { afterUpdate } from 'svelte';

import T from '$components/_utilities/locale-strings.svelte';
import ShieldIcon from '$components/icons/shield-icon.svelte';
// Import primitives
import Alert from '$components/primitives/alert/alert.svelte';
import Button from '$components/primitives/button/button.svelte';
import Form from '$components/primitives/form/form.svelte';
import Text from '$components/primitives/text/text.svelte';
// i18n
import { interpolate } from '$core/_utilities/i18n.utilities';
import { convertStringToKey } from '$journey/stages/_utilities/step.utilities';

import type {
ConfirmationCallback,
HiddenValueCallback,
JourneyStep,
} from '@forgerock/journey-client/types';

// Types
import type { StageFormObject, StageJourneyObject } from '$journey/journey.interfaces';

export let componentStyle: 'app' | 'inline' | 'modal';
export let form: StageFormObject;
export let formEl: HTMLFormElement | null = null;
export let journey: StageJourneyObject;
export let step: JourneyStep;

type SubStage = 'enrollment' | 'getApp' | 'appLinks';

const formFailureMessageId = 'mfaEnrollmentFailureMessage';
const formHeaderId = 'mfaEnrollmentHeader';
const formElementId = 'mfaEnrollmentForm';

let alertNeedsFocus = false;
let formMessageKey = '';
let formAriaDescriptor = formHeaderId;
let formNeedsFocus = false;
let subStage: SubStage = 'enrollment';
let confirmationCb: ConfirmationCallback | null = null;
let hiddenValueCb: HiddenValueCallback | null = null;

afterUpdate(() => {
if (form?.message) {
formAriaDescriptor = formFailureMessageId;
alertNeedsFocus = true;
formNeedsFocus = false;
} else {
formAriaDescriptor = formHeaderId;
alertNeedsFocus = false;
formNeedsFocus = true;
}
});

$: {
formMessageKey = convertStringToKey(form?.message);

const confirmationCbs = step.getCallbacksOfType(
callbackType.ConfirmationCallback,
) as ConfirmationCallback[];
confirmationCb = confirmationCbs[0] ?? null;

const hiddenCbs = step.getCallbacksOfType(
callbackType.HiddenValueCallback,
) as HiddenValueCallback[];
hiddenValueCb = hiddenCbs[0] ?? null;

const hiddenId = (hiddenValueCb?.getOutputByName('id', '') as string) ?? '';

if (hiddenId.startsWith('getapp-')) {
subStage = 'getApp';
} else if (hiddenId.startsWith('skip-')) {
subStage = 'enrollment';
} else {
subStage = 'appLinks';
}
}

function submitWithValue(value: string) {
confirmationCb?.setInputValue(value);
form?.submit();
}

function submitHidden(value: string) {
hiddenValueCb?.setInputValue(value);
form?.submit();
}
</script>

<Form
bind:formEl
ariaDescribedBy={formAriaDescriptor}
id={formElementId}
needsFocus={formNeedsFocus}
onSubmitWhenValid={() => form?.submit()}
>
{#if form?.icon && componentStyle !== 'inline'}
<div class="tw_flex tw_justify-center">
<ShieldIcon classes="tw_text-gray-400 tw_fill-current" size="72px" />
</div>
{/if}

{#if form?.message}
<Alert id={formFailureMessageId} needsFocus={alertNeedsFocus} type="error">
{interpolate(formMessageKey, null, form?.message)}
</Alert>
{/if}

{#if subStage === 'enrollment'}
<header id={formHeaderId}>
<h1 class="tw_primary-header dark:tw_primary-header_dark">
<T key="setupTwoStepVerification" />
</h1>
<Text classes="tw_text-center tw_-mt-5 tw_mb-2 tw_py-4">
<T key="setupTwoStepVerificationDescription" />
</Text>
</header>

<Alert id="mfaWarning" needsFocus={false} type="warning">
<T html={true} key="setupTwoStepVerificationWarning" />
</Alert>

<Button
busy={journey?.loading}
style="primary"
type="button"
width="full"
onClick={() => submitWithValue('0')}
>
<T key="setupTwoStepVerificationButton" />
</Button>

<p class="tw_my-4 tw_text-center tw_text-sm">
<button
class="tw_text-link-dark dark:tw_text-link-light tw_underline"
type="button"
on:click={() => submitHidden('Skip')}
>
<T key="skipForNow" />
</button>
</p>
{:else if subStage === 'getApp'}
<header id={formHeaderId}>
<h1 class="tw_primary-header dark:tw_primary-header_dark">
<T key="getAuthenticatorApp" />
</h1>
<Text classes="tw_text-center tw_-mt-5 tw_mb-2 tw_py-4">
<T key="getAuthenticatorAppDescription" />
</Text>
</header>

<Button
busy={journey?.loading}
style="primary"
type="button"
width="full"
onClick={() => submitWithValue('0')}
>
<T key="next" />
</Button>

<p class="tw_my-4 tw_text-center tw_text-sm">
<button
class="tw_text-link-dark dark:tw_text-link-light tw_underline"
type="button"
on:click={() => submitHidden('Get app')}
>
<T key="downloadTheApp" />
</button>
</p>
{:else}
<header id={formHeaderId}>
<h1 class="tw_primary-header dark:tw_primary-header_dark">
<T key="getAuthenticatorApp" />
</h1>
<Text classes="tw_text-center tw_-mt-5 tw_mb-2 tw_py-4">
<T html={true} key="getAuthenticatorAppLinks" />
</Text>
</header>

<Button
busy={journey?.loading}
style="primary"
type="button"
width="full"
onClick={() => submitWithValue('0')}
>
<T key="continueButton" />
</Button>
{/if}
</Form>
Loading
Loading