diff --git a/bun.lock b/bun.lock
index 95a85fc5..e5bfbdfe 100644
--- a/bun.lock
+++ b/bun.lock
@@ -235,7 +235,7 @@
},
"packages/auth": {
"name": "@wrnexus/auth",
- "version": "0.8.21",
+ "version": "0.8.22",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
diff --git a/packages/auth/assets/client/auth.js b/packages/auth/assets/client/auth.js
index 5471cc32..1744ac3f 100644
--- a/packages/auth/assets/client/auth.js
+++ b/packages/auth/assets/client/auth.js
@@ -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);
diff --git a/packages/auth/components/TwoFactorChallenge.wrn b/packages/auth/components/TwoFactorChallenge.wrn
index 66e3db6b..89503234 100644
--- a/packages/auth/components/TwoFactorChallenge.wrn
+++ b/packages/auth/components/TwoFactorChallenge.wrn
@@ -1,4 +1,4 @@
-import { Button, PinInput, Select } from "@wrnexus/ui"
+import { Button, Input, PinInput, Radio } from "@wrnexus/ui"
component TwoFactorChallenge {
props {
@@ -40,8 +40,11 @@ component TwoFactorChallenge {
-
-