@zoreal/oauth2-react 0.2.18 → 0.2.20

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,41 +89,76 @@ 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, the way a production sign-in page uses it.
93
+ import { useState } from 'react';
94
+ import { useZorealLogin, ZorealBusyRing, ZorealMark } from '@zoreal/oauth2-react';
95
+
96
+ 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);
94
101
 
95
- function ZorealSignIn() {
96
102
  // `email` (and profile.name, etc.) are returned from /userinfo on your backend.
97
103
  const login = useZorealLogin({
98
104
  flow: 'auth-code',
99
105
  scope: 'openid email profile.name',
100
106
  onSuccess: async ({ code, code_verifier, nonce }) => {
107
+ setBusy(false);
101
108
  // Send ALL THREE to your backend over TLS. It calls POST /token with the
102
109
  // code and verifier plus its client authentication, verifies the ID
103
110
  // token's nonce is this one, then reads the email and name from
104
111
  // /userinfo. That is where personal data is delivered.
105
- await fetch('/api/auth/zoreal', {
112
+ const res = await fetch('/api/auth/zoreal', {
106
113
  method: 'POST',
107
114
  headers: { 'Content-Type': 'application/json' },
108
115
  body: JSON.stringify({ code, code_verifier, nonce }),
109
116
  });
117
+ if (res.ok) onSignedIn();
118
+ },
119
+ onError: (e) => {
120
+ setBusy(false);
121
+ console.error(e.description ?? e.error);
110
122
  },
111
- onError: (e) => console.error(e.description ?? e.error),
112
123
  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;
124
+ setBusy(false);
125
+ // The holder declining or ignoring the request, or closing the dialog,
126
+ // is not an error to surface: the button is simply ready again.
127
+ if (e.type === 'request_denied' || e.type === 'request_expired' || e.type === 'popup_closed') return;
115
128
  console.error(e.description ?? e.type);
116
129
  },
117
130
  });
118
131
 
119
- return <button onClick={login}>Continue with ZOREAL</button>;
132
+ 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>
150
+ );
120
151
  }
121
152
  ```
122
153
 
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.
154
+ 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.
127
162
 
128
163
  ## Quick start: the button (no backend, pseudonymous)
129
164
 
@@ -251,7 +286,7 @@ What the modal does:
251
286
 
252
287
  | | |
253
288
  | --- | --- |
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; 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'`. |
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'`. |
255
290
  | **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
291
  | **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
292
  | **Countdown** | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
@@ -551,6 +586,7 @@ These reach your callbacks before any backend is involved — handle them here:
551
586
  | `/pair` | `onError` | `login_required` | `prompt: 'none'` with no silent session to resume — the expected quiet outcome, not a failure (`useZorealAutoLogin` turns it into `onUnavailable`) |
552
587
  | pairing | `onNonOAuthError` | `request_denied` | The holder declined in their ZOREAL ID app — **not an error to alarm on**; offer to try again |
553
588
  | pairing | `onNonOAuthError` | `request_expired` | The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
589
+ | pairing | `onNonOAuthError` | `popup_closed` | The person closed the dialog, cancelled it, pressed Escape, tapped outside it, or let it time out — **not an error to alarm on**; clear your busy state and let them tap again |
554
590
 
555
591
  ### The type unions
556
592
 
@@ -576,28 +612,28 @@ error path.
576
612
 
577
613
  ## A complete example
578
614
 
579
- A full sign-in component, end to end the shape a real auth-code integration
580
- takes. It renders the button, renders its own pairing UI (the hook renders
581
- none), hands `{ code, code_verifier, nonce }` to your backend on success, and
582
- treats the decline/expiry path as the non-events they are.
615
+ 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.
583
621
 
