595 lines
23 KiB
JavaScript
595 lines
23 KiB
JavaScript
/* global window, document, navigator, CustomEvent, HTMLElement, HTMLFormElement, MutationObserver, fetch, atob, btoa, Blob, URL, sessionStorage, localStorage, location, crypto */
|
|
(() => {
|
|
if (window.WRNexusAuth) return;
|
|
|
|
const mounted = new WeakSet();
|
|
const requestControllers = new WeakMap();
|
|
const cleanups = new WeakMap();
|
|
const recoveryMounted = new WeakSet();
|
|
const recoveryCleanups = new WeakMap();
|
|
const flowMounted = new WeakSet();
|
|
const flowCleanups = new WeakMap();
|
|
const MFA_STORAGE_KEY = "wrnexus.auth.mfa";
|
|
const DEVICE_STORAGE_KEY = "wrnexus.auth.device";
|
|
|
|
function fromBase64Url(value) {
|
|
const input = String(value);
|
|
if (!input || !/^[A-Za-z0-9_-]+$/.test(input) || input.length % 4 === 1) {
|
|
throw new TypeError("Passkey data must be canonical Base64URL without padding");
|
|
}
|
|
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
|
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
if (toBase64Url(bytes) !== input) {
|
|
throw new TypeError("Passkey data must be canonical Base64URL without padding");
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function toBase64Url(value) {
|
|
const bytes =
|
|
value instanceof ArrayBuffer
|
|
? new Uint8Array(value)
|
|
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
let binary = "";
|
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function publicKeyCreationOptions(options) {
|
|
return {
|
|
...options,
|
|
challenge: fromBase64Url(options.challenge),
|
|
user: { ...options.user, id: fromBase64Url(options.user.id) },
|
|
excludeCredentials: (options.excludeCredentials || []).map((item) => ({
|
|
...item,
|
|
id: fromBase64Url(item.id),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function publicKeyRequestOptions(options) {
|
|
return {
|
|
...options,
|
|
challenge: fromBase64Url(options.challenge),
|
|
allowCredentials: (options.allowCredentials || []).map((item) => ({
|
|
...item,
|
|
id: fromBase64Url(item.id),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function credentialToJson(credential) {
|
|
if (!credential) return null;
|
|
const response = credential.response;
|
|
const base = {
|
|
id: credential.id,
|
|
type: credential.type,
|
|
rawId: toBase64Url(credential.rawId),
|
|
authenticatorAttachment: credential.authenticatorAttachment,
|
|
clientExtensionResults: credential.getClientExtensionResults
|
|
? credential.getClientExtensionResults()
|
|
: {},
|
|
};
|
|
if (response && "attestationObject" in response) {
|
|
return {
|
|
...base,
|
|
response: {
|
|
clientDataJSON: toBase64Url(response.clientDataJSON),
|
|
attestationObject: toBase64Url(response.attestationObject),
|
|
transports: response.getTransports ? response.getTransports() : [],
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
response: {
|
|
clientDataJSON: toBase64Url(response.clientDataJSON),
|
|
authenticatorData: toBase64Url(response.authenticatorData),
|
|
signature: toBase64Url(response.signature),
|
|
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function csrfHeaders() {
|
|
const match = document.cookie.match(/(?:^|;\s*)wrn-csrf=([^;]+)/);
|
|
return match ? { "x-csrf-token": decodeURIComponent(match[1]) } : {};
|
|
}
|
|
|
|
async function json(url, body, signal) {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
...csrfHeaders(),
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal,
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok || payload.ok === false) {
|
|
const error = new Error(
|
|
payload.message || payload.error || `Request failed (${response.status})`,
|
|
);
|
|
error.status = response.status;
|
|
error.payload = payload;
|
|
throw error;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function setState(element, state, message) {
|
|
element.dataset.authState = state;
|
|
element.setAttribute("aria-busy", state === "loading" ? "true" : "false");
|
|
const status = element.querySelector("[data-auth-status]");
|
|
if (status) {
|
|
status.textContent = message || "";
|
|
status.hidden = !message;
|
|
}
|
|
const button = element.matches("button") ? element : element.querySelector("button");
|
|
if (button) button.disabled = state === "loading";
|
|
}
|
|
|
|
function dispatch(element, name, detail) {
|
|
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
|
}
|
|
|
|
function navigate(target) {
|
|
if (!target) return false;
|
|
const url = new URL(target, location.href);
|
|
if (url.origin !== location.origin) return false;
|
|
if (window.__wrnexusNavigate) {
|
|
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
|
|
} else {
|
|
location.assign(url.href);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function randomDeviceId() {
|
|
if (crypto.randomUUID) return crypto.randomUUID();
|
|
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
|
return toBase64Url(bytes);
|
|
}
|
|
|
|
function deviceFingerprint() {
|
|
try {
|
|
let value = localStorage.getItem(DEVICE_STORAGE_KEY);
|
|
if (!value) {
|
|
value = randomDeviceId();
|
|
localStorage.setItem(DEVICE_STORAGE_KEY, value);
|
|
}
|
|
return value;
|
|
} catch {
|
|
return randomDeviceId();
|
|
}
|
|
}
|
|
|
|
function toggle(element, visible) {
|
|
if (!element) return;
|
|
element.hidden = !visible;
|
|
element.classList.toggle("hidden", !visible);
|
|
}
|
|
|
|
function setMessage(element, message, isError = false) {
|
|
if (!element) return;
|
|
element.textContent = message || "";
|
|
element.hidden = !message;
|
|
element.classList.toggle("hidden", !message);
|
|
element.dataset.status = isError ? "error" : "success";
|
|
}
|
|
|
|
function setFormBusy(form, busy) {
|
|
form?.querySelectorAll("[type=submit]").forEach((button) => {
|
|
button.disabled = busy;
|
|
});
|
|
}
|
|
|
|
async function registerPasskey(element) {
|
|
setState(element, "loading", element.dataset.loadingMessage || "Preparing passkey…");
|
|
try {
|
|
if (!window.PublicKeyCredential || !navigator.credentials || !window.AbortController) {
|
|
throw new Error("Passkeys are not supported in this browser");
|
|
}
|
|
const controller = new window.AbortController();
|
|
requestControllers.get(element)?.abort();
|
|
requestControllers.set(element, controller);
|
|
const optionsPayload = await json(
|
|
element.dataset.optionsEndpoint || "/api/auth/passkeys/register/options",
|
|
{},
|
|
controller.signal,
|
|
);
|
|
const credential = await navigator.credentials.create({
|
|
publicKey: publicKeyCreationOptions(optionsPayload.options),
|
|
signal: controller.signal,
|
|
});
|
|
const result = await json(
|
|
element.dataset.verifyEndpoint || "/api/auth/passkeys/register/verify",
|
|
{
|
|
key: optionsPayload.key,
|
|
response: credentialToJson(credential),
|
|
name: element.dataset.passkeyName || "Passkey",
|
|
},
|
|
controller.signal,
|
|
);
|
|
setState(element, "success", element.dataset.successMessage || "Passkey added.");
|
|
dispatch(element, "auth:passkey-registered", result);
|
|
} catch (error) {
|
|
if (error && error.name === "AbortError") return;
|
|
setState(
|
|
element,
|
|
"error",
|
|
error instanceof Error ? error.message : "Passkey registration failed",
|
|
);
|
|
dispatch(element, "auth:error", { error });
|
|
}
|
|
}
|
|
|
|
async function authenticatePasskey(element) {
|
|
setState(element, "loading", element.dataset.loadingMessage || "Waiting for your passkey…");
|
|
try {
|
|
if (!window.PublicKeyCredential || !navigator.credentials || !window.AbortController) {
|
|
throw new Error("Passkeys are not supported in this browser");
|
|
}
|
|
const controller = new window.AbortController();
|
|
requestControllers.get(element)?.abort();
|
|
requestControllers.set(element, controller);
|
|
const identifier =
|
|
element.dataset.identifier ||
|
|
element.closest("form")?.querySelector("[name=identifier]")?.value ||
|
|
element.closest("[data-auth-sign-in]")?.querySelector("form [name=identifier]")?.value ||
|
|
undefined;
|
|
const optionsPayload = await json(
|
|
element.dataset.optionsEndpoint || "/api/auth/passkeys/login/options",
|
|
{ identifier },
|
|
controller.signal,
|
|
);
|
|
const credential = await navigator.credentials.get({
|
|
publicKey: publicKeyRequestOptions(optionsPayload.options),
|
|
signal: controller.signal,
|
|
mediation: element.dataset.conditional === "true" ? "conditional" : "optional",
|
|
});
|
|
const result = await json(
|
|
element.dataset.verifyEndpoint || "/api/auth/passkeys/login/verify",
|
|
{
|
|
key: optionsPayload.key,
|
|
response: credentialToJson(credential),
|
|
},
|
|
controller.signal,
|
|
);
|
|
setState(element, "success", element.dataset.successMessage || "Signed in.");
|
|
dispatch(element, "auth:passkey-authenticated", result);
|
|
if (element.dataset.redirect) navigate(element.dataset.redirect);
|
|
} catch (error) {
|
|
if (error && error.name === "AbortError") return;
|
|
const detail = error?.payload || {};
|
|
if (detail.code === "mfa-required" && detail.mfaToken) {
|
|
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
|
|
dispatch(element, "auth:mfa-required", detail);
|
|
navigate(element.dataset.mfaHref || "/two-factor");
|
|
return;
|
|
}
|
|
setState(element, "error", error instanceof Error ? error.message : "Passkey sign-in failed");
|
|
dispatch(element, "auth:error", { error, ...detail });
|
|
}
|
|
}
|
|
|
|
function recoveryCodes(element) {
|
|
return Array.from(element.querySelectorAll("[data-recovery-code]"))
|
|
.map((item) => String(item.textContent || "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function renderRecoveryCodes(element, codes) {
|
|
const list = element.querySelector("[data-recovery-code-list]");
|
|
if (!list || !Array.isArray(codes)) return;
|
|
list.textContent = "";
|
|
for (const code of codes) {
|
|
const item = document.createElement("code");
|
|
item.dataset.recoveryCode = "";
|
|
item.className = "rounded bg-[var(--wrn-color-surface)] px-2 py-1.5 text-center";
|
|
item.textContent = String(code);
|
|
list.appendChild(item);
|
|
}
|
|
}
|
|
|
|
function downloadRecoveryCodes(element) {
|
|
const codes = recoveryCodes(element);
|
|
if (!codes.length) return;
|
|
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = element.dataset.recoveryFilename || "wrnexus-recovery-codes.txt";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function mountRecoveryCodes(element) {
|
|
if (!(element instanceof HTMLElement) || recoveryMounted.has(element)) return;
|
|
recoveryMounted.add(element);
|
|
const download = element.querySelector("[data-recovery-download]");
|
|
const onDownload = () => downloadRecoveryCodes(element);
|
|
const onSuccess = (event) => {
|
|
if (event.target instanceof HTMLFormElement && event.detail?.codes) {
|
|
renderRecoveryCodes(element, event.detail.codes);
|
|
}
|
|
};
|
|
download?.addEventListener("click", onDownload);
|
|
element.addEventListener("wrn:success", onSuccess);
|
|
recoveryCleanups.set(element, () => {
|
|
download?.removeEventListener("click", onDownload);
|
|
element.removeEventListener("wrn:success", onSuccess);
|
|
});
|
|
}
|
|
|
|
function mountOtpSignIn(element) {
|
|
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
|
|
flowMounted.add(element);
|
|
const requestForm = element.querySelector("[data-auth-otp-request]");
|
|
const completeForm = element.querySelector("[data-auth-otp-complete]");
|
|
const back = element.querySelector("[data-auth-otp-back]");
|
|
const onSuccess = (event) => {
|
|
if (event.target !== requestForm || !event.detail?.id) return;
|
|
const challenge = completeForm?.querySelector("[name=challengeId]");
|
|
if (challenge) challenge.value = String(event.detail.id);
|
|
toggle(requestForm, false);
|
|
toggle(completeForm, true);
|
|
completeForm?.querySelector("[name=code]")?.focus();
|
|
dispatch(element, "auth:otp-requested", event.detail);
|
|
};
|
|
const onError = (event) => {
|
|
if (event.target !== completeForm) return;
|
|
const detail = event.detail || {};
|
|
if (detail.code !== "mfa-required" || !detail.mfaToken) return;
|
|
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
|
|
dispatch(element, "auth:mfa-required", detail);
|
|
navigate(element.dataset.mfaHref || "/two-factor");
|
|
};
|
|
const onBack = () => {
|
|
toggle(completeForm, false);
|
|
toggle(requestForm, true);
|
|
requestForm?.querySelector("[name=identifier]")?.focus();
|
|
};
|
|
element.addEventListener("wrn:success", onSuccess);
|
|
element.addEventListener("wrn:error", onError);
|
|
back?.addEventListener("click", onBack);
|
|
flowCleanups.set(element, () => {
|
|
element.removeEventListener("wrn:success", onSuccess);
|
|
element.removeEventListener("wrn:error", onError);
|
|
back?.removeEventListener("click", onBack);
|
|
});
|
|
}
|
|
|
|
function mountSignIn(element) {
|
|
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
|
|
flowMounted.add(element);
|
|
const form = element.querySelector("form[data-schema]");
|
|
const fingerprintInput = form?.querySelector("[name=deviceFingerprint]");
|
|
const deviceNameInput = form?.querySelector("[name=deviceName]");
|
|
if (fingerprintInput && !fingerprintInput.value) fingerprintInput.value = deviceFingerprint();
|
|
if (deviceNameInput && !deviceNameInput.value) {
|
|
deviceNameInput.value = String(navigator.userAgent || navigator.platform || "Browser").slice(
|
|
0,
|
|
120,
|
|
);
|
|
}
|
|
const onError = (event) => {
|
|
if (!(event.target instanceof HTMLFormElement)) return;
|
|
const detail = event.detail || {};
|
|
if (detail.code !== "mfa-required" || !detail.mfaToken) return;
|
|
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
|
|
dispatch(element, "auth:mfa-required", detail);
|
|
navigate(element.dataset.mfaHref || "/two-factor");
|
|
};
|
|
element.addEventListener("wrn:error", onError);
|
|
flowCleanups.set(element, () => element.removeEventListener("wrn:error", onError));
|
|
}
|
|
|
|
function readMfaState() {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(MFA_STORAGE_KEY) || "null");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function mountMfaChallenge(element) {
|
|
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
|
|
flowMounted.add(element);
|
|
const form = element.querySelector("[data-auth-mfa-form]");
|
|
const tokenInput = form?.querySelector("[name=mfaToken]");
|
|
const challengeInput = form?.querySelector("[name=challengeId]");
|
|
const methodInput = form?.querySelector("[data-auth-mfa-method]");
|
|
const codeInput = form?.querySelector("[name=code]");
|
|
const status = form?.querySelector("[data-auth-mfa-status]");
|
|
const saved = readMfaState();
|
|
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) => {
|
|
const method = methodInput?.value;
|
|
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
if (!tokenInput?.value) {
|
|
setMessage(status, "Sign-in verification has expired. Start again.", true);
|
|
return;
|
|
}
|
|
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,
|
|
});
|
|
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,
|
|
);
|
|
} finally {
|
|
setFormBusy(form, false);
|
|
}
|
|
};
|
|
const onSuccess = (event) => {
|
|
if (event.target !== form) return;
|
|
sessionStorage.removeItem(MFA_STORAGE_KEY);
|
|
};
|
|
onMethodChange();
|
|
methodInput?.addEventListener("change", onMethodChange);
|
|
form?.addEventListener("submit", onSubmit, true);
|
|
element.addEventListener("wrn:success", onSuccess);
|
|
flowCleanups.set(element, () => {
|
|
methodInput?.removeEventListener("change", onMethodChange);
|
|
form?.removeEventListener("submit", onSubmit, true);
|
|
element.removeEventListener("wrn:success", onSuccess);
|
|
});
|
|
}
|
|
|
|
function mountAuthenticatorSetup(element) {
|
|
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
|
|
flowMounted.add(element);
|
|
const startForm = element.querySelector("[data-auth-authenticator-start]");
|
|
const details = element.querySelector("[data-auth-authenticator-details]");
|
|
const confirmForm = element.querySelector("[data-auth-authenticator-confirm]");
|
|
const secret = element.querySelector("[data-auth-authenticator-secret]");
|
|
const uri = element.querySelector("[data-auth-authenticator-uri]");
|
|
const onSuccess = (event) => {
|
|
if (event.target === startForm && event.detail?.credentialId) {
|
|
const credential = confirmForm?.querySelector("[name=credentialId]");
|
|
if (credential) credential.value = String(event.detail.credentialId);
|
|
if (secret) secret.textContent = String(event.detail.secret || "");
|
|
if (uri) uri.textContent = String(event.detail.uri || "");
|
|
toggle(startForm, false);
|
|
toggle(details, true);
|
|
confirmForm?.querySelector("[name=code]")?.focus();
|
|
dispatch(element, "auth:authenticator-created", event.detail);
|
|
}
|
|
if (event.target === confirmForm) {
|
|
dispatch(element, "auth:authenticator-enabled", event.detail);
|
|
}
|
|
};
|
|
element.addEventListener("wrn:success", onSuccess);
|
|
flowCleanups.set(element, () => element.removeEventListener("wrn:success", onSuccess));
|
|
}
|
|
|
|
function mountElement(element) {
|
|
if (!(element instanceof HTMLElement) || mounted.has(element)) return;
|
|
mounted.add(element);
|
|
const action = element.dataset.authPasskey;
|
|
const button = element.matches("button") ? element : element.querySelector("button");
|
|
if (!button || !action) return;
|
|
const handler = () =>
|
|
action === "register" ? registerPasskey(element) : authenticatePasskey(element);
|
|
button.addEventListener("click", handler);
|
|
cleanups.set(element, () => button.removeEventListener("click", handler));
|
|
}
|
|
|
|
function mount(root = document) {
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-passkey]")) mountElement(root);
|
|
root.querySelectorAll?.("[data-auth-passkey]").forEach(mountElement);
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-recovery-codes]"))
|
|
mountRecoveryCodes(root);
|
|
root.querySelectorAll?.("[data-auth-recovery-codes]").forEach(mountRecoveryCodes);
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-otp-sign-in]"))
|
|
mountOtpSignIn(root);
|
|
root.querySelectorAll?.("[data-auth-otp-sign-in]").forEach(mountOtpSignIn);
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-sign-in]")) mountSignIn(root);
|
|
root.querySelectorAll?.("[data-auth-sign-in]").forEach(mountSignIn);
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-mfa-challenge]"))
|
|
mountMfaChallenge(root);
|
|
root.querySelectorAll?.("[data-auth-mfa-challenge]").forEach(mountMfaChallenge);
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-authenticator-setup]"))
|
|
mountAuthenticatorSetup(root);
|
|
root.querySelectorAll?.("[data-auth-authenticator-setup]").forEach(mountAuthenticatorSetup);
|
|
}
|
|
|
|
function unmount(root = document) {
|
|
const elements = [];
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-passkey]")) elements.push(root);
|
|
root.querySelectorAll?.("[data-auth-passkey]").forEach((element) => elements.push(element));
|
|
for (const element of elements) {
|
|
requestControllers.get(element)?.abort();
|
|
requestControllers.delete(element);
|
|
cleanups.get(element)?.();
|
|
cleanups.delete(element);
|
|
mounted.delete(element);
|
|
}
|
|
|
|
const recoveryElements = [];
|
|
if (root instanceof HTMLElement && root.matches("[data-auth-recovery-codes]"))
|
|
recoveryElements.push(root);
|
|
root
|
|
.querySelectorAll?.("[data-auth-recovery-codes]")
|
|
.forEach((element) => recoveryElements.push(element));
|
|
for (const element of recoveryElements) {
|
|
recoveryCleanups.get(element)?.();
|
|
recoveryCleanups.delete(element);
|
|
recoveryMounted.delete(element);
|
|
}
|
|
|
|
const flowElements = [];
|
|
const selector =
|
|
"[data-auth-otp-sign-in],[data-auth-sign-in],[data-auth-mfa-challenge],[data-auth-authenticator-setup]";
|
|
if (root instanceof HTMLElement && root.matches(selector)) flowElements.push(root);
|
|
root.querySelectorAll?.(selector).forEach((element) => flowElements.push(element));
|
|
for (const element of flowElements) {
|
|
flowCleanups.get(element)?.();
|
|
flowCleanups.delete(element);
|
|
flowMounted.delete(element);
|
|
}
|
|
}
|
|
|
|
window.WRNexusAuth = { mount, unmount, registerPasskey, authenticatePasskey };
|
|
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
|
window.__wrnexusRuntimes.auth = { mount, unmount };
|
|
|
|
if (document.readyState === "loading")
|
|
document.addEventListener("DOMContentLoaded", () => mount(document), { once: true });
|
|
else mount(document);
|
|
|
|
new MutationObserver((records) => {
|
|
for (const record of records)
|
|
for (const node of record.addedNodes) if (node instanceof HTMLElement) mount(node);
|
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
})();
|