@zoreal/oauth2-react 0.2.20 → 0.2.21

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 CHANGED
@@ -89,22 +89,15 @@ import { ZorealOAuthProvider } from '@zoreal/oauth2-react';
89
89
  ```
90
90
 
91
91
  ```tsx
92
- // ZorealSignIn.tsx — your own button, the way a production sign-in page uses it.
93
- import { useState } from 'react';
94
- import { useZorealLogin, ZorealBusyRing, ZorealMark } from '@zoreal/oauth2-react';
92
+ // ZorealSignIn.tsx — your own button, styled your way.
93
+ import { useZorealLogin, ZorealMark } from '@zoreal/oauth2-react';
95
94
 
96
95
  function ZorealSignIn({ onSignedIn }: { onSignedIn: () => void }) {
97
- // Busy from the tap until the flow ends. On a computer that is while the
98
- // pairing modal is open; on a phone it is the moment between the tap and
99
- // the hand-over to the ZOREAL ID app. Cleared by every outcome below.
100
- const [busy, setBusy] = useState(false);
101
-
102
96
  // `email` (and profile.name, etc.) are returned from /userinfo on your backend.
103
97
  const login = useZorealLogin({
104
98
  flow: 'auth-code',
105
99
  scope: 'openid email profile.name',
106
100
  onSuccess: async ({ code, code_verifier, nonce }) => {
107
- setBusy(false);
108
101
  // Send ALL THREE to your backend over TLS. It calls POST /token with the
109
102
  // code and verifier plus its client authentication, verifies the ID
110
103
  // token's nonce is this one, then reads the email and name from
@@ -116,12 +109,8 @@ function ZorealSignIn({ onSignedIn }: { onSignedIn: () => void }) {
116
109
  });
117
110
  if (res.ok) onSignedIn();
118
111
  },
119
- onError: (e) => {
120
- setBusy(false);
121
- console.error(e.description ?? e.error);
122
- },
112
+ onError: (e) => console.error(e.description ?? e.error),
123
113
  onNonOAuthError: (e) => {
124
- setBusy(false);
125
114
  // The holder declining or ignoring the request, or closing the dialog,
126
115
  // is not an error to surface: the button is simply ready again.
127
116
  if (e.type === 'request_denied' || e.type === 'request_expired' || e.type === 'popup_closed') return;
@@ -129,36 +118,26 @@ function ZorealSignIn({ onSignedIn }: { onSignedIn: () => void }) {
129
118
  },
130
119
  });
131
120
 
121
+ // Pass the click through as it is. The SDK takes the button from it, holds it
122
+ // busy with the pairing modal's light round it until the login ends, and lets
123
+ // it go on every outcome. No wrapper, no busy state of your own.
132
124
  return (
133
- // The wrapper runs the pairing modal's light around the button while busy.
134
- // `block` because this button fills its row; `radius` is the button's own.
135
- <ZorealBusyRing busy={busy} radius={12} block>
136
- <button
137
- type="button"
138
- disabled={busy}
139
- aria-busy={busy}
140
- onClick={() => {
141
- if (busy) return;
142
- setBusy(true);
143
- login(); // from the click handler itself: on a phone this tap is the navigation
144
- }}
145
- >
146
- <ZorealMark size={22} brand />
147
- Continue with ZOREAL
148
- </button>
149
- </ZorealBusyRing>
125
+ <button type="button" onClick={login}>
126
+ <ZorealMark size={22} brand />
127
+ Continue with ZOREAL
128
+ </button>
150
129
  );
151
130
  }
