@zoreal/oauth2-react 0.2.8 → 0.2.10
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/README.md +14 -3
- package/dist/index.cjs +256 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +256 -56
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -239,6 +239,14 @@ What the modal does:
|
|
|
239
239
|
| **Language** | Ships its own copy in 39 locales (listed below). Pass `locale` and the modal matches the page it opened on; omit it and it follows the browser's preference list, English as the floor. Arabic, Hebrew and Urdu flip the dialog to RTL. |
|
|
240
240
|
| **Accessibility** | `role="dialog"`, `aria-modal`, labelled by its title, focus moved in on open, scroll locked, visible focus rings, and a `prefers-reduced-motion` fallback. |
|
|
241
241
|
|
|
242
|
+
The code on screen is not static. A QR sign-in shows one frame of a sequence
|
|
243
|
+
the provider rotates every few seconds, and it refuses a frame the sequence has
|
|
244
|
+
already moved past. That is what stops a code being relayed: a screenshot of a
|
|
245
|
+
sign-in code, sent to someone else to scan and approve, is spent before it
|
|
246
|
+
arrives, so the picture buys an attacker nothing. The SDK re-fetches the frame
|
|
247
|
+
on the provider's cadence and the modal loads the next one before it swaps, so
|
|
248
|
+
the code never blinks out while a camera is aimed at it.
|
|
249
|
+
|
|
242
250
|
### Languages
|
|
243
251
|
|
|
244
252
|
Arabic · Bengali · Bosnian · Bulgarian · Chinese (Simplified) · Chinese (Traditional) ·
|
|
@@ -266,9 +274,12 @@ If you already have a design system you want the QR to live inside, opt out:
|
|
|
266
274
|
```
|
|
267
275
|
|
|
268
276
|
`onPairingStateChange` then carries everything you need on every state:
|
|
269
|
-
`qrUrl`, `pairUrl`, `status`, `expiresIn` and `cancel`.
|
|
270
|
-
`<img>`; do not draw your own.
|
|
271
|
-
|
|
277
|
+
`qrUrl`, `pairUrl`, `status`, `expiresIn`, `qrRefreshSeconds` and `cancel`.
|
|
278
|
+
Render `qrUrl` in an `<img>`; do not draw your own. Render it **on every state
|
|
279
|
+
you are given**, never the first one you saw: `qrUrl` is a moving frame, the
|
|
280
|
+
SDK hands you a new one every few seconds, and a UI that caches the first
|
|
281
|
+
value shows a code that stops working. `PairingModal` is also exported if you
|
|
282
|
+
want the real dialog but on your own terms.
|
|
272
283
|
|
|
273
284
|
## Scopes and claims
|
|
274
285
|
|
package/dist/index.cjs
CHANGED
|
@@ -39,10 +39,11 @@ var import_react2 = require("react");
|
|
|
39
39
|
|
|
40
40
|
// src/wire.ts
|
|
41
41
|
var WIRE_VERSION = 1;
|
|
42
|
-
var SDK_VERSION = "0.2.
|
|
42
|
+
var SDK_VERSION = "0.2.10";
|
|
43
43
|
var DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
44
44
|
var POLL_INTERVAL_MS = 2e3;
|
|
45
45
|
var POLL_INTERVAL_ENROLLING_MS = 5e3;
|
|
46
|
+
var DEFAULT_QR_REFRESH_SECONDS = 3;
|
|
46
47
|
|
|
47
48
|
// src/PairingModal.tsx
|
|
48
49
|
var import_react = require("react");
|
|
@@ -939,20 +940,110 @@ var CSS = `
|
|
|
939
940
|
color: var(--zrl-ink-soft);
|
|
940
941
|
}
|
|
941
942
|
|
|
943
|
+
/* The light around the well: a thin ring in the accent, one bright arc
|
|
944
|
+
travelling round it, and a soft glow of the same colour bleeding a few
|
|
945
|
+
pixels outward. It says the pairing is live without a word.
|
|
946
|
+
|
|
947
|
+
It sits BELOW the well, which is why the well is wrapped instead of given a
|
|
948
|
+
pseudo-element. Anything drawn inside the well paints over the white quiet
|
|
949
|
+
zone the camera needs, and a negative z-index from inside the well lands
|
|
950
|
+
wherever the card's stacking context happens to be that frame (the card's
|
|
951
|
+
entrance animation makes and unmakes one). The wrapper is the well's old
|
|
952
|
+
box exactly, same size, same margin, so nothing moves; the well keeps its
|
|
953
|
+
own stacking order on top, and the ring cannot touch the QR. */
|
|
954
|
+
.${PREFIX}-qr-halo {
|
|
955
|
+
position: relative;
|
|
956
|
+
isolation: isolate;
|
|
957
|
+
width: 204px;
|
|
958
|
+
height: 204px;
|
|
959
|
+
margin: 20px auto 0;
|
|
960
|
+
}
|
|
961
|
+
|
|
942
962
|
.${PREFIX}-qr-well {
|
|
943
963
|
position: relative;
|
|
964
|
+
z-index: 1;
|
|
944
965
|
display: grid;
|
|
945
966
|
place-items: center;
|
|
946
967
|
box-sizing: border-box;
|
|
947
968
|
width: 204px;
|
|
948
969
|
height: 204px;
|
|
949
|
-
margin:
|
|
970
|
+
margin: 0;
|
|
950
971
|
padding: 12px;
|
|
951
972
|
border: 1px solid var(--zrl-line);
|
|
952
973
|
border-radius: 16px;
|
|
953
974
|
background: var(--zrl-qr-bg);
|
|
954
975
|
}
|
|
955
976
|
|
|
977
|
+
/* The glow is the ring's own alpha, blurred and drawn beneath it, so it
|
|
978
|
+
follows the arc round and fades with it. It has to be a parent of the
|
|
979
|
+
masked band: a filter is applied before a mask, so a shadow cast from the
|
|
980
|
+
band itself would be cut off at the band's edge. Two shadows because one
|
|
981
|
+
2px source blurred over 10px is too faint to read as light; the second
|
|
982
|
+
blurs the first. */
|
|
983
|
+
.${PREFIX}-qr-glow {
|
|
984
|
+
position: absolute;
|
|
985
|
+
inset: -2px;
|
|
986
|
+
pointer-events: none;
|
|
987
|
+
filter: drop-shadow(0 0 3px var(--zrl-accent)) drop-shadow(0 0 10px var(--zrl-accent));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/* The band: a 2px ring just outside the well, cut with a mask that keeps the
|
|
991
|
+
padding and drops the content box. The gradient is on a pseudo-element
|
|
992
|
+
rather than on the band, because in the fallback it has to rotate as a
|
|
993
|
+
whole while the band stays put. The prefixed mask is for Chrome before 120;
|
|
994
|
+
the unprefixed one, declared after it, wins everywhere else. */
|
|
995
|
+
.${PREFIX}-qr-arc {
|
|
996
|
+
position: absolute;
|
|
997
|
+
inset: 0;
|
|
998
|
+
padding: 2px;
|
|
999
|
+
border-radius: 18px;
|
|
1000
|
+
overflow: hidden;
|
|
1001
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
1002
|
+
-webkit-mask-composite: xor;
|
|
1003
|
+
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
1004
|
+
mask-composite: exclude;
|
|
1005
|
+
}
|
|
1006
|
+
.${PREFIX}-qr-arc::before,
|
|
1007
|
+
.${PREFIX}-qr-arc::after {
|
|
1008
|
+
content: '';
|
|
1009
|
+
position: absolute;
|
|
1010
|
+
transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/* The light. Mostly transparent, a tail that builds over a quarter turn, a
|
|
1014
|
+
short full-strength head and a drop-off within a few degrees, so it reads
|
|
1015
|
+
as travelling rather than blinking. One revolution every 3s, linear:
|
|
1016
|
+
continuous motion has no ease. Oversized so its corners still cover the
|
|
1017
|
+
band mid-rotation in the transform fallback; the property path shrinks it
|
|
1018
|
+
back to the band. */
|
|
1019
|
+
.${PREFIX}-qr-arc::before {
|
|
1020
|
+
inset: -50%;
|
|
1021
|
+
background: conic-gradient(
|
|
1022
|
+
from var(--zrl-angle, 0deg),
|
|
1023
|
+
transparent 0deg 245deg,
|
|
1024
|
+
var(--zrl-accent) 332deg 346deg,
|
|
1025
|
+
transparent 356deg 360deg
|
|
1026
|
+
);
|
|
1027
|
+
animation: ${PREFIX}-orbit 3s linear infinite;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/* The rest state: the same ring, evenly lit. Faint under the moving light so
|
|
1031
|
+
the ring reads as a ring; stronger once the light has stopped. */
|
|
1032
|
+
.${PREFIX}-qr-arc::after {
|
|
1033
|
+
inset: 0;
|
|
1034
|
+
background: var(--zrl-accent);
|
|
1035
|
+
opacity: 0.16;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/* Spent: the light stops where it is and fades, and the ring settles to a
|
|
1039
|
+
dim, even glow. Paused rather than removed, so it does not jump back to
|
|
1040
|
+
its start on the way out. */
|
|
1041
|
+
.${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::before {
|
|
1042
|
+
animation-play-state: paused;
|
|
1043
|
+
opacity: 0;
|
|
1044
|
+
}
|
|
1045
|
+
.${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::after { opacity: 0.34; }
|
|
1046
|
+
|
|
956
1047
|
.${PREFIX}-qr {
|
|
957
1048
|
display: block;
|
|
958
1049
|
width: 100%;
|
|
@@ -1095,6 +1186,33 @@ var CSS = `
|
|
|
1095
1186
|
70%, 100% { transform: scale(2.6); opacity: 0 }
|
|
1096
1187
|
}
|
|
1097
1188
|
|
|
1189
|
+
/* Two ways to turn the light, one look.
|
|
1190
|
+
Where the browser can register a typed custom property, the gradient's own
|
|
1191
|
+
angle animates and the element never moves. Where it cannot (Firefox before
|
|
1192
|
+
128), an untyped custom property cannot interpolate and the light would
|
|
1193
|
+
snap once a cycle, so the oversized pseudo-element rotates instead and the
|
|
1194
|
+
band's mask keeps the ring in place. At rest the two are the same pixels:
|
|
1195
|
+
same gradient, same centre, angle zero.
|
|
1196
|
+
There is no direct query for @property support. transition-behavior
|
|
1197
|
+
shipped after it in every engine (Chrome 117 vs 85, Firefox 129 vs 128,
|
|
1198
|
+
Safari 17.4 vs 16.4), so it stands in: a yes is never wrong, and a no only
|
|
1199
|
+
sends a capable browser down the fallback, which looks the same.
|
|
1200
|
+
inherits: false so the angle stays on the element that animates it and
|
|
1201
|
+
never reaches the host page or the well. */
|
|
1202
|
+
@property --zrl-angle {
|
|
1203
|
+
syntax: '<angle>';
|
|
1204
|
+
inherits: false;
|
|
1205
|
+
initial-value: 0deg;
|
|
1206
|
+
}
|
|
1207
|
+
@keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
|
|
1208
|
+
@keyframes ${PREFIX}-orbit-angle { to { --zrl-angle: 360deg } }
|
|
1209
|
+
@supports (transition-behavior: allow-discrete) {
|
|
1210
|
+
.${PREFIX}-qr-arc::before {
|
|
1211
|
+
inset: 0;
|
|
1212
|
+
animation-name: ${PREFIX}-orbit-angle;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1098
1216
|
@media (prefers-reduced-motion: reduce) {
|
|
1099
1217
|
.${PREFIX}-scrim,
|
|
1100
1218
|
.${PREFIX}-card,
|
|
@@ -1104,6 +1222,10 @@ var CSS = `
|
|
|
1104
1222
|
.${PREFIX}-cancel,
|
|
1105
1223
|
.${PREFIX}-close,
|
|
1106
1224
|
.${PREFIX}-timer { transition: none }
|
|
1225
|
+
/* No travelling light. The ring keeps a still, soft glow instead, a step
|
|
1226
|
+
brighter than the spent state so pending and spent still read apart. */
|
|
1227
|
+
.${PREFIX}-qr-arc::before { animation: none; opacity: 0 }
|
|
1228
|
+
.${PREFIX}-qr-arc::after { opacity: 0.5 }
|
|
1107
1229
|
}
|
|
1108
1230
|
`;
|
|
1109
1231
|
function ensureStyles() {
|
|
@@ -1147,6 +1269,23 @@ function PairingModal({
|
|
|
1147
1269
|
const deadlineRef = (0, import_react.useRef)(0);
|
|
1148
1270
|
const [remaining, setRemaining] = (0, import_react.useState)(Math.round(timeoutMs / 1e3));
|
|
1149
1271
|
const settled = state.status === "claimed" || state.status === "enrolling";
|
|
1272
|
+
const [frameUrl, setFrameUrl] = (0, import_react.useState)(qrUrl);
|
|
1273
|
+
(0, import_react.useEffect)(() => {
|
|
1274
|
+
if (settled || frameUrl === qrUrl) return;
|
|
1275
|
+
let abandoned = false;
|
|
1276
|
+
const next = new Image();
|
|
1277
|
+
next.onload = () => {
|
|
1278
|
+
if (!abandoned) setFrameUrl(qrUrl);
|
|
1279
|
+
};
|
|
1280
|
+
next.onerror = () => {
|
|
1281
|
+
};
|
|
1282
|
+
next.src = qrUrl;
|
|
1283
|
+
return () => {
|
|
1284
|
+
abandoned = true;
|
|
1285
|
+
next.onload = null;
|
|
1286
|
+
next.onerror = null;
|
|
1287
|
+
};
|
|
1288
|
+
}, [qrUrl, settled, frameUrl]);
|
|
1150
1289
|
const onCancelRef = (0, import_react.useRef)(onCancel);
|
|
1151
1290
|
onCancelRef.current = onCancel;
|
|
1152
1291
|
(0, import_react.useEffect)(() => {
|
|
@@ -1197,9 +1336,12 @@ function PairingModal({
|
|
|
1197
1336
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ZorealLockup, { height: 44, className: cx("lockup") }),
|
|
1198
1337
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
|
|
1199
1338
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("body-text"), children: body }),
|
|
1200
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("qr-
|
|
1201
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("
|
|
1202
|
-
|
|
1339
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("qr-halo"), "data-spent": settled, children: [
|
|
1340
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-glow"), "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-arc") }) }),
|
|
1341
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("qr-well"), children: [
|
|
1342
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
|
|
1343
|
+
settled && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-badge"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(IconPhone, {}) }) })
|
|
1344
|
+
] })
|
|
1203
1345
|
] }),
|
|
1204
1346
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("status"), children: [
|
|
1205
1347
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: cx("dot"), children: [
|
|
@@ -1336,55 +1478,99 @@ async function startPairing(issuer, params) {
|
|
|
1336
1478
|
}
|
|
1337
1479
|
return body;
|
|
1338
1480
|
}
|
|
1481
|
+
function qrRefreshSecondsOf(started) {
|
|
1482
|
+
const seconds = started.qr_refresh_seconds;
|
|
1483
|
+
return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
|
|
1484
|
+
}
|
|
1485
|
+
function qrFrameUrl(issuer, requestId) {
|
|
1486
|
+
return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
|
|
1487
|
+
}
|
|
1339
1488
|
var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
1340
1489
|
if (signal?.aborted) {
|
|
1341
1490
|
reject(new DOMException("aborted", "AbortError"));
|
|
1342
1491
|
return;
|
|
1343
1492
|
}
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
clearTimeout(t);
|
|
1493
|
+
const onAbort = () => {
|
|
1494
|
+
clearTimeout(timer);
|
|
1347
1495
|
reject(new DOMException("aborted", "AbortError"));
|
|
1348
|
-
}
|
|
1496
|
+
};
|
|
1497
|
+
const timer = setTimeout(() => {
|
|
1498
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1499
|
+
resolve();
|
|
1500
|
+
}, ms);
|
|
1501
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1349
1502
|
});
|
|
1350
|
-
async function pollUntilApproved(issuer, requestId, onState, signal) {
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1503
|
+
async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
|
|
1504
|
+
let last = { status: "pending" };
|
|
1505
|
+
const emit = (state) => {
|
|
1506
|
+
last = state;
|
|
1507
|
+
onState?.(state);
|
|
1508
|
+
};
|
|
1509
|
+
let frames = null;
|
|
1510
|
+
const stopFrames = () => {
|
|
1511
|
+
frames?.abort();
|
|
1512
|
+
frames = null;
|
|
1513
|
+
};
|
|
1514
|
+
const startFrames = () => {
|
|
1515
|
+
const seconds = options.qrRefreshSeconds;
|
|
1516
|
+
if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
|
|
1517
|
+
const period = seconds * 1e3;
|
|
1518
|
+
const controller = new AbortController();
|
|
1519
|
+
frames = controller;
|
|
1520
|
+
signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
|
|
1521
|
+
void (async () => {
|
|
1522
|
+
let due = Date.now() + period;
|
|
1523
|
+
for (; ; ) {
|
|
1524
|
+
await sleep(Math.max(0, due - Date.now()), controller.signal);
|
|
1525
|
+
emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
|
|
1526
|
+
due = Date.now() + period;
|
|
1527
|
+
}
|
|
1528
|
+
})().catch(() => {
|
|
1366
1529
|
});
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1530
|
+
};
|
|
1531
|
+
try {
|
|
1532
|
+
for (; ; ) {
|
|
1533
|
+
const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
|
|
1534
|
+
signal
|
|
1535
|
+
});
|
|
1536
|
+
const body = await parseJson(response);
|
|
1537
|
+
if (!response.ok) {
|
|
1538
|
+
throw new OAuthFlowError(
|
|
1539
|
+
body.error ?? "server_error",
|
|
1540
|
+
body.error_description ?? `Pairing status failed (${response.status})`
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
emit({
|
|
1544
|
+
status: body.status,
|
|
1545
|
+
expiresIn: body.expires_in,
|
|
1546
|
+
enrolmentDeadline: body.enrolment_deadline
|
|
1547
|
+
});
|
|
1548
|
+
if (body.status === "pending") startFrames();
|
|
1549
|
+
else stopFrames();
|
|
1550
|
+
switch (body.status) {
|
|
1551
|
+
case "approved":
|
|
1552
|
+
if (!body.code) {
|
|
1553
|
+
throw new OAuthFlowError("server_error", "approved with no authorization code");
|
|
1554
|
+
}
|
|
1555
|
+
return body.code;
|
|
1556
|
+
case "denied":
|
|
1557
|
+
throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
|
|
1558
|
+
case "expired":
|
|
1559
|
+
throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
|
|
1560
|
+
case "cancelled":
|
|
1561
|
+
throw new FlowAbandonedError({
|
|
1562
|
+
type: "request_expired",
|
|
1563
|
+
description: body.error_description ?? "the provider cancelled the pairing request"
|
|
1564
|
+
});
|
|
1565
|
+
case "enrolling":
|
|
1566
|
+
await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
|
|
1567
|
+
break;
|
|
1568
|
+
default:
|
|
1569
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
1570
|
+
}
|
|
1387
1571
|
}
|
|
1572
|
+
} finally {
|
|
1573
|
+
stopFrames();
|
|
1388
1574
|
}
|
|
1389
1575
|
}
|
|
1390
1576
|
async function exchangeCode(issuer, input) {
|
|
@@ -1411,6 +1597,11 @@ function isMobileUserAgent() {
|
|
|
1411
1597
|
if (typeof navigator === "undefined") return false;
|
|
1412
1598
|
return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
1413
1599
|
}
|
|
1600
|
+
function resolveDisplay(display) {
|
|
1601
|
+
if (display === "link") return "link";
|
|
1602
|
+
if (display === "qr") return "qr";
|
|
1603
|
+
return isMobileUserAgent() ? "link" : "qr";
|
|
1604
|
+
}
|
|
1414
1605
|
|
|
1415
1606
|
// src/pkce.ts
|
|
1416
1607
|
var VERIFIER_BYTES = 32;
|
|
@@ -1457,6 +1648,8 @@ function useZorealFlow(options) {
|
|
|
1457
1648
|
const verifier = generateVerifier();
|
|
1458
1649
|
const state = generateState();
|
|
1459
1650
|
const nonce = generateState();
|
|
1651
|
+
const display = resolveDisplay(opts.display);
|
|
1652
|
+
const useAppLink = display === "link";
|
|
1460
1653
|
try {
|
|
1461
1654
|
const started = await startPairing(issuer, {
|
|
1462
1655
|
client_id: clientId,
|
|
@@ -1468,7 +1661,8 @@ function useZorealFlow(options) {
|
|
|
1468
1661
|
acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
|
|
1469
1662
|
max_age: opts.max_age,
|
|
1470
1663
|
prompt: opts.prompt,
|
|
1471
|
-
locale
|
|
1664
|
+
locale,
|
|
1665
|
+
display
|
|
1472
1666
|
});
|
|
1473
1667
|
let code;
|
|
1474
1668
|
let selectBy = "device";
|
|
@@ -1476,8 +1670,8 @@ function useZorealFlow(options) {
|
|
|
1476
1670
|
code = started.code;
|
|
1477
1671
|
selectBy = "session";
|
|
1478
1672
|
} else {
|
|
1479
|
-
const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
|
|
1480
1673
|
selectBy = useAppLink ? "app_link" : "qr";
|
|
1674
|
+
const qrRefreshSeconds = qrRefreshSecondsOf(started);
|
|
1481
1675
|
const cancel = () => {
|
|
1482
1676
|
controller.abort();
|
|
1483
1677
|
setPairing(null);
|
|
@@ -1485,21 +1679,23 @@ function useZorealFlow(options) {
|
|
|
1485
1679
|
};
|
|
1486
1680
|
const surface = {
|
|
1487
1681
|
pairUrl: started.pair_url,
|
|
1488
|
-
qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
|
|
1489
1682
|
appLink: useAppLink,
|
|
1490
|
-
cancel
|
|
1683
|
+
cancel,
|
|
1684
|
+
// The app link has no QR and therefore no cadence to report.
|
|
1685
|
+
...useAppLink ? null : { qrRefreshSeconds }
|
|
1491
1686
|
};
|
|
1687
|
+
let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
|
|
1492
1688
|
const active = {
|
|
1493
1689
|
requestId: started.request_id,
|
|
1494
1690
|
pairUrl: surface.pairUrl,
|
|
1495
|
-
qrUrl
|
|
1496
|
-
state: { status: "pending", expiresIn: started.expires_in, ...surface },
|
|
1691
|
+
qrUrl,
|
|
1692
|
+
state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
|
|
1497
1693
|
appLink: useAppLink,
|
|
1498
1694
|
cancel
|
|
1499
1695
|
};
|
|
1500
1696
|
setPairing(active);
|
|
1501
1697
|
if (!useAppLink) {
|
|
1502
|
-
publishRef.current?.({ state: active.state, qrUrl
|
|
1698
|
+
publishRef.current?.({ state: active.state, qrUrl, cancel });
|
|
1503
1699
|
}
|
|
1504
1700
|
opts.onPairingStateChange?.(active.state);
|
|
1505
1701
|
if (useAppLink) {
|
|
@@ -1509,16 +1705,20 @@ function useZorealFlow(options) {
|
|
|
1509
1705
|
issuer,
|
|
1510
1706
|
started.request_id,
|
|
1511
1707
|
(s) => {
|
|
1512
|
-
|
|
1708
|
+
if (s.qrUrl) qrUrl = s.qrUrl;
|
|
1709
|
+
const enriched = { ...s, ...surface, qrUrl };
|
|
1513
1710
|
setPairing(
|
|
1514
|
-
(p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
|
|
1711
|
+
(p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
|
|
1515
1712
|
);
|
|
1516
1713
|
if (!useAppLink) {
|
|
1517
|
-
publishRef.current?.({ state: enriched, qrUrl
|
|
1714
|
+
publishRef.current?.({ state: enriched, qrUrl, cancel });
|
|
1518
1715
|
}
|
|
1519
1716
|
opts.onPairingStateChange?.(enriched);
|
|
1520
1717
|
},
|
|
1521
|
-
controller.signal
|
|
1718
|
+
controller.signal,
|
|
1719
|
+
// The app link has no QR to animate, and the provider answers 404
|
|
1720
|
+
// for one, so the frames are asked for only on the QR surface.
|
|
1721
|
+
{ qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
|
|
1522
1722
|
);
|
|
1523
1723
|
}
|
|
1524
1724
|
setPairing(null);
|