@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/dist/index.d.cts
CHANGED
|
@@ -37,8 +37,25 @@ interface PairingState {
|
|
|
37
37
|
* carrying nothing to render. Present on every callback of a QR/link flow.
|
|
38
38
|
*/
|
|
39
39
|
pairUrl?: string;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The provider-served SVG to show right now. Put it in an <img>; do not draw
|
|
42
|
+
* your own.
|
|
43
|
+
*
|
|
44
|
+
* IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
|
|
45
|
+
* rotating sequence, and a frame the provider has moved past is refused when
|
|
46
|
+
* the phone tries to claim it, which is what stops a screenshot of the code
|
|
47
|
+
* from being relayed to someone else's browser. So render the qrUrl of the
|
|
48
|
+
* state you are handed, every time you are handed one; a UI that caches the
|
|
49
|
+
* first value shows a code that stops working seconds later.
|
|
50
|
+
*/
|
|
41
51
|
qrUrl?: string;
|
|
52
|
+
/**
|
|
53
|
+
* How often, in seconds, `qrUrl` is replaced while the pairing is pending.
|
|
54
|
+
* Informational: the SDK already emits a new state on that cadence, so a
|
|
55
|
+
* custom UI does not need a timer of its own. Absent on the app link, which
|
|
56
|
+
* has no QR.
|
|
57
|
+
*/
|
|
58
|
+
qrRefreshSeconds?: number;
|
|
42
59
|
/** True when the flow resolved to the app link (mobile) rather than a QR. */
|
|
43
60
|
appLink?: boolean;
|
|
44
61
|
/** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
|
package/dist/index.d.ts
CHANGED
|
@@ -37,8 +37,25 @@ interface PairingState {
|
|
|
37
37
|
* carrying nothing to render. Present on every callback of a QR/link flow.
|
|
38
38
|
*/
|
|
39
39
|
pairUrl?: string;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The provider-served SVG to show right now. Put it in an <img>; do not draw
|
|
42
|
+
* your own.
|
|
43
|
+
*
|
|
44
|
+
* IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
|
|
45
|
+
* rotating sequence, and a frame the provider has moved past is refused when
|
|
46
|
+
* the phone tries to claim it, which is what stops a screenshot of the code
|
|
47
|
+
* from being relayed to someone else's browser. So render the qrUrl of the
|
|
48
|
+
* state you are handed, every time you are handed one; a UI that caches the
|
|
49
|
+
* first value shows a code that stops working seconds later.
|
|
50
|
+
*/
|
|
41
51
|
qrUrl?: string;
|
|
52
|
+
/**
|
|
53
|
+
* How often, in seconds, `qrUrl` is replaced while the pairing is pending.
|
|
54
|
+
* Informational: the SDK already emits a new state on that cadence, so a
|
|
55
|
+
* custom UI does not need a timer of its own. Absent on the app link, which
|
|
56
|
+
* has no QR.
|
|
57
|
+
*/
|
|
58
|
+
qrRefreshSeconds?: number;
|
|
42
59
|
/** True when the flow resolved to the app link (mobile) rather than a QR. */
|
|
43
60
|
appLink?: boolean;
|
|
44
61
|
/** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,11 @@ import { createContext, useContext, useMemo, useState as useState2 } from "react
|
|
|
5
5
|
|
|
6
6
|
// src/wire.ts
|
|
7
7
|
var WIRE_VERSION = 1;
|
|
8
|
-
var SDK_VERSION = "0.2.
|
|
8
|
+
var SDK_VERSION = "0.2.10";
|
|
9
9
|
var DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
10
10
|
var POLL_INTERVAL_MS = 2e3;
|
|
11
11
|
var POLL_INTERVAL_ENROLLING_MS = 5e3;
|
|
12
|
+
var DEFAULT_QR_REFRESH_SECONDS = 3;
|
|
12
13
|
|
|
13
14
|
// src/PairingModal.tsx
|
|
14
15
|
import { useEffect, useId, useRef, useState } from "react";
|
|
@@ -905,20 +906,110 @@ var CSS = `
|
|
|
905
906
|
color: var(--zrl-ink-soft);
|
|
906
907
|
}
|
|
907
908
|
|
|
909
|
+
/* The light around the well: a thin ring in the accent, one bright arc
|
|
910
|
+
travelling round it, and a soft glow of the same colour bleeding a few
|
|
911
|
+
pixels outward. It says the pairing is live without a word.
|
|
912
|
+
|
|
913
|
+
It sits BELOW the well, which is why the well is wrapped instead of given a
|
|
914
|
+
pseudo-element. Anything drawn inside the well paints over the white quiet
|
|
915
|
+
zone the camera needs, and a negative z-index from inside the well lands
|
|
916
|
+
wherever the card's stacking context happens to be that frame (the card's
|
|
917
|
+
entrance animation makes and unmakes one). The wrapper is the well's old
|
|
918
|
+
box exactly, same size, same margin, so nothing moves; the well keeps its
|
|
919
|
+
own stacking order on top, and the ring cannot touch the QR. */
|
|
920
|
+
.${PREFIX}-qr-halo {
|
|
921
|
+
position: relative;
|
|
922
|
+
isolation: isolate;
|
|
923
|
+
width: 204px;
|
|
924
|
+
height: 204px;
|
|
925
|
+
margin: 20px auto 0;
|
|
926
|
+
}
|
|
927
|
+
|
|
908
928
|
.${PREFIX}-qr-well {
|
|
909
929
|
position: relative;
|
|
930
|
+
z-index: 1;
|
|
910
931
|
display: grid;
|
|
911
932
|
place-items: center;
|
|
912
933
|
box-sizing: border-box;
|
|
913
934
|
width: 204px;
|
|
914
935
|
height: 204px;
|
|
915
|
-
margin:
|
|
936
|
+
margin: 0;
|
|
916
937
|
padding: 12px;
|
|
917
938
|
border: 1px solid var(--zrl-line);
|
|
918
939
|
border-radius: 16px;
|
|
919
940
|
background: var(--zrl-qr-bg);
|
|
920
941
|
}
|
|
921
942
|
|
|
943
|
+
/* The glow is the ring's own alpha, blurred and drawn beneath it, so it
|
|
944
|
+
follows the arc round and fades with it. It has to be a parent of the
|
|
945
|
+
masked band: a filter is applied before a mask, so a shadow cast from the
|
|
946
|
+
band itself would be cut off at the band's edge. Two shadows because one
|
|
947
|
+
2px source blurred over 10px is too faint to read as light; the second
|
|
948
|
+
blurs the first. */
|
|
949
|
+
.${PREFIX}-qr-glow {
|
|
950
|
+
position: absolute;
|
|
951
|
+
inset: -2px;
|
|
952
|
+
pointer-events: none;
|
|
953
|
+
filter: drop-shadow(0 0 3px var(--zrl-accent)) drop-shadow(0 0 10px var(--zrl-accent));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/* The band: a 2px ring just outside the well, cut with a mask that keeps the
|
|
957
|
+
padding and drops the content box. The gradient is on a pseudo-element
|
|
958
|
+
rather than on the band, because in the fallback it has to rotate as a
|
|
959
|
+
whole while the band stays put. The prefixed mask is for Chrome before 120;
|
|
960
|
+
the unprefixed one, declared after it, wins everywhere else. */
|
|
961
|
+
.${PREFIX}-qr-arc {
|
|
962
|
+
position: absolute;
|
|
963
|
+
inset: 0;
|
|
964
|
+
padding: 2px;
|
|
965
|
+
border-radius: 18px;
|
|
966
|
+
overflow: hidden;
|
|
967
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
968
|
+
-webkit-mask-composite: xor;
|
|
969
|
+
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
970
|
+
mask-composite: exclude;
|
|
971
|
+
}
|
|
972
|
+
.${PREFIX}-qr-arc::before,
|
|
973
|
+
.${PREFIX}-qr-arc::after {
|
|
974
|
+
content: '';
|
|
975
|
+
position: absolute;
|
|
976
|
+
transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/* The light. Mostly transparent, a tail that builds over a quarter turn, a
|
|
980
|
+
short full-strength head and a drop-off within a few degrees, so it reads
|
|
981
|
+
as travelling rather than blinking. One revolution every 3s, linear:
|
|
982
|
+
continuous motion has no ease. Oversized so its corners still cover the
|
|
983
|
+
band mid-rotation in the transform fallback; the property path shrinks it
|
|
984
|
+
back to the band. */
|
|
985
|
+
.${PREFIX}-qr-arc::before {
|
|
986
|
+
inset: -50%;
|
|
987
|
+
background: conic-gradient(
|
|
988
|
+
from var(--zrl-angle, 0deg),
|
|
989
|
+
transparent 0deg 245deg,
|
|
990
|
+
var(--zrl-accent) 332deg 346deg,
|
|
991
|
+
transparent 356deg 360deg
|
|
992
|
+
);
|
|
993
|
+
animation: ${PREFIX}-orbit 3s linear infinite;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/* The rest state: the same ring, evenly lit. Faint under the moving light so
|
|
997
|
+
the ring reads as a ring; stronger once the light has stopped. */
|
|
998
|
+
.${PREFIX}-qr-arc::after {
|
|
999
|
+
inset: 0;
|
|
1000
|
+
background: var(--zrl-accent);
|
|
1001
|
+
opacity: 0.16;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/* Spent: the light stops where it is and fades, and the ring settles to a
|
|
1005
|
+
dim, even glow. Paused rather than removed, so it does not jump back to
|
|
1006
|
+
its start on the way out. */
|
|
1007
|
+
.${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::before {
|
|
1008
|
+
animation-play-state: paused;
|
|
1009
|
+
opacity: 0;
|
|
1010
|
+
}
|
|
1011
|
+
.${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::after { opacity: 0.34; }
|
|
1012
|
+
|
|
922
1013
|
.${PREFIX}-qr {
|
|
923
1014
|
display: block;
|
|
924
1015
|
width: 100%;
|
|
@@ -1061,6 +1152,33 @@ var CSS = `
|
|
|
1061
1152
|
70%, 100% { transform: scale(2.6); opacity: 0 }
|
|
1062
1153
|
}
|
|
1063
1154
|
|
|
1155
|
+
/* Two ways to turn the light, one look.
|
|
1156
|
+
Where the browser can register a typed custom property, the gradient's own
|
|
1157
|
+
angle animates and the element never moves. Where it cannot (Firefox before
|
|
1158
|
+
128), an untyped custom property cannot interpolate and the light would
|
|
1159
|
+
snap once a cycle, so the oversized pseudo-element rotates instead and the
|
|
1160
|
+
band's mask keeps the ring in place. At rest the two are the same pixels:
|
|
1161
|
+
same gradient, same centre, angle zero.
|
|
1162
|
+
There is no direct query for @property support. transition-behavior
|
|
1163
|
+
shipped after it in every engine (Chrome 117 vs 85, Firefox 129 vs 128,
|
|
1164
|
+
Safari 17.4 vs 16.4), so it stands in: a yes is never wrong, and a no only
|
|
1165
|
+
sends a capable browser down the fallback, which looks the same.
|
|
1166
|
+
inherits: false so the angle stays on the element that animates it and
|
|
1167
|
+
never reaches the host page or the well. */
|
|
1168
|
+
@property --zrl-angle {
|
|
1169
|
+
syntax: '<angle>';
|
|
1170
|
+
inherits: false;
|
|
1171
|
+
initial-value: 0deg;
|
|
1172
|
+
}
|
|
1173
|
+
@keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
|
|
1174
|
+
@keyframes ${PREFIX}-orbit-angle { to { --zrl-angle: 360deg } }
|
|
1175
|
+
@supports (transition-behavior: allow-discrete) {
|
|
1176
|
+
.${PREFIX}-qr-arc::before {
|
|
1177
|
+
inset: 0;
|
|
1178
|
+
animation-name: ${PREFIX}-orbit-angle;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1064
1182
|
@media (prefers-reduced-motion: reduce) {
|
|
1065
1183
|
.${PREFIX}-scrim,
|
|
1066
1184
|
.${PREFIX}-card,
|
|
@@ -1070,6 +1188,10 @@ var CSS = `
|
|
|
1070
1188
|
.${PREFIX}-cancel,
|
|
1071
1189
|
.${PREFIX}-close,
|
|
1072
1190
|
.${PREFIX}-timer { transition: none }
|
|
1191
|
+
/* No travelling light. The ring keeps a still, soft glow instead, a step
|
|
1192
|
+
brighter than the spent state so pending and spent still read apart. */
|
|
1193
|
+
.${PREFIX}-qr-arc::before { animation: none; opacity: 0 }
|
|
1194
|
+
.${PREFIX}-qr-arc::after { opacity: 0.5 }
|
|
1073
1195
|
}
|
|
1074
1196
|
`;
|
|
1075
1197
|
function ensureStyles() {
|
|
@@ -1113,6 +1235,23 @@ function PairingModal({
|
|
|
1113
1235
|
const deadlineRef = useRef(0);
|
|
1114
1236
|
const [remaining, setRemaining] = useState(Math.round(timeoutMs / 1e3));
|
|
1115
1237
|
const settled = state.status === "claimed" || state.status === "enrolling";
|
|
1238
|
+
const [frameUrl, setFrameUrl] = useState(qrUrl);
|
|
1239
|
+
useEffect(() => {
|
|
1240
|
+
if (settled || frameUrl === qrUrl) return;
|
|
1241
|
+
let abandoned = false;
|
|
1242
|
+
const next = new Image();
|
|
1243
|
+
next.onload = () => {
|
|
1244
|
+
if (!abandoned) setFrameUrl(qrUrl);
|
|
1245
|
+
};
|
|
1246
|
+
next.onerror = () => {
|
|
1247
|
+
};
|
|
1248
|
+
next.src = qrUrl;
|
|
1249
|
+
return () => {
|
|
1250
|
+
abandoned = true;
|
|
1251
|
+
next.onload = null;
|
|
1252
|
+
next.onerror = null;
|
|
1253
|
+
};
|
|
1254
|
+
}, [qrUrl, settled, frameUrl]);
|
|
1116
1255
|
const onCancelRef = useRef(onCancel);
|
|
1117
1256
|
onCancelRef.current = onCancel;
|
|
1118
1257
|
useEffect(() => {
|
|
@@ -1163,9 +1302,12 @@ function PairingModal({
|
|
|
1163
1302
|
/* @__PURE__ */ jsx3(ZorealLockup, { height: 44, className: cx("lockup") }),
|
|
1164
1303
|
/* @__PURE__ */ jsx3("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
|
|
1165
1304
|
/* @__PURE__ */ jsx3("p", { className: cx("body-text"), children: body }),
|
|
1166
|
-
/* @__PURE__ */ jsxs3("div", { className: cx("qr-
|
|
1167
|
-
/* @__PURE__ */ jsx3("
|
|
1168
|
-
|
|
1305
|
+
/* @__PURE__ */ jsxs3("div", { className: cx("qr-halo"), "data-spent": settled, children: [
|
|
1306
|
+
/* @__PURE__ */ jsx3("span", { className: cx("qr-glow"), "aria-hidden": true, children: /* @__PURE__ */ jsx3("span", { className: cx("qr-arc") }) }),
|
|
1307
|
+
/* @__PURE__ */ jsxs3("div", { className: cx("qr-well"), children: [
|
|
1308
|
+
/* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
|
|
1309
|
+
settled && /* @__PURE__ */ jsx3("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ jsx3("span", { className: cx("qr-badge"), children: /* @__PURE__ */ jsx3(IconPhone, {}) }) })
|
|
1310
|
+
] })
|
|
1169
1311
|
] }),
|
|
1170
1312
|
/* @__PURE__ */ jsxs3("div", { className: cx("status"), children: [
|
|
1171
1313
|
/* @__PURE__ */ jsxs3("span", { className: cx("dot"), children: [
|
|
@@ -1302,55 +1444,99 @@ async function startPairing(issuer, params) {
|
|
|
1302
1444
|
}
|
|
1303
1445
|
return body;
|
|
1304
1446
|
}
|
|
1447
|
+
function qrRefreshSecondsOf(started) {
|
|
1448
|
+
const seconds = started.qr_refresh_seconds;
|
|
1449
|
+
return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
|
|
1450
|
+
}
|
|
1451
|
+
function qrFrameUrl(issuer, requestId) {
|
|
1452
|
+
return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
|
|
1453
|
+
}
|
|
1305
1454
|
var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
1306
1455
|
if (signal?.aborted) {
|
|
1307
1456
|
reject(new DOMException("aborted", "AbortError"));
|
|
1308
1457
|
return;
|
|
1309
1458
|
}
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
clearTimeout(t);
|
|
1459
|
+
const onAbort = () => {
|
|
1460
|
+
clearTimeout(timer);
|
|
1313
1461
|
reject(new DOMException("aborted", "AbortError"));
|
|
1314
|
-
}
|
|
1462
|
+
};
|
|
1463
|
+
const timer = setTimeout(() => {
|
|
1464
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1465
|
+
resolve();
|
|
1466
|
+
}, ms);
|
|
1467
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1315
1468
|
});
|
|
1316
|
-
async function pollUntilApproved(issuer, requestId, onState, signal) {
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1469
|
+
async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
|
|
1470
|
+
let last = { status: "pending" };
|
|
1471
|
+
const emit = (state) => {
|
|
1472
|
+
last = state;
|
|
1473
|
+
onState?.(state);
|
|
1474
|
+
};
|
|
1475
|
+
let frames = null;
|
|
1476
|
+
const stopFrames = () => {
|
|
1477
|
+
frames?.abort();
|
|
1478
|
+
frames = null;
|
|
1479
|
+
};
|
|
1480
|
+
const startFrames = () => {
|
|
1481
|
+
const seconds = options.qrRefreshSeconds;
|
|
1482
|
+
if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
|
|
1483
|
+
const period = seconds * 1e3;
|
|
1484
|
+
const controller = new AbortController();
|
|
1485
|
+
frames = controller;
|
|
1486
|
+
signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
|
|
1487
|
+
void (async () => {
|
|
1488
|
+
let due = Date.now() + period;
|
|
1489
|
+
for (; ; ) {
|
|
1490
|
+
await sleep(Math.max(0, due - Date.now()), controller.signal);
|
|
1491
|
+
emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
|
|
1492
|
+
due = Date.now() + period;
|
|
1493
|
+
}
|
|
1494
|
+
})().catch(() => {
|
|
1332
1495
|
});
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1496
|
+
};
|
|
1497
|
+
try {
|
|
1498
|
+
for (; ; ) {
|
|
1499
|
+
const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
|
|
1500
|
+
signal
|
|
1501
|
+
});
|
|
1502
|
+
const body = await parseJson(response);
|
|
1503
|
+
if (!response.ok) {
|
|
1504
|
+
throw new OAuthFlowError(
|
|
1505
|
+
body.error ?? "server_error",
|
|
1506
|
+
body.error_description ?? `Pairing status failed (${response.status})`
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
emit({
|
|
1510
|
+
status: body.status,
|
|
1511
|
+
expiresIn: body.expires_in,
|
|
1512
|
+
enrolmentDeadline: body.enrolment_deadline
|
|
1513
|
+
});
|
|
1514
|
+
if (body.status === "pending") startFrames();
|
|
1515
|
+
else stopFrames();
|
|
1516
|
+
switch (body.status) {
|
|
1517
|
+
case "approved":
|
|
1518
|
+
if (!body.code) {
|
|
1519
|
+
throw new OAuthFlowError("server_error", "approved with no authorization code");
|
|
1520
|
+
}
|
|
1521
|
+
return body.code;
|
|
1522
|
+
case "denied":
|
|
1523
|
+
throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
|
|
1524
|
+
case "expired":
|
|
1525
|
+
throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
|
|
1526
|
+
case "cancelled":
|
|
1527
|
+
throw new FlowAbandonedError({
|
|
1528
|
+
type: "request_expired",
|
|
1529
|
+
description: body.error_description ?? "the provider cancelled the pairing request"
|
|
1530
|
+
});
|
|
1531
|
+
case "enrolling":
|
|
1532
|
+
await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
|
|
1533
|
+
break;
|
|
1534
|
+
default:
|
|
1535
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
1536
|
+
}
|
|
1353
1537
|
}
|
|
1538
|
+
} finally {
|
|
1539
|
+
stopFrames();
|
|
1354
1540
|
}
|
|
1355
1541
|
}
|
|
1356
1542
|
async function exchangeCode(issuer, input) {
|
|
@@ -1377,6 +1563,11 @@ function isMobileUserAgent() {
|
|
|
1377
1563
|
if (typeof navigator === "undefined") return false;
|
|
1378
1564
|
return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
1379
1565
|
}
|
|
1566
|
+
function resolveDisplay(display) {
|
|
1567
|
+
if (display === "link") return "link";
|
|
1568
|
+
if (display === "qr") return "qr";
|
|
1569
|
+
return isMobileUserAgent() ? "link" : "qr";
|
|
1570
|
+
}
|
|
1380
1571
|
|
|
1381
1572
|
// src/pkce.ts
|
|
1382
1573
|
var VERIFIER_BYTES = 32;
|
|
@@ -1423,6 +1614,8 @@ function useZorealFlow(options) {
|
|
|
1423
1614
|
const verifier = generateVerifier();
|
|
1424
1615
|
const state = generateState();
|
|
1425
1616
|
const nonce = generateState();
|
|
1617
|
+
const display = resolveDisplay(opts.display);
|
|
1618
|
+
const useAppLink = display === "link";
|
|
1426
1619
|
try {
|
|
1427
1620
|
const started = await startPairing(issuer, {
|
|
1428
1621
|
client_id: clientId,
|
|
@@ -1434,7 +1627,8 @@ function useZorealFlow(options) {
|
|
|
1434
1627
|
acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
|
|
1435
1628
|
max_age: opts.max_age,
|
|
1436
1629
|
prompt: opts.prompt,
|
|
1437
|
-
locale
|
|
1630
|
+
locale,
|
|
1631
|
+
display
|
|
1438
1632
|
});
|
|
1439
1633
|
let code;
|
|
1440
1634
|
let selectBy = "device";
|
|
@@ -1442,8 +1636,8 @@ function useZorealFlow(options) {
|
|
|
1442
1636
|
code = started.code;
|
|
1443
1637
|
selectBy = "session";
|
|
1444
1638
|
} else {
|
|
1445
|
-
const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
|
|
1446
1639
|
selectBy = useAppLink ? "app_link" : "qr";
|
|
1640
|
+
const qrRefreshSeconds = qrRefreshSecondsOf(started);
|
|
1447
1641
|
const cancel = () => {
|
|
1448
1642
|
controller.abort();
|
|
1449
1643
|
setPairing(null);
|
|
@@ -1451,21 +1645,23 @@ function useZorealFlow(options) {
|
|
|
1451
1645
|
};
|
|
1452
1646
|
const surface = {
|
|
1453
1647
|
pairUrl: started.pair_url,
|
|
1454
|
-
qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
|
|
1455
1648
|
appLink: useAppLink,
|
|
1456
|
-
cancel
|
|
1649
|
+
cancel,
|
|
1650
|
+
// The app link has no QR and therefore no cadence to report.
|
|
1651
|
+
...useAppLink ? null : { qrRefreshSeconds }
|
|
1457
1652
|
};
|
|
1653
|
+
let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
|
|
1458
1654
|
const active = {
|
|
1459
1655
|
requestId: started.request_id,
|
|
1460
1656
|
pairUrl: surface.pairUrl,
|
|
1461
|
-
qrUrl
|
|
1462
|
-
state: { status: "pending", expiresIn: started.expires_in, ...surface },
|
|
1657
|
+
qrUrl,
|
|
1658
|
+
state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
|
|
1463
1659
|
appLink: useAppLink,
|
|
1464
1660
|
cancel
|
|
1465
1661
|
};
|
|
1466
1662
|
setPairing(active);
|
|
1467
1663
|
if (!useAppLink) {
|
|
1468
|
-
publishRef.current?.({ state: active.state, qrUrl
|
|
1664
|
+
publishRef.current?.({ state: active.state, qrUrl, cancel });
|
|
1469
1665
|
}
|
|
1470
1666
|
opts.onPairingStateChange?.(active.state);
|
|
1471
1667
|
if (useAppLink) {
|
|
@@ -1475,16 +1671,20 @@ function useZorealFlow(options) {
|
|
|
1475
1671
|
issuer,
|
|
1476
1672
|
started.request_id,
|
|
1477
1673
|
(s) => {
|
|
1478
|
-
|
|
1674
|
+
if (s.qrUrl) qrUrl = s.qrUrl;
|
|
1675
|
+
const enriched = { ...s, ...surface, qrUrl };
|
|
1479
1676
|
setPairing(
|
|
1480
|
-
(p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
|
|
1677
|
+
(p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
|
|
1481
1678
|
);
|
|
1482
1679
|
if (!useAppLink) {
|
|
1483
|
-
publishRef.current?.({ state: enriched, qrUrl
|
|
1680
|
+
publishRef.current?.({ state: enriched, qrUrl, cancel });
|
|
1484
1681
|
}
|
|
1485
1682
|
opts.onPairingStateChange?.(enriched);
|
|
1486
1683
|
},
|
|
1487
|
-
controller.signal
|
|
1684
|
+
controller.signal,
|
|
1685
|
+
// The app link has no QR to animate, and the provider answers 404
|
|
1686
|
+
// for one, so the frames are asked for only on the QR surface.
|
|
1687
|
+
{ qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
|
|
1488
1688
|
);
|
|
1489
1689
|
}
|
|
1490
1690
|
setPairing(null);
|