152
131
  ```
153
132
 
154
133
  That is the whole integration. When `login()` runs on a computer, the provider
155
- puts the pairing modal on screen. On a phone the tap itself navigates to the
156
- provider, which opens the ZOREAL ID app; once the person has approved, the app
157
- brings them back to this page, and the same hook finishes the sign-in and calls
158
- your `onSuccess` there. So mount this component on the page the sign-in starts
159
- from, and expect `onSuccess` on a fresh page load. See
160
- [The pairing modal](#the-pairing-modal) for what it does and how to theme,
161
- translate, time out or replace it.
134
+ puts the pairing modal on screen and the button you tapped waits, disabled, with
135
+ the light round it. On a phone the tap itself navigates to the provider, which
136
+ opens the ZOREAL ID app; once the person has approved, the app brings them back
137
+ to this page, and the same hook finishes the sign-in and calls your `onSuccess`
138
+ there. So mount this component on the page the sign-in starts from, and expect
139
+ `onSuccess` on a fresh page load. See [The pairing modal](#the-pairing-modal)
140
+ for what it does and how to theme, translate, time out or replace it.
162
141
 
163
142
  ## Quick start: the button (no backend, pseudonymous)
164
143
 
@@ -286,7 +265,7 @@ What the modal does:
286
265
 
287
266
  | | |
288
267
  | --- | --- |
289
- | **Mobile** | No QR and no modal. The tap itself is a navigation: the SDK sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously from the click, and the provider answers with a redirect to the pairing's universal link, which the ZOREAL ID app claims while the page stays put and polls. A browser hands a link to an app only inside a navigation the person began, which is why nothing is fetched first. With no app installed the same redirect lands on the page that installs it. Call `login()` from the click handler itself: `ZorealLogin` does, disables itself and runs a light round its edge until the flow ends. Once the holder has approved, the app reopens your page with the pairing named in the fragment, and the first `useZorealLogin` or `ZorealLogin` on that page finishes the sign-in there, through the same `onSuccess` and `onError`; the tab that was left behind stands down when the returned page finishes first; a site with its own button keeps that button and draws its own busy state from the tap until `onSuccess` or `onError` fires, or wraps it in `ZorealBusyRing` to get the same light (a wrapper: pass `block` for a full-width button, keep `overflow: hidden` off its ancestors, and expect `.parent > button` selectors to stop matching). Force one or the other with `display: 'qr'` / `'link'`. |
268
+ | **Mobile** | No QR and no modal. The tap itself is a navigation: the SDK sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously from the click, and the provider answers with a redirect to the pairing's universal link, which the ZOREAL ID app claims while the page stays put and polls. A browser hands a link to an app only inside a navigation the person began, which is why nothing is fetched first. With no app installed the same redirect lands on the page that installs it. Pass the click event through to `login` (`onClick={login}`): the SDK takes the button from it, disables it and runs the light round it until the flow ends, then lets it go, on every outcome. `ZorealLogin` does the same for itself. Once the holder has approved, the app reopens your page with the pairing named in the fragment, and the first `useZorealLogin` or `ZorealLogin` on that page finishes the sign-in there, through the same `onSuccess` and `onError`; the tab that was left behind stands down when the returned page finishes first; a site with its own button gets the same by passing the click to `login`; `ZorealBusyRing` remains for a site that wants to drive the light itself (a wrapper: pass `block` for a full-width button, keep `overflow: hidden` off its ancestors, and expect `.parent > button` selectors to stop matching). Force one or the other with `display: 'qr'` / `'link'`. |
290
269
  | **Live status** | The copy and the title follow the pairing: waiting for a scan, then waiting for approval once the holder has claimed the code (the spent QR blurs out behind a phone glyph). |
291
270
  | **Title** | Says what the scan is for, inferred from the request: "Scan to sign in" for `openid`, `email` and `profile.name`; "Scan to verify your identity" once a document attribute such as `zoreal.age` or `profile.birthdate` is requested; "Scan to prove you are a real human" for `openid` alone with `acr_values: 'zoreal.live'`. Override with `intent`, one of `'sign-in'`, `'identify'`, `'presence'`, when the scope does not say. |
292
271
  | **Countdown** | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
@@ -613,18 +592,17 @@ error path.
613
592
  ## A complete example
614
593
 
615
594
  A full sign-in component, end to end, the shape a production auth-code
616
- integration takes: your own button, busy from the tap until the flow ends,
617
- the SDK's pairing modal on a computer and the app hand-over on a phone,
618
- `{ code, code_verifier, nonce }` to your backend on success, the human outcomes
619
- treated as the non-events they are, and the return from the app on a phone
620
- handled by the same hook.
595
+ integration takes: your own button, held busy by the SDK from the tap until
596
+ the flow ends, the SDK's pairing modal on a computer and the app hand-over on
597
+ a phone, `{ code, code_verifier, nonce }` to your backend on success, the human
598
+ outcomes treated as the non-events they are, and the return from the app on a
599
+ phone handled by the same hook.
621
600
 
622
601
  ```tsx
623
602
  import { useState } from 'react';
624
- import { ZorealOAuthProvider, useZorealLogin, ZorealBusyRing, ZorealMark } from '@zoreal/oauth2-react';
603
+ import { ZorealOAuthProvider, useZorealLogin, ZorealMark } from '@zoreal/oauth2-react';
625
604
 
