@zoreal/oauth2-react 0.2.19 → 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,10 +89,10 @@ import { ZorealOAuthProvider } from '@zoreal/oauth2-react';
89
89
  ```
90
90
 
91
91
  ```tsx
92
- // ZorealSignIn.tsx
93
- import { useZorealLogin } from '@zoreal/oauth2-react';
92
+ // ZorealSignIn.tsx — your own button, styled your way.
93
+ import { useZorealLogin, ZorealMark } from '@zoreal/oauth2-react';
94
94
 
95
- function ZorealSignIn() {
95
+ function ZorealSignIn({ onSignedIn }: { onSignedIn: () => void }) {
96
96
  // `email` (and profile.name, etc.) are returned from /userinfo on your backend.
97
97
  const login = useZorealLogin({
98
98
  flow: 'auth-code',
@@ -102,28 +102,42 @@ function ZorealSignIn() {
102
102
  // code and verifier plus its client authentication, verifies the ID
103
103
  // token's nonce is this one, then reads the email and name from
104
104
  // /userinfo. That is where personal data is delivered.
105
- await fetch('/api/auth/zoreal', {
105
+ const res = await fetch('/api/auth/zoreal', {
106
106
  method: 'POST',
107
107
  headers: { 'Content-Type': 'application/json' },
108
108
  body: JSON.stringify({ code, code_verifier, nonce }),
109
109
  });
110
+ if (res.ok) onSignedIn();
110
111
  },
111
112
  onError: (e) => console.error(e.description ?? e.error),
112
113
  onNonOAuthError: (e) => {
113
- // The holder closing or ignoring the request is not an error to surface.
114
- if (e.type === 'request_denied' || e.type === 'request_expired') return;
114
+ // The holder declining or ignoring the request, or closing the dialog,
115
+ // is not an error to surface: the button is simply ready again.
116
+ if (e.type === 'request_denied' || e.type === 'request_expired' || e.type === 'popup_closed') return;
115
117
  console.error(e.description ?? e.type);
116
118
  },
117
119
  });
118
120
 
119
- return <button onClick={login}>Continue with ZOREAL</button>;
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.
124
+ return (
125
+ <button type="button" onClick={login}>
126
+ <ZorealMark size={22} brand />
127
+ Continue with ZOREAL
128
+ </button>
129
+ );
120
130
  }
121
131
  ```
122
132
 
123
- That is the whole integration. When `login()` runs, the provider puts the
124
- pairing modal on screen; on a phone it skips the QR and opens the ZOREAL ID
125
- app instead. See [The pairing modal](#the-pairing-modal) for what it does and
126
- how to theme, translate, time out or replace it.
133
+ That is the whole integration. When `login()` runs on a computer, the provider
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.
127
141
 
128
142
  ## Quick start: the button (no backend, pseudonymous)
129
143
 
@@ -251,7 +265,7 @@ What the modal does:
251
265
 
252
266
  | | |
253
267
  | --- | --- |
254
- | **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'`. |
255
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). |
256
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. |
257
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. |
@@ -577,28 +591,26 @@ error path.
577
591
 
578
592
  ## A complete example
579
593
 
580
- A full sign-in component, end to end the shape a real auth-code integration
581
- takes. It renders the button, renders its own pairing UI (the hook renders
582
- none), hands `{ code, code_verifier, nonce }` to your backend on success, and
583
- treats the decline/expiry path as the non-events they are.
594
+ A full sign-in component, end to end, the shape a production auth-code
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.
584
600
 
585
601
  ```tsx
586
602
  import { useState } from 'react';
587
- import { ZorealOAuthProvider, useZorealLogin } from '@zoreal/oauth2-react';
588
- import type { PairingState } from '@zoreal/oauth2-react';
603
+ import { ZorealOAuthProvider, useZorealLogin, ZorealMark } from '@zoreal/oauth2-react';
589
604
 
590
605
  function ZorealSignIn() {
591
- const [pairing, setPairing] = useState<PairingState | null>(null);
592
606
  const [note, setNote] = useState<string | null>(null);
593
607
 
594
608
  const login = useZorealLogin({
595
609
  flow: 'auth-code',
596
610
  scope: 'openid email profile.name',
597
611
  // acr_values: 'zoreal.live', // request a fresh liveness for a step-up / high-value login
598
- onPairingStateChange: setPairing,
599
612
 
600
613
  onSuccess: async ({ code, code_verifier, nonce }) => {
601
- setPairing(null);
602
614
  // Post ALL THREE to YOUR backend over TLS. Your backend does the /token
603
615
  // exchange with its client authentication, verifies the ID token
604
616
  // (ES256 against the JWKS, iss/aud/exp, and this nonce), checks the acr
@@ -619,38 +631,26 @@ function ZorealSignIn() {
619
631
  },
620
632
 
621
633
  // An OAuth error from the provider (e.g. a scope not on your allow list).
622
- onError: (e) => {
623
- setPairing(null);
624
- setNote(e.description ?? e.error); // the provider's words, verbatim
625
- },
634
+ onError: (e) => setNote(e.description ?? e.error), // the provider's words, verbatim
626
635
 
627
- // The human outcomes: declined, expired, cancelled. Not faults — clear the
628
- // pairing UI and let them try again. Do not alarm on these.
636
+ // The human outcomes: declined, expired, the dialog closed. Not faults:
637
+ // the button is ready again. Do not alarm on these.
629
638
  onNonOAuthError: (e) => {
630
- setPairing(null);
631
639
  if (e.type === 'request_denied') setNote('Login was declined. Try again when ready.');
632
640
  else if (e.type === 'request_expired') setNote('That took too long. Try again.');
641
+ else if (e.type === 'popup_closed') setNote(null);
633
642
  else setNote('Something went wrong. Try again.');
634
643
  },
635
644
  });
636
645
 
637
646
  return (
638
647
  <div>
639
- <button onClick={login}>Continue with ZOREAL</button>
640
-
641
- {/* The hook renders nothing, so the pairing UI is yours. On desktop the
642
- login cannot complete until something shows pairing.qrUrl to scan. */}
643
- {pairing && !pairing.appLink && ['pending', 'claimed'].includes(pairing.status) && (
644
- <div role="dialog" aria-label="Log in with ZOREAL">
645
- <img src={pairing.qrUrl} alt="Log in with ZOREAL" width={200} height={200} />
646
- <p>
647
- {pairing.status === 'claimed'
648
- ? 'Approve the login in your ZOREAL ID app.'
649
- : 'Scan with your phone camera or the ZOREAL ID app.'}
650
- </p>
651
- <button onClick={pairing.cancel}>Cancel</button>
652
- </div>
653
- )}
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>
654
654
 
655
655
  {note && <p role="status">{note}</p>}
656
656
  </div>
@@ -659,13 +659,20 @@ function ZorealSignIn() {
659
659
 
660
660
  export default function App() {
661
661
  return (
662
- <ZorealOAuthProvider clientId="ast_your_asset_id">
662
+ <ZorealOAuthProvider clientId="ast_your_asset_id" locale="en" theme="auto">
663
663
  <ZorealSignIn />
664
664
  </ZorealOAuthProvider>
665
665
  );
666
666
  }
667
667
  ```
668
668
 
669
+ On a computer the provider renders the pairing modal for this button; nothing
670
+ here draws a QR. To draw your own instead, see
671
+ [Rendering it yourself](#rendering-it-yourself). On a phone there is no dialog:
672
+ the tap navigates to the provider, the ZOREAL ID app opens, and after the
673
+ approval the app reopens this page, where `useZorealLogin` finishes the sign-in
674
+ and `onSuccess` runs on that fresh page load.
675
+
669
676
  **The backend must verify.** This component only starts the flow and forwards a
670
677
  code; on its own it proves nothing. The security is your backend exchanging the
671
678
  code with its client authentication and verifying the signed ID token — use a
package/dist/index.cjs CHANGED
@@ -25,6 +25,7 @@ __export(index_exports, {
25
25
  PairingModal: () => PairingModal,
26
26
  ZorealBusyRing: () => ZorealBusyRing,
27
27
  ZorealLogin: () => ZorealLogin,
28
+ ZorealMark: () => ZorealMark,
28
29
  ZorealOAuthProvider: () => ZorealOAuthProvider,
29
30
  hasGrantedAllScopesZoreal: () => hasGrantedAllScopesZoreal,
30
31
  hasGrantedAnyScopeZoreal: () => hasGrantedAnyScopeZoreal,
@@ -41,7 +42,7 @@ var import_react2 = require("react");
41
42
 
42
43
  // src/wire.ts
43
44
  var WIRE_VERSION = 1;
44
- var SDK_VERSION = "0.2.19";
45
+ var SDK_VERSION = "0.2.21";
45
46
  var DEFAULT_ISSUER = "https://id.zoreal.com";
46
47
  var POLL_INTERVAL_MS = 2e3;
47
48
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
@@ -1396,6 +1397,16 @@ var CSS = `
1396
1397
  transition: opacity 200ms ease-out;
1397
1398
  }
1398
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
+ }
1399
1410
  .${PREFIX}-ring-svg rect {
1400
1411
  --zrl-l: var(--zrl-ring-len, 600px);
1401
1412
  x: 2px;
@@ -1636,6 +1647,96 @@ var import_react5 = require("react");
1636
1647
  // src/useZorealLogin.ts
1637
1648
  var import_react3 = require("react");
1638
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
+
1639
1740
  // src/return.ts
1640
1741
  var PREFIX2 = "zoreal:oauth2:return:";
1641
1742
  var DONE = "zoreal:oauth2:done:";
@@ -2100,6 +2201,7 @@ function useZorealFlow(options) {
2100
2201
  () => () => {
2101
2202
  abortRef.current?.abort();
2102
2203
  publishRef.current?.(null);
2204
+ releaseRef.current();
2103
2205
  },
2104
2206
  []
2105
2207
  );
@@ -2158,10 +2260,21 @@ function useZorealFlow(options) {
2158
2260
  }
2159
2261
  })();
2160
2262
  }, [clientId, issuer]);
2161
- const login = (0, import_react3.useCallback)(() => {
2263
+ const releaseRef = (0, import_react3.useRef)(() => {
2264
+ });
2265
+ const login = (0, import_react3.useCallback)((event) => {
2162
2266
  const opts = optionsRef.current;
2267
+ const control = controlFrom(event);
2163
2268
  const run = async () => {
2164
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
+ };
2165
2278
  const controller = new AbortController();
2166
2279
  abortRef.current = controller;
2167
2280
  const flow = opts.flow;
@@ -2310,6 +2423,7 @@ function useZorealFlow(options) {
2310
2423
  setPairing(null);
2311
2424
  publishRef.current?.(null);
2312
2425
  if (returnId) markReturnDone(returnId);
2426
+ release();
2313
2427
  if (flow === "auth-code") {
2314
2428
  opts.onCode?.({
2315
2429
  code,
@@ -2334,6 +2448,7 @@ function useZorealFlow(options) {
2334
2448
  };
2335
2449
  opts.onCredential?.(response);
2336
2450
  } catch (e) {
2451
+ release();
2337
2452
  setPairing(null);
2338
2453
  publishRef.current?.(null);
2339
2454
  if (e instanceof DOMException && e.name === "AbortError") {
@@ -2382,9 +2497,9 @@ function useZorealLogin(options) {
2382
2497
  // src/ring.tsx
2383
2498
  var import_react4 = require("react");
2384
2499
  var import_jsx_runtime5 = require("react/jsx-runtime");
2385
- var TAIL = 0.3;
2386
- var STACK = Array.from({ length: 12 }, (_, i) => 12 - i);
2387
- 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];
2388
2503
  function ZorealBusyRing({
2389
2504
  busy,
2390
2505
  radius = 8,
@@ -2423,7 +2538,7 @@ function ZorealBusyRing({
2423
2538
  ry: rx,
2424
2539
  style: {
2425
2540
  strokeDasharray: `calc(var(--zrl-l) * ${len}) calc(var(--zrl-l) * ${1 - len})`,
2426
- "--zrl-s": `calc(var(--zrl-l) * ${-(TAIL - len)})`,
2541
+ "--zrl-s": `calc(var(--zrl-l) * ${-(TAIL2 - len)})`,
2427
2542
  opacity: alpha
2428
2543
  }
2429
2544
  },
@@ -2440,10 +2555,10 @@ function ZorealBusyRing({
2440
2555
  children: [
2441
2556
  children,
2442
2557
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("svg", { className: cx("ring-svg"), "aria-hidden": "true", children: [
2443
- HALO.map(
2444
- (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)
2445
2560
  ),
2446
- 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)))
2447
2562
  ] })
2448
2563
  ]
2449
2564
  }
@@ -2590,6 +2705,7 @@ function hasGrantedAnyScopeZoreal(response, firstScope, ...restScopes) {
2590
2705
  PairingModal,
2591
2706
  ZorealBusyRing,
2592
2707
  ZorealLogin,
2708
+ ZorealMark,
2593
2709
  ZorealOAuthProvider,
2594
2710
  hasGrantedAllScopesZoreal,
2595
2711
  hasGrantedAnyScopeZoreal,