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
4 changes: 3 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"@forgerock/oidc-suites",
"@forgerock/local-release-tool",
"@forgerock/protect-app",
"@forgerock/protect-suites"
"@forgerock/protect-suites",
"@forgerock/journey-app",
"@forgerock/journey-suites"
]
}
5 changes: 5 additions & 0 deletions .changeset/eleven-baboons-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/journey-client': patch
---

Add support for KBA `allowUserDefinedQuestions` flag
32 changes: 31 additions & 1 deletion e2e/journey-app/components/kba-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export default function kbaCreateComponent(
questionInput.appendChild(option);
});

// Add option to create a question if allowed
if (callback.isAllowedUserDefinedQuestions()) {
const userDefinedOption = document.createElement('option');
userDefinedOption.value = 'user-defined';
userDefinedOption.text = 'Enter your own question';
questionInput.appendChild(userDefinedOption);
}

// Answer field
const answerLabel = document.createElement('label');
answerLabel.htmlFor = `${collectorKey}-answer`;
Expand All @@ -53,10 +61,32 @@ export default function kbaCreateComponent(

// Event listeners
questionInput.addEventListener('input', (event) => {
callback.setQuestion((event.target as HTMLInputElement).value);
const selectedQuestion = (event.target as HTMLInputElement).value;
if (selectedQuestion === 'user-defined') {
// If user-defined option is selected, prompt for custom question
const customQuestionLabel = document.createElement('label');
customQuestionLabel.htmlFor = `${collectorKey}-question-user-defined`;
customQuestionLabel.innerText = 'Type your question ' + idx + ':';

const customQuestionInput = document.createElement('input');
customQuestionInput.type = 'text';
customQuestionInput.id = `${collectorKey}-question-user-defined`;
customQuestionInput.placeholder = 'Type your question';

container.lastElementChild?.before(customQuestionLabel);
container.lastElementChild?.before(customQuestionInput);
customQuestionInput.addEventListener('input', (e) => {
callback.setQuestion((e.target as HTMLInputElement).value);
console.log('Custom question ' + idx + ':', callback.getInputValue(0));
});
} else {
callback.setQuestion((event.target as HTMLInputElement).value);
console.log('Selected question ' + idx + ':', callback.getInputValue(0));
}
Comment on lines +64 to +85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix DOM insertion order and avoid duplicate custom question inputs

The dynamic custom-question handling works functionally, but there are a couple of rough edges:

  • Using container.lastElementChild?.before(...) inserts the custom label/input between the answer label and answer input, so the “Answer N” label is visually separated from its input.
  • Each time the user re-selects the “user-defined” option, a new label/input pair and event listener are created; nothing prevents duplicates.

You can keep behavior the same while improving UX and robustness by:

  • Anchoring the custom fields directly after the question <select>.
  • Creating the custom input only once per component.
  • Typing the event target as HTMLSelectElement and using a change listener which is more idiomatic for <select>.

For example:

-  questionInput.addEventListener('input', (event) => {
-    const selectedQuestion = (event.target as HTMLInputElement).value;
+  questionInput.addEventListener('change', (event) => {
+    const selectEl = event.target as HTMLSelectElement;
+    const selectedQuestion = selectEl.value;
     if (selectedQuestion === 'user-defined') {
-      // If user-defined option is selected, prompt for custom question
-      const customQuestionLabel = document.createElement('label');
-      customQuestionLabel.htmlFor = `${collectorKey}-question-user-defined`;
-      customQuestionLabel.innerText = 'Type your question ' + idx + ':';
-
-      const customQuestionInput = document.createElement('input');
-      customQuestionInput.type = 'text';
-      customQuestionInput.id = `${collectorKey}-question-user-defined`;
-      customQuestionInput.placeholder = 'Type your question';
-
-      container.lastElementChild?.before(customQuestionLabel);
-      container.lastElementChild?.before(customQuestionInput);
-      customQuestionInput.addEventListener('input', (e) => {
-        callback.setQuestion((e.target as HTMLInputElement).value);
-        console.log('Custom question ' + idx + ':', callback.getInputValue(0));
-      });
+      // If user-defined option is selected, prompt for custom question
+      let customQuestionInput = container.querySelector<HTMLInputElement>(
+        `#${collectorKey}-question-user-defined`,
+      );
+
+      if (!customQuestionInput) {
+        const customQuestionLabel = document.createElement('label');
+        customQuestionLabel.htmlFor = `${collectorKey}-question-user-defined`;
+        customQuestionLabel.innerText = 'Type your question ' + idx + ':';
+
+        customQuestionInput = document.createElement('input');
+        customQuestionInput.type = 'text';
+        customQuestionInput.id = `${collectorKey}-question-user-defined`;
+        customQuestionInput.placeholder = 'Type your question';
+
+        // Insert the custom question fields immediately after the select
+        questionInput.after(customQuestionLabel, customQuestionInput);
+
+        customQuestionInput.addEventListener('input', (e) => {
+          callback.setQuestion((e.target as HTMLInputElement).value);
+          console.log('Custom question ' + idx + ':', callback.getInputValue(0));
+        });
+      }
     } else {
-      callback.setQuestion((event.target as HTMLInputElement).value);
+      callback.setQuestion(selectEl.value);
       console.log('Selected question ' + idx + ':', callback.getInputValue(0));
     }
   });

This keeps the console output and tests intact while tightening the DOM behavior.

Also applies to: 88-90

🤖 Prompt for AI Agents
In e2e/journey-app/components/kba-create.ts around lines 64-85 (and similarly
lines 88-90), the select handling inserts duplicate custom inputs and places
them before the answer label/input; change the listener to use a "change" event
on an HTMLSelectElement, type the event target as HTMLSelectElement, and anchor
the custom label/input directly after the question <select> (e.g.,
insertAdjacentElement or nextSibling insertion) instead of using
container.lastElementChild.before(...). Ensure you only create the custom
label/input once by checking for an existing element with the user-defined id
(or querySelector) and reuse it (and its event listener) on subsequent
selections; when switching away from "user-defined" hide or remove the existing
custom input and update callback.setQuestion(...) from the select's value or the
persistent custom input's value accordingly.

});

answerInput.addEventListener('input', (event) => {
callback.setAnswer((event.target as HTMLInputElement).value);
console.log('Answer ' + idx + ':', callback.getInputValue(1));
});
}
15 changes: 11 additions & 4 deletions e2e/journey-suites/src/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ test('Test happy paths on test page', async ({ page }) => {
await page.getByLabel('Send me news and updates').check();
// Fill password
await page.getByLabel('Password').fill(password);
// Select "Select a security question 7" dropdown and choose "What's your favorite color?"
await page.getByLabel('Select a security question 7').selectOption("What's your favorite color?");
// Fill answer with "Red"
await page.getByLabel('Answer 7').fill('Red');

// Select "Select a security question 7" dropdown and choose custom question
await page.getByLabel('Select a security question 7').selectOption('user-defined');
await page.getByLabel('Type your question 7:').fill(`What is your pet's name?`);
// Fill answer with "Rover"
await page.getByLabel('Answer 7').fill('Rover');

// Select "Select a security question 8" dropdown and choose "Who was your first employer?"
await page
.getByLabel('Select a security question 8')
Expand All @@ -61,6 +64,10 @@ test('Test happy paths on test page', async ({ page }) => {
await clickButton('Logout', '/authenticate');

// Test assertions
expect(messageArray.includes(`Custom question 7: What is your pet's name?`)).toBe(true);
expect(messageArray.includes('Answer 7: Rover')).toBe(true);
expect(messageArray.includes(`Selected question 8: Who was your first employer?`)).toBe(true);
expect(messageArray.includes('Answer 8: AAA Engineering')).toBe(true);
expect(messageArray.includes('Journey completed successfully')).toBe(true);
expect(messageArray.includes('Logout successful')).toBe(true);
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ describe('KbaCreateCallback', () => {
name: 'predefinedQuestions',
value: ['Question 1', 'Question 2'],
},
{
name: 'allowUserDefinedQuestions',
value: true,
},
],
input: [
{
Expand All @@ -50,4 +54,9 @@ describe('KbaCreateCallback', () => {
expect(cb.getInputValue('IDToken1question')).toBe('My custom question');
expect(cb.getInputValue('IDToken1answer')).toBe('Blue');
});

it('should indicate if user-defined questions are allowed', () => {
const cb = new KbaCreateCallback(payload);
expect(cb.isAllowedUserDefinedQuestions()).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export class KbaCreateCallback extends BaseCallback {
return this.getOutputByName<string[]>('predefinedQuestions', []);
}

/**
* Gets whether the user can define questions.
*/
public isAllowedUserDefinedQuestions(): boolean {
return this.getOutputByName<boolean>('allowUserDefinedQuestions', false);
}

/**
* Sets the callback's security question.
*/
Expand Down
Loading