Skip to content

Commit bc799f0

Browse files
committed
fix(mfa-step): create a new step for handling MFA set up page text output callback script
1 parent cf76688 commit bc799f0

11 files changed

Lines changed: 512 additions & 5 deletions

File tree

apps/login-app/src/routes/(app)/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
query.gotoOnFail = data.redirectParams?.gotoOnFail;
6666
6767
journeyStore.start({
68-
journey: journeyParam || authIndexValue || 'Login',
68+
journey: journeyParam || authIndexValue || '',
6969
query,
7070
// recaptchaAction: 'MyTestAction',
7171
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!--
2+
3+
Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
5+
This software may be modified and distributed under the terms
6+
of the MIT license. See the LICENSE file for details.
7+
8+
-->
9+
10+
<script lang="ts">
11+
import '../app.css';
12+
import { page } from '$app/stores';
13+
14+
function getErrorMessage(err: unknown): string {
15+
if (typeof err === 'string') {
16+
return err;
17+
}
18+
19+
const maybe = err as { message?: unknown; body?: { message?: unknown } } | null | undefined;
20+
const message = maybe?.body?.message ?? maybe?.message;
21+
return typeof message === 'string' ? message : 'An unexpected error occurred.';
22+
}
23+
24+
$: message = getErrorMessage($page.error);
25+
</script>
26+
27+
<div
28+
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"
29+
>
30+
<div
31+
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"
32+
>
33+
<h1 class="tw_primary-header dark:tw_primary-header_dark">Configuration error</h1>
34+
<p class="tw_text-secondary-dark dark:tw_text-secondary-light">{message}</p>
35+
<p class="tw_text-secondary-dark dark:tw_text-secondary-light">Status: {$page.status}</p>
36+
</div>
37+
</div>

core/journey/_utilities/data-analysis.utilities.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { describe, expect, it } from 'vitest';
1212

1313
import { createJourneyStep } from '$journey/_utilities/step.mock';
1414
import { isStepReadyToSubmit, requiresUserInput } from './data-analysis.utilities';
15-
import { createJourneyStep } from '$journey/_utilities/step.mock';
1615

1716
describe('Test data analysis functions for step and callback', () => {
1817
it('should identify a step ready to be self-submitted', () => {

core/journey/_utilities/data-analysis.utilities.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99

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

12-
import type { BaseCallback, ConfirmationCallback, SelectIdPCallback } from '@forgerock/journey-client/types';
12+
import type {
13+
BaseCallback,
14+
ConfirmationCallback,
15+
SelectIdPCallback,
16+
} from '@forgerock/journey-client/types';
1317

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

core/journey/_utilities/map-stage.utilities.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { WebAuthn } from '@forgerock/journey-client/webauthn';
1515
import EmailSuspend from '$journey/stages/email-suspend.svelte';
1616
import Generic from '$journey/stages/generic.svelte';
1717
import Login from '$journey/stages/login.svelte';
18+
import MfaEnrollment from '$journey/stages/mfa-enrollment.svelte';
1819
import OneTimePassword from '$journey/stages/one-time-password.svelte';
1920
import QrCode from '$journey/stages/qr-code.svelte';
2021
import RecoveryCodesStage from '$journey/stages/recovery-codes.svelte';
@@ -23,7 +24,11 @@ import WebAuthnStage from '$journey/stages/webauthn.svelte';
2324
import { isMixedLoginWebAuthnStep } from '../stages/_utilities/webauthn.utilities';
2425
import { customStageRegistry } from './custom-registry';
2526

26-
import type { SuspendedTextOutputCallback } from '@forgerock/journey-client/types';
27+
import type {
28+
HiddenValueCallback,
29+
SuspendedTextOutputCallback,
30+
TextOutputCallback,
31+
} from '@forgerock/journey-client/types';
2732
import type { Component } from 'svelte';
2833

2934
import type { StepTypes } from '$journey/journey.interfaces';
@@ -33,9 +38,11 @@ type StageTypes =
3338
| typeof Registration
3439
| typeof Login
3540
| typeof Generic
41+
| typeof MfaEnrollment
3642
| typeof QrCode
3743
| typeof EmailSuspend
3844
| typeof RecoveryCodesStage;
45+
3946
/**
4047
* @function mapStepToStage - Maps the current step to the proper stage component.
4148
* @param {object} currentStep - The current step to check
@@ -95,5 +102,24 @@ export function mapStepToStage(currentStep: StepTypes): StageTypes | Component {
95102
return EmailSuspend;
96103
}
97104

105+
const hiddenValueCallbacks = currentStep.getCallbacksOfType(
106+
callbackType.HiddenValueCallback,
107+
) as HiddenValueCallback[];
108+
const hasMfaHidden = hiddenValueCallbacks.some((cb) => {
109+
const id = cb.getOutputByName('id', '') as string;
110+
return id.startsWith('skip-') || id.startsWith('getapp-');
111+
});
112+
if (hasMfaHidden) return MfaEnrollment;
113+
114+
// App links screen: type-4 script targets the callback's XUI-rendered DOM element directly
115+
const textOutputCbs = currentStep.getCallbacksOfType(
116+
callbackType.TextOutputCallback,
117+
) as TextOutputCallback[];
118+
const hasAppLinksScript = textOutputCbs.some(
119+
(cb) =>
120+
cb.getMessageType() === '4' && cb.getMessage().includes('document.getElementById("callback_'),
121+
);
122+
if (hasAppLinksScript) return MfaEnrollment;
123+
98124
return Generic;
99125
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<!--
2+
3+
Copyright © 2025-2026 Ping Identity Corporation. All right reserved.
4+
5+
This software may be modified and distributed under the terms
6+
of the MIT license. See the LICENSE file for details.
7+
8+
-->
9+
10+
<script lang="ts">
11+
import { callbackType } from '@forgerock/journey-client';
12+
import { afterUpdate } from 'svelte';
13+
14+
import T from '$components/_utilities/locale-strings.svelte';
15+
import ShieldIcon from '$components/icons/shield-icon.svelte';
16+
// Import primitives
17+
import Alert from '$components/primitives/alert/alert.svelte';
18+
import Button from '$components/primitives/button/button.svelte';
19+
import Form from '$components/primitives/form/form.svelte';
20+
import Text from '$components/primitives/text/text.svelte';
21+
// i18n
22+
import { interpolate } from '$core/_utilities/i18n.utilities';
23+
import { convertStringToKey } from '$journey/stages/_utilities/step.utilities';
24+
25+
import type {
26+
ConfirmationCallback,
27+
HiddenValueCallback,
28+
JourneyStep,
29+
} from '@forgerock/journey-client/types';
30+
31+
// Types
32+
import type { StageFormObject, StageJourneyObject } from '$journey/journey.interfaces';
33+
34+
export let componentStyle: 'app' | 'inline' | 'modal';
35+
export let form: StageFormObject;
36+
export let formEl: HTMLFormElement | null = null;
37+
export let journey: StageJourneyObject;
38+
export let step: JourneyStep;
39+
40+
type SubStage = 'enrollment' | 'getApp' | 'appLinks';
41+
42+
const formFailureMessageId = 'mfaEnrollmentFailureMessage';
43+
const formHeaderId = 'mfaEnrollmentHeader';
44+
const formElementId = 'mfaEnrollmentForm';
45+
46+
let alertNeedsFocus = false;
47+
let formMessageKey = '';
48+
let formAriaDescriptor = formHeaderId;
49+
let formNeedsFocus = false;
50+
let subStage: SubStage = 'enrollment';
51+
let confirmationCb: ConfirmationCallback | null = null;
52+
let hiddenValueCb: HiddenValueCallback | null = null;
53+
54+
afterUpdate(() => {
55+
if (form?.message) {
56+
formAriaDescriptor = formFailureMessageId;
57+
alertNeedsFocus = true;
58+
formNeedsFocus = false;
59+
} else {
60+
formAriaDescriptor = formHeaderId;
61+
alertNeedsFocus = false;
62+
formNeedsFocus = true;
63+
}
64+
});
65+
66+
$: {
67+
formMessageKey = convertStringToKey(form?.message);
68+
69+
const confirmationCbs = step.getCallbacksOfType(
70+
callbackType.ConfirmationCallback,
71+
) as ConfirmationCallback[];
72+
confirmationCb = confirmationCbs[0] ?? null;
73+
74+
const hiddenCbs = step.getCallbacksOfType(
75+
callbackType.HiddenValueCallback,
76+
) as HiddenValueCallback[];
77+
hiddenValueCb = hiddenCbs[0] ?? null;
78+
79+
const hiddenId = (hiddenValueCb?.getOutputByName('id', '') as string) ?? '';
80+
81+
if (hiddenId.startsWith('getapp-')) {
82+
subStage = 'getApp';
83+
} else if (hiddenId.startsWith('skip-')) {
84+
subStage = 'enrollment';
85+
} else {
86+
subStage = 'appLinks';
87+
}
88+
}
89+
90+
function submitWithValue(value: string) {
91+
confirmationCb?.setInputValue(value);
92+
form?.submit();
93+
}
94+
95+
function submitHidden(value: string) {
96+
hiddenValueCb?.setInputValue(value);
97+
form?.submit();
98+
}
99+
</script>
100+
101+
<Form
102+
bind:formEl
103+
ariaDescribedBy={formAriaDescriptor}
104+
id={formElementId}
105+
needsFocus={formNeedsFocus}
106+
onSubmitWhenValid={() => form?.submit()}
107+
>
108+
{#if form?.icon && componentStyle !== 'inline'}
109+
<div class="tw_flex tw_justify-center">
110+
<ShieldIcon classes="tw_text-gray-400 tw_fill-current" size="72px" />
111+
</div>
112+
{/if}
113+
114+
{#if form?.message}
115+
<Alert id={formFailureMessageId} needsFocus={alertNeedsFocus} type="error">
116+
{interpolate(formMessageKey, null, form?.message)}
117+
</Alert>
118+
{/if}
119+
120+
{#if subStage === 'enrollment'}
121+
<header id={formHeaderId}>
122+
<h1 class="tw_primary-header dark:tw_primary-header_dark">
123+
<T key="setupTwoStepVerification" />
124+
</h1>
125+
<Text classes="tw_text-center tw_-mt-5 tw_mb-2 tw_py-4">
126+
<T key="setupTwoStepVerificationDescription" />
127+
</Text>
128+
</header>
129+
130+
<Alert id="mfaWarning" needsFocus={false} type="warning">
131+
<T html={true} key="setupTwoStepVerificationWarning" />
132+
</Alert>
133+
134+
<Button
135+
busy={journey?.loading}
136+
style="primary"
137+
type="button"
138+
width="full"
139+
onClick={() => submitWithValue('0')}
140+
>
141+
<T key="setupTwoStepVerificationButton" />
142+
</Button>
143+
144+
<p class="tw_my-4 tw_text-center tw_text-sm">
145+
<button
146+
class="tw_text-link-dark dark:tw_text-link-light tw_underline"
147+
type="button"
148+
on:click={() => submitHidden('Skip')}
149+
>
150+
<T key="skipForNow" />
151+
</button>
152+
</p>
153+
{:else if subStage === 'getApp'}
154+
<header id={formHeaderId}>
155+
<h1 class="tw_primary-header dark:tw_primary-header_dark">
156+
<T key="getAuthenticatorApp" />
157+
</h1>
158+
<Text classes="tw_text-center tw_-mt-5 tw_mb-2 tw_py-4">
159+
<T key="getAuthenticatorAppDescription" />
160+
</Text>
161+
</header>
162+
163+
<Button
164+
busy={journey?.loading}
165+
style="primary"
166+
type="button"
167+
width="full"
168+
onClick={() => submitWithValue('0')}
169+
>
170+
<T key="next" />
171+
</Button>
172+
173+
<p class="tw_my-4 tw_text-center tw_text-sm">
174+
<button
175+
class="tw_text-link-dark dark:tw_text-link-light tw_underline"
176+
type="button"
177+
on:click={() => submitHidden('Get app')}
178+
>
179+
<T key="downloadTheApp" />
180+
</button>
181+
</p>
182+
{:else}
183+
<header id={formHeaderId}>
184+
<h1 class="tw_primary-header dark:tw_primary-header_dark">
185+
<T key="getAuthenticatorApp" />
186+
</h1>
187+
<Text classes="tw_text-center tw_-mt-5 tw_mb-2 tw_py-4">
188+
<T html={true} key="getAuthenticatorAppLinks" />
189+
</Text>
190+
</header>
191+
192+
<Button
193+
busy={journey?.loading}
194+
style="primary"
195+
type="button"
196+
width="full"
197+
onClick={() => submitWithValue('0')}
198+
>
199+
<T key="continueButton" />
200+
</Button>
201+
{/if}
202+
</Form>

0 commit comments

Comments
 (0)