To accomplish this, we ended up using jQuery to listen to the actual XHR requests that B2C sends out, and then binding to their responses to determine what mode we're in. There's a risk that Microsoft could change how the API works and completely break this implementation, but it's more reliable than anything else I could come up with.
Prior to that approach, we tried looking for CSS classes that B2C adds to fields that have invalid values, as well as for basing the mode on what UI controls are or are not visible, but those approaches were extremely fragile. Either our code was getting invoked before Azure's (so the classes in the page weren't yet up to date), or our code didn't handle all of the error cases we needed to respond to, for when the email address is invalid, the user has tried too many times, etc.
Our approach takes the form of JavaScript and CSS embedded in the custom HTML template for the flow. We've also made sure to enable JavaScript in the User Journey.
Here's the code we're using:
const STATUS_OK = 0,
STATUS_BAD_EMAIL = 2;
const REQUEST_TYPE_CODE_REQUEST = 'VERIFICATION_REQUEST',
REQUEST_TYPE_CODE_VALIDATION = 'VALIDATION_REQUEST';
let $body = $('body'),
$emailField = $('#email');
$changeEmailButton = $('#email_ver_but_edit');
/**
* Switches the mode of the form into requesting a confirmation code.
*/
function switchToRequestConfirmationCodeMode() {
$body.attr('data-mode', 'send-code');
}
/**
* Switches the mode of the form into validating a confirmation code.
*/
function switchToVerifyCodeMode() {
$body.attr('data-mode', 'verify-code');
}
/**
* Switches the mode of the form into completing sign-up.
*/
function switchToEmailVerifiedMode() {
$body.attr('data-mode', 'email-verified');
}
/**
* Callback invoked when a verification code is being requested via XHR.
*
* @param {jqXHR} jqXhr
* The XHR being sent out to get a verification code sent out.
*/
function handleSendVerificationCodeRequest(jqXhr) {
let controlsToDisableDuringRequest = [
'#email_ver_but_send',
];
disableControlsDuringRequest(controlsToDisableDuringRequest, jqXhr);
jqXhr.done((data) => {
if ((data.status === "200") && (data.result === STATUS_OK)) {
// Code sent successfully.
switchToVerifyCodeMode();
}
});
}
/**
* Callback invoked when a verification code is being validated via XHR.
*
* @param {jqXHR} jqXhr
* The XHR being sent out to validate a verification code entered by the
* user.
*/
function handleValidateCodeRequest(jqXhr) {
let controlsToDisableDuringRequest = [
'#email_ver_input',
'#email_ver_but_verify',
'#email_ver_but_resend',
];
disableControlsDuringRequest(controlsToDisableDuringRequest, jqXhr);
jqXhr.done((data) => {
if (data.status === "200") {
if (data.result === STATUS_OK) {
// Code was accepted.
switchToEmailVerifiedMode();
} else if (data.result === STATUS_BAD_EMAIL) {
// Too many attempts; switch back to requesting a new code.
switchToRequestConfirmationCodeMode();
}
}
});
}
/**
* Disables the given controls during the provided XHR.
*
* @param {string[]} controls
* A list of jQuery selectors for the controls to disable during the
* XHR.
* @param {jqXHR} jqXhr
* The XHR during which controls should be disabled.
*/
function disableControlsDuringRequest(controls, jqXhr) {
let $controls = $(controls.join(','));
// Disable controls during the XHR.
$controls.prop("disabled", true);
// Release the controls after the XHR, even on failure.
jqXhr.always(() => {
$controls.prop("disabled", false);
})
}
// At present, there doesn't seem to be a way to be notified about/detect
// whether the user should be prompted for a verification code. But, for
// our styling needs, we need a way to determine what "mode" the form is
// in.
//
// To accomplish this, we listen for when B2C is sending out a
// verification code, and then toggle the "mode" of the form only if the
// request is successful. That way, we do not toggle the form if there are
// client-side or server-side validation errors with the email address
// that the user provided.
$(document).ajaxSend(function (e, jqXhr, settings) {
if (settings.contentType.startsWith('application/x-www-form-urlencoded')) {
let parsedData = new URLSearchParams(settings.data),
requestType = parsedData.get('request_type')
if (requestType === REQUEST_TYPE_CODE_REQUEST) {
handleSendVerificationCodeRequest(jqXhr);
}
else if (requestType === REQUEST_TYPE_CODE_VALIDATION) {
handleValidateCodeRequest(jqXhr);
}
}
});
// Reset the mode of the form if the user changes the email address entry.
$emailField.keydown(() => switchToRequestConfirmationCodeMode());
$emailField.change(() => switchToRequestConfirmationCodeMode());
// Reset the mode of the form if the user decides to change emails after
// verification.
$changeEmailButton.click(() => switchToRequestConfirmationCodeMode());
switchToRequestConfirmationCodeMode();
Then our CSS looks like this:
/* Simplify UX by hiding buttons that aren't relevant. */
body[data-mode="send-code"] #continue,
body[data-mode="verify-code"] #continue {
display: none;
}
/*
Override B2C's inline style that sets "display: inline" on instructions
and field labels.
*/
body[data-mode="send-code"] #email_intro,
body[data-mode="verify-code"] #email_info,
body[data-mode="verify-code"] #attributeList label,
body[data-mode="email-verified"] #email_success {
display: block !important;
}
/*
Override B2C's logic that hides buttons and controls during requests.
*/
body[data-mode="send-code"] #email_ver_but_send,
body[data-mode="verify-code"] #email_ver_input,
body[data-mode="verify-code"] #email_ver_but_verify,
body[data-mode="verify-code"] #email_ver_but_resend {
display: block !important;
}
/*
BUGBUG: This button serves no function but can randomly appear on the
page if the user reloads it during sign-up.
*/
#email_ver_but_default {
display: none !important;
}