584
622
  ```tsx
585
623
  import { useState } from 'react';
586
- import { ZorealOAuthProvider, useZorealLogin } from '@zoreal/oauth2-react';
587
- import type { PairingState } from '@zoreal/oauth2-react';
624
+ import { ZorealOAuthProvider, useZorealLogin, ZorealBusyRing, ZorealMark } from '@zoreal/oauth2-react';
588
625
 
589
626
  function ZorealSignIn() {
590
- const [pairing, setPairing] = useState<PairingState | null>(null);
627
+ const [busy, setBusy] = useState(false);
591
628
  const [note, setNote] = useState<string | null>(null);
592
629
 
593
630
  const login = useZorealLogin({
594
631
  flow: 'auth-code',
595
632
  scope: 'openid email profile.name',
596
633
  // acr_values: 'zoreal.live', // request a fresh liveness for a step-up / high-value login
597
- onPairingStateChange: setPairing,
598
634
 
599
635
  onSuccess: async ({ code, code_verifier, nonce }) => {
600
- setPairing(null);
636
+ setBusy(false);
601
637
  // Post ALL THREE to YOUR backend over TLS. Your backend does the /token
602
638
  // exchange with its client authentication, verifies the ID token
603
639
  // (ES256 against the JWKS, iss/aud/exp, and this nonce), checks the acr
@@ -619,37 +655,38 @@ function ZorealSignIn() {
619
655
 
620
656
  // An OAuth error from the provider (e.g. a scope not on your allow list).
621
657
  onError: (e) => {
622
- setPairing(null);
658
+ setBusy(false);
623
659
  setNote(e.description ?? e.error); // the provider's words, verbatim
624
660
  },
625
661
 
626
- // The human outcomes: declined, expired, cancelled. Not faults — clear the
627
- // pairing UI and let them try again. Do not alarm on these.
662
+ // The human outcomes: declined, expired, the dialog closed. Not faults:
663
+ // the button is ready again. Do not alarm on these.
628
664
  onNonOAuthError: (e) => {
629
- setPairing(null);
665
+ setBusy(false);
630
666
  if (e.type === 'request_denied') setNote('Login was declined. Try again when ready.');
631
667
  else if (e.type === 'request_expired') setNote('That took too long. Try again.');
668
+ else if (e.type === 'popup_closed') setNote(null);
632
669
  else setNote('Something went wrong. Try again.');
633
670
  },
634
671
  });
635
672
 
636
673
  return (
637
674
  <div>
638
- <button onClick={login}>Continue with ZOREAL</button>
639
-
640
- {/* The hook renders nothing, so the pairing UI is yours. On desktop the
641
- login cannot complete until something shows pairing.qrUrl to scan. */}
642
- {pairing && !pairing.appLink && ['pending', 'claimed'].includes(pairing.status) && (
643
- <div role="dialog" aria-label="Log in with ZOREAL">
644
- <img src={pairing.qrUrl} alt="Log in with ZOREAL" width={200} height={200} />
645
- <p>
646
- {pairing.status === 'claimed'
647
- ? 'Approve the login in your ZOREAL ID app.'
648
- : 'Scan with your phone camera or the ZOREAL ID app.'}
649
- </p>
650
- <button onClick={pairing.cancel}>Cancel</button>
651
- </div>
652
- )}
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>
653
690
 
654
691
  {note && <p role="status">{note}</p>}
655
692
  </div>
@@ -658,13 +695,20 @@ function ZorealSignIn() {
658
695
 
659
696
  export default function App() {
660
697
  return (
661
- <ZorealOAuthProvider clientId="ast_your_asset_id">
698
+ <ZorealOAuthProvider clientId="ast_your_asset_id" locale="en" theme="auto">
662
699
  <ZorealSignIn />
663
700
  </ZorealOAuthProvider>
664
701
  );
665
702
  }
666
703
  ```
667
704
 
