feat(auth): use MFA radio methods and request selected delivery codes

This commit is contained in:
Clintchiz committed 2026-08-26 18:17:02 +05:30
1 parent 65d63dfbe7
commit 298e34ef2e
6 files changed
+171 -75

No files matched your search

+104 -65
View File
@@ -405,85 +405,122 @@
const form = element.querySelector("[data-auth-mfa-form]");
const tokenInput = form?.querySelector("[name=mfaToken]");
const challengeInput = form?.querySelector("[name=challengeId]");
const methodInput = form?.querySelector("select[name=method]");
const codeInput = form?.querySelector("[name=code]");
const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []);
const status = form?.querySelector("[data-auth-mfa-status]");
const saved = readMfaState();
let expired = false;
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
tokenInput.value = String(saved.mfaToken);
}
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
const initialMethod = element.dataset.mfaInitialMethod;
if (methodInput && allowed.length) {
Array.from(methodInput.options).forEach((option) => {
option.disabled = !allowed.includes(option.value);
option.hidden = option.disabled;
});
const selected = allowed.includes(initialMethod)
? initialMethod
: allowed.includes(methodInput.value)
? methodInput.value
: allowed[0];
if (selected) methodInput.value = selected;
} else if (methodInput && initialMethod) {
methodInput.value = initialMethod;
}
const onMethodChange = () => {
if (challengeInput) challengeInput.value = "";
setMessage(status, "");
if (codeInput) {
codeInput.value = "";
codeInput.setAttribute(
"inputmode",
methodInput?.value === "recovery-code" ? "text" : "numeric",
);
}
};
const onSubmit = async (event) => {
// Hydration may restore the component's empty initial prop after mount.
// Recover only this tab's existing challenge; never mint or extend one.
if (expired) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
const challenges = new Map();
const pending = new Map();
let expired = false;
let disposed = false;
const restoreToken = () => {
if (!expired && tokenInput && !tokenInput.value && saved?.mfaToken)
tokenInput.value = String(saved.mfaToken);
}
const method = methodInput?.value;
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
event.preventDefault();
event.stopImmediatePropagation();
};
const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value;
const isOtp = (value) => ["email-otp", "sms-otp"].includes(value);
for (const radio of radios) {
radio.disabled = allowed.length > 0 && !allowed.includes(radio.value);
const label = radio.closest("label");
if (label) label.hidden = radio.disabled;
}
const initial =
radios.find((radio) => !radio.disabled && radio.value === element.dataset.mfaInitialMethod) ||
radios.find((radio) => !radio.disabled);
for (const radio of radios) radio.checked = radio === initial;
restoreToken();
const sendCode = async (selected) => {
if (disposed || expired || !isOtp(selected)) return;
restoreToken();
if (!tokenInput?.value) {
setMessage(status, "Sign-in verification has expired. Start again.", true);
const restart = element.querySelector("[data-auth-mfa-restart]");
if (restart) restart.hidden = false;
return;
}
setFormBusy(form, true);
setMessage(status, "Sending a one-time code…");
const previous = challenges.get(selected);
if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now())
challenges.delete(selected);
if (!pending.has(selected) && !challenges.has(selected)) {
pending.set(
selected,
json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method: selected,
}),
);
}
if (method() === selected) {
setFormBusy(form, true);
setMessage(status, "Sending a one-time code…");
}
try {
const result = await json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method,
});
const result = challenges.get(selected) || (await pending.get(selected));
if (disposed || expired) return;
challenges.set(selected, result);
if (method() !== selected) return;
if (challengeInput) challengeInput.value = String(result.id || "");
setMessage(status, "A one-time code has been sent.");
codeInput?.focus();
} catch (error) {
setMessage(
status,
error instanceof Error ? error.message : "Unable to send a one-time code",
true,
selected === "email-otp"
? "A code has been sent to your email."
: "A code has been sent to your mobile number.",
);
} catch (error) {
if (!disposed && !expired && method() === selected)
setMessage(
status,
error instanceof Error ? error.message : "Unable to send a one-time code",
true,
);
} finally {
setFormBusy(form, false);
pending.delete(selected);
if (!disposed && method() === selected) setFormBusy(form, false);
}
};
const updateMethod = () => {
const selected = method();
setFormBusy(form, false);
if (challengeInput) challengeInput.value = "";
const code = form?.querySelector("[name=code]");
if (code) code.value = "";
const error = form?.querySelector("[data-error=code]");
if (error) error.textContent = "";
setMessage(status, "");
for (const [selector, active] of [
["[data-auth-mfa-pin]", selected !== "recovery-code"],
["[data-auth-mfa-recovery]", selected === "recovery-code"],
]) {
const group = form?.querySelector(selector);
if (!group) continue;
group.hidden = !active;
for (const input of group.querySelectorAll("input")) {
input.disabled = !active;
input.value = "";
input.dispatchEvent(new window.Event("input", { bubbles: true }));
}
}
if (isOtp(selected)) void sendCode(selected);
};
const onMethodChange = (event) => {
if (radios.includes(event.target)) updateMethod();
};
const onSubmit = (event) => {
restoreToken();
const code = form?.querySelector("[name=code]");
const active = form?.querySelector(
method() === "recovery-code" ? "[name=recoveryCode]" : "[name=otpCode]",
);
if (code && active) code.value = active.value;
if (expired || !method() || (isOtp(method()) && !challengeInput?.value)) {
event.preventDefault();
event.stopImmediatePropagation();
if (!expired && isOtp(method())) void sendCode(method());
}
};
const onSuccess = (event) => {
if (event.target !== form) return;
sessionStorage.removeItem(MFA_STORAGE_KEY);
if (event.target === form) sessionStorage.removeItem(MFA_STORAGE_KEY);
};
const onError = (event) => {
if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return;
@@ -493,13 +530,15 @@
const restart = element.querySelector("[data-auth-mfa-restart]");
if (restart) restart.hidden = false;
};
onMethodChange();
methodInput?.addEventListener("change", onMethodChange);
updateMethod();
// Capture native change before the shared Radio emits its component output.
form?.addEventListener("change", onMethodChange, true);
form?.addEventListener("submit", onSubmit, true);
element.addEventListener("wrn:success", onSuccess);
element.addEventListener("wrn:error", onError);
flowCleanups.set(element, () => {
methodInput?.removeEventListener("change", onMethodChange);
disposed = true;
form?.removeEventListener("change", onMethodChange, true);
form?.removeEventListener("submit", onSubmit, true);
element.removeEventListener("wrn:success", onSuccess);
element.removeEventListener("wrn:error", onError);