626
605
  function ZorealSignIn() {
627
- const [busy, setBusy] = useState(false);
628
606
  const [note, setNote] = useState<string | null>(null);
629
607
 
630
608
  const login = useZorealLogin({
@@ -633,7 +611,6 @@ function ZorealSignIn() {
633
611
  // acr_values: 'zoreal.live', // request a fresh liveness for a step-up / high-value login
634
612
 
635
613
  onSuccess: async ({ code, code_verifier, nonce }) => {
636
- setBusy(false);
637
614
  // Post ALL THREE to YOUR backend over TLS. Your backend does the /token
638
615
  // exchange with its client authentication, verifies the ID token
639
616
  // (ES256 against the JWKS, iss/aud/exp, and this nonce), checks the acr
@@ -654,15 +631,11 @@ function ZorealSignIn() {
654
631
  },
655
632
 
656
633
  // An OAuth error from the provider (e.g. a scope not on your allow list).
657
- onError: (e) => {
658
- setBusy(false);
659
- setNote(e.description ?? e.error); // the provider's words, verbatim
660
- },
634
+ onError: (e) => setNote(e.description ?? e.error), // the provider's words, verbatim
661
635
 
662
636
  // The human outcomes: declined, expired, the dialog closed. Not faults:
663
637
  // the button is ready again. Do not alarm on these.
664
638
  onNonOAuthError: (e) => {
665
- setBusy(false);
666
639
  if (e.type === 'request_denied') setNote('Login was declined. Try again when ready.');
667
640
  else if (e.type === 'request_expired') setNote('That took too long. Try again.');
668
641
  else if (e.type === 'popup_closed') setNote(null);
@@ -672,21 +645,12 @@ function ZorealSignIn() {
672
645
 
673
646
  return (
674
647
  <div>
675
- <ZorealBusyRing busy={busy} radius={12} block>
676
- <button
677
- type="button"
678
- disabled={busy}
679
- aria-busy={busy}
680
- onClick={() => {
681
- if (busy) return;
682
- setBusy(true);
683
- login();
684
- }}
685
- >
686
- <ZorealMark size={22} brand />
687
- Continue with ZOREAL
688
- </button>
689
- </ZorealBusyRing>
648
+ {/* The click goes through as it is: the SDK holds this button busy, with
649
+ the light round it, until the login ends. */}
650
+ <button type="button" onClick={login}>
651
+ <ZorealMark size={22} brand />
652
+ Continue with ZOREAL
653
+ </button>
690
654
 
691
655
  {note && <p role="status">{note}</p>}
692
656
  </div>
package/dist/index.cjs CHANGED
@@ -42,7 +42,7 @@ var import_react2 = require("react");
42
42
 
43
43
  // src/wire.ts
44
44
  var WIRE_VERSION = 1;
45
- var SDK_VERSION = "0.2.20";
45
+ var SDK_VERSION = "0.2.21";
46
46
  var DEFAULT_ISSUER = "https://id.zoreal.com";
47
47
  var POLL_INTERVAL_MS = 2e3;
48
48
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
@@ -1397,6 +1397,16 @@ var CSS = `
1397
1397
  transition: opacity 200ms ease-out;
1398
1398
  }
1399
1399
  .${PREFIX}-ring[data-busy="true"] > .${PREFIX}-ring-svg { opacity: 1; }
1400
+ /* The same light as an overlay in the document body, placed over a site's
1401
+ own control by the package itself (busy.ts): nothing of the site's
1402
+ markup or CSS is touched, and neither an ancestor's overflow nor a
1403
+ selector on the control's parent is affected. */
1404
+ .${PREFIX}-ring-overlay {
1405
+ position: fixed;
1406
+ display: block;
1407
+ z-index: 2147483000;
1408
+ pointer-events: none;
1409
+ }
1400
1410
  .${PREFIX}-ring-svg rect {
1401
1411
  --zrl-l: var(--zrl-ring-len, 600px);
1402
1412
  x: 2px;
@@ -1637,6 +1647,96 @@ var import_react5 = require("react");
1637
1647
  // src/useZorealLogin.ts
1638
1648
  var import_react3 = require("react");
1639
1649
 
1650
+ // src/busy.ts
1651
+ var SVG_NS = "http://www.w3.org/2000/svg";
1652
+ var TAIL = 0.3;
1653
+ var STACK = Array.from({ length: 12 }, (_, i) => 12 - i);
1654
+ var HALO = [3, 2, 1];
1655
+ function radiusOf(control) {
1656
+ const value = parseFloat(getComputedStyle(control).borderTopLeftRadius);
1657
+ return Number.isFinite(value) ? value : 8;
1658
+ }
1659
+ function holdBusy(control) {
1660
+ if (typeof document === "undefined") return () => {
1661
+ };
1662
+ ensureStyles();
1663
+ const hadDisabled = control.hasAttribute("disabled");
1664
+ const hadBusy = control.getAttribute("aria-busy");
1665
+ if (control instanceof HTMLButtonElement || control instanceof HTMLInputElement) {
1666
+ control.disabled = true;
1667
+ } else {
1668
+ control.setAttribute("aria-disabled", "true");
1669
+ }
1670
+ control.setAttribute("aria-busy", "true");
1671
+ const overlay = document.createElement("div");
1672
+ overlay.className = `${cx("root")} ${cx("ring")} ${cx("ring-overlay")}`;
1673
+ overlay.dataset.theme = "auto";
1674
+ overlay.dataset.busy = "true";
1675
+ overlay.setAttribute("aria-hidden", "true");
1676
+ const svg = document.createElementNS(SVG_NS, "svg");
1677
+ svg.setAttribute("class", cx("ring-svg"));
1678
+ const rx = radiusOf(control) + 2;
1679
+ const layer = (name, len, alpha) => {
1680
+ const rect = document.createElementNS(SVG_NS, "rect");
1681
+ rect.setAttribute("class", cx(name));
1682
+ rect.setAttribute("rx", String(rx));
1683
+ rect.setAttribute("ry", String(rx));
1684
+ rect.style.strokeDasharray = `calc(var(--zrl-l) * ${len}) calc(var(--zrl-l) * ${1 - len})`;
1685
+ rect.style.setProperty("--zrl-s", `calc(var(--zrl-l) * ${-(TAIL - len)})`);
1686
+ rect.style.opacity = alpha;
1687
+ svg.appendChild(rect);
1688
+ return rect;
1689
+ };
1690
+ let measured = null;
1691
+ for (const n of HALO) {
1692
+ const rect = layer("ring-halo", TAIL * n / HALO.length, `calc(var(--zrl-glow-opacity) * 0.6 / ${n})`);
1693
+ measured ?? (measured = rect);
1694
+ }
1695
+ for (const n of STACK) layer(n <= 2 ? "ring-head" : "ring-tail", TAIL * n / STACK.length, String(1 / n));
1696
+ overlay.appendChild(svg);
1697
+ document.body.appendChild(overlay);
1698
+ let frame = 0;
1699
+ const place = () => {
1700
+ frame = 0;
1701
+ const box = control.getBoundingClientRect();
1702
+ overlay.style.left = `${box.left}px`;
1703
+ overlay.style.top = `${box.top}px`;
1704
+ overlay.style.width = `${box.width}px`;
1705
+ overlay.style.height = `${box.height}px`;
1706
+ const length = typeof measured?.getTotalLength === "function" ? measured.getTotalLength() : 0;
1707
+ if (length > 0) overlay.style.setProperty("--zrl-ring-len", `${length}px`);
1708
+ };
1709
+ const schedule = () => {
1710
+ if (!frame) frame = requestAnimationFrame(place);
1711
+ };
1712
+ place();
1713
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
1714
+ observer?.observe(control);
1715
+ window.addEventListener("scroll", schedule, true);
1716
+ window.addEventListener("resize", schedule);
1717
+ let released = false;
1718
+ return () => {
1719
+ if (released) return;
1720
+ released = true;
1721
+ if (frame) cancelAnimationFrame(frame);
1722
+ observer?.disconnect();
1723
+ window.removeEventListener("scroll", schedule, true);
1724
+ window.removeEventListener("resize", schedule);
1725
+ overlay.remove();
1726
+ if (control instanceof HTMLButtonElement || control instanceof HTMLInputElement) {
1727
+ control.disabled = hadDisabled;
1728
+ } else {
1729
+ control.removeAttribute("aria-disabled");
1730
+ }
1731
+ if (hadBusy === null) control.removeAttribute("aria-busy");
1732
+ else control.setAttribute("aria-busy", hadBusy);
1733
+ };
1734
+ }
1735
+ function controlFrom(event) {
1736
+ const target = event?.currentTarget;
1737
+ return typeof HTMLElement !== "undefined" && target instanceof HTMLElement ? target : null;
1738
+ }
1739
+
1640
1740
  // src/return.ts
1641
1741
  var PREFIX2 = "zoreal:oauth2:return:";
1642
1742
  var DONE = "zoreal:oauth2:done:";
@@ -2101,6 +2201,7 @@ function useZorealFlow(options) {
2101
2201
  () => () => {
2102
2202
  abortRef.current?.abort();
2103
2203
  publishRef.current?.(null);
2204
+ releaseRef.current();
2104
2205
  },
2105
2206
  []
2106
2207
  );
@@ -2159,10 +2260,21 @@ function useZorealFlow(options) {
2159
2260
  }
2160
2261
  })();
2161
2262
  }, [clientId, issuer]);
2162
- const login = (0, import_react3.useCallback)(() => {
2263
+ const releaseRef = (0, import_react3.useRef)(() => {
2264
+ });
2265
+ const login = (0, import_react3.useCallback)((event) => {
2163
2266
  const opts = optionsRef.current;
2267
+ const control = controlFrom(event);
2164
2268
  const run = async () => {
2165
2269
  abortRef.current?.abort();
2270
+ releaseRef.current();
2271
+ releaseRef.current = control ? holdBusy(control) : () => {
2272
+ };
2273
+ const release = () => {
2274
+ releaseRef.current();
2275
+ releaseRef.current = () => {
2276
+ };
2277
+ };
2166
2278
  const controller = new AbortController();
2167
2279
  abortRef.current = controller;
2168
2280
  const flow = opts.flow;
@@ -2311,6 +2423,7 @@ function useZorealFlow(options) {
2311
2423
  setPairing(null);
2312
2424
  publishRef.current?.(null);
2313
2425
  if (returnId) markReturnDone(returnId);
2426
+ release();
2314
2427
  if (flow === "auth-code") {
2315
2428
  opts.onCode?.({
2316
2429
  code,
@@ -2335,6 +2448,7 @@ function useZorealFlow(options) {
2335
2448
  };
2336
2449
  opts.onCredential?.(response);
2337
2450
  } catch (e) {
2451
+ release();
2338
2452
  setPairing(null);
2339
2453
  publishRef.current?.(null);
2340
2454
  if (e instanceof DOMException && e.name === "AbortError") {
@@ -2383,9 +2497,9 @@ function useZorealLogin(options) {
2383
2497
  // src/ring.tsx
2384
2498
  var import_react4 = require("react");
2385
2499
  var import_jsx_runtime5 = require("react/jsx-runtime");
2386
- var TAIL = 0.3;
2387
- var STACK = Array.from({ length: 12 }, (_, i) => 12 - i);
2388
- var HALO = [3, 2, 1];
2500
+ var TAIL2 = 0.3;
2501
+ var STACK2 = Array.from({ length: 12 }, (_, i) => 12 - i);
2502
+ var HALO2 = [3, 2, 1];
2389
2503
  function ZorealBusyRing({
2390
2504
  busy,
2391
2505
  radius = 8,
@@ -2424,7 +2538,7 @@ function ZorealBusyRing({
2424
2538
  ry: rx,
2425
2539
  style: {
2426
2540
  strokeDasharray: `calc(var(--zrl-l) * ${len}) calc(var(--zrl-l) * ${1 - len})`,
2427
- "--zrl-s": `calc(var(--zrl-l) * ${-(TAIL - len)})`,
2541
+ "--zrl-s": `calc(var(--zrl-l) * ${-(TAIL2 - len)})`,
2428
2542
  opacity: alpha
2429
2543
  }
2430
2544
  },
@@ -2441,10 +2555,10 @@ function ZorealBusyRing({
2441
2555
  children: [
2442
2556
  children,
2443
2557
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("svg", { className: cx("ring-svg"), "aria-hidden": "true", children: [
2444
- HALO.map(
2445
- (n, i) => layer(`h${n}`, "ring-halo", TAIL * n / HALO.length, `calc(var(--zrl-glow-opacity) * 0.6 / ${n})`, i === 0 ? measureRef : void 0)
2558
+ HALO2.map(
2559
+ (n, i) => layer(`h${n}`, "ring-halo", TAIL2 * n / HALO2.length, `calc(var(--zrl-glow-opacity) * 0.6 / ${n})`, i === 0 ? measureRef : void 0)
2446
2560
  ),
2447
- STACK.map((n) => layer(`t${n}`, n <= 2 ? "ring-head" : "ring-tail", TAIL * n / STACK.length, String(1 / n)))
2561
+ STACK2.map((n) => layer(`t${n}`, n <= 2 ? "ring-head" : "ring-tail", TAIL2 * n / STACK2.length, String(1 / n)))
2448
2562
  ] })
2449
2563
  ]
2450
2564
  }