705
+ On a computer the provider renders the pairing modal for this button; nothing
706
+ here draws a QR. To draw your own instead, see
707
+ [Rendering it yourself](#rendering-it-yourself). On a phone there is no dialog:
708
+ the tap navigates to the provider, the ZOREAL ID app opens, and after the
709
+ approval the app reopens this page, where `useZorealLogin` finishes the sign-in
710
+ and `onSuccess` runs on that fresh page load.
711
+
668
712
  **The backend must verify.** This component only starts the flow and forwards a
669
713
  code; on its own it proves nothing. The security is your backend exchanging the
670
714
  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.18";
45
+ var SDK_VERSION = "0.2.20";
45
46
  var DEFAULT_ISSUER = "https://id.zoreal.com";
46
47
  var POLL_INTERVAL_MS = 2e3;
47
48
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
@@ -1636,6 +1637,76 @@ var import_react5 = require("react");
1636
1637
  // src/useZorealLogin.ts
1637
1638
  var import_react3 = require("react");
1638
1639
 
1640
+ // src/return.ts
1641
+ var PREFIX2 = "zoreal:oauth2:return:";
1642
+ var DONE = "zoreal:oauth2:done:";
1643
+ var MAX_AGE_MS = 10 * 60 * 1e3;
1644
+ function storage() {
1645
+ try {
1646
+ return typeof localStorage === "undefined" ? null : localStorage;
1647
+ } catch {
1648
+ return null;
1649
+ }
1650
+ }
1651
+ function saveReturnFlow(flow) {
1652
+ try {
1653
+ storage()?.setItem(PREFIX2 + flow.requestId, JSON.stringify(flow));
1654
+ } catch {
1655
+ }
1656
+ }
1657
+ function peekReturnFlow(requestId) {
1658
+ const store = storage();
1659
+ if (!store) return null;
1660
+ const raw = store.getItem(PREFIX2 + requestId);
1661
+ if (!raw) return null;
1662
+ try {
1663
+ const flow = JSON.parse(raw);
1664
+ if (flow.v !== 1 || flow.requestId !== requestId) return null;
1665
+ if (Date.now() - flow.createdAt > MAX_AGE_MS) return null;
1666
+ return flow;
1667
+ } catch {
1668
+ return null;
1669
+ }
1670
+ }
1671
+ function forgetReturnFlow(requestId) {
1672
+ try {
1673
+ storage()?.removeItem(PREFIX2 + requestId);
1674
+ } catch {
1675
+ }
1676
+ if (pending === requestId) pending = null;
1677
+ }
1678
+ function markReturnDone(requestId) {
1679
+ try {
1680
+ const store = storage();
1681
+ store?.setItem(DONE + requestId, String(Date.now()));
1682
+ store?.removeItem(PREFIX2 + requestId);
1683
+ } catch {
1684
+ }
1685
+ }
1686
+ function isReturnDone(requestId) {
1687
+ return storage()?.getItem(DONE + requestId) !== null && storage()?.getItem(DONE + requestId) !== void 0;
1688
+ }
1689
+ function returnToUrl() {
1690
+ if (typeof window === "undefined") return void 0;
1691
+ const { href } = window.location;
1692
+ const hash = href.indexOf("#");
1693
+ return hash === -1 ? href : href.slice(0, hash);
1694
+ }
1695
+ var RETURN_MARK = /(?:^|[#&])zoreal_return=([A-Za-z0-9]{32})(?:&|$)/;
1696
+ var pending = null;
1697
+ function pendingReturnId() {
1698
+ if (pending) return pending;
1699
+ if (typeof window === "undefined") return null;
1700
+ const match = RETURN_MARK.exec(window.location.hash);
1701
+ if (!match) return null;
1702
+ pending = match[1];
1703
+ try {
1704
+ window.history.replaceState(window.history.state, "", returnToUrl());
1705
+ } catch {
1706
+ }
1707
+ return pending;
1708
+ }
1709
+
1639
1710
  // src/jwt.ts
1640
1711
  function unsafeClaims(idToken) {
1641
1712
  try {
@@ -2015,6 +2086,7 @@ function generateRequestId() {
2015
2086
  }
2016
2087
 
2017
2088
  // src/useZorealLogin.ts
2089
+ var resumedReturns = /* @__PURE__ */ new Set();
2018
2090
  function useZorealFlow(options) {
2019
2091
  const { clientId, issuer, locale } = useZorealOAuth();
2020
2092
  const [pairing, setPairing] = (0, import_react3.useState)(null);
@@ -2024,6 +2096,7 @@ function useZorealFlow(options) {
2024
2096
  const abortRef = (0, import_react3.useRef)(null);
2025
2097
  const optionsRef = (0, import_react3.useRef)(options);
2026
2098
  optionsRef.current = options;
2099
+ const closedByPerson = (0, import_react3.useRef)(false);
2027
2100
  (0, import_react3.useEffect)(
2028
2101
  () => () => {
2029
2102
  abortRef.current?.abort();
@@ -2031,6 +2104,61 @@ function useZorealFlow(options) {
2031
2104
  },
2032
2105
  []
2033
2106
  );
2107
+ (0, import_react3.useEffect)(() => {
2108
+ const id = pendingReturnId();
2109
+ if (!id || resumedReturns.has(id)) return;
2110
+ const saved = peekReturnFlow(id);
2111
+ if (!saved || saved.clientId !== clientId) return;
2112
+ forgetReturnFlow(id);
2113
+ resumedReturns.add(id);
2114
+ const controller = new AbortController();
2115
+ abortRef.current = controller;
2116
+ void (async () => {
2117
+ const opts = optionsRef.current;
2118
+ try {
2119
+ const code = await pollUntilApproved(issuer, id, void 0, controller.signal, {
2120
+ tolerateUnknownUntil: Date.now() + 5e3
2121
+ });
2122
+ if (saved.flow === "auth-code") {
2123
+ opts.onCode?.({
2124
+ code,
2125
+ scope: saved.scope,
2126
+ app_state: saved.appState,
2127
+ code_verifier: saved.verifier,
2128
+ nonce: saved.nonce
2129
+ });
2130
+ } else {
2131
+ const tokens = await exchangeCode(issuer, {
2132
+ code,
2133
+ code_verifier: saved.verifier,
2134
+ client_id: clientId
2135
+ });
2136
+ const claims = unsafeClaims(tokens.id_token);
2137
+ opts.onCredential?.({
2138
+ credential: tokens.id_token,
2139
+ clientId,
2140
+ select_by: "app_link",
2141
+ acr: claims.acr ?? "zoreal.device"
2142
+ });
2143
+ }
2144
+ markReturnDone(id);
2145
+ } catch (e) {
2146
+ if (e instanceof DOMException && e.name === "AbortError") return;
2147
+ if (e instanceof FlowAbandonedError) {
2148
+ opts.onNonOAuthError?.(e.reason);
2149
+ return;
2150
+ }
2151
+ if (e instanceof OAuthFlowError) {
2152
+ opts.onError?.({ error: e.error, description: e.description });
2153
+ return;
2154
+ }
2155
+ opts.onNonOAuthError?.({
2156
+ type: "unknown",
2157
+ description: e instanceof Error ? e.message : String(e)
2158
+ });
2159
+ }
2160
+ })();
2161
+ }, [clientId, issuer]);
2034
2162
  const login = (0, import_react3.useCallback)(() => {
2035
2163
  const opts = optionsRef.current;
2036
2164
  const run = async () => {
@@ -2047,8 +2175,23 @@ function useZorealFlow(options) {
2047
2175
  try {
2048
2176
  let code;
2049
2177
  let selectBy = "device";
2178
+ let returnId = null;
2050
2179
  if (useAppLink) {
2051
2180
  const requestId = generateRequestId();
2181
+ saveReturnFlow({
2182
+ v: 1,
2183
+ issuer,
2184
+ clientId,
2185
+ flow,
2186
+ verifier,
2187
+ nonce,
2188
+ state,
2189
+ scope: opts.scope ?? "openid",
2190
+ appState: opts.app_state,
2191
+ requestId,
2192
+ createdAt: Date.now()
2193
+ });
2194
+ returnId = requestId;
2052
2195
  const startUrl = sameDeviceStartUrl(issuer, {
2053
2196
  client_id: clientId,
2054
2197
  scope: opts.scope ?? "openid",
@@ -2061,10 +2204,12 @@ function useZorealFlow(options) {
2061
2204
  prompt: opts.prompt,
2062
2205
  locale,
2063
2206
  request_id: requestId,
2064
- origin: window.location.origin
2207
+ origin: window.location.origin,
2208
+ return_to: returnToUrl()
2065
2209
  });
2066
2210
  selectBy = "app_link";
2067
2211
  const cancel = () => {
2212
+ closedByPerson.current = true;
2068
2213
  controller.abort();
2069
2214
  setPairing(null);
2070
2215
  };
@@ -2091,6 +2236,9 @@ function useZorealFlow(options) {
2091
2236
  controller.signal,
2092
2237
  { tolerateUnknownUntil: Date.now() + 15e3 }
2093
2238
  );
2239
+ if (isReturnDone(requestId)) {
2240
+ throw new DOMException("aborted", "AbortError");
2241
+ }
2094
2242
  } else {
2095
2243
  const started = await startPairing(issuer, {
2096
2244
  client_id: clientId,
@@ -2112,6 +2260,7 @@ function useZorealFlow(options) {
2112
2260
  selectBy = "qr";
2113
2261
  const qrRefreshSeconds = qrRefreshSecondsOf(started);
2114
2262
  const cancel = () => {
2263
+ closedByPerson.current = true;
2115
2264
  controller.abort();
2116
2265
  setPairing(null);
2117
2266
  publishRef.current?.(null);
@@ -2161,6 +2310,7 @@ function useZorealFlow(options) {
2161
2310
  }
2162
2311
  setPairing(null);
2163
2312
  publishRef.current?.(null);
2313
+ if (returnId) markReturnDone(returnId);
2164
2314
  if (flow === "auth-code") {
2165
2315
  opts.onCode?.({
2166
2316
  code,
@@ -2187,7 +2337,16 @@ function useZorealFlow(options) {
2187
2337
  } catch (e) {
2188
2338
  setPairing(null);
2189
2339
  publishRef.current?.(null);
2190
- if (e instanceof DOMException && e.name === "AbortError") return;
2340
+ if (e instanceof DOMException && e.name === "AbortError") {
2341
+ if (closedByPerson.current) {
2342
+ closedByPerson.current = false;
2343
+ opts.onNonOAuthError?.({
2344
+ type: "popup_closed",
2345
+ description: "the sign-in dialog was closed before the holder approved"
2346
+ });
2347
+ }
2348
+ return;
2349
+ }
2191
2350
  if (e instanceof FlowAbandonedError) {
2192
2351
  opts.onNonOAuthError?.(e.reason);
2193
2352
  return;
@@ -2432,6 +2591,7 @@ function hasGrantedAnyScopeZoreal(response, firstScope, ...restScopes) {
2432
2591
  PairingModal,
2433
2592
  ZorealBusyRing,
2434
2593
  ZorealLogin,
2594
+ ZorealMark,
2435
2595
  ZorealOAuthProvider,
2436
2596
  hasGrantedAllScopesZoreal,
2437
2597
  hasGrantedAnyScopeZoreal,