@auth-link/web 2.0.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/react.js CHANGED
@@ -2,7 +2,7 @@
2
2
  "use client";
3
3
 
4
4
  // src/react.tsx
5
- import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { useEffect, useRef } from "react";
6
6
 
7
7
  // node_modules/qrcode-generator/dist/qrcode.mjs
8
8
  var qrcode = function(typeNumber, errorCorrectionLevel) {
@@ -1639,195 +1639,36 @@ var qrcode_default = qrcode;
1639
1639
  var stringToBytes = qrcode.stringToBytes;
1640
1640
 
1641
1641
  // src/core.ts
1642
- var token = /^[A-Za-z0-9_-]{43}$/;
1643
1642
  var AuthLinkError = class extends Error {
1643
+ code;
1644
1644
  constructor(code) {
1645
1645
  super(code);
1646
- this.code = code;
1647
1646
  this.name = "AuthLinkError";
1647
+ this.code = code;
1648
1648
  }
1649
- code;
1650
1649
  };
1651
- function fail(code) {
1652
- throw new AuthLinkError(code);
1650
+ function deadline(value) {
1651
+ const at = typeof value === "string" ? Date.parse(value) : NaN;
1652
+ if (!Number.isFinite(at) || at <= Date.now()) throw new AuthLinkError("INVALID_EXPIRY");
1653
+ return at;
1653
1654
  }
1654
- function object(value) {
1655
- if (!value || typeof value !== "object" || Array.isArray(value)) fail("INVALID_RESPONSE");
1656
- return value;
1657
- }
1658
- function validateDisplayCode(value) {
1659
- const m = object(value);
1660
- const span = Date.parse(String(m.validUntil)) - Date.parse(String(m.serverTime));
1661
- 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");
1662
- return { value: m.value, serverTime: String(m.serverTime), validUntil: String(m.validUntil), periodSeconds: 30 };
1663
- }
1664
- function validateTicket(value, expectedMode, trustedOrigin) {
1665
- const m = object(value);
1666
- if ("pairing" in m || "seed" in m || "apiKey" in m) fail("SERVER_SECRET_FORBIDDEN");
1667
- 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");
1668
- let qr;
1655
+ function bindingImage(ticket, expectedOrigin) {
1656
+ if (typeof ticket.qr !== "string" || ticket.qr.length > 4096) throw new AuthLinkError("INVALID_QR");
1657
+ if (deadline(ticket.expiresAt) > Date.now() + 36e5) throw new AuthLinkError("INVALID_EXPIRY");
1658
+ let payload, origin;
1669
1659
  try {
1670
- qr = object(JSON.parse(m.qr));
1660
+ payload = JSON.parse(ticket.qr);
1661
+ origin = new URL(String(payload.origin));
1671
1662
  } catch {
1672
- return fail("INVALID_QR");
1673
- }
1674
- let origin;
1675
- try {
1676
- origin = new URL(String(qr.origin));
1677
- } catch {
1678
- return fail("INVALID_QR");
1679
- }
1680
- 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");
1681
- const displayCode = m.displayCode === void 0 ? void 0 : validateDisplayCode(m.displayCode);
1682
- const expiry = Date.parse(String(m.expiresAt));
1683
- const now = displayCode ? Date.parse(displayCode.serverTime) : Date.now();
1684
- if (!Number.isFinite(expiry) || expiry > now + 36e4) fail("INVALID_EXPIRY");
1685
- return {
1686
- requestId: m.requestId,
1687
- purpose: m.purpose,
1688
- qr: m.qr,
1689
- pairingCode: m.pairingCode,
1690
- expiresAt: String(m.expiresAt),
1691
- ...displayCode ? { displayCode } : {}
1692
- };
1693
- }
1694
- function qrImage(ticket) {
1695
- const value = validateTicket(ticket);
1696
- const payload = JSON.parse(value.qr);
1697
- const qr = qrcode_default(0, "M");
1698
- qr.addData(JSON.stringify({ v: 1, origin: payload.origin, requestId: payload.requestId, token: payload.token }), "Byte");
1699
- qr.make();
1700
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.createSvgTag({ cellSize: 4, margin: 16, scalable: true }))}`;
1701
- }
1702
- function initialSnapshot() {
1703
- return { status: "idle", code: null, secondsRemaining: 0, codeSecondsRemaining: 0, error: null };
1704
- }
1705
- function abortable(promise, signal) {
1706
- signal.throwIfAborted();
1707
- let cancel;
1708
- const abort = new Promise((_, reject) => {
1709
- cancel = () => reject(signal.reason);
1710
- signal.addEventListener("abort", cancel, { once: true });
1711
- });
1712
- return Promise.race([promise, abort]).finally(() => signal.removeEventListener("abort", cancel));
1713
- }
1714
- async function observeRequest(input, options) {
1715
- const { client, signal, onUpdate } = options;
1716
- let ticket;
1717
- try {
1718
- ticket = validateTicket(input, options.expectedMode);
1719
- } catch (error) {
1720
- if (!signal.aborted) options.onError?.(error);
1721
- return;
1722
- }
1723
- const expiry = Date.parse(ticket.expiresAt);
1724
- const deadline = performance.now() + Math.max(0, expiry - Date.now());
1725
- let ended = false, busy = false;
1726
- let done;
1727
- const finished = new Promise((resolve) => {
1728
- done = resolve;
1729
- });
1730
- const controller = new AbortController();
1731
- const cancel = () => {
1732
- ended = true;
1733
- controller.abort();
1734
- done();
1735
- };
1736
- signal.addEventListener("abort", cancel, { once: true });
1737
- let snapshot = initialSnapshot();
1738
- const remaining = () => Math.max(0, (deadline - performance.now()) / 1e3);
1739
- const tick = () => {
1740
- if (ended || signal.aborted) return;
1741
- if (remaining() <= 0) {
1742
- ended = true;
1743
- controller.abort();
1744
- onUpdate({ ...snapshot, status: "expired", code: null, submitting: false });
1745
- void Promise.resolve().then(() => options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
1746
- done();
1747
- return;
1748
- }
1749
- snapshot = {
1750
- ...snapshot,
1751
- status: "pending",
1752
- code: ticket.pairingCode,
1753
- secondsRemaining: Math.ceil(remaining()),
1754
- codeSecondsRemaining: Math.ceil(remaining()),
1755
- submitting: busy
1756
- };
1757
- onUpdate(snapshot);
1758
- };
1759
- const submit = async (confirmationCode) => {
1760
- if (ended || signal.aborted || busy) return;
1761
- if (!/^[0-9]{6}$/.test(confirmationCode)) {
1762
- snapshot = { ...snapshot, error: new AuthLinkError("CONFIRMATION_CODE_REQUIRED") };
1763
- tick();
1764
- return;
1765
- }
1766
- if (remaining() <= 0) {
1767
- tick();
1768
- return;
1769
- }
1770
- busy = true;
1771
- snapshot = { ...snapshot, error: null };
1772
- tick();
1773
- try {
1774
- const value = object(await abortable(client.result(ticket, controller.signal, confirmationCode), controller.signal));
1775
- if (ended || signal.aborted) return;
1776
- if (value.requestId !== ticket.requestId) fail("REQUEST_MISMATCH");
1777
- if (value.status === "confirmed") {
1778
- 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");
1779
- ended = true;
1780
- onUpdate({ ...snapshot, status: "confirmed", code: null, submitting: false });
1781
- try {
1782
- await options.onConfirmed?.(value);
1783
- } catch {
1784
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
1785
- }
1786
- done();
1787
- } else if (value.status === "rejected" || value.status === "expired") {
1788
- ended = true;
1789
- onUpdate({ ...snapshot, status: value.status, code: null, submitting: false });
1790
- try {
1791
- if (value.status === "rejected") await options.onRejected?.({ status: "rejected", code: "REJECTED", requestId: ticket.requestId, purpose: ticket.purpose });
1792
- else await options.onExpired?.();
1793
- } catch {
1794
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
1795
- } finally {
1796
- done();
1797
- }
1798
- } else {
1799
- fail("CONFIRMATION_NOT_READY");
1800
- }
1801
- } catch (error) {
1802
- if (!ended && !signal.aborted) {
1803
- const raw = error instanceof Error ? error : new AuthLinkError("NETWORK_ERROR");
1804
- const code = raw.message.replace(/^AUTHLINK_/, "");
1805
- const failure = ["CONFIRMATION_CODE_LOCKED", "CONFIRMATION_CODE_INVALID", "APPROVAL_REQUIRED", "REQUEST_EXPIRED"].includes(code) ? new AuthLinkError(code) : raw;
1806
- snapshot = { ...snapshot, error: failure };
1807
- if (code === "CONFIRMATION_CODE_LOCKED" || code === "REQUEST_EXPIRED") {
1808
- ended = true;
1809
- onUpdate({ ...snapshot, code: null, submitting: false, status: code === "REQUEST_EXPIRED" ? "expired" : "error" });
1810
- if (code === "REQUEST_EXPIRED") void Promise.resolve().then(() => options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
1811
- else options.onError?.(failure);
1812
- done();
1813
- } else options.onError?.(failure);
1814
- }
1815
- } finally {
1816
- busy = false;
1817
- tick();
1818
- }
1819
- };
1820
- tick();
1821
- options.onReady?.(submit);
1822
- const timer = setInterval(tick, 1e3);
1823
- if (signal.aborted) cancel();
1824
- try {
1825
- await finished;
1826
- } finally {
1827
- clearInterval(timer);
1828
- controller.abort();
1829
- signal.removeEventListener("abort", cancel);
1663
+ throw new AuthLinkError("INVALID_QR");
1830
1664
  }
1665
+ const token = /^[A-Za-z0-9_-]{43}$/;
1666
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
1667
+ 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");
1668
+ const image = qrcode_default(0, "M");
1669
+ image.addData(JSON.stringify({ v: 1, origin: origin.origin, requestId: payload.requestId, token: payload.token }), "Byte");
1670
+ image.make();
1671
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.createSvgTag({ cellSize: 4, margin: 16, scalable: true }))}`;
1831
1672
  }
1832
1673
 
1833
1674
  // src/locales/en.json
@@ -2259,26 +2100,20 @@ function getMessages(locale) {
2259
2100
 
2260
2101
  // src/view.ts
2261
2102
  var node = (tag, props, ...children) => ({ tag, props, children });
2103
+ var digits = (value) => value.replace(/[^0-9]/g, "");
2262
2104
  function widgetView(options) {
2263
- const { snapshot: s, mode, retry, refresh } = options;
2264
- const locale = resolveLocale(options.locale);
2265
- const l = getMessages(locale);
2266
- const pending = s.status === "pending", hasQr = pending && (mode === "bind" || Boolean(options.showQr)) && Boolean(options.qr);
2267
- 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];
2268
- const expiring = pending && s.secondsRemaining <= 30;
2269
- const expiryTime = `00:${String(Math.max(0, Math.ceil(s.secondsRemaining))).padStart(2, "0")}`;
2270
- const statusNode = () => node(
2271
- "p",
2272
- {
2273
- class: "authlink-status",
2274
- role: s.status === "error" ? "alert" : "status",
2275
- "data-expiring": String(expiring)
2276
- },
2277
- node("span", { class: "authlink-status-dot", "aria-hidden": "true" }),
2278
- expiring ? l.expiring : status,
2279
- ...expiring ? [node("span", { class: "authlink-expiry", "aria-hidden": "true" }, ` \xB7 ${expiryTime}`)] : []
2280
- );
2105
+ const { snapshot: s, mode } = options, locale = resolveLocale(options.locale), l = getMessages(locale);
2106
+ const pending = s.status === "pending", hasQr = pending && mode === "bind" && Boolean(options.qr);
2107
+ const expiring = pending && s.secondsRemaining !== void 0 && s.secondsRemaining <= 30;
2108
+ const status = s.status === "pending" ? l.otpWaiting : l[s.status];
2109
+ const remaining = s.secondsRemaining ?? 0;
2110
+ const expiryTime = `${String(Math.floor(remaining / 60)).padStart(2, "0")}:${String(remaining % 60).padStart(2, "0")}`;
2281
2111
  const children = [node("h3", { class: "authlink-title" }, l[mode])];
2112
+ const errorNode = s.error ? node(
2113
+ "p",
2114
+ { class: "authlink-input-error", role: "alert" },
2115
+ ["PASSCODE_LOCKED", "RATE_LIMITED", "AUTHLINK_PASSCODE_LOCKED"].includes(s.error.message) ? l.codeLocked : l.otpError
2116
+ ) : void 0;
2282
2117
  if (hasQr) children.push(node("p", { class: "authlink-subtitle" }, l.scan));
2283
2118
  if (pending) {
2284
2119
  const qr = hasQr ? [node(
@@ -2288,18 +2123,18 @@ function widgetView(options) {
2288
2123
  node("figcaption", { class: "authlink-qr-caption" }, l.scan)
2289
2124
  )] : [];
2290
2125
  const detail = [
2291
- node("p", { class: "authlink-help" }, options.passcodeMode ? l.otpHelp : l.help),
2126
+ node("p", { class: "authlink-help" }, l.otpHelp),
2292
2127
  node(
2293
2128
  "form",
2294
2129
  { class: "authlink-code-form", onSubmit: (event) => {
2295
2130
  event.preventDefault();
2296
2131
  const input = event.currentTarget.querySelector("input");
2297
- if (input) void options.submitCode?.(input.value);
2132
+ if (input) void options.submitCode(input.value);
2298
2133
  } },
2299
- node("label", { for: "authlink-confirmation-code" }, options.passcodeMode ? l.otpCode : l.confirmationCode),
2134
+ node("label", { for: "authlink-passcode" }, l.otpCode),
2300
2135
  node("input", {
2301
- id: "authlink-confirmation-code",
2302
- name: options.passcodeMode ? "passcode" : "confirmationCode",
2136
+ id: "authlink-passcode",
2137
+ name: "passcode",
2303
2138
  type: "text",
2304
2139
  inputmode: "numeric",
2305
2140
  autocomplete: "one-time-code",
@@ -2307,262 +2142,74 @@ function widgetView(options) {
2307
2142
  maxlength: 6,
2308
2143
  minlength: 6,
2309
2144
  required: true,
2310
- "data-authlink-focus": "confirmation-code",
2311
- value: options.inputCode ?? "",
2312
- onInput: (event) => options.setCode?.(event.target.value)
2145
+ ...s.submitting ? { disabled: true } : {},
2146
+ "data-authlink-focus": "passcode",
2147
+ value: options.inputCode,
2148
+ onInput: (event) => {
2149
+ const input = event.target, original = input.value;
2150
+ const start = input.selectionStart ?? original.length, end = input.selectionEnd ?? start;
2151
+ const code = digits(original).slice(0, 6);
2152
+ if (original !== code) {
2153
+ input.value = code;
2154
+ input.setSelectionRange(Math.min(6, digits(original.slice(0, start)).length), Math.min(6, digits(original.slice(0, end)).length));
2155
+ }
2156
+ options.setCode(code);
2157
+ },
2158
+ onPaste: (event) => {
2159
+ if (!event.clipboardData) return;
2160
+ event.preventDefault();
2161
+ const input = event.target, pasted = digits(event.clipboardData.getData("text"));
2162
+ if (!pasted) return;
2163
+ const start = input.selectionStart ?? input.value.length, end = input.selectionEnd ?? start;
2164
+ const before = input.value.slice(0, start), after = input.value.slice(end);
2165
+ const insert = pasted.slice(0, Math.max(0, 6 - before.length - after.length));
2166
+ input.value = before + insert + after;
2167
+ input.setSelectionRange(before.length + insert.length, before.length + insert.length);
2168
+ options.setCode(input.value);
2169
+ }
2313
2170
  }),
2314
- node(
2315
- "button",
2316
- { type: "submit", class: "authlink-button", ...s.submitting ? { disabled: true } : {} },
2317
- s.submitting ? l.loading : l.submitCode
2318
- )
2319
- ),
2320
- ...s.error ? [node("p", { class: "authlink-input-error", role: "alert" }, options.passcodeMode ? s.error.message === "OTP_NOT_CONFIGURED" ? l.otpSetup : l.otpError : l.codeError)] : []
2171
+ node("button", { type: "submit", class: "authlink-button", ...s.submitting ? { disabled: true } : {} }, s.submitting ? l.loading : l.submitCode)
2172
+ )
2321
2173
  ];
2322
- children.push(node("div", { class: "authlink-body" }, ...qr, node("div", { class: "authlink-details" }, ...detail)));
2323
- children.push(node("div", { class: "authlink-footer" }, statusNode()));
2324
- } else {
2325
- children.push(statusNode());
2326
- if (s.error?.message === "CONFIRMATION_CODE_LOCKED") children.push(node("p", { class: "authlink-input-error", role: "alert" }, l.codeLocked));
2327
- if (s.status === "error" && s.error?.message !== "CONFIRMATION_CODE_LOCKED") children.push(node("button", {
2328
- type: "button",
2329
- class: "authlink-button",
2330
- "data-authlink-focus": "retry",
2331
- onClick: retry
2332
- }, l.retry));
2333
- if (refresh && ["expired", "rejected", "error"].includes(s.status)) children.push(node("button", {
2334
- type: "button",
2335
- class: "authlink-button",
2336
- "data-authlink-focus": "refresh",
2337
- onClick: refresh
2338
- }, l.refresh));
2174
+ children.push(node("div", { class: "authlink-body" }, ...qr, node("div", { class: "authlink-details" }, ...detail, ...errorNode ? [errorNode] : [])));
2339
2175
  }
2340
- return node("section", {
2341
- lang: locale,
2342
- class: `authlink-widget ${options.className ?? ""}`.trim(),
2343
- "data-status": s.status,
2344
- "data-has-qr": String(hasQr),
2345
- "aria-label": l[mode]
2346
- }, ...children);
2176
+ if (!pending && errorNode) children.push(errorNode);
2177
+ children.push(node("div", { class: "authlink-footer" }, node(
2178
+ "p",
2179
+ { class: "authlink-status", role: "status", "data-expiring": String(expiring) },
2180
+ node("span", { class: "authlink-status-dot", "aria-hidden": "true" }),
2181
+ expiring ? l.expiring : status,
2182
+ ...expiring ? [node("span", { class: "authlink-expiry", "aria-hidden": "true" }, ` \xB7 ${expiryTime}`)] : []
2183
+ )));
2184
+ if (s.status === "expired" && options.refresh) children.push(node("button", {
2185
+ type: "button",
2186
+ class: "authlink-button",
2187
+ ...s.submitting ? { disabled: true } : {},
2188
+ onClick: options.refresh
2189
+ }, s.submitting ? l.loading : l.refresh));
2190
+ return node("section", { lang: locale, class: "authlink-widget", "data-status": s.status, "data-has-qr": String(hasQr), "aria-label": l[mode] }, ...children);
2347
2191
  }
2348
2192
 
2349
2193
  // src/modal.ts
2350
- function element(value, svg = false) {
2194
+ function element(value) {
2351
2195
  if (typeof value === "string") return document.createTextNode(value);
2352
- svg ||= value.tag === "svg";
2353
- const result = svg ? document.createElementNS("http://www.w3.org/2000/svg", value.tag) : document.createElement(value.tag);
2196
+ const result = document.createElement(value.tag);
2354
2197
  for (const [key, v] of Object.entries(value.props)) {
2355
2198
  if (key.startsWith("on") && typeof v === "function") result.addEventListener(key.slice(2).toLowerCase(), v);
2356
2199
  else if (v !== void 0 && v !== null) result.setAttribute(key, String(v));
2357
2200
  }
2358
- result.append(...value.children.map((child) => element(child, svg)));
2201
+ result.append(...value.children.map(element));
2359
2202
  return result;
2360
2203
  }
2361
- function openAuthLink(options) {
2204
+ function open(options, mode) {
2362
2205
  if (typeof document === "undefined" || !document.body) throw new AuthLinkError("BROWSER_REQUIRED");
2363
- const mode = options.mode ?? options.ticket.purpose;
2364
- const ticket = validateTicket(options.ticket, mode);
2206
+ if (typeof options.submit !== "function") throw new AuthLinkError("SUBMIT_REQUIRED");
2207
+ const binding = mode === "bind" ? options : void 0;
2208
+ let image = binding ? bindingImage(binding, binding.authLinkOrigin) : void 0;
2209
+ let expires = options.expiresAt === void 0 ? void 0 : deadline(options.expiresAt);
2365
2210
  const dialog = document.createElement("dialog");
2366
2211
  if (typeof dialog.showModal !== "function") throw new AuthLinkError("DIALOG_UNSUPPORTED");
2367
2212
  const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2368
- dialog.className = `authlink-dialog ${options.className ?? ""}`.trim();
2369
- dialog.setAttribute("aria-modal", "true");
2370
- const header = document.createElement("div");
2371
- header.className = "authlink-header";
2372
- const brand = document.createElement("span");
2373
- brand.className = "authlink-brand";
2374
- brand.textContent = "AuthLink";
2375
- brand.tabIndex = -1;
2376
- const closeButton = document.createElement("button");
2377
- closeButton.type = "button";
2378
- closeButton.className = "authlink-close";
2379
- const closeIcon = document.createElement("span");
2380
- closeIcon.className = "authlink-close-icon";
2381
- closeIcon.setAttribute("aria-hidden", "true");
2382
- closeButton.append(closeIcon);
2383
- const content = document.createElement("div");
2384
- content.className = "authlink-modal-content";
2385
- header.append(brand, closeButton);
2386
- dialog.append(header, content);
2387
- let locale = options.locale, snapshot = initialSnapshot(), closed = false;
2388
- let abort;
2389
- let inputCode = "";
2390
- let submitCode;
2391
- const image = qrImage(ticket);
2392
- let progressFill, progressFrame, progressDeadline = 0;
2393
- const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
2394
- const stopProgress = () => {
2395
- if (progressFrame !== void 0) window.cancelAnimationFrame(progressFrame);
2396
- progressFrame = void 0;
2397
- };
2398
- const drawProgress = () => {
2399
- stopProgress();
2400
- if (closed || !progressFill?.isConnected) return;
2401
- const remaining = Math.max(0, progressDeadline - performance.now());
2402
- progressFill.style.transform = `scaleX(${Math.min(1, remaining / 3e4)})`;
2403
- if (remaining > 0 && !motion?.matches && typeof window.requestAnimationFrame === "function") {
2404
- progressFrame = window.requestAnimationFrame(drawProgress);
2405
- }
2406
- };
2407
- const syncProgress = (remainingMs) => {
2408
- progressDeadline = performance.now() + remainingMs;
2409
- drawProgress();
2410
- };
2411
- motion?.addEventListener?.("change", drawProgress);
2412
- const render = () => {
2413
- if (closed) return;
2414
- const language = resolveLocale(locale), messages2 = getMessages(language);
2415
- dialog.lang = language;
2416
- dialog.setAttribute("aria-label", messages2[mode]);
2417
- dialog.dataset.mode = mode;
2418
- dialog.dataset.hasQr = String(snapshot.status === "pending" && (mode === "bind" || Boolean(options.showQr)));
2419
- closeButton.setAttribute("aria-label", messages2.close);
2420
- const focused = document.activeElement;
2421
- const focusKey = focused instanceof HTMLElement && content.contains(focused) ? focused.dataset.authlinkFocus : void 0;
2422
- const next = element(widgetView({
2423
- mode,
2424
- locale,
2425
- snapshot,
2426
- qr: image,
2427
- showQr: options.showQr,
2428
- inputCode,
2429
- setCode: (code) => {
2430
- inputCode = code;
2431
- },
2432
- submitCode: (code) => submitCode?.(code) ?? Promise.resolve(),
2433
- retry,
2434
- refresh: options.onRefresh
2435
- }));
2436
- const oldCode = content.querySelector(".authlink-code");
2437
- const nextCode = next.querySelector(".authlink-code");
2438
- const codeChanged = Boolean(oldCode && nextCode && snapshot.code && oldCode.textContent !== nextCode.textContent);
2439
- if (oldCode && nextCode && oldCode.textContent === nextCode.textContent) {
2440
- oldCode.setAttribute("aria-label", nextCode.getAttribute("aria-label"));
2441
- nextCode.replaceWith(oldCode);
2442
- }
2443
- const oldProgress = content.querySelector(".authlink-progress");
2444
- const nextProgress = next.querySelector(".authlink-progress");
2445
- if (oldProgress && nextProgress) {
2446
- oldProgress.setAttribute("aria-valuenow", nextProgress.getAttribute("aria-valuenow"));
2447
- oldProgress.setAttribute("aria-label", nextProgress.getAttribute("aria-label"));
2448
- nextProgress.replaceWith(oldProgress);
2449
- }
2450
- const oldInput = content.querySelector("input[name=confirmationCode]");
2451
- const nextInput = next.querySelector("input[name=confirmationCode]");
2452
- if (oldInput && nextInput) nextInput.replaceWith(oldInput);
2453
- content.replaceChildren(next);
2454
- progressFill = content.querySelector(".authlink-progress-fill") ?? void 0;
2455
- drawProgress();
2456
- if (codeChanged && !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
2457
- nextCode?.animate?.([{ opacity: 0.5 }, { opacity: 1 }], { duration: 180, easing: "ease-out" });
2458
- }
2459
- if (focusKey) (Array.from(content.querySelectorAll("[data-authlink-focus]")).find((item) => item.dataset.authlinkFocus === focusKey) ?? brand).focus({ preventScroll: true });
2460
- };
2461
- const dispose = (notify) => {
2462
- if (closed) return;
2463
- closed = true;
2464
- abort?.abort();
2465
- stopProgress();
2466
- progressFill = void 0;
2467
- motion?.removeEventListener?.("change", drawProgress);
2468
- if (dialog.open) dialog.close();
2469
- dialog.remove();
2470
- if (!document.querySelector("dialog.authlink-dialog[open]")) {
2471
- if (document.body.style.overflow === "hidden") document.body.style.overflow = document.body.dataset.authlinkOverflow ?? "";
2472
- delete document.body.dataset.authlinkOverflow;
2473
- }
2474
- if (previousFocus?.isConnected) previousFocus.focus();
2475
- if (notify) options.onClose?.();
2476
- };
2477
- const delivered = /* @__PURE__ */ new Set();
2478
- const once = (status, callback) => {
2479
- if (delivered.has(status)) return;
2480
- delivered.add(status);
2481
- return callback();
2482
- };
2483
- const retry = () => {
2484
- if (closed) return;
2485
- abort?.abort();
2486
- abort = new AbortController();
2487
- snapshot = initialSnapshot();
2488
- render();
2489
- void observeRequest(ticket, {
2490
- client: options.client,
2491
- intervalMs: options.intervalMs,
2492
- expectedMode: mode,
2493
- signal: abort.signal,
2494
- onReady: (submit) => {
2495
- submitCode = submit;
2496
- },
2497
- onCodeTiming: syncProgress,
2498
- onUpdate: (value) => {
2499
- snapshot = value;
2500
- if (value.status !== "rejected") render();
2501
- },
2502
- onConfirmed: (value) => once("confirmed", () => {
2503
- dispose(false);
2504
- try {
2505
- return Promise.resolve(options.onConfirmed?.(value)).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
2506
- } catch {
2507
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
2508
- }
2509
- }),
2510
- onRejected: (value) => once("rejected", () => {
2511
- dispose(false);
2512
- try {
2513
- return Promise.resolve(options.onRejected?.(value)).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
2514
- } catch {
2515
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
2516
- }
2517
- }),
2518
- onExpired: () => once("expired", () => options.onExpired?.()),
2519
- onError: options.onError
2520
- });
2521
- };
2522
- closeButton.addEventListener("click", () => dispose(true));
2523
- dialog.addEventListener("cancel", (event) => {
2524
- event.preventDefault();
2525
- dispose(true);
2526
- });
2527
- dialog.addEventListener("close", () => dispose(true));
2528
- render();
2529
- document.body.append(dialog);
2530
- try {
2531
- dialog.showModal();
2532
- } catch (error) {
2533
- dialog.remove();
2534
- throw error;
2535
- }
2536
- brand.focus({ preventScroll: true });
2537
- if (document.body.dataset.authlinkOverflow === void 0) document.body.dataset.authlinkOverflow = document.body.style.overflow;
2538
- document.body.style.overflow = "hidden";
2539
- retry();
2540
- return {
2541
- close: () => dispose(true),
2542
- destroy: () => dispose(false),
2543
- setLocale: (value) => {
2544
- locale = value;
2545
- render();
2546
- },
2547
- retry
2548
- };
2549
- }
2550
-
2551
- // src/passcode.ts
2552
- function validateOTPResult(value) {
2553
- if (!value || typeof value !== "object") throw new AuthLinkError("INVALID_RESPONSE");
2554
- const result = value;
2555
- const expiry = typeof result.expiresAt === "string" ? Date.parse(result.expiresAt) : NaN;
2556
- 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");
2557
- return result;
2558
- }
2559
- function openPasscode(options) {
2560
- if (typeof document === "undefined" || !document.body) throw new AuthLinkError("BROWSER_REQUIRED");
2561
- const deadline = Date.parse(options.expiresAt);
2562
- if (!Number.isFinite(deadline) || deadline <= Date.now() || deadline > Date.now() + 3e5) throw new AuthLinkError("INVALID_EXPIRY");
2563
- const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2564
- const dialog = document.createElement("dialog");
2565
- if (typeof dialog.showModal !== "function") throw new AuthLinkError("DIALOG_UNSUPPORTED");
2566
2213
  dialog.className = "authlink-dialog";
2567
2214
  dialog.setAttribute("aria-modal", "true");
2568
2215
  const header = document.createElement("div");
@@ -2581,91 +2228,143 @@ function openPasscode(options) {
2581
2228
  content.className = "authlink-modal-content";
2582
2229
  header.append(brand, close);
2583
2230
  dialog.append(header, content);
2584
- let locale = options.locale, closed = false, busy = false, code = "", error;
2585
- const mode = options.bindingQr ? "bind" : "confirm";
2586
- let image;
2587
- if (options.bindingQr) {
2588
- let payload;
2231
+ let locale = options.locale, closed = false, busy = false, code = "", error = null;
2232
+ let abort, timer, expiredDelivered = false;
2233
+ const notifyError = (cause) => {
2234
+ const value = cause instanceof Error ? cause : new AuthLinkError("VERIFICATION_FAILED");
2589
2235
  try {
2590
- payload = JSON.parse(options.bindingQr);
2236
+ options.onError?.(value);
2591
2237
  } catch {
2592
- throw new AuthLinkError("INVALID_TICKET");
2593
2238
  }
2594
- image = qrImage(validateTicket({ requestId: payload.requestId, purpose: "bind", qr: options.bindingQr, pairingCode: "000000", expiresAt: options.expiresAt }, "bind", options.authLinkOrigin));
2595
- }
2596
- const abort = new AbortController();
2597
- let timer;
2239
+ return value;
2240
+ };
2241
+ const callback = (fn) => {
2242
+ try {
2243
+ void Promise.resolve(fn?.()).catch(() => notifyError(new AuthLinkError("CALLBACK_FAILED")));
2244
+ } catch {
2245
+ notifyError(new AuthLinkError("CALLBACK_FAILED"));
2246
+ }
2247
+ };
2598
2248
  const dispose = (notify) => {
2599
2249
  if (closed) return;
2600
2250
  closed = true;
2601
- abort.abort();
2251
+ abort?.abort();
2602
2252
  clearInterval(timer);
2253
+ if (dialog.open) dialog.close();
2603
2254
  dialog.remove();
2604
- previousFocus?.focus();
2605
- if (notify) options.onClose?.();
2255
+ if (previousFocus?.isConnected) previousFocus.focus();
2256
+ if (notify) callback(options.onClose);
2606
2257
  };
2258
+ const expired = () => expires !== void 0 && Date.now() >= expires;
2607
2259
  const render = () => {
2608
2260
  if (closed) return;
2609
2261
  const l = getMessages(locale);
2610
2262
  dialog.lang = resolveLocale(locale);
2611
2263
  dialog.dataset.mode = mode;
2612
- dialog.dataset.hasQr = String(Boolean(image));
2264
+ dialog.dataset.hasQr = String(Boolean(image) && !expired());
2613
2265
  dialog.setAttribute("aria-label", l[mode]);
2614
2266
  close.setAttribute("aria-label", l.close);
2615
- const focused = content.contains(document.activeElement);
2616
- const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
2617
- const snapshot = { status: remaining ? "pending" : "expired", code: null, secondsRemaining: remaining, codeSecondsRemaining: remaining, submitting: busy, error: error ?? null };
2618
- content.replaceChildren(element(widgetView({
2267
+ const focused = document.activeElement;
2268
+ const focusKey = focused instanceof HTMLElement && content.contains(focused) ? focused.dataset.authlinkFocus : void 0;
2269
+ const oldInput = content.querySelector("input");
2270
+ const next = element(widgetView({
2619
2271
  mode,
2620
2272
  locale,
2621
- passcodeMode: true,
2622
- snapshot,
2273
+ snapshot: {
2274
+ status: expired() ? "expired" : "pending",
2275
+ submitting: busy,
2276
+ error,
2277
+ secondsRemaining: expires === void 0 ? void 0 : Math.max(0, Math.ceil((expires - Date.now()) / 1e3))
2278
+ },
2623
2279
  qr: image,
2624
- showQr: Boolean(image),
2625
2280
  inputCode: code,
2626
2281
  setCode: (value) => {
2627
2282
  code = value;
2628
2283
  },
2629
2284
  submitCode: submit,
2630
- retry: () => {
2631
- error = void 0;
2632
- render();
2633
- }
2634
- })));
2635
- if (focused) content.querySelector("input")?.focus();
2285
+ refresh: binding?.onRefresh ? refresh : void 0
2286
+ }));
2287
+ const nextInput = next.querySelector("input");
2288
+ if (oldInput && nextInput) {
2289
+ oldInput.disabled = busy;
2290
+ if (oldInput.value !== code) oldInput.value = code;
2291
+ nextInput.replaceWith(oldInput);
2292
+ }
2293
+ content.replaceChildren(next);
2294
+ if (focusKey) content.querySelector(`[data-authlink-focus="${focusKey}"]`)?.focus({ preventScroll: true });
2636
2295
  };
2637
- const submit = async (value) => {
2638
- if (closed || busy) return;
2639
- if (Date.now() >= deadline) {
2296
+ const tick = () => {
2297
+ if (closed) return;
2298
+ if (expired() && !expiredDelivered) {
2299
+ expiredDelivered = true;
2300
+ abort?.abort();
2301
+ busy = false;
2640
2302
  render();
2303
+ callback(options.onExpired);
2304
+ } else if (expires !== void 0) render();
2305
+ };
2306
+ const startTimer = () => {
2307
+ clearInterval(timer);
2308
+ if (expires !== void 0) timer = setInterval(tick, 1e3);
2309
+ };
2310
+ const submit = async (value) => {
2311
+ if (closed || busy || expired()) {
2312
+ tick();
2641
2313
  return;
2642
2314
  }
2643
2315
  if (!/^[0-9]{6}$/.test(value)) {
2644
- error = new AuthLinkError("PASSCODE_INVALID");
2316
+ error = notifyError(new AuthLinkError("PASSCODE_INVALID"));
2645
2317
  render();
2646
2318
  return;
2647
2319
  }
2648
2320
  code = value;
2649
2321
  busy = true;
2650
- error = void 0;
2322
+ error = null;
2323
+ const current = new AbortController();
2324
+ abort = current;
2651
2325
  render();
2652
2326
  try {
2653
- const result = validateOTPResult(await options.client.verify(value, abort.signal));
2654
- if (closed || abort.signal.aborted || Date.now() >= deadline) return;
2655
- try {
2656
- await options.onVerified?.(result);
2657
- } catch {
2658
- options.onError?.(new AuthLinkError("CALLBACK_FAILED"));
2659
- }
2327
+ const pending = options.submit(value, { signal: current.signal });
2328
+ if (!pending || typeof pending.then !== "function") throw new AuthLinkError("SUBMIT_MUST_RETURN_PROMISE");
2329
+ await pending;
2330
+ if (closed || current.signal.aborted || abort !== current) return;
2660
2331
  dispose(false);
2332
+ callback(options.onSuccess);
2661
2333
  } catch (cause) {
2662
- if (!closed && !abort.signal.aborted) {
2663
- error = cause instanceof Error ? cause : new AuthLinkError("NETWORK_ERROR");
2664
- options.onError?.(error);
2334
+ if (!closed && !current.signal.aborted && abort === current) error = notifyError(cause);
2335
+ } finally {
2336
+ if (!closed && abort === current) {
2337
+ busy = false;
2338
+ render();
2665
2339
  }
2340
+ }
2341
+ };
2342
+ const refresh = async () => {
2343
+ if (closed || busy || !binding?.onRefresh || !expired()) return;
2344
+ busy = true;
2345
+ error = null;
2346
+ expiredDelivered = true;
2347
+ clearInterval(timer);
2348
+ const current = new AbortController();
2349
+ abort?.abort();
2350
+ abort = current;
2351
+ render();
2352
+ try {
2353
+ const ticket = await binding.onRefresh({ signal: current.signal });
2354
+ if (closed || current.signal.aborted || abort !== current) return;
2355
+ const nextImage = bindingImage(ticket, binding.authLinkOrigin), nextDeadline = deadline(ticket.expiresAt);
2356
+ image = nextImage;
2357
+ expires = nextDeadline;
2358
+ code = "";
2359
+ expiredDelivered = false;
2360
+ startTimer();
2361
+ } catch (cause) {
2362
+ if (!closed && !current.signal.aborted && abort === current) error = notifyError(cause);
2666
2363
  } finally {
2667
- busy = false;
2668
- render();
2364
+ if (!closed && abort === current) {
2365
+ busy = false;
2366
+ render();
2367
+ }
2669
2368
  }
2670
2369
  };
2671
2370
  close.addEventListener("click", () => dispose(true));
@@ -2673,18 +2372,17 @@ function openPasscode(options) {
2673
2372
  event.preventDefault();
2674
2373
  dispose(true);
2675
2374
  });
2375
+ dialog.addEventListener("close", () => dispose(true));
2676
2376
  document.body.append(dialog);
2677
2377
  render();
2678
- dialog.showModal();
2378
+ try {
2379
+ dialog.showModal();
2380
+ } catch (cause) {
2381
+ dispose(false);
2382
+ throw cause;
2383
+ }
2679
2384
  content.querySelector("input")?.focus();
2680
- timer = setInterval(() => {
2681
- if (closed) return;
2682
- if (Date.now() >= deadline) {
2683
- render();
2684
- clearInterval(timer);
2685
- void Promise.resolve(options.onExpired?.()).catch(() => options.onError?.(new AuthLinkError("CALLBACK_FAILED")));
2686
- }
2687
- }, 1e3);
2385
+ startTimer();
2688
2386
  return {
2689
2387
  close: () => dispose(true),
2690
2388
  destroy: () => dispose(false),
@@ -2693,116 +2391,61 @@ function openPasscode(options) {
2693
2391
  render();
2694
2392
  },
2695
2393
  retry: () => {
2696
- error = void 0;
2697
- render();
2394
+ if (!closed && !busy) {
2395
+ error = null;
2396
+ tick();
2397
+ render();
2398
+ }
2698
2399
  }
2699
2400
  };
2700
2401
  }
2402
+ var openVerify = (options) => open(options, "confirm");
2403
+ var openBind = (options) => open(options, "bind");
2701
2404
 
2702
2405
  // src/react.tsx
2703
2406
  import { jsx } from "react/jsx-runtime";
2704
- function AuthLinkPasscode(props) {
2705
- const latest = useRef(props);
2706
- latest.current = props;
2707
- const modal = useRef(void 0);
2708
- useEffect(() => {
2709
- const handle = openPasscode({
2710
- ...props,
2711
- onVerified: (value) => latest.current.onVerified?.(value),
2712
- onExpired: () => latest.current.onExpired?.(),
2713
- onClose: () => latest.current.onClose?.(),
2714
- onError: (value) => latest.current.onError?.(value)
2715
- });
2716
- modal.current = handle;
2717
- return () => {
2718
- handle.destroy();
2719
- if (modal.current === handle) modal.current = void 0;
2720
- };
2721
- }, [props.client, props.expiresAt, props.bindingQr, props.authLinkOrigin]);
2722
- useEffect(() => {
2723
- modal.current?.setLocale(props.locale);
2724
- }, [props.locale]);
2725
- return null;
2726
- }
2727
- function useAuthLink(options) {
2407
+ function Prompt({ options, bind }) {
2728
2408
  const latest = useRef(options);
2729
2409
  latest.current = options;
2730
- const submit = useRef(void 0);
2731
- const delivered = useRef(/* @__PURE__ */ new Set());
2732
- const [snapshot, setSnapshot] = useState(initialSnapshot);
2733
- const [attempt, setAttempt] = useState(0);
2734
- const retry = useCallback(() => setAttempt((value) => value + 1), []);
2735
- const { ticket, client, intervalMs, mode } = options;
2736
- useEffect(() => {
2737
- const abort = new AbortController();
2738
- setSnapshot(initialSnapshot());
2739
- const once = (status, callback) => {
2740
- const key = `${ticket.requestId}:${status}`;
2741
- if (delivered.current.has(key)) return;
2742
- delivered.current.add(key);
2743
- return callback();
2744
- };
2745
- void observeRequest(ticket, {
2746
- client,
2747
- intervalMs,
2748
- expectedMode: mode,
2749
- signal: abort.signal,
2750
- onReady: (fn) => {
2751
- submit.current = fn;
2752
- },
2753
- onUpdate: setSnapshot,
2754
- onConfirmed: (result) => once("confirmed", () => latest.current.onConfirmed?.(result)),
2755
- onRejected: (result) => once("rejected", () => latest.current.onRejected?.(result)),
2756
- onExpired: () => once("expired", () => latest.current.onExpired?.()),
2757
- onError: (error) => latest.current.onError?.(error)
2758
- });
2759
- return () => abort.abort();
2760
- }, [ticket, client, intervalMs, mode, attempt]);
2761
- return { snapshot, retry, submitCode: (code) => submit.current?.(code) ?? Promise.resolve() };
2762
- }
2763
- function Widget(props) {
2764
- const latest = useRef(props);
2765
- latest.current = props;
2766
2410
  const modal = useRef(void 0);
2767
- const delivered = useRef(/* @__PURE__ */ new Set());
2768
- const hasRefresh = Boolean(props.onRefresh);
2411
+ const binding = bind ? options : void 0;
2412
+ const hasRefresh = Boolean(binding?.onRefresh);
2769
2413
  useEffect(() => {
2770
- const once = (status, callback) => {
2771
- const key = `${props.ticket.requestId}:${status}`;
2772
- if (delivered.current.has(key)) return;
2773
- delivered.current.add(key);
2774
- return callback();
2775
- };
2776
- const handle = openAuthLink({
2777
- ...props,
2778
- onConfirmed: (result) => once("confirmed", () => latest.current.onConfirmed?.(result)),
2779
- onRejected: (result) => once("rejected", () => latest.current.onRejected?.(result)),
2780
- onExpired: () => once("expired", () => latest.current.onExpired?.()),
2414
+ const common = {
2415
+ expiresAt: options.expiresAt,
2416
+ locale: latest.current.locale,
2417
+ submit: (code, context) => latest.current.submit(code, context),
2418
+ onSuccess: () => latest.current.onSuccess?.(),
2419
+ onClose: () => latest.current.onClose?.(),
2781
2420
  onError: (error) => latest.current.onError?.(error),
2782
- onRefresh: hasRefresh ? () => latest.current.onRefresh?.() : void 0,
2783
- onClose: () => latest.current.onClose?.()
2784
- });
2421
+ onExpired: () => latest.current.onExpired?.()
2422
+ };
2423
+ const handle = bind ? openBind({
2424
+ ...common,
2425
+ qr: binding.qr,
2426
+ expiresAt: binding.expiresAt,
2427
+ authLinkOrigin: binding.authLinkOrigin,
2428
+ onRefresh: hasRefresh ? (context) => latest.current.onRefresh(context) : void 0
2429
+ }) : openVerify(common);
2785
2430
  modal.current = handle;
2786
2431
  return () => {
2787
2432
  handle.destroy();
2788
2433
  if (modal.current === handle) modal.current = void 0;
2789
2434
  };
2790
- }, [props.ticket, props.client, props.intervalMs, props.mode, props.showQr, props.className, hasRefresh]);
2435
+ }, [bind, options.expiresAt, binding?.qr, binding?.authLinkOrigin, hasRefresh]);
2791
2436
  useEffect(() => {
2792
- modal.current?.setLocale(props.locale);
2793
- }, [props.locale]);
2437
+ modal.current?.setLocale(options.locale);
2438
+ }, [options.locale]);
2794
2439
  return null;
2795
2440
  }
2796
- function AuthLinkBind(props) {
2797
- return /* @__PURE__ */ jsx(Widget, { ...props, mode: "bind" });
2441
+ function AuthLinkVerify(props) {
2442
+ return /* @__PURE__ */ jsx(Prompt, { options: props, bind: false });
2798
2443
  }
2799
- function AuthLinkConfirm(props) {
2800
- return /* @__PURE__ */ jsx(Widget, { ...props, mode: "confirm" });
2444
+ function AuthLinkBind(props) {
2445
+ return /* @__PURE__ */ jsx(Prompt, { options: props, bind: true });
2801
2446
  }
2802
2447
  export {
2803
2448
  AuthLinkBind,
2804
- AuthLinkConfirm,
2805
- AuthLinkPasscode,
2806
- useAuthLink
2449
+ AuthLinkVerify
2807
2450
  };
2808
2451
  //# sourceMappingURL=react.js.map