@auth-link/web 1.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1633,333 +1633,36 @@ var qrcode_default = qrcode;
1633
1633
  var stringToBytes = qrcode.stringToBytes;
1634
1634
 
1635
1635
  // src/core.ts
1636
- var token = /^[A-Za-z0-9_-]{43}$/;
1637
1636
  var AuthLinkError = class extends Error {
1637
+ code;
1638
1638
  constructor(code) {
1639
1639
  super(code);
1640
- this.code = code;
1641
1640
  this.name = "AuthLinkError";
1641
+ this.code = code;
1642
1642
  }
1643
- code;
1644
1643
  };
1645
- function fail(code) {
1646
- throw new AuthLinkError(code);
1647
- }
1648
- function object(value) {
1649
- if (!value || typeof value !== "object" || Array.isArray(value)) fail("INVALID_RESPONSE");
1650
- return value;
1651
- }
1652
- function validateDisplayCode(value) {
1653
- const m = object(value);
1654
- const span = Date.parse(String(m.validUntil)) - Date.parse(String(m.serverTime));
1655
- if (typeof m.value !== "string" || !/^\d{6}$/.test(m.value) || m.periodSeconds !== 30 || !Number.isFinite(span) || span <= 0 || span > 3e4 || "seed" in m || "secret" in m || "key" in m) fail("INVALID_DISPLAY_CODE");
1656
- return { value: m.value, serverTime: String(m.serverTime), validUntil: String(m.validUntil), periodSeconds: 30 };
1657
- }
1658
- function validateTicket(value, expectedMode, trustedOrigin) {
1659
- const m = object(value);
1660
- if ("pairing" in m || "seed" in m || "apiKey" in m) fail("SERVER_SECRET_FORBIDDEN");
1661
- if (typeof m.requestId !== "string" || !token.test(m.requestId) || !["bind", "confirm"].includes(String(m.purpose)) || expectedMode && m.purpose !== expectedMode || typeof m.qr !== "string" || m.qr.length > 4096 || typeof m.pairingCode !== "string" || !/^\d{6}$/.test(m.pairingCode)) fail("INVALID_TICKET");
1662
- let qr;
1663
- try {
1664
- qr = object(JSON.parse(m.qr));
1665
- } catch {
1666
- return fail("INVALID_QR");
1667
- }
1668
- let origin;
1669
- try {
1670
- origin = new URL(String(qr.origin));
1671
- } catch {
1672
- return fail("INVALID_QR");
1673
- }
1674
- if (qr.v !== 1 || qr.requestId !== m.requestId || typeof qr.token !== "string" || !token.test(qr.token) || origin.protocol !== "https:" || origin.origin !== qr.origin || origin.origin.length > 512 || origin.username || origin.password || trustedOrigin && origin.origin !== trustedOrigin) fail("UNTRUSTED_QR");
1675
- const displayCode = m.displayCode === void 0 ? void 0 : validateDisplayCode(m.displayCode);
1676
- const expiry = Date.parse(String(m.expiresAt));
1677
- const now = displayCode ? Date.parse(displayCode.serverTime) : Date.now();
1678
- if (!Number.isFinite(expiry) || expiry > now + 36e4) fail("INVALID_EXPIRY");
1679
- return {
1680
- requestId: m.requestId,
1681
- purpose: m.purpose,
1682
- qr: m.qr,
1683
- pairingCode: m.pairingCode,
1684
- expiresAt: String(m.expiresAt),
1685
- ...displayCode ? { displayCode } : {}
1686
- };
1687
- }
1688
- function qrImage(ticket) {
1689
- const value = validateTicket(ticket);
1690
- const payload = JSON.parse(value.qr);
1691
- const qr = qrcode_default(0, "M");
1692
- qr.addData(JSON.stringify({ v: 1, origin: payload.origin, requestId: payload.requestId, token: payload.token }), "Byte");
1693
- qr.make();
1694
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.createSvgTag({ cellSize: 4, margin: 16, scalable: true }))}`;
1695
- }
1696
- function backendProtocol(url) {
1697
- return url.protocol === "https:" || url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
1698
- }
1699
- async function limitedJson(response) {
1700
- const reader = response.body?.getReader();
1701
- if (!reader) fail("INVALID_RESPONSE");
1702
- const chunks = [];
1703
- let size = 0;
1704
- try {
1705
- for (; ; ) {
1706
- const { done, value } = await reader.read();
1707
- if (done) break;
1708
- size += value.length;
1709
- if (size > 65536) {
1710
- await reader.cancel();
1711
- fail("RESPONSE_TOO_LARGE");
1712
- }
1713
- chunks.push(value);
1714
- }
1715
- } finally {
1716
- reader.releaseLock();
1717
- }
1718
- const bytes = new Uint8Array(size);
1719
- let at = 0;
1720
- for (const chunk of chunks) {
1721
- bytes.set(chunk, at);
1722
- at += chunk.length;
1723
- }
1724
- try {
1725
- return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
1726
- } catch {
1727
- return fail("INVALID_RESPONSE");
1728
- }
1644
+ function deadline(value) {
1645
+ const at = typeof value === "string" ? Date.parse(value) : NaN;
1646
+ if (!Number.isFinite(at) || at <= Date.now()) throw new AuthLinkError("INVALID_EXPIRY");
1647
+ return at;
1729
1648
  }
1730
- function createBackendCall(options) {
1731
- if ("apiKey" in options || "secret" in options) fail("SERVER_SECRET_FORBIDDEN");
1732
- const base = options.baseUrl ?? "/api/authlink";
1733
- const allowedOrigins = new Set(options.allowedBackendOrigins ?? []);
1734
- for (const origin of allowedOrigins) {
1735
- let url;
1736
- try {
1737
- url = new URL(origin);
1738
- } catch {
1739
- return fail("INVALID_BACKEND_ORIGIN");
1740
- }
1741
- if (url.origin !== origin || url.hostname.includes("*") || !backendProtocol(url)) fail("INVALID_BACKEND_ORIGIN");
1742
- }
1743
- const credentials = options.credentials ?? (options.accessToken ? "omit" : "same-origin");
1744
- if (!["omit", "same-origin", "include"].includes(credentials)) fail("INVALID_CREDENTIALS");
1745
- const csrfHeaderName = options.csrfHeaderName ?? "X-CSRF-Token";
1746
- const reserved = /* @__PURE__ */ new Set(["accept", "content-type", "authorization", "idempotency-key"]);
1649
+ function bindingImage(ticket, expectedOrigin) {
1650
+ if (typeof ticket.qr !== "string" || ticket.qr.length > 4096) throw new AuthLinkError("INVALID_QR");
1651
+ if (deadline(ticket.expiresAt) > Date.now() + 36e5) throw new AuthLinkError("INVALID_EXPIRY");
1652
+ let payload, origin;
1747
1653
  try {
1748
- new Headers([[csrfHeaderName, "test"]]);
1654
+ payload = JSON.parse(ticket.qr);
1655
+ origin = new URL(String(payload.origin));
1749
1656
  } catch {
1750
- return fail("INVALID_CSRF_HEADER");
1751
- }
1752
- if (reserved.has(csrfHeaderName.toLowerCase())) fail("INVALID_CSRF_HEADER");
1753
- reserved.add(csrfHeaderName.toLowerCase());
1754
- const call = async (path, body, signal, key) => {
1755
- if (typeof window === "undefined") fail("BROWSER_REQUIRED");
1756
- let url;
1757
- try {
1758
- url = new URL(base.replace(/\/+$/, "") + path, window.location.href);
1759
- } catch {
1760
- return fail("INVALID_BASE_URL");
1761
- }
1762
- if (url.username || url.password || url.search || url.hash || !backendProtocol(url)) fail("INVALID_BASE_URL");
1763
- if (url.origin !== window.location.origin && !allowedOrigins.has(url.origin)) fail("BACKEND_ORIGIN_NOT_ALLOWED");
1764
- signal?.throwIfAborted();
1765
- const controller = new AbortController();
1766
- const cancel = () => controller.abort(signal?.reason);
1767
- signal?.addEventListener("abort", cancel, { once: true });
1768
- const timeout = setTimeout(() => controller.abort(), 1e4);
1769
- try {
1770
- const headers = new Headers(typeof options.headers === "function" ? await abortable(Promise.resolve().then(options.headers), controller.signal) : options.headers);
1771
- for (const name of reserved) if (headers.has(name)) fail("RESERVED_HEADER");
1772
- headers.set("Accept", "application/json");
1773
- headers.set("Content-Type", "application/json");
1774
- if (key) headers.set("Idempotency-Key", key);
1775
- if (options.csrfToken) {
1776
- const csrf = await abortable(Promise.resolve().then(options.csrfToken), controller.signal);
1777
- if (typeof csrf !== "string" || !csrf.trim() || csrf.length > 1024 || /[\r\n]/.test(csrf)) fail("CSRF_REQUIRED");
1778
- headers.set(csrfHeaderName, csrf);
1779
- } else if (!options.accessToken || credentials !== "omit") fail("CSRF_REQUIRED");
1780
- if (options.accessToken) {
1781
- const accessToken = await abortable(Promise.resolve().then(options.accessToken), controller.signal);
1782
- if (typeof accessToken !== "string" || !accessToken || /\s/.test(accessToken)) fail("ACCESS_TOKEN_REQUIRED");
1783
- headers.set("Authorization", `Bearer ${accessToken}`);
1784
- }
1785
- controller.signal.throwIfAborted();
1786
- const response = await (options.fetch ?? globalThis.fetch)(url, {
1787
- method: "POST",
1788
- credentials,
1789
- redirect: "error",
1790
- cache: "no-store",
1791
- signal: controller.signal,
1792
- headers,
1793
- body: JSON.stringify(body)
1794
- });
1795
- if (!response.ok) {
1796
- let code = "";
1797
- try {
1798
- code = String(object(await limitedJson(response)).code ?? "").replace(/^AUTHLINK_/, "");
1799
- } catch {
1800
- }
1801
- if (["CONFIRMATION_CODE_INVALID", "CONFIRMATION_CODE_LOCKED", "APPROVAL_REQUIRED", "REQUEST_EXPIRED", "PASSCODE_INVALID", "PASSCODE_USED", "PASSCODE_LOCKED", "OTP_NOT_CONFIGURED"].includes(code)) fail(code);
1802
- fail(response.status === 401 || response.status === 403 ? "CMS_UNAUTHORIZED" : "CMS_REQUEST_FAILED");
1803
- }
1804
- return await limitedJson(response);
1805
- } catch (error) {
1806
- if (error instanceof AuthLinkError) throw error;
1807
- if (signal?.aborted) throw signal.reason;
1808
- return fail("NETWORK_ERROR");
1809
- } finally {
1810
- clearTimeout(timeout);
1811
- signal?.removeEventListener("abort", cancel);
1812
- }
1813
- };
1814
- return call;
1815
- }
1816
- function createAuthLinkClient(options) {
1817
- const call = createBackendCall(options);
1818
- return {
1819
- async create(mode, { idempotencyKey, signal }) {
1820
- if (!["bind", "confirm"].includes(mode) || !token.test(idempotencyKey)) fail("INVALID_CREATE_OPTIONS");
1821
- return validateTicket(await call("/requests", { purpose: mode }, signal, idempotencyKey), mode, options.authLinkOrigin);
1822
- },
1823
- async result(ticket, signal, confirmationCode) {
1824
- if (!token.test(ticket.requestId)) fail("INVALID_TICKET");
1825
- if (typeof confirmationCode !== "string" || !/^[0-9]{6}$/.test(confirmationCode)) fail("CONFIRMATION_CODE_REQUIRED");
1826
- return await call(`/requests/${ticket.requestId}/result`, { confirmationCode }, signal);
1827
- }
1828
- };
1829
- }
1830
- function requestKey() {
1831
- const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
1832
- return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1833
- }
1834
- function initialSnapshot() {
1835
- return { status: "idle", code: null, secondsRemaining: 0, codeSecondsRemaining: 0, error: null };
1836
- }
1837
- function abortable(promise, signal) {
1838
- signal.throwIfAborted();
1839
- let cancel;
1840
- const abort = new Promise((_, reject) => {
1841
- cancel = () => reject(signal.reason);
1842
- signal.addEventListener("abort", cancel, { once: true });
1843
- });
1844
- return Promise.race([promise, abort]).finally(() => signal.removeEventListener("abort", cancel));
1845
- }
1846
- async function observeRequest(input, options) {
1847
- const { client, signal, onUpdate } = options;
1848
- let ticket;
1849
- try {
1850
- ticket = validateTicket(input, options.expectedMode);
1851
- } catch (error) {
1852
- if (!signal.aborted) options.onError?.(error);
1853
- return;
1854
- }
1855
- const expiry = Date.parse(ticket.expiresAt);
1856
- const deadline = performance.now() + Math.max(0, expiry - Date.now());
1857
- let ended = false, busy = false;
1858
- let done;
1859
- const finished = new Promise((resolve) => {
1860
- done = resolve;
1861
- });
1862
- const controller = new AbortController();
1863
- const cancel = () => {
1864
- ended = true;
1865
- controller.abort();
1866
- done();
1867
- };
1868
- signal.addEventListener("abort", cancel, { once: true });
1869
- let snapshot = initialSnapshot();
1870
- const remaining = () => Math.max(0, (deadline - performance.now()) / 1e3);
1871
- const tick = () => {
1872
- if (ended || signal.aborted) return;
1873
- if (remaining() <= 0) {
1874
- ended = true;
1875
- controller.abort();
1876
- onUpdate({ ...snapshot, status: "expired", code: null, submitting: false });
1877
- void Promise.resolve().then(() => options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
1878
- done();
1879
- return;
1880
- }
1881
- snapshot = {
1882
- ...snapshot,
1883
- status: "pending",
1884
- code: ticket.pairingCode,
1885
- secondsRemaining: Math.ceil(remaining()),
1886
- codeSecondsRemaining: Math.ceil(remaining()),
1887
- submitting: busy
1888
- };
1889
- onUpdate(snapshot);
1890
- };
1891
- const submit = async (confirmationCode) => {
1892
- if (ended || signal.aborted || busy) return;
1893
- if (!/^[0-9]{6}$/.test(confirmationCode)) {
1894
- snapshot = { ...snapshot, error: new AuthLinkError("CONFIRMATION_CODE_REQUIRED") };
1895
- tick();
1896
- return;
1897
- }
1898
- if (remaining() <= 0) {
1899
- tick();
1900
- return;
1901
- }
1902
- busy = true;
1903
- snapshot = { ...snapshot, error: null };
1904
- tick();
1905
- try {
1906
- const value = object(await abortable(client.result(ticket, controller.signal, confirmationCode), controller.signal));
1907
- if (ended || signal.aborted) return;
1908
- if (value.requestId !== ticket.requestId) fail("REQUEST_MISMATCH");
1909
- if (value.status === "confirmed") {
1910
- if (!token.test(String(value.verificationId)) || value.authenticationPolicy !== "device-presence.v1" || value.expiresAt !== ticket.expiresAt || value.purpose !== ticket.purpose || remaining() <= 0 || "action" in value || "transactionHash" in value) fail("INVALID_CONFIRMATION");
1911
- ended = true;
1912
- onUpdate({ ...snapshot, status: "confirmed", code: null, submitting: false });
1913
- try {
1914
- await options.onConfirmed?.(value);
1915
- } catch {
1916
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
1917
- }
1918
- done();
1919
- } else if (value.status === "rejected" || value.status === "expired") {
1920
- ended = true;
1921
- onUpdate({ ...snapshot, status: value.status, code: null, submitting: false });
1922
- try {
1923
- if (value.status === "rejected") await options.onRejected?.({ status: "rejected", code: "REJECTED", requestId: ticket.requestId, purpose: ticket.purpose });
1924
- else await options.onExpired?.();
1925
- } catch {
1926
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
1927
- } finally {
1928
- done();
1929
- }
1930
- } else {
1931
- fail("CONFIRMATION_NOT_READY");
1932
- }
1933
- } catch (error) {
1934
- if (!ended && !signal.aborted) {
1935
- const raw = error instanceof Error ? error : new AuthLinkError("NETWORK_ERROR");
1936
- const code = raw.message.replace(/^AUTHLINK_/, "");
1937
- const failure = ["CONFIRMATION_CODE_LOCKED", "CONFIRMATION_CODE_INVALID", "APPROVAL_REQUIRED", "REQUEST_EXPIRED"].includes(code) ? new AuthLinkError(code) : raw;
1938
- snapshot = { ...snapshot, error: failure };
1939
- if (code === "CONFIRMATION_CODE_LOCKED" || code === "REQUEST_EXPIRED") {
1940
- ended = true;
1941
- onUpdate({ ...snapshot, code: null, submitting: false, status: code === "REQUEST_EXPIRED" ? "expired" : "error" });
1942
- if (code === "REQUEST_EXPIRED") void Promise.resolve().then(() => options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
1943
- else options.onError?.(failure);
1944
- done();
1945
- } else options.onError?.(failure);
1946
- }
1947
- } finally {
1948
- busy = false;
1949
- tick();
1950
- }
1951
- };
1952
- tick();
1953
- options.onReady?.(submit);
1954
- const timer = setInterval(tick, 1e3);
1955
- if (signal.aborted) cancel();
1956
- try {
1957
- await finished;
1958
- } finally {
1959
- clearInterval(timer);
1960
- controller.abort();
1961
- signal.removeEventListener("abort", cancel);
1657
+ throw new AuthLinkError("INVALID_QR");
1962
1658
  }
1659
+ const token = /^[A-Za-z0-9_-]{43}$/;
1660
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
1661
+ if (payload.v !== 1 || typeof payload.requestId !== "string" || !token.test(payload.requestId) || typeof payload.token !== "string" || !token.test(payload.token) || origin.origin !== payload.origin || origin.protocol !== "https:" && !(origin.protocol === "http:" && loopback) || expectedOrigin !== void 0 && expectedOrigin !== origin.origin) throw new AuthLinkError("INVALID_QR");
1662
+ const image = qrcode_default(0, "M");
1663
+ image.addData(JSON.stringify({ v: 1, origin: origin.origin, requestId: payload.requestId, token: payload.token }), "Byte");
1664
+ image.make();
1665
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.createSvgTag({ cellSize: 4, margin: 16, scalable: true }))}`;
1963
1666
  }
1964
1667
 
1965
1668
  // src/locales/en.json
@@ -2392,35 +2095,20 @@ function getMessages(locale) {
2392
2095
 
2393
2096
  // src/view.ts
2394
2097
  var node = (tag, props, ...children) => ({ tag, props, children });
2395
- function resolveVariant(variant) {
2396
- return variant === "B" || variant === "C" ? variant : "A";
2397
- }
2098
+ var digits = (value) => value.replace(/[^0-9]/g, "");
2398
2099
  function widgetView(options) {
2399
- const { snapshot: s, mode, retry, refresh } = options;
2400
- const locale = resolveLocale(options.locale), variant = resolveVariant(options.variant);
2401
- const l = getMessages(locale);
2402
- const pending = s.status === "pending", hasQr = pending && (mode === "bind" || Boolean(options.showQr)) && Boolean(options.qr);
2403
- const status = { idle: l.loading, pending: options.passcodeMode ? l.otpWaiting : l.waiting, confirmed: l.confirmed, rejected: l.rejected, expired: l.expired, error: l.error }[s.status];
2404
- const expiring = pending && s.secondsRemaining <= 30;
2405
- const expiryTime = `00:${String(Math.max(0, Math.ceil(s.secondsRemaining))).padStart(2, "0")}`;
2406
- const statusNode = () => node(
2407
- "p",
2408
- {
2409
- class: "authlink-status",
2410
- role: s.status === "error" ? "alert" : "status",
2411
- "data-expiring": String(expiring)
2412
- },
2413
- node("span", { class: "authlink-status-dot", "aria-hidden": "true" }),
2414
- expiring ? l.expiring : status,
2415
- ...expiring ? [node("span", { class: "authlink-expiry", "aria-hidden": "true" }, ` \xB7 ${expiryTime}`)] : []
2416
- );
2417
- const cancel = () => options.close ? [node("button", {
2418
- type: "button",
2419
- class: "authlink-cancel",
2420
- "data-authlink-focus": "cancel",
2421
- onClick: options.close
2422
- }, l.cancel)] : [];
2100
+ const { snapshot: s, mode } = options, locale = resolveLocale(options.locale), l = getMessages(locale);
2101
+ const pending = s.status === "pending", hasQr = pending && mode === "bind" && Boolean(options.qr);
2102
+ const expiring = pending && s.secondsRemaining !== void 0 && s.secondsRemaining <= 30;
2103
+ const status = s.status === "pending" ? l.otpWaiting : l[s.status];
2104
+ const remaining = s.secondsRemaining ?? 0;
2105
+ const expiryTime = `${String(Math.floor(remaining / 60)).padStart(2, "0")}:${String(remaining % 60).padStart(2, "0")}`;
2423
2106
  const children = [node("h3", { class: "authlink-title" }, l[mode])];
2107
+ const errorNode = s.error ? node(
2108
+ "p",
2109
+ { class: "authlink-input-error", role: "alert" },
2110
+ ["PASSCODE_LOCKED", "RATE_LIMITED", "AUTHLINK_PASSCODE_LOCKED"].includes(s.error.message) ? l.codeLocked : l.otpError
2111
+ ) : void 0;
2424
2112
  if (hasQr) children.push(node("p", { class: "authlink-subtitle" }, l.scan));
2425
2113
  if (pending) {
2426
2114
  const qr = hasQr ? [node(
@@ -2430,18 +2118,18 @@ function widgetView(options) {
2430
2118
  node("figcaption", { class: "authlink-qr-caption" }, l.scan)
2431
2119
  )] : [];
2432
2120
  const detail = [
2433
- node("p", { class: "authlink-help" }, options.passcodeMode ? l.otpHelp : l.help),
2121
+ node("p", { class: "authlink-help" }, l.otpHelp),
2434
2122
  node(
2435
2123
  "form",
2436
2124
  { class: "authlink-code-form", onSubmit: (event) => {
2437
2125
  event.preventDefault();
2438
2126
  const input = event.currentTarget.querySelector("input");
2439
- if (input) void options.submitCode?.(input.value);
2127
+ if (input) void options.submitCode(input.value);
2440
2128
  } },
2441
- node("label", { for: "authlink-confirmation-code" }, options.passcodeMode ? l.otpCode : l.confirmationCode),
2129
+ node("label", { for: "authlink-passcode" }, l.otpCode),
2442
2130
  node("input", {
2443
- id: "authlink-confirmation-code",
2444
- name: options.passcodeMode ? "passcode" : "confirmationCode",
2131
+ id: "authlink-passcode",
2132
+ name: "passcode",
2445
2133
  type: "text",
2446
2134
  inputmode: "numeric",
2447
2135
  autocomplete: "one-time-code",
@@ -2449,283 +2137,74 @@ function widgetView(options) {
2449
2137
  maxlength: 6,
2450
2138
  minlength: 6,
2451
2139
  required: true,
2452
- "data-authlink-focus": "confirmation-code",
2453
- value: options.inputCode ?? "",
2454
- onInput: (event) => options.setCode?.(event.target.value)
2140
+ ...s.submitting ? { disabled: true } : {},
2141
+ "data-authlink-focus": "passcode",
2142
+ value: options.inputCode,
2143
+ onInput: (event) => {
2144
+ const input = event.target, original = input.value;
2145
+ const start = input.selectionStart ?? original.length, end = input.selectionEnd ?? start;
2146
+ const code = digits(original).slice(0, 6);
2147
+ if (original !== code) {
2148
+ input.value = code;
2149
+ input.setSelectionRange(Math.min(6, digits(original.slice(0, start)).length), Math.min(6, digits(original.slice(0, end)).length));
2150
+ }
2151
+ options.setCode(code);
2152
+ },
2153
+ onPaste: (event) => {
2154
+ if (!event.clipboardData) return;
2155
+ event.preventDefault();
2156
+ const input = event.target, pasted = digits(event.clipboardData.getData("text"));
2157
+ if (!pasted) return;
2158
+ const start = input.selectionStart ?? input.value.length, end = input.selectionEnd ?? start;
2159
+ const before = input.value.slice(0, start), after = input.value.slice(end);
2160
+ const insert = pasted.slice(0, Math.max(0, 6 - before.length - after.length));
2161
+ input.value = before + insert + after;
2162
+ input.setSelectionRange(before.length + insert.length, before.length + insert.length);
2163
+ options.setCode(input.value);
2164
+ }
2455
2165
  }),
2456
- node(
2457
- "button",
2458
- { type: "submit", class: "authlink-button", ...s.submitting ? { disabled: true } : {} },
2459
- s.submitting ? l.loading : l.submitCode
2460
- )
2461
- ),
2462
- ...s.error ? [node("p", { class: "authlink-input-error", role: "alert" }, options.passcodeMode ? s.error.message === "OTP_NOT_CONFIGURED" ? l.otpSetup : l.otpError : l.codeError)] : []
2166
+ node("button", { type: "submit", class: "authlink-button", ...s.submitting ? { disabled: true } : {} }, s.submitting ? l.loading : l.submitCode)
2167
+ )
2463
2168
  ];
2464
- children.push(node("div", { class: "authlink-body" }, ...qr, node("div", { class: "authlink-details" }, ...detail)));
2465
- if (variant === "A") children.push(node("div", { class: "authlink-footer" }, statusNode()));
2466
- if (variant === "B") children.push(node("div", { class: "authlink-footer" }, statusNode(), ...cancel()));
2467
- if (variant === "C") children.push(node("div", { class: "authlink-footer" }, statusNode(), ...cancel()));
2468
- } else {
2469
- children.push(statusNode());
2470
- if (s.error?.message === "CONFIRMATION_CODE_LOCKED") children.push(node("p", { class: "authlink-input-error", role: "alert" }, l.codeLocked));
2471
- if (s.status === "error" && s.error?.message !== "CONFIRMATION_CODE_LOCKED") children.push(node("button", {
2472
- type: "button",
2473
- class: "authlink-button",
2474
- "data-authlink-focus": "retry",
2475
- onClick: retry
2476
- }, l.retry));
2477
- if (refresh && ["expired", "rejected", "error"].includes(s.status)) children.push(node("button", {
2478
- type: "button",
2479
- class: "authlink-button",
2480
- "data-authlink-focus": "refresh",
2481
- onClick: refresh
2482
- }, l.refresh));
2169
+ children.push(node("div", { class: "authlink-body" }, ...qr, node("div", { class: "authlink-details" }, ...detail, ...errorNode ? [errorNode] : [])));
2483
2170
  }
2484
- return node("section", {
2485
- lang: locale,
2486
- class: `authlink-widget ${options.className ?? ""}`.trim(),
2487
- "data-status": s.status,
2488
- "data-variant": variant,
2489
- "data-has-qr": String(hasQr),
2490
- "aria-label": l[mode]
2491
- }, ...children);
2171
+ if (!pending && errorNode) children.push(errorNode);
2172
+ children.push(node("div", { class: "authlink-footer" }, node(
2173
+ "p",
2174
+ { class: "authlink-status", role: "status", "data-expiring": String(expiring) },
2175
+ node("span", { class: "authlink-status-dot", "aria-hidden": "true" }),
2176
+ expiring ? l.expiring : status,
2177
+ ...expiring ? [node("span", { class: "authlink-expiry", "aria-hidden": "true" }, ` \xB7 ${expiryTime}`)] : []
2178
+ )));
2179
+ if (s.status === "expired" && options.refresh) children.push(node("button", {
2180
+ type: "button",
2181
+ class: "authlink-button",
2182
+ ...s.submitting ? { disabled: true } : {},
2183
+ onClick: options.refresh
2184
+ }, s.submitting ? l.loading : l.refresh));
2185
+ return node("section", { lang: locale, class: "authlink-widget", "data-status": s.status, "data-has-qr": String(hasQr), "aria-label": l[mode] }, ...children);
2492
2186
  }
2493
2187
 
2494
2188
  // src/modal.ts
2495
- function element(value, svg = false) {
2189
+ function element(value) {
2496
2190
  if (typeof value === "string") return document.createTextNode(value);
2497
- svg ||= value.tag === "svg";
2498
- const result = svg ? document.createElementNS("http://www.w3.org/2000/svg", value.tag) : document.createElement(value.tag);
2191
+ const result = document.createElement(value.tag);
2499
2192
  for (const [key, v] of Object.entries(value.props)) {
2500
2193
  if (key.startsWith("on") && typeof v === "function") result.addEventListener(key.slice(2).toLowerCase(), v);
2501
2194
  else if (v !== void 0 && v !== null) result.setAttribute(key, String(v));
2502
2195
  }
2503
- result.append(...value.children.map((child) => element(child, svg)));
2196
+ result.append(...value.children.map(element));
2504
2197
  return result;
2505
2198
  }
2506
- function openAuthLink(options) {
2199
+ function open(options, mode) {
2507
2200
  if (typeof document === "undefined" || !document.body) throw new AuthLinkError("BROWSER_REQUIRED");
2508
- const mode = options.mode ?? options.ticket.purpose;
2509
- const ticket = validateTicket(options.ticket, mode);
2201
+ if (typeof options.submit !== "function") throw new AuthLinkError("SUBMIT_REQUIRED");
2202
+ const binding = mode === "bind" ? options : void 0;
2203
+ let image = binding ? bindingImage(binding, binding.authLinkOrigin) : void 0;
2204
+ let expires = options.expiresAt === void 0 ? void 0 : deadline(options.expiresAt);
2510
2205
  const dialog = document.createElement("dialog");
2511
2206
  if (typeof dialog.showModal !== "function") throw new AuthLinkError("DIALOG_UNSUPPORTED");
2512
2207
  const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2513
- dialog.className = `authlink-dialog ${options.className ?? ""}`.trim();
2514
- dialog.setAttribute("aria-modal", "true");
2515
- const header = document.createElement("div");
2516
- header.className = "authlink-header";
2517
- const brand = document.createElement("span");
2518
- brand.className = "authlink-brand";
2519
- brand.textContent = "AuthLink";
2520
- brand.tabIndex = -1;
2521
- const closeButton = document.createElement("button");
2522
- closeButton.type = "button";
2523
- closeButton.className = "authlink-close";
2524
- const closeIcon = document.createElement("span");
2525
- closeIcon.className = "authlink-close-icon";
2526
- closeIcon.setAttribute("aria-hidden", "true");
2527
- closeButton.append(closeIcon);
2528
- const content = document.createElement("div");
2529
- content.className = "authlink-modal-content";
2530
- header.append(brand, closeButton);
2531
- dialog.append(header, content);
2532
- let locale = options.locale, variant = resolveVariant(options.variant), snapshot = initialSnapshot(), closed = false;
2533
- let abort;
2534
- let inputCode = "";
2535
- let submitCode;
2536
- const image = qrImage(ticket);
2537
- let progressFill, progressFrame, progressDeadline = 0;
2538
- const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
2539
- const stopProgress = () => {
2540
- if (progressFrame !== void 0) window.cancelAnimationFrame(progressFrame);
2541
- progressFrame = void 0;
2542
- };
2543
- const drawProgress = () => {
2544
- stopProgress();
2545
- if (closed || !progressFill?.isConnected) return;
2546
- const remaining = Math.max(0, progressDeadline - performance.now());
2547
- progressFill.style.transform = `scaleX(${Math.min(1, remaining / 3e4)})`;
2548
- if (remaining > 0 && !motion?.matches && typeof window.requestAnimationFrame === "function") {
2549
- progressFrame = window.requestAnimationFrame(drawProgress);
2550
- }
2551
- };
2552
- const syncProgress = (remainingMs) => {
2553
- progressDeadline = performance.now() + remainingMs;
2554
- drawProgress();
2555
- };
2556
- motion?.addEventListener?.("change", drawProgress);
2557
- const render = () => {
2558
- if (closed) return;
2559
- const language = resolveLocale(locale), messages2 = getMessages(language);
2560
- dialog.lang = language;
2561
- dialog.setAttribute("aria-label", messages2[mode]);
2562
- dialog.dataset.variant = variant;
2563
- dialog.dataset.mode = mode;
2564
- dialog.dataset.hasQr = String(snapshot.status === "pending" && (mode === "bind" || Boolean(options.showQr)));
2565
- closeButton.setAttribute("aria-label", messages2.close);
2566
- const focused = document.activeElement;
2567
- const focusKey = focused instanceof HTMLElement && content.contains(focused) ? focused.dataset.authlinkFocus : void 0;
2568
- const next = element(widgetView({
2569
- mode,
2570
- locale,
2571
- variant,
2572
- snapshot,
2573
- qr: image,
2574
- showQr: options.showQr,
2575
- inputCode,
2576
- setCode: (code) => {
2577
- inputCode = code;
2578
- },
2579
- submitCode: (code) => submitCode?.(code) ?? Promise.resolve(),
2580
- retry,
2581
- refresh: options.onRefresh,
2582
- close: () => dispose(true)
2583
- }));
2584
- const oldCode = content.querySelector(".authlink-code");
2585
- const nextCode = next.querySelector(".authlink-code");
2586
- const codeChanged = Boolean(oldCode && nextCode && snapshot.code && oldCode.textContent !== nextCode.textContent);
2587
- if (oldCode && nextCode && oldCode.textContent === nextCode.textContent) {
2588
- oldCode.setAttribute("aria-label", nextCode.getAttribute("aria-label"));
2589
- nextCode.replaceWith(oldCode);
2590
- }
2591
- const oldProgress = content.querySelector(".authlink-progress");
2592
- const nextProgress = next.querySelector(".authlink-progress");
2593
- if (oldProgress && nextProgress) {
2594
- oldProgress.setAttribute("aria-valuenow", nextProgress.getAttribute("aria-valuenow"));
2595
- oldProgress.setAttribute("aria-label", nextProgress.getAttribute("aria-label"));
2596
- nextProgress.replaceWith(oldProgress);
2597
- }
2598
- const oldInput = content.querySelector("input[name=confirmationCode]");
2599
- const nextInput = next.querySelector("input[name=confirmationCode]");
2600
- if (oldInput && nextInput) nextInput.replaceWith(oldInput);
2601
- content.replaceChildren(next);
2602
- progressFill = content.querySelector(".authlink-progress-fill") ?? void 0;
2603
- drawProgress();
2604
- if (codeChanged && !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
2605
- nextCode?.animate?.([{ opacity: 0.5 }, { opacity: 1 }], { duration: 180, easing: "ease-out" });
2606
- }
2607
- if (focusKey) (Array.from(content.querySelectorAll("[data-authlink-focus]")).find((item) => item.dataset.authlinkFocus === focusKey) ?? brand).focus({ preventScroll: true });
2608
- };
2609
- const dispose = (notify) => {
2610
- if (closed) return;
2611
- closed = true;
2612
- abort?.abort();
2613
- stopProgress();
2614
- progressFill = void 0;
2615
- motion?.removeEventListener?.("change", drawProgress);
2616
- if (dialog.open) dialog.close();
2617
- dialog.remove();
2618
- if (!document.querySelector("dialog.authlink-dialog[open]")) {
2619
- if (document.body.style.overflow === "hidden") document.body.style.overflow = document.body.dataset.authlinkOverflow ?? "";
2620
- delete document.body.dataset.authlinkOverflow;
2621
- }
2622
- if (previousFocus?.isConnected) previousFocus.focus();
2623
- if (notify) options.onClose?.();
2624
- };
2625
- const delivered = /* @__PURE__ */ new Set();
2626
- const once = (status, callback) => {
2627
- if (delivered.has(status)) return;
2628
- delivered.add(status);
2629
- return callback();
2630
- };
2631
- const retry = () => {
2632
- if (closed) return;
2633
- abort?.abort();
2634
- abort = new AbortController();
2635
- snapshot = initialSnapshot();
2636
- render();
2637
- void observeRequest(ticket, {
2638
- client: options.client,
2639
- intervalMs: options.intervalMs,
2640
- expectedMode: mode,
2641
- signal: abort.signal,
2642
- onReady: (submit) => {
2643
- submitCode = submit;
2644
- },
2645
- onCodeTiming: syncProgress,
2646
- onUpdate: (value) => {
2647
- snapshot = value;
2648
- if (value.status !== "rejected") render();
2649
- },
2650
- onConfirmed: (value) => once("confirmed", () => {
2651
- dispose(false);
2652
- try {
2653
- return Promise.resolve(options.onConfirmed?.(value)).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
2654
- } catch {
2655
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
2656
- }
2657
- }),
2658
- onRejected: (value) => once("rejected", () => {
2659
- dispose(false);
2660
- try {
2661
- return Promise.resolve(options.onRejected?.(value)).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
2662
- } catch {
2663
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
2664
- }
2665
- }),
2666
- onExpired: () => once("expired", () => options.onExpired?.()),
2667
- onError: options.onError
2668
- });
2669
- };
2670
- closeButton.addEventListener("click", () => dispose(true));
2671
- dialog.addEventListener("cancel", (event) => {
2672
- event.preventDefault();
2673
- dispose(true);
2674
- });
2675
- dialog.addEventListener("close", () => dispose(true));
2676
- render();
2677
- document.body.append(dialog);
2678
- try {
2679
- dialog.showModal();
2680
- } catch (error) {
2681
- dialog.remove();
2682
- throw error;
2683
- }
2684
- brand.focus({ preventScroll: true });
2685
- if (document.body.dataset.authlinkOverflow === void 0) document.body.dataset.authlinkOverflow = document.body.style.overflow;
2686
- document.body.style.overflow = "hidden";
2687
- retry();
2688
- return {
2689
- close: () => dispose(true),
2690
- destroy: () => dispose(false),
2691
- setLocale: (value) => {
2692
- locale = value;
2693
- render();
2694
- },
2695
- setVariant: (value) => {
2696
- variant = resolveVariant(value);
2697
- render();
2698
- },
2699
- retry
2700
- };
2701
- }
2702
- var openAuthLinkBind = (options) => openAuthLink({ ...options, mode: "bind" });
2703
- var openAuthLinkConfirm = (options) => openAuthLink({ ...options, mode: "confirm" });
2704
-
2705
- // src/passcode.ts
2706
- function validateOTPResult(value) {
2707
- if (!value || typeof value !== "object") throw new AuthLinkError("INVALID_RESPONSE");
2708
- const result = value;
2709
- const expiry = typeof result.expiresAt === "string" ? Date.parse(result.expiresAt) : NaN;
2710
- if (result.status !== "confirmed" || result.authenticationPolicy !== "device-totp.v1" || typeof result.verificationId !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(result.verificationId) || !Number.isFinite(expiry) || expiry <= Date.now() || expiry > Date.now() + 9e4 || "requestId" in result || "state" in result || "businessId" in result) throw new AuthLinkError("INVALID_CONFIRMATION");
2711
- return result;
2712
- }
2713
- function createPasscodeClient(options) {
2714
- const call = createBackendCall(options);
2715
- return {
2716
- async verify(passcode, signal) {
2717
- if (!/^[0-9]{6}$/.test(passcode)) throw new AuthLinkError("PASSCODE_INVALID");
2718
- return validateOTPResult(await call("/auth", { passcode }, signal));
2719
- }
2720
- };
2721
- }
2722
- function openPasscode(options) {
2723
- if (typeof document === "undefined" || !document.body) throw new AuthLinkError("BROWSER_REQUIRED");
2724
- const deadline = Date.parse(options.expiresAt);
2725
- if (!Number.isFinite(deadline) || deadline <= Date.now() || deadline > Date.now() + 3e5) throw new AuthLinkError("INVALID_EXPIRY");
2726
- const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2727
- const dialog = document.createElement("dialog");
2728
- if (typeof dialog.showModal !== "function") throw new AuthLinkError("DIALOG_UNSUPPORTED");
2729
2208
  dialog.className = "authlink-dialog";
2730
2209
  dialog.setAttribute("aria-modal", "true");
2731
2210
  const header = document.createElement("div");
@@ -2744,94 +2223,143 @@ function openPasscode(options) {
2744
2223
  content.className = "authlink-modal-content";
2745
2224
  header.append(brand, close);
2746
2225
  dialog.append(header, content);
2747
- let locale = options.locale, variant = resolveVariant(options.variant), closed = false, busy = false, code = "", error;
2748
- const mode = options.bindingQr ? "bind" : "confirm";
2749
- let image;
2750
- if (options.bindingQr) {
2751
- let payload;
2226
+ let locale = options.locale, closed = false, busy = false, code = "", error = null;
2227
+ let abort, timer, expiredDelivered = false;
2228
+ const notifyError = (cause) => {
2229
+ const value = cause instanceof Error ? cause : new AuthLinkError("VERIFICATION_FAILED");
2752
2230
  try {
2753
- payload = JSON.parse(options.bindingQr);
2231
+ options.onError?.(value);
2754
2232
  } catch {
2755
- throw new AuthLinkError("INVALID_TICKET");
2756
2233
  }
2757
- image = qrImage(validateTicket({ requestId: payload.requestId, purpose: "bind", qr: options.bindingQr, pairingCode: "000000", expiresAt: options.expiresAt }, "bind", options.authLinkOrigin));
2758
- }
2759
- const abort = new AbortController();
2760
- let timer;
2234
+ return value;
2235
+ };
2236
+ const callback = (fn) => {
2237
+ try {
2238
+ void Promise.resolve(fn?.()).catch(() => notifyError(new AuthLinkError("CALLBACK_FAILED")));
2239
+ } catch {
2240
+ notifyError(new AuthLinkError("CALLBACK_FAILED"));
2241
+ }
2242
+ };
2761
2243
  const dispose = (notify) => {
2762
2244
  if (closed) return;
2763
2245
  closed = true;
2764
- abort.abort();
2246
+ abort?.abort();
2765
2247
  clearInterval(timer);
2248
+ if (dialog.open) dialog.close();
2766
2249
  dialog.remove();
2767
- previousFocus?.focus();
2768
- if (notify) options.onClose?.();
2250
+ if (previousFocus?.isConnected) previousFocus.focus();
2251
+ if (notify) callback(options.onClose);
2769
2252
  };
2253
+ const expired = () => expires !== void 0 && Date.now() >= expires;
2770
2254
  const render = () => {
2771
2255
  if (closed) return;
2772
2256
  const l = getMessages(locale);
2773
2257
  dialog.lang = resolveLocale(locale);
2774
- dialog.dataset.variant = variant;
2775
2258
  dialog.dataset.mode = mode;
2776
- dialog.dataset.hasQr = String(Boolean(image));
2259
+ dialog.dataset.hasQr = String(Boolean(image) && !expired());
2777
2260
  dialog.setAttribute("aria-label", l[mode]);
2778
2261
  close.setAttribute("aria-label", l.close);
2779
- const focused = content.contains(document.activeElement);
2780
- const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
2781
- const snapshot = { status: remaining ? "pending" : "expired", code: null, secondsRemaining: remaining, codeSecondsRemaining: remaining, submitting: busy, error: error ?? null };
2782
- content.replaceChildren(element(widgetView({
2262
+ const focused = document.activeElement;
2263
+ const focusKey = focused instanceof HTMLElement && content.contains(focused) ? focused.dataset.authlinkFocus : void 0;
2264
+ const oldInput = content.querySelector("input");
2265
+ const next = element(widgetView({
2783
2266
  mode,
2784
2267
  locale,
2785
- variant,
2786
- passcodeMode: true,
2787
- snapshot,
2268
+ snapshot: {
2269
+ status: expired() ? "expired" : "pending",
2270
+ submitting: busy,
2271
+ error,
2272
+ secondsRemaining: expires === void 0 ? void 0 : Math.max(0, Math.ceil((expires - Date.now()) / 1e3))
2273
+ },
2788
2274
  qr: image,
2789
- showQr: Boolean(image),
2790
2275
  inputCode: code,
2791
2276
  setCode: (value) => {
2792
2277
  code = value;
2793
2278
  },
2794
2279
  submitCode: submit,
2795
- retry: () => {
2796
- error = void 0;
2797
- render();
2798
- },
2799
- close: () => dispose(true)
2800
- })));
2801
- if (focused) content.querySelector("input")?.focus();
2280
+ refresh: binding?.onRefresh ? refresh : void 0
2281
+ }));
2282
+ const nextInput = next.querySelector("input");
2283
+ if (oldInput && nextInput) {
2284
+ oldInput.disabled = busy;
2285
+ if (oldInput.value !== code) oldInput.value = code;
2286
+ nextInput.replaceWith(oldInput);
2287
+ }
2288
+ content.replaceChildren(next);
2289
+ if (focusKey) content.querySelector(`[data-authlink-focus="${focusKey}"]`)?.focus({ preventScroll: true });
2802
2290
  };
2803
- const submit = async (value) => {
2804
- if (closed || busy) return;
2805
- if (Date.now() >= deadline) {
2291
+ const tick = () => {
2292
+ if (closed) return;
2293
+ if (expired() && !expiredDelivered) {
2294
+ expiredDelivered = true;
2295
+ abort?.abort();
2296
+ busy = false;
2806
2297
  render();
2298
+ callback(options.onExpired);
2299
+ } else if (expires !== void 0) render();
2300
+ };
2301
+ const startTimer = () => {
2302
+ clearInterval(timer);
2303
+ if (expires !== void 0) timer = setInterval(tick, 1e3);
2304
+ };
2305
+ const submit = async (value) => {
2306
+ if (closed || busy || expired()) {
2307
+ tick();
2807
2308
  return;
2808
2309
  }
2809
2310
  if (!/^[0-9]{6}$/.test(value)) {
2810
- error = new AuthLinkError("PASSCODE_INVALID");
2311
+ error = notifyError(new AuthLinkError("PASSCODE_INVALID"));
2811
2312
  render();
2812
2313
  return;
2813
2314
  }
2814
2315
  code = value;
2815
2316
  busy = true;
2816
- error = void 0;
2317
+ error = null;
2318
+ const current = new AbortController();
2319
+ abort = current;
2817
2320
  render();
2818
2321
  try {
2819
- const result = validateOTPResult(await options.client.verify(value, abort.signal));
2820
- if (closed || abort.signal.aborted || Date.now() >= deadline) return;
2821
- try {
2822
- await options.onVerified?.(result);
2823
- } catch {
2824
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
2825
- }
2322
+ const pending = options.submit(value, { signal: current.signal });
2323
+ if (!pending || typeof pending.then !== "function") throw new AuthLinkError("SUBMIT_MUST_RETURN_PROMISE");
2324
+ await pending;
2325
+ if (closed || current.signal.aborted || abort !== current) return;
2826
2326
  dispose(false);
2327
+ callback(options.onSuccess);
2827
2328
  } catch (cause) {
2828
- if (!closed && !abort.signal.aborted) {
2829
- error = cause instanceof Error ? cause : new AuthLinkError("NETWORK_ERROR");
2830
- options.onError?.(error);
2329
+ if (!closed && !current.signal.aborted && abort === current) error = notifyError(cause);
2330
+ } finally {
2331
+ if (!closed && abort === current) {
2332
+ busy = false;
2333
+ render();
2831
2334
  }
2335
+ }
2336
+ };
2337
+ const refresh = async () => {
2338
+ if (closed || busy || !binding?.onRefresh || !expired()) return;
2339
+ busy = true;
2340
+ error = null;
2341
+ expiredDelivered = true;
2342
+ clearInterval(timer);
2343
+ const current = new AbortController();
2344
+ abort?.abort();
2345
+ abort = current;
2346
+ render();
2347
+ try {
2348
+ const ticket = await binding.onRefresh({ signal: current.signal });
2349
+ if (closed || current.signal.aborted || abort !== current) return;
2350
+ const nextImage = bindingImage(ticket, binding.authLinkOrigin), nextDeadline = deadline(ticket.expiresAt);
2351
+ image = nextImage;
2352
+ expires = nextDeadline;
2353
+ code = "";
2354
+ expiredDelivered = false;
2355
+ startTimer();
2356
+ } catch (cause) {
2357
+ if (!closed && !current.signal.aborted && abort === current) error = notifyError(cause);
2832
2358
  } finally {
2833
- busy = false;
2834
- render();
2359
+ if (!closed && abort === current) {
2360
+ busy = false;
2361
+ render();
2362
+ }
2835
2363
  }
2836
2364
  };
2837
2365
  close.addEventListener("click", () => dispose(true));
@@ -2839,18 +2367,17 @@ function openPasscode(options) {
2839
2367
  event.preventDefault();
2840
2368
  dispose(true);
2841
2369
  });
2370
+ dialog.addEventListener("close", () => dispose(true));
2842
2371
  document.body.append(dialog);
2843
2372
  render();
2844
- dialog.showModal();
2373
+ try {
2374
+ dialog.showModal();
2375
+ } catch (cause) {
2376
+ dispose(false);
2377
+ throw cause;
2378
+ }
2845
2379
  content.querySelector("input")?.focus();
2846
- timer = setInterval(() => {
2847
- if (closed) return;
2848
- if (Date.now() >= deadline) {
2849
- render();
2850
- clearInterval(timer);
2851
- void Promise.resolve(options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
2852
- }
2853
- }, 1e3);
2380
+ startTimer();
2854
2381
  return {
2855
2382
  close: () => dispose(true),
2856
2383
  destroy: () => dispose(false),
@@ -2858,29 +2385,21 @@ function openPasscode(options) {
2858
2385
  locale = value;
2859
2386
  render();
2860
2387
  },
2861
- setVariant: (value) => {
2862
- variant = resolveVariant(value);
2863
- render();
2864
- },
2865
2388
  retry: () => {
2866
- error = void 0;
2867
- render();
2389
+ if (!closed && !busy) {
2390
+ error = null;
2391
+ tick();
2392
+ render();
2393
+ }
2868
2394
  }
2869
2395
  };
2870
2396
  }
2397
+ var openVerify = (options) => open(options, "confirm");
2398
+ var openBind = (options) => open(options, "bind");
2871
2399
  export {
2872
2400
  AuthLinkError,
2873
- createAuthLinkClient,
2874
- createPasscodeClient,
2875
- initialSnapshot,
2876
- observeRequest,
2877
- openAuthLink,
2878
- openAuthLinkBind,
2879
- openAuthLinkConfirm,
2880
- openPasscode,
2881
- qrImage,
2882
- requestKey,
2883
- supportedLocales,
2884
- validateTicket
2401
+ openBind,
2402
+ openVerify,
2403
+ supportedLocales
2885
2404
  };
2886
2405
  //# sourceMappingURL=index.js.map