@iqauth/sdk 2.6.4 → 2.8.1

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.
Files changed (117) hide show
  1. package/README.md +173 -1
  2. package/dist/browser-session.d.mts +4 -4
  3. package/dist/browser-session.d.ts +4 -4
  4. package/dist/browser-session.js +212 -46
  5. package/dist/browser-session.mjs +3 -3
  6. package/dist/browser.d.mts +5 -5
  7. package/dist/browser.d.ts +5 -5
  8. package/dist/browser.js +293 -34
  9. package/dist/browser.mjs +5 -5
  10. package/dist/{chunk-BVV54LPI.mjs → chunk-25SSYDIP.mjs} +10 -4
  11. package/dist/{chunk-XAWYUPMO.mjs → chunk-4V7FKOTG.mjs} +242 -22
  12. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  13. package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
  14. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  15. package/dist/chunk-GLXSIGVS.mjs +66 -0
  16. package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
  17. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  18. package/dist/chunk-JRDVUWAL.mjs +46 -0
  19. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  20. package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
  21. package/dist/chunk-VYQ3ETCK.mjs +244 -0
  22. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  23. package/dist/chunk-WHT6WKTY.mjs +3180 -0
  24. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  25. package/dist/chunk-WSH4SW7F.mjs +490 -0
  26. package/dist/{chunk-W3F4JYGP.mjs → chunk-ZLJPABB7.mjs} +139 -23
  27. package/dist/cli/index.js +2 -2
  28. package/dist/cli/index.mjs +2 -2
  29. package/dist/{client-BNQe3AgF.d.ts → client-D8L-PaWr.d.mts} +59 -6
  30. package/dist/{client-kYlJFgPv.d.mts → client-DkPL0EPZ.d.ts} +59 -6
  31. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  32. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  33. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  34. package/dist/{express-CHpfa7D_.d.ts → express-Budysq4h.d.ts} +2 -2
  35. package/dist/{express-B6_1vBYZ.d.mts → express-DDTA3qV1.d.mts} +2 -2
  36. package/dist/express.d.mts +7 -6
  37. package/dist/express.d.ts +7 -6
  38. package/dist/express.js +563 -85
  39. package/dist/express.mjs +73 -34
  40. package/dist/fastify.d.mts +10 -0
  41. package/dist/fastify.d.ts +10 -0
  42. package/dist/fastify.js +589 -65
  43. package/dist/fastify.mjs +101 -11
  44. package/dist/hono.d.mts +10 -0
  45. package/dist/hono.d.ts +10 -0
  46. package/dist/hono.js +566 -65
  47. package/dist/hono.mjs +78 -11
  48. package/dist/index-Cko-d5po.d.mts +1848 -0
  49. package/dist/index-RNqwEcmY.d.ts +1848 -0
  50. package/dist/index.d.mts +56 -8
  51. package/dist/index.d.ts +56 -8
  52. package/dist/index.js +694 -75
  53. package/dist/index.mjs +30 -10
  54. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  55. package/dist/locales.d.mts +1 -1
  56. package/dist/locales.d.ts +1 -1
  57. package/dist/locales.js +36 -0
  58. package/dist/locales.mjs +1 -1
  59. package/dist/mobile.d.mts +77 -7
  60. package/dist/mobile.d.ts +77 -7
  61. package/dist/mobile.js +307 -46
  62. package/dist/mobile.mjs +98 -3
  63. package/dist/next.d.mts +10 -1
  64. package/dist/next.d.ts +10 -1
  65. package/dist/next.js +596 -205
  66. package/dist/next.mjs +83 -10
  67. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
  68. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
  69. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  70. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  71. package/dist/react-permissions.d.mts +52 -0
  72. package/dist/react-permissions.d.ts +52 -0
  73. package/dist/react-permissions.js +239 -0
  74. package/dist/react-permissions.mjs +98 -0
  75. package/dist/react.d.mts +9 -1624
  76. package/dist/react.d.ts +9 -1624
  77. package/dist/react.js +882 -73
  78. package/dist/react.mjs +71 -2631
  79. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  80. package/dist/server/handlers.d.mts +200 -4
  81. package/dist/server/handlers.d.ts +200 -4
  82. package/dist/server/handlers.js +530 -16
  83. package/dist/server/handlers.mjs +14 -3
  84. package/dist/server.d.mts +171 -8
  85. package/dist/server.d.ts +171 -8
  86. package/dist/server.js +579 -61
  87. package/dist/server.mjs +99 -12
  88. package/dist/service.d.mts +4 -4
  89. package/dist/service.d.ts +4 -4
  90. package/dist/service.js +212 -46
  91. package/dist/service.mjs +3 -3
  92. package/dist/{signIn-CiIBTJIh.d.mts → signIn-CReqfXsh.d.mts} +95 -3
  93. package/dist/{signIn-OCr88Zf8.d.ts → signIn-Cfa1GTpO.d.ts} +95 -3
  94. package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
  95. package/dist/test.mjs +3 -3
  96. package/dist/{tokens-DCyzzn8L.d.mts → tokens-9F6ETrzk.d.ts} +9 -2
  97. package/dist/{tokens-aHiGFr_E.d.ts → tokens-B06VtvUi.d.mts} +9 -2
  98. package/dist/{types-DZAflmmq.d.mts → types-Bn8O-OEd.d.mts} +164 -11
  99. package/dist/{types-DZAflmmq.d.ts → types-Bn8O-OEd.d.ts} +164 -11
  100. package/dist/{types-6bNdxesb.d.ts → types-DnU2LhXR.d.mts} +7 -1
  101. package/dist/{types-6bNdxesb.d.mts → types-DnU2LhXR.d.ts} +7 -1
  102. package/dist/webhooks.d.mts +113 -17
  103. package/dist/webhooks.d.ts +113 -17
  104. package/dist/webhooks.js +179 -15
  105. package/dist/webhooks.mjs +7 -1
  106. package/dist/ws.d.mts +2 -2
  107. package/dist/ws.d.ts +2 -2
  108. package/dist/ws.js +80 -30
  109. package/dist/ws.mjs +4 -4
  110. package/docs/error-handling.md +101 -0
  111. package/docs/guides/effective-permissions.md +171 -0
  112. package/docs/guides/invitations.md +65 -0
  113. package/package.json +19 -4
  114. package/dist/chunk-6TDJJER7.mjs +0 -217
  115. package/dist/chunk-UKZLOHZG.mjs +0 -83
  116. package/dist/errors-CDdl24MP.d.mts +0 -52
  117. package/dist/errors-CDdl24MP.d.ts +0 -52
package/dist/react.js CHANGED
@@ -25,13 +25,30 @@ var IQAuthError;
25
25
  var init_errors = __esm({
26
26
  "src/errors.ts"() {
27
27
  "use strict";
28
- IQAuthError = class extends Error {
29
- constructor(code, message, status, raw) {
28
+ IQAuthError = class _IQAuthError extends Error {
29
+ constructor(code, message, status, cause) {
30
30
  super(message);
31
31
  this.name = "IQAuthError";
32
32
  this.code = code;
33
33
  this.status = status;
34
- this.raw = raw;
34
+ this.cause = cause;
35
+ this.raw = cause;
36
+ }
37
+ /**
38
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
39
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
40
+ */
41
+ static isIQAuthError(value) {
42
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
43
+ }
44
+ /**
45
+ * Type-narrowed code check. Lets callers write
46
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
47
+ * taxonomy without losing the ability to handle server codes via
48
+ * `err.code === "TOKEN_REVOKED"`.
49
+ */
50
+ is(code) {
51
+ return this.code === code;
35
52
  }
36
53
  };
37
54
  }
@@ -177,7 +194,7 @@ async function buildSignInUrl(manager, opts = {}) {
177
194
  returnTo,
178
195
  createdAt: Date.now()
179
196
  });
180
- const url2 = new URL(opts.signInPath ?? DEFAULT_SIGN_IN_PATH, manager.issuerUrl);
197
+ const url2 = new URL(opts.signInPath ?? DEFAULT_SIGN_IN_PATH, manager.hostedIssuerUrl);
181
198
  url2.searchParams.set("response_type", "code");
182
199
  url2.searchParams.set("app", manager.appKey);
183
200
  url2.searchParams.set("publishable_key", manager.publishableKey.raw);
@@ -192,33 +209,50 @@ async function buildSignInUrl(manager, opts = {}) {
192
209
  return url2.toString();
193
210
  }
194
211
  async function redirectToSignIn(manager, opts = {}) {
195
- const url2 = await buildSignInUrl(manager, opts);
196
- if (typeof window === "undefined") {
197
- throw new Error("redirectToSignIn requires a browser environment");
212
+ const t0 = Date.now();
213
+ let ok = false;
214
+ let code;
215
+ try {
216
+ const url2 = await buildSignInUrl(manager, opts);
217
+ if (typeof window === "undefined") {
218
+ code = "NO_WINDOW";
219
+ throw new Error("redirectToSignIn requires a browser environment");
220
+ }
221
+ ok = true;
222
+ manager.recordTiming("signIn", Date.now() - t0, true);
223
+ window.location.assign(url2);
224
+ } catch (err) {
225
+ if (!ok) manager.recordTiming("signIn", Date.now() - t0, false, code ?? (err instanceof Error ? err.message : "ERROR"));
226
+ throw err;
198
227
  }
199
- window.location.assign(url2);
200
228
  }
201
229
  async function signIn(manager, opts = {}) {
202
230
  return redirectToSignIn(manager, opts);
203
231
  }
204
232
  async function handleAuthCallback(manager, options = {}) {
233
+ const t0 = Date.now();
234
+ const emit = (ok, code2) => manager.recordTiming("signIn", Date.now() - t0, ok, code2);
205
235
  const url2 = new URL(options.url ?? (typeof window !== "undefined" ? window.location.href : ""));
206
236
  const code = url2.searchParams.get("code");
207
237
  const state = url2.searchParams.get("state");
208
238
  const errorParam = url2.searchParams.get("error");
209
239
  if (errorParam) {
240
+ emit(false, errorParam);
210
241
  return { ok: false, returnTo: "/", error: errorParam };
211
242
  }
212
243
  if (!code || !state) {
244
+ emit(false, "missing_code_or_state");
213
245
  return { ok: false, returnTo: "/", error: "missing_code_or_state" };
214
246
  }
215
247
  const record = loadPkce(state);
216
248
  if (!record) {
249
+ emit(false, "unknown_state");
217
250
  return { ok: false, returnTo: "/", error: "unknown_state" };
218
251
  }
219
252
  clearPkce(state);
220
253
  const fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
221
254
  if (!fetchImpl) {
255
+ emit(false, "no_fetch");
222
256
  return { ok: false, returnTo: record.returnTo, error: "no_fetch" };
223
257
  }
224
258
  const tokenUrl = `${manager.issuerUrl}${options.tokenPath ?? DEFAULT_TOKEN_PATH}`;
@@ -237,10 +271,12 @@ async function handleAuthCallback(manager, options = {}) {
237
271
  const body = await res.json().catch(() => ({}));
238
272
  if (!res.ok) {
239
273
  const desc = body.error_description ?? body.error ?? "token_exchange_failed";
274
+ emit(false, desc);
240
275
  return { ok: false, returnTo: record.returnTo, error: desc };
241
276
  }
242
277
  const tokens = body;
243
278
  if (!tokens.access_token) {
279
+ emit(false, "missing_access_token");
244
280
  return { ok: false, returnTo: record.returnTo, error: "missing_access_token" };
245
281
  }
246
282
  if (tokens.refresh_token) {
@@ -248,21 +284,24 @@ async function handleAuthCallback(manager, options = {}) {
248
284
  setCookie(cookieName, tokens.refresh_token, { maxAgeSeconds: 60 * 60 * 24 * 30 });
249
285
  }
250
286
  manager.applyAccessToken(tokens.access_token, tokens.refresh_token);
287
+ emit(true);
251
288
  return { ok: true, returnTo: record.returnTo };
252
289
  }
253
290
  async function signOut(manager, opts = {}) {
254
291
  if (!opts.localOnly) {
255
292
  const issuer = manager.issuerUrl.replace(/\/$/, "");
293
+ const idempotency = manager.getIdempotencyToken();
256
294
  try {
257
295
  const url2 = `${issuer}${opts.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
258
- await manager.fetch(url2, { method: "POST" }).catch(() => void 0);
296
+ await manager.fetch(url2, { method: "POST", headers: { "X-IQAuth-Idempotency": idempotency } }).catch(() => void 0);
259
297
  } catch {
260
298
  }
261
299
  if (opts.endSsoSession !== false) {
262
300
  try {
263
301
  await fetch(`${issuer}${DEFAULT_SSO_LOGOUT_PATH}`, {
264
302
  method: "POST",
265
- credentials: "include"
303
+ credentials: "include",
304
+ headers: { "X-IQAuth-Idempotency": idempotency }
266
305
  }).catch(() => void 0);
267
306
  } catch {
268
307
  }
@@ -782,6 +821,7 @@ __export(react_exports, {
782
821
  Protect: () => Protect,
783
822
  RedirectToSignIn: () => RedirectToSignIn,
784
823
  RedirectToSignedIn: () => RedirectToSignedIn,
824
+ ScopeSwitcher: () => ScopeSwitcher,
785
825
  SignIn: () => SignIn,
786
826
  SignUp: () => SignUp,
787
827
  SignedIn: () => SignedIn,
@@ -789,10 +829,15 @@ __export(react_exports, {
789
829
  UserButton: () => UserButton,
790
830
  UserProfile: () => UserProfile,
791
831
  Waitlist: () => Waitlist,
832
+ __useIQAuthInternal: () => __useIQAuthInternal,
792
833
  __version__: () => __version__,
834
+ claimSatisfiesScope: () => claimSatisfiesScope,
793
835
  isReturnToAllowed: () => isReturnToAllowed,
794
836
  isSilentSsoEligible: () => isSilentSsoEligible,
837
+ performScopeSwitch: () => performScopeSwitch,
838
+ performTenantSwitch: () => performTenantSwitch,
795
839
  preflightReturnTo: () => preflightReturnTo,
840
+ resolveAfterSignInDestination: () => resolveAfterSignInDestination,
796
841
  revokeSession: () => revokeSession,
797
842
  sanitizeBrandCss: () => sanitizeBrandCss,
798
843
  sanitizeReturnTo: () => sanitizeReturnTo,
@@ -806,6 +851,7 @@ __export(react_exports, {
806
851
  useLinkedIdentities: () => useLinkedIdentities,
807
852
  useLocale: () => useLocale,
808
853
  useMagicLink: () => useMagicLink,
854
+ useMemberships: () => useMemberships,
809
855
  useOrganization: () => useOrganization,
810
856
  usePasskey: () => usePasskey,
811
857
  useResolvedSdkBranding: () => useResolvedSdkBranding,
@@ -854,14 +900,14 @@ function assertPublishableKey(raw, opts) {
854
900
  const ctx = opts?.context ? `${opts.context}: ` : "";
855
901
  if (typeof raw !== "string" || raw.length === 0) {
856
902
  throw new IQAuthError(
857
- "CONFIG_INVALID",
903
+ "config_invalid",
858
904
  `${ctx}IQAuth publishable key is missing. Set IQAUTH_PUBLISHABLE_KEY (or pass publishableKey) to a pk_test_\u2026 or pk_live_\u2026 value from the IQAuth admin console.`
859
905
  );
860
906
  }
861
907
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
862
908
  if (!shapeMatch) {
863
909
  throw new IQAuthError(
864
- "CONFIG_INVALID",
910
+ "config_invalid",
865
911
  `${ctx}IQAuth publishable key is malformed (got ${raw.slice(0, 12)}\u2026). Expected pk_test_\u2026 or pk_live_\u2026; regenerate the key from the IQAuth admin console.`
866
912
  );
867
913
  }
@@ -870,19 +916,19 @@ function assertPublishableKey(raw, opts) {
870
916
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
871
917
  } catch {
872
918
  throw new IQAuthError(
873
- "CONFIG_INVALID",
919
+ "config_invalid",
874
920
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
875
921
  );
876
922
  }
877
923
  if (!isPublishableKeyPayload(decoded)) {
878
924
  throw new IQAuthError(
879
- "CONFIG_INVALID",
925
+ "config_invalid",
880
926
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
881
927
  );
882
928
  }
883
929
  if (!isValidIssuerUrl(decoded.iss)) {
884
930
  throw new IQAuthError(
885
- "CONFIG_INVALID",
931
+ "config_invalid",
886
932
  `${ctx}IQAuth publishable key encodes an invalid issuer (iss=${JSON.stringify(decoded.iss)}). Expected a fully-qualified URL like "https://auth.example.com" (scheme required). Regenerate the key from the IQAuth admin console \u2014 the new key will encode a valid issuer URL.`
887
933
  );
888
934
  }
@@ -896,6 +942,7 @@ function isPublishableKeyPayload(value) {
896
942
 
897
943
  // src/browser/sessionManager.ts
898
944
  init_storage();
945
+ var PROBE_WAIT_MS = 80;
899
946
  var DEFAULT_REFRESH_PATH = "/api/v1/auth/refresh";
900
947
  var DEFAULT_USERINFO_PATH = "/api/v1/auth/me";
901
948
  async function readAuthErrorCode(res) {
@@ -933,7 +980,16 @@ function claimsToSessionUser(claims) {
933
980
  tenantId: claims.tenantId,
934
981
  vendorId: claims.vendorId,
935
982
  roles: claims.roles ?? [],
936
- entitlements: claims.entitlements ?? []
983
+ entitlements: claims.entitlements ?? [],
984
+ // SDK 2.7.0 (Task #124) — pass through identity claims when issued.
985
+ ...claims.picture !== void 0 ? { picture: claims.picture } : {},
986
+ ...claims.email_verified !== void 0 ? { emailVerified: claims.email_verified } : {},
987
+ ...claims.given_name !== void 0 ? { givenName: claims.given_name } : {},
988
+ ...claims.family_name !== void 0 ? { familyName: claims.family_name } : {},
989
+ ...claims.locale !== void 0 ? { locale: claims.locale } : {},
990
+ // Task #171 — surface the active source/client scope when the token was
991
+ // minted scoped, so consumers reading useUser().user can branch on it.
992
+ ...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
937
993
  };
938
994
  }
939
995
  var EMPTY = {
@@ -957,11 +1013,50 @@ var NO_OP_STORE = {
957
1013
  write: () => void 0,
958
1014
  clear: () => void 0
959
1015
  };
1016
+ var IDEMPOTENCY_HEADER = "X-IQAuth-Idempotency";
1017
+ function randomIdempotencyToken() {
1018
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
1019
+ const bytes = new Uint8Array(16);
1020
+ crypto.getRandomValues(bytes);
1021
+ let out = "";
1022
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
1023
+ return out;
1024
+ }
1025
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1026
+ }
960
1027
  var SessionManager = class {
961
1028
  constructor(options) {
962
1029
  this.snapshot = { ...EMPTY };
963
1030
  this.listeners = /* @__PURE__ */ new Set();
964
1031
  this.refreshPromise = null;
1032
+ /**
1033
+ * Cancellation handle for the in-flight refresh, if any. `signOut()` (or a
1034
+ * `session:signout` broadcast from another tab) calls `abort()` so the
1035
+ * refresh response is dropped before it can write a fresh access cookie
1036
+ * on top of the just-cleared session — the second root cause of "ghost
1037
+ * signed-in" sessions after Sign Out.
1038
+ */
1039
+ this.refreshAbort = null;
1040
+ /**
1041
+ * Set to `true` by `signOut()` / `signOutLocal()` for the lifetime of the
1042
+ * call. Used as a safety belt: even if a refresh response arrives while
1043
+ * `refreshAbort` was unable to interrupt the network call (e.g. the body
1044
+ * was already streaming back), `runRefresh` checks this flag before
1045
+ * mutating session state and bails out.
1046
+ */
1047
+ this.signoutInProgress = false;
1048
+ /**
1049
+ * Per-session opaque idempotency token. Sent as `X-IQAuth-Idempotency` on
1050
+ * every /refresh and /signout request the SDK makes through a framework
1051
+ * adapter (Express/Fastify/Hono/Next), so the adapter's `SignoutRegistry`
1052
+ * can collapse a refresh that lands moments after a signout — even when
1053
+ * the two requests are routed to different server instances (multi-replica
1054
+ * deployments).
1055
+ *
1056
+ * Generated lazily on first use, rotated on signout so the next session
1057
+ * starts with a fresh token. Opaque random — never the raw refresh token.
1058
+ */
1059
+ this.idempotencyToken = null;
965
1060
  this.channel = null;
966
1061
  this.proactiveTimer = null;
967
1062
  this.bootstrapped = false;
@@ -969,6 +1064,8 @@ var SessionManager = class {
969
1064
  this.remoteRefreshWaiters = [];
970
1065
  /** Active claims by other tabs (keyed by source tabId). */
971
1066
  this.foreignClaim = null;
1067
+ /** Resolver for an in-flight cross-tab `session:probe`, set during bootstrap. */
1068
+ this.probeResolver = null;
972
1069
  const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/browser SessionManager" });
973
1070
  this.key = parsed;
974
1071
  const inferred = options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`);
@@ -981,6 +1078,8 @@ var SessionManager = class {
981
1078
  this.refreshCookieName = options.cookieNames?.refresh ?? REFRESH_COOKIE;
982
1079
  this.tokenStore = options.tokenStore ?? (this.serverManagedSession ? NO_OP_STORE : this.useCookies ? defaultCookieStore(this.refreshCookieName) : NO_OP_STORE);
983
1080
  this.crossTabLockTimeoutMs = options.crossTabLockTimeoutMs ?? 4e3;
1081
+ this.debug = options.debug ?? false;
1082
+ this.onTimingEvent = options.onTimingEvent ?? null;
984
1083
  this.fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
985
1084
  throw new Error("global fetch is not available; pass fetchImpl");
986
1085
  }));
@@ -1007,10 +1106,35 @@ var SessionManager = class {
1007
1106
  get issuerUrl() {
1008
1107
  return this.issuer;
1009
1108
  }
1109
+ /**
1110
+ * SDK 2.7.0 (Task #124) — The hosted IQAuth host derived from the
1111
+ * publishable key's `iss` claim, normalized to URL form. This is what
1112
+ * `<SignIn/>` and `buildSignInUrl` use to talk to the hosted UI; it
1113
+ * deliberately ignores the `issuer` constructor override so a misrouted
1114
+ * `issuer` (e.g. pointed at the consumer app's own domain) cannot break
1115
+ * the hosted flow. Use {@link issuerUrl} for token / discovery endpoints.
1116
+ */
1117
+ get hostedIssuerUrl() {
1118
+ const iss = this.key.iss;
1119
+ return (iss.startsWith("http") ? iss : `https://${iss}`).replace(/\/+$/, "");
1120
+ }
1010
1121
  /** Cookie name the SDK uses for the refresh token (overridable via `cookieNames.refresh`). */
1011
1122
  get refreshCookie() {
1012
1123
  return this.refreshCookieName;
1013
1124
  }
1125
+ /**
1126
+ * Returns the current per-session idempotency token, generating one
1127
+ * lazily on first use. Sent as the `X-IQAuth-Idempotency` header on
1128
+ * /refresh and /signout requests so the framework adapter's
1129
+ * `SignoutRegistry` can collapse a refresh-vs-signout race even across
1130
+ * server instances.
1131
+ */
1132
+ getIdempotencyToken() {
1133
+ if (!this.idempotencyToken) {
1134
+ this.idempotencyToken = randomIdempotencyToken();
1135
+ }
1136
+ return this.idempotencyToken;
1137
+ }
1014
1138
  getSnapshot() {
1015
1139
  return this.snapshot;
1016
1140
  }
@@ -1024,9 +1148,44 @@ var SessionManager = class {
1024
1148
  * One-time bootstrap: warm the session from the refresh cookie if present.
1025
1149
  * Safe to call multiple times.
1026
1150
  */
1151
+ /**
1152
+ * Task #126: Public timing-event emitter. Used by the browser sign-in
1153
+ * helpers (redirectToSignIn / handleAuthCallback) to surface signIn-phase
1154
+ * timings through the same `debug` + `onTimingEvent` channel as
1155
+ * bootstrap/refresh. Safe to call from anywhere — internal callers
1156
+ * pre-compute durationMs.
1157
+ */
1158
+ recordTiming(phase, durationMs, ok, code) {
1159
+ this.emitTiming(phase, durationMs, ok, code);
1160
+ }
1161
+ /** Task #126: emit a session timing event to debug log + onTimingEvent hook. */
1162
+ emitTiming(phase, durationMs, ok, code) {
1163
+ if (this.debug) {
1164
+ try {
1165
+ console.debug("[iqauth_session]", { phase, durationMs, ok, code });
1166
+ } catch {
1167
+ }
1168
+ }
1169
+ if (this.onTimingEvent) {
1170
+ try {
1171
+ this.onTimingEvent({ phase, durationMs, ok, code });
1172
+ } catch {
1173
+ }
1174
+ }
1175
+ }
1027
1176
  async bootstrap() {
1028
1177
  if (this.bootstrapped) return;
1029
1178
  this.bootstrapped = true;
1179
+ const t0 = Date.now();
1180
+ try {
1181
+ await this.bootstrapInner();
1182
+ this.emitTiming("bootstrap", Date.now() - t0, this.snapshot.status === "authenticated");
1183
+ } catch (err) {
1184
+ this.emitTiming("bootstrap", Date.now() - t0, false, err instanceof Error ? err.message : "ERROR");
1185
+ throw err;
1186
+ }
1187
+ }
1188
+ async bootstrapInner() {
1030
1189
  if (this.serverManagedSession) {
1031
1190
  try {
1032
1191
  const res = await this.fetchImpl(`${this.issuer}${this.userinfoPath}`, {
@@ -1061,6 +1220,15 @@ var SessionManager = class {
1061
1220
  return;
1062
1221
  }
1063
1222
  }
1223
+ const peerSnapshot = await this.probePeers();
1224
+ if (peerSnapshot && peerSnapshot.status === "authenticated") {
1225
+ this.update({
1226
+ ...peerSnapshot,
1227
+ version: Math.max(this.snapshot.version, peerSnapshot.version) + 1
1228
+ });
1229
+ this.scheduleProactiveRefresh();
1230
+ return;
1231
+ }
1064
1232
  const stored = await Promise.resolve(this.tokenStore.read());
1065
1233
  if (!stored) {
1066
1234
  this.setStatus("unauthenticated");
@@ -1069,6 +1237,22 @@ var SessionManager = class {
1069
1237
  const ok = await this.refresh();
1070
1238
  if (!ok) this.setStatus("unauthenticated");
1071
1239
  }
1240
+ probePeers() {
1241
+ if (!this.channel) return Promise.resolve(null);
1242
+ return new Promise((resolve) => {
1243
+ let settled = false;
1244
+ const finish = (snap) => {
1245
+ if (settled) return;
1246
+ settled = true;
1247
+ this.probeResolver = null;
1248
+ clearTimeout(timer);
1249
+ resolve(snap);
1250
+ };
1251
+ this.probeResolver = (snap) => finish(snap);
1252
+ const timer = setTimeout(() => finish(null), PROBE_WAIT_MS);
1253
+ this.broadcastEnvelope({ type: "session:probe", source: this.tabId, ts: Date.now() });
1254
+ });
1255
+ }
1072
1256
  /**
1073
1257
  * Single-flight token refresh, coordinated across tabs via BroadcastChannel.
1074
1258
  *
@@ -1082,30 +1266,48 @@ var SessionManager = class {
1082
1266
  */
1083
1267
  refresh() {
1084
1268
  if (this.refreshPromise) return this.refreshPromise;
1085
- this.refreshPromise = this.runRefresh().finally(() => {
1269
+ const t0 = Date.now();
1270
+ this.refreshPromise = this.runRefresh().then((ok) => {
1271
+ this.emitTiming("refresh", Date.now() - t0, ok, ok ? void 0 : this.snapshot.error?.code ?? "REFRESH_FAILED");
1272
+ return ok;
1273
+ }).finally(() => {
1086
1274
  this.refreshPromise = null;
1087
1275
  });
1088
1276
  return this.refreshPromise;
1089
1277
  }
1090
1278
  async runRefresh() {
1091
- const myClaim = { source: this.tabId, ts: Date.now() };
1092
- if (this.channel) {
1093
- this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
1094
- await new Promise((r) => setTimeout(r, 25));
1095
- const foreign = this.foreignClaim;
1096
- if (foreign && this.claimWins(foreign, myClaim)) {
1097
- return this.waitForForeignRefresh();
1098
- }
1099
- }
1279
+ const abort = new AbortController();
1280
+ this.refreshAbort = abort;
1100
1281
  try {
1282
+ const myClaim = { source: this.tabId, ts: Date.now() };
1283
+ if (this.channel) {
1284
+ this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
1285
+ await new Promise((r) => setTimeout(r, 25));
1286
+ if (abort.signal.aborted || this.signoutInProgress) {
1287
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1288
+ return false;
1289
+ }
1290
+ const foreign = this.foreignClaim;
1291
+ if (foreign && this.claimWins(foreign, myClaim)) {
1292
+ return this.waitForForeignRefresh();
1293
+ }
1294
+ }
1101
1295
  const refreshToken = await Promise.resolve(this.tokenStore.read());
1102
1296
  const res = await this.fetchImpl(`${this.issuer}${this.refreshPath}`, {
1103
1297
  method: "POST",
1104
1298
  credentials: "include",
1105
- headers: { "Content-Type": "application/json" },
1106
- body: JSON.stringify(refreshToken ? { refreshToken } : {})
1299
+ headers: {
1300
+ "Content-Type": "application/json",
1301
+ [IDEMPOTENCY_HEADER]: this.getIdempotencyToken()
1302
+ },
1303
+ body: JSON.stringify(refreshToken ? { refreshToken } : {}),
1304
+ signal: abort.signal
1107
1305
  });
1108
1306
  const body = await res.json().catch(() => ({}));
1307
+ if (this.signoutInProgress || abort.signal.aborted) {
1308
+ this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1309
+ return false;
1310
+ }
1109
1311
  const data = body.data;
1110
1312
  if (!res.ok || !body.success || !data?.accessToken) {
1111
1313
  const err = body.error;
@@ -1124,14 +1326,18 @@ var SessionManager = class {
1124
1326
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: true });
1125
1327
  return true;
1126
1328
  } catch (err) {
1127
- this.setError({
1128
- code: "NETWORK_ERROR",
1129
- message: err instanceof Error ? err.message : "Refresh request failed"
1130
- });
1329
+ const aborted = err?.name === "AbortError" || abort.signal.aborted || this.signoutInProgress;
1330
+ if (!aborted) {
1331
+ this.setError({
1332
+ code: "NETWORK_ERROR",
1333
+ message: err instanceof Error ? err.message : "Refresh request failed"
1334
+ });
1335
+ }
1131
1336
  this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
1132
1337
  return false;
1133
1338
  } finally {
1134
1339
  this.foreignClaim = null;
1340
+ if (this.refreshAbort === abort) this.refreshAbort = null;
1135
1341
  }
1136
1342
  }
1137
1343
  claimWins(foreign, mine) {
@@ -1158,10 +1364,27 @@ var SessionManager = class {
1158
1364
  * session and notify subscribers and other tabs.
1159
1365
  */
1160
1366
  applyAccessToken(accessToken, refreshToken) {
1367
+ this.adoptAccessToken(accessToken, { refreshToken });
1368
+ }
1369
+ /**
1370
+ * Task #197 — Adopt an access token that the server has already minted
1371
+ * for us (e.g. from `POST /api/v1/auth/switch-scope`) without contacting
1372
+ * the issuer. Swaps the in-memory token, re-decodes claims, bumps
1373
+ * `version`, schedules proactive refresh, and broadcasts a
1374
+ * `session:update` to peer tabs.
1375
+ *
1376
+ * This is the safe path for any server endpoint that returns a fresh
1377
+ * access token in its JSON body: we want the new claims (scope, roles,
1378
+ * etc.) to take effect immediately, even if the refresh-cookie round-trip
1379
+ * would have failed (network blip, rate limit, signout race). When the
1380
+ * server also rotated the refresh token, pass it via
1381
+ * `opts.refreshToken` so the cookie stays aligned.
1382
+ */
1383
+ adoptAccessToken(accessToken, opts) {
1161
1384
  const claims = decodeClaims(accessToken);
1162
1385
  const user = claimsToSessionUser(claims);
1163
- if (refreshToken) {
1164
- void Promise.resolve(this.tokenStore.write(refreshToken, { claims }));
1386
+ if (opts?.refreshToken) {
1387
+ void Promise.resolve(this.tokenStore.write(opts.refreshToken, { claims }));
1165
1388
  }
1166
1389
  this.update({
1167
1390
  status: user ? "authenticated" : "unauthenticated",
@@ -1239,6 +1462,14 @@ var SessionManager = class {
1239
1462
  * the server-side logout request.
1240
1463
  */
1241
1464
  signOutLocal(status = "unauthenticated") {
1465
+ this.signoutInProgress = true;
1466
+ if (this.refreshAbort) {
1467
+ try {
1468
+ this.refreshAbort.abort();
1469
+ } catch {
1470
+ }
1471
+ this.refreshAbort = null;
1472
+ }
1242
1473
  void Promise.resolve(this.tokenStore.clear());
1243
1474
  if (this.proactiveTimer) {
1244
1475
  clearTimeout(this.proactiveTimer);
@@ -1253,7 +1484,12 @@ var SessionManager = class {
1253
1484
  error: null,
1254
1485
  version: this.snapshot.version + 1
1255
1486
  });
1487
+ this.broadcastEnvelope({ type: "refresh:abort", source: this.tabId, ts: Date.now() });
1256
1488
  this.broadcast("session:signout");
1489
+ this.idempotencyToken = null;
1490
+ setTimeout(() => {
1491
+ this.signoutInProgress = false;
1492
+ }, 0);
1257
1493
  }
1258
1494
  /**
1259
1495
  * Replace the refresh-token store at runtime. Used by the F22
@@ -1317,6 +1553,12 @@ var SessionManager = class {
1317
1553
  }
1318
1554
  onBroadcast(env) {
1319
1555
  if (!env || env.source === this.tabId) return;
1556
+ if (env.type === "session:probe") {
1557
+ if (this.snapshot.status === "authenticated") {
1558
+ this.broadcast("session:update");
1559
+ }
1560
+ return;
1561
+ }
1320
1562
  if (env.type === "refresh:claim") {
1321
1563
  this.foreignClaim = { source: env.source, ts: env.ts };
1322
1564
  return;
@@ -1329,6 +1571,24 @@ var SessionManager = class {
1329
1571
  this.foreignClaim = null;
1330
1572
  return;
1331
1573
  }
1574
+ if (env.type === "refresh:abort") {
1575
+ this.signoutInProgress = true;
1576
+ if (this.refreshAbort) {
1577
+ try {
1578
+ this.refreshAbort.abort();
1579
+ } catch {
1580
+ }
1581
+ this.refreshAbort = null;
1582
+ }
1583
+ const waiters = this.remoteRefreshWaiters;
1584
+ this.remoteRefreshWaiters = [];
1585
+ for (const w of waiters) w(false);
1586
+ this.foreignClaim = null;
1587
+ setTimeout(() => {
1588
+ this.signoutInProgress = false;
1589
+ }, 0);
1590
+ return;
1591
+ }
1332
1592
  if (env.type === "session:signout") {
1333
1593
  this.update({
1334
1594
  status: "unauthenticated",
@@ -1342,6 +1602,12 @@ var SessionManager = class {
1342
1602
  return;
1343
1603
  }
1344
1604
  if ((env.type === "session:update" || env.type === "session:refresh") && env.payload) {
1605
+ if (this.probeResolver && env.payload.status === "authenticated") {
1606
+ const r = this.probeResolver;
1607
+ this.probeResolver = null;
1608
+ r(env.payload);
1609
+ return;
1610
+ }
1345
1611
  this.update({
1346
1612
  ...env.payload,
1347
1613
  version: Math.max(this.snapshot.version, env.payload.version) + 1
@@ -1355,6 +1621,32 @@ var SessionManager = class {
1355
1621
  }
1356
1622
  };
1357
1623
 
1624
+ // src/browser/hostedIssuerGuard.ts
1625
+ var HOSTED_ISSUER_MISMATCH_CODE = "IQAUTH_HOSTED_ISSUER_MISMATCH";
1626
+ var HOSTED_ISSUER_MISMATCH_DOCS_URL = "https://docs.dispositioniq.com/iqauth/errors#hosted-issuer-mismatch";
1627
+ function computeHostedIssuerMismatch(input) {
1628
+ const {
1629
+ nodeEnv,
1630
+ fetchError,
1631
+ explicitOverride,
1632
+ resolvedBaseUrl,
1633
+ managerIssuerUrl,
1634
+ hostedIssuerUrl,
1635
+ appKey
1636
+ } = input;
1637
+ if (nodeEnv === "production") return null;
1638
+ if (!fetchError) return null;
1639
+ if (explicitOverride) return null;
1640
+ if (!managerIssuerUrl || !hostedIssuerUrl) return null;
1641
+ if (resolvedBaseUrl !== managerIssuerUrl) return null;
1642
+ if (resolvedBaseUrl === hostedIssuerUrl) return null;
1643
+ const e = new Error(
1644
+ `[IQAuth] ${HOSTED_ISSUER_MISMATCH_CODE}: <SignIn /> targeted "${resolvedBaseUrl}" (inherited from <IQAuthProvider issuer="\u2026"/>), but that host does not serve /api/public/apps/${appKey}/sign-in-context. The hosted UI lives at the publishable key's issuer: "${hostedIssuerUrl}". Fix: drop the <IQAuthProvider issuer="\u2026"/> override so the SDK uses the publishable key's iss, OR pass <SignIn iqAuthBaseUrl="${hostedIssuerUrl}"/> explicitly. Docs: ${HOSTED_ISSUER_MISMATCH_DOCS_URL}. Underlying fetch error: ${fetchError}`
1645
+ );
1646
+ e.code = HOSTED_ISSUER_MISMATCH_CODE;
1647
+ return e;
1648
+ }
1649
+
1358
1650
  // src/react/index.tsx
1359
1651
  init_signIn();
1360
1652
 
@@ -1540,6 +1832,7 @@ var enUS = {
1540
1832
  "signIn.useDifferentAccount": "Use a different account",
1541
1833
  "signIn.selectTenant": "Choose an organization",
1542
1834
  "signIn.selectTenantSubtitle": "You belong to multiple organizations. Pick one to continue.",
1835
+ "signIn.selectScope": "Choose scope",
1543
1836
  "signIn.dividerOr": "or",
1544
1837
  "signIn.preparingExperience": "Preparing your sign-in experience.",
1545
1838
  "signIn.applicationUnavailable": "Application unavailable",
@@ -1628,6 +1921,11 @@ var enUS = {
1628
1921
  "orgSwitcher.createNew": "Create organization",
1629
1922
  "orgSwitcher.manage": "Manage organization",
1630
1923
  "orgSwitcher.noOrgs": "You don't belong to any organizations yet.",
1924
+ "orgSwitcher.mfaRequiredTitle": "Two-factor verification required",
1925
+ "orgSwitcher.mfaRequiredBody": "This organization requires an extra verification step. Continue in the hosted sign-in to finish switching.",
1926
+ "orgSwitcher.scopeSelectionRequiredTitle": "Choose what to access",
1927
+ "orgSwitcher.scopeSelectionRequiredBody": "This organization needs you to pick which workspace to open. Continue in the hosted sign-in to choose.",
1928
+ "orgSwitcher.continueInHostedSignIn": "Continue in hosted sign-in \u2192",
1631
1929
  "orgProfile.title": "Organization settings",
1632
1930
  "orgProfile.generalTab": "General",
1633
1931
  "orgProfile.membersTab": "Members",
@@ -1738,6 +2036,7 @@ function sanitizeReturnTo(input, options = {}) {
1738
2036
  if (!input || typeof input !== "string") return fallback;
1739
2037
  const trimmed = input.trim();
1740
2038
  if (!trimmed) return fallback;
2039
+ if (trimmed.includes("\\")) return fallback;
1741
2040
  if (trimmed.startsWith("//")) return fallback;
1742
2041
  if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
1743
2042
  return trimmed;
@@ -1909,6 +2208,9 @@ function IQAuthProvider({
1909
2208
  );
1910
2209
  return (0, import_react.createElement)(IQAuthContext.Provider, { value }, children);
1911
2210
  }
2211
+ function __useIQAuthInternal() {
2212
+ return useCtx();
2213
+ }
1912
2214
  function useCtx() {
1913
2215
  const ctx = (0, import_react.useContext)(IQAuthContext);
1914
2216
  if (!ctx) throw new Error("IQAuth hooks must be used inside <IQAuthProvider>");
@@ -2220,6 +2522,65 @@ function RedirectToSignIn(props = {}) {
2220
2522
  }
2221
2523
  return null;
2222
2524
  }
2525
+ async function performScopeSwitch(manager, base, target) {
2526
+ const res = await manager.fetch(`${base}/api/v1/auth/switch-scope`, {
2527
+ method: "POST",
2528
+ headers: { "Content-Type": "application/json" },
2529
+ body: JSON.stringify({ scopeType: target.type, scopeId: target.id })
2530
+ });
2531
+ const body = await res.json().catch(() => ({}));
2532
+ if (!res.ok) {
2533
+ throw new Error(body?.error?.message || `HTTP ${res.status}`);
2534
+ }
2535
+ const newToken = body?.data?.accessToken;
2536
+ if (newToken) {
2537
+ manager.adoptAccessToken(newToken);
2538
+ void manager.refresh().catch(() => void 0);
2539
+ return;
2540
+ }
2541
+ const ok = await manager.refresh();
2542
+ if (!ok) {
2543
+ throw new Error("scope switch succeeded server-side but token refresh failed; reload to recover");
2544
+ }
2545
+ }
2546
+ async function performTenantSwitch(manager, base, tenantId) {
2547
+ const res = await manager.fetch(`${base.replace(/\/$/, "")}/api/v1/auth/select-tenant`, {
2548
+ method: "POST",
2549
+ headers: { "Content-Type": "application/json" },
2550
+ body: JSON.stringify({ tenantId })
2551
+ });
2552
+ const body = await res.json().catch(() => ({}));
2553
+ if (!res.ok) {
2554
+ throw new Error(body?.error?.message || `HTTP ${res.status}`);
2555
+ }
2556
+ if (body?.data?.mfaChallengeToken) {
2557
+ return {
2558
+ kind: "mfa_required",
2559
+ tenantId: body?.data?.tenantId || tenantId,
2560
+ mfaChallengeToken: body.data.mfaChallengeToken,
2561
+ availableMethods: Array.isArray(body.data.availableMethods) ? body.data.availableMethods : []
2562
+ };
2563
+ }
2564
+ if (body?.data?.type === "scope_selection" || body?.data?.scopeSelectionToken) {
2565
+ return {
2566
+ kind: "scope_selection_required",
2567
+ tenantId: body?.data?.tenantId || tenantId,
2568
+ scopeSelectionToken: body?.data?.scopeSelectionToken || "",
2569
+ scopes: Array.isArray(body?.data?.scopes) ? body.data.scopes : []
2570
+ };
2571
+ }
2572
+ const newToken = body?.data?.accessToken;
2573
+ if (newToken) {
2574
+ manager.adoptAccessToken(newToken);
2575
+ void manager.refresh().catch(() => void 0);
2576
+ return { kind: "ok", tenantId: body?.data?.user?.tenantId || tenantId };
2577
+ }
2578
+ const ok = await manager.refresh();
2579
+ if (!ok) {
2580
+ throw new Error("tenant switch succeeded server-side but token refresh failed; reload to recover");
2581
+ }
2582
+ return { kind: "ok", tenantId };
2583
+ }
2223
2584
  function asArray(v) {
2224
2585
  if (v == null) return [];
2225
2586
  return Array.isArray(v) ? v : [v];
@@ -2246,7 +2607,14 @@ function claimPermissions(c) {
2246
2607
  }
2247
2608
  return Array.from(out);
2248
2609
  }
2249
- function Protect({ role, permission, condition, fallback = null, children }) {
2610
+ function claimSatisfiesScope(claims, required) {
2611
+ if (!claims) return false;
2612
+ const ctx = claims.scopeContext;
2613
+ if (!ctx || !ctx.type || !ctx.id) return false;
2614
+ const list = Array.isArray(required) ? required : [required];
2615
+ return list.some((r) => r.type === ctx.type && r.id === ctx.id);
2616
+ }
2617
+ function Protect({ role, permission, scope, condition, fallback = null, children }) {
2250
2618
  const { snapshot } = useCtx();
2251
2619
  if (snapshot.status !== "authenticated") return (0, import_react.createElement)(import_react.Fragment, null, fallback);
2252
2620
  const wantedRoles = asArray(role);
@@ -2259,6 +2627,9 @@ function Protect({ role, permission, condition, fallback = null, children }) {
2259
2627
  const have = new Set(claimPermissions(snapshot.claims));
2260
2628
  if (!wantedPerms.some((p) => have.has(p))) return (0, import_react.createElement)(import_react.Fragment, null, fallback);
2261
2629
  }
2630
+ if (scope) {
2631
+ if (!claimSatisfiesScope(snapshot.claims, scope)) return (0, import_react.createElement)(import_react.Fragment, null, fallback);
2632
+ }
2262
2633
  if (condition && !condition(snapshot.claims)) return (0, import_react.createElement)(import_react.Fragment, null, fallback);
2263
2634
  return (0, import_react.createElement)(import_react.Fragment, null, children);
2264
2635
  }
@@ -2815,12 +3186,54 @@ function isSilentSsoEligible(ctx, effectivePrompt) {
2815
3186
  if (!ctx.returnAllowed) return false;
2816
3187
  return true;
2817
3188
  }
3189
+ function resolveAfterSignInDestination(args) {
3190
+ let raw = args.prop ?? null;
3191
+ if (!raw && typeof args.search === "string") {
3192
+ try {
3193
+ const params = new URLSearchParams(args.search);
3194
+ raw = params.get("return_to") ?? params.get("next");
3195
+ } catch {
3196
+ }
3197
+ }
3198
+ return sanitizeReturnTo(raw, {
3199
+ allowedOrigins: args.allowedOrigins,
3200
+ currentOrigin: args.currentOrigin,
3201
+ fallback: "/"
3202
+ });
3203
+ }
2818
3204
  function SignIn(props) {
2819
3205
  const providerCtx = (0, import_react.useContext)(IQAuthContext);
2820
- const iqAuthBaseUrl = props.iqAuthBaseUrl ?? providerCtx?.manager.issuerUrl ?? "";
3206
+ const iqAuthBaseUrl = props.iqAuthBaseUrl ?? providerCtx?.manager.hostedIssuerUrl ?? "";
2821
3207
  const appKey = props.appKey ?? providerCtx?.manager.appKey ?? "";
2822
3208
  const returnTo = props.returnTo ?? (typeof window !== "undefined" ? `${window.location.origin}/api/iqauth/callback` : "");
2823
- const { onRedirect, className, prompt, appearance: instanceAppearance, silentSso: instanceSilentSso } = props;
3209
+ const afterSignInUrl = resolveAfterSignInDestination({
3210
+ prop: props.afterSignInUrl,
3211
+ search: typeof window !== "undefined" ? window.location.search : "",
3212
+ allowedOrigins: providerCtx?.allowedReturnOrigins
3213
+ });
3214
+ const { onRedirect, className, prompt, appearance: instanceAppearance, silentSso: instanceSilentSso, scopeHint: scopeHintProp } = props;
3215
+ const effectiveScopeHint = (() => {
3216
+ const fromProp = scopeHintProp ?? null;
3217
+ let raw = fromProp;
3218
+ if (!raw && typeof window !== "undefined") {
3219
+ try {
3220
+ raw = new URLSearchParams(window.location.search).get("scope_hint");
3221
+ } catch {
3222
+ }
3223
+ }
3224
+ if (!raw) return null;
3225
+ if (typeof raw === "object" && raw && raw.type && raw.id) {
3226
+ const t3 = raw.type;
3227
+ if (t3 === "vendor" || t3 === "source" || t3 === "client") return { type: t3, id: String(raw.id) };
3228
+ return null;
3229
+ }
3230
+ if (typeof raw === "string" && raw.includes(":")) {
3231
+ const [t3, id] = raw.split(":", 2);
3232
+ if ((t3 === "vendor" || t3 === "source" || t3 === "client") && id) return { type: t3, id };
3233
+ }
3234
+ return null;
3235
+ })();
3236
+ const scopeHintBody = effectiveScopeHint ? { scopeHint: effectiveScopeHint } : {};
2824
3237
  const silentSsoEnabled = instanceSilentSso ?? providerCtx?.silentSso ?? false;
2825
3238
  const appearance = instanceAppearance && providerCtx?.appearance ? { elements: { ...providerCtx.appearance.elements, ...instanceAppearance.elements } } : instanceAppearance ?? providerCtx?.appearance ?? null;
2826
3239
  if (!iqAuthBaseUrl || !appKey) {
@@ -2831,6 +3244,21 @@ function SignIn(props) {
2831
3244
  const t2 = useT();
2832
3245
  const localeBundle = useLocale();
2833
3246
  const { ctx, loading, error } = useIQAuthSignInContext(iqAuthBaseUrl, appKey, returnTo);
3247
+ const guardError = computeHostedIssuerMismatch({
3248
+ nodeEnv: typeof process !== "undefined" ? process.env?.NODE_ENV : void 0,
3249
+ fetchError: error,
3250
+ explicitOverride: !!props.iqAuthBaseUrl,
3251
+ resolvedBaseUrl: iqAuthBaseUrl,
3252
+ managerIssuerUrl: providerCtx?.manager.issuerUrl,
3253
+ hostedIssuerUrl: providerCtx?.manager.hostedIssuerUrl,
3254
+ appKey
3255
+ });
3256
+ if (guardError) throw guardError;
3257
+ (0, import_react.useEffect)(() => {
3258
+ if (typeof document === "undefined") return;
3259
+ const attrs = "; path=/; SameSite=Lax" + (typeof window !== "undefined" && window.location.protocol === "https:" ? "; Secure" : "");
3260
+ document.cookie = `iqauth_return_to=${encodeURIComponent(afterSignInUrl)}${attrs}`;
3261
+ }, [afterSignInUrl]);
2834
3262
  const preflightLoggedRef = (0, import_react.useRef)(false);
2835
3263
  (0, import_react.useEffect)(() => {
2836
3264
  if (!ctx || preflightLoggedRef.current) return;
@@ -2846,6 +3274,7 @@ function SignIn(props) {
2846
3274
  const [formError, setFormError] = (0, import_react.useState)("");
2847
3275
  const [mfa, setMfa] = (0, import_react.useState)(null);
2848
3276
  const [tenantSel, setTenantSel] = (0, import_react.useState)(null);
3277
+ const [scopeSel, setScopeSel] = (0, import_react.useState)(null);
2849
3278
  const [oauthExchanging, setOauthExchanging] = (0, import_react.useState)(false);
2850
3279
  const [silent, setSilent] = (0, import_react.useState)("idle");
2851
3280
  const [forcePrompt, setForcePrompt] = (0, import_react.useState)(false);
@@ -2876,6 +3305,12 @@ function SignIn(props) {
2876
3305
  setTenantSel({ token: payload.tenantSelectionToken, tenants: payload.tenants || [] });
2877
3306
  return true;
2878
3307
  }
3308
+ if (payload.type === "scope_selection") {
3309
+ setScopeSel({ token: payload.scopeSelectionToken, tenantId: payload.tenantId, scopes: payload.scopes || [] });
3310
+ setTenantSel(null);
3311
+ setMfa(null);
3312
+ return true;
3313
+ }
2879
3314
  if (payload.type === "mfa_required") {
2880
3315
  const methods = payload.availableMethods || ["totp"];
2881
3316
  setMfa({ token: payload.mfaChallengeToken, methods, selected: methods[0], code: "", backup: false });
@@ -2896,7 +3331,7 @@ function SignIn(props) {
2896
3331
  method: "POST",
2897
3332
  headers: { "Content-Type": "application/json" },
2898
3333
  credentials: "include",
2899
- body: JSON.stringify({ email, password, ...oidcPayload() })
3334
+ body: JSON.stringify({ email, password, ...oidcPayload(), ...scopeHintBody })
2900
3335
  });
2901
3336
  const payload = await r.json().catch(() => ({}));
2902
3337
  if (!handlePayload(payload)) setFormError(localizeError(localeBundle, { code: payload.error, message: payload.error_description }));
@@ -2919,7 +3354,8 @@ function SignIn(props) {
2919
3354
  code: mfa.code,
2920
3355
  method: mfa.selected,
2921
3356
  useBackup: mfa.backup,
2922
- ...oidcPayload()
3357
+ ...oidcPayload(),
3358
+ ...scopeHintBody
2923
3359
  })
2924
3360
  });
2925
3361
  const payload = await r.json().catch(() => ({}));
@@ -2934,7 +3370,21 @@ function SignIn(props) {
2934
3370
  method: "POST",
2935
3371
  headers: { "Content-Type": "application/json" },
2936
3372
  credentials: "include",
2937
- body: JSON.stringify({ tenantSelectionToken: tenantSel.token, tenantId, ...oidcPayload() })
3373
+ body: JSON.stringify({ tenantSelectionToken: tenantSel.token, tenantId, ...oidcPayload(), ...scopeHintBody })
3374
+ });
3375
+ const payload = await r.json().catch(() => ({}));
3376
+ if (!handlePayload(payload)) setFormError(localizeError(localeBundle, { code: payload.error, message: payload.error_description }));
3377
+ setSubmitting(false);
3378
+ };
3379
+ const submitScope = async (membershipId) => {
3380
+ if (!scopeSel) return;
3381
+ setSubmitting(true);
3382
+ setFormError("");
3383
+ const r = await fetch(`${iqAuthBaseUrl.replace(/\/$/, "")}/oidc/sso-scope-select`, {
3384
+ method: "POST",
3385
+ headers: { "Content-Type": "application/json" },
3386
+ credentials: "include",
3387
+ body: JSON.stringify({ scopeSelectionToken: scopeSel.token, membershipId, ...oidcPayload(), ...scopeHintBody })
2938
3388
  });
2939
3389
  const payload = await r.json().catch(() => ({}));
2940
3390
  if (!handlePayload(payload)) setFormError(localizeError(localeBundle, { code: payload.error, message: payload.error_description }));
@@ -3002,6 +3452,11 @@ function SignIn(props) {
3002
3452
  setSilent("failed");
3003
3453
  return;
3004
3454
  }
3455
+ if (payload?.type === "scope_selection") {
3456
+ setScopeSel({ token: payload.scopeSelectionToken, tenantId: payload.tenantId, scopes: payload.scopes || [] });
3457
+ setSilent("failed");
3458
+ return;
3459
+ }
3005
3460
  setSilent("failed");
3006
3461
  } catch {
3007
3462
  setSilent("failed");
@@ -3013,6 +3468,7 @@ function SignIn(props) {
3013
3468
  setForcePrompt(true);
3014
3469
  setSilent("skipped");
3015
3470
  setTenantSel(null);
3471
+ setScopeSel(null);
3016
3472
  if (typeof window !== "undefined") {
3017
3473
  try {
3018
3474
  const u = new URL(window.location.href);
@@ -3083,7 +3539,27 @@ function SignIn(props) {
3083
3539
  ]
3084
3540
  },
3085
3541
  tn.tenantId
3086
- )) }) : mfa ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("form", { onSubmit: submitMfa, style: { display: "flex", flexDirection: "column", gap: 12 }, "aria-label": t2("mfa.title"), children: [
3542
+ )) }) : scopeSel ? (
3543
+ // Task #171 — scope picker (source/client memberships)
3544
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { role: "radiogroup", "aria-label": t2("signIn.selectScope") || "Choose scope", "data-iqauth-scope-picker": "1", style: { display: "flex", flexDirection: "column", gap: 8 }, children: scopeSel.scopes.map((s) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
3545
+ "button",
3546
+ {
3547
+ type: "button",
3548
+ "data-iqauth-scope": s.membershipId,
3549
+ onClick: () => submitScope(s.membershipId),
3550
+ style: { textAlign: "left", padding: "10px 14px", border: "1px solid rgba(15,23,42,0.15)", borderRadius: 8, background: "transparent", color: "inherit", cursor: "pointer" },
3551
+ children: [
3552
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { margin: 0, fontWeight: 500 }, children: s.scopeName }),
3553
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: { margin: 0, fontSize: 12, opacity: 0.6 }, children: [
3554
+ s.scopeType,
3555
+ " \xB7 ",
3556
+ s.roleName
3557
+ ] })
3558
+ ]
3559
+ },
3560
+ s.membershipId
3561
+ )) })
3562
+ ) : mfa ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("form", { onSubmit: submitMfa, style: { display: "flex", flexDirection: "column", gap: 12 }, "aria-label": t2("mfa.title"), children: [
3087
3563
  !mfa.backup && mfa.methods.length > 1 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { label: t2("mfa.title"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("select", { style: inputStyle(), value: mfa.selected, onChange: (e) => setMfa({ ...mfa, selected: e.target.value }), children: mfa.methods.map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: m, children: m.toUpperCase() }, m)) }) }) : null,
3088
3564
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { label: mfa.backup ? t2("mfa.backupCodeLabel") : t2("mfa.totpLabel"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
3089
3565
  "input",
@@ -3375,9 +3851,12 @@ function OrganizationSwitcher({ iqAuthBaseUrl, onSwitched, appearance: _appearan
3375
3851
  const t2 = useT();
3376
3852
  const branding = useResolvedSdkBranding(iqAuthBaseUrl);
3377
3853
  const accent = branding?.accentColor || "#6366f1";
3854
+ const { manager } = useCtx();
3378
3855
  const [memberships, setMemberships] = (0, import_react.useState)([]);
3379
3856
  const [activeTenantId, setActiveTenantId] = (0, import_react.useState)(null);
3380
3857
  const [open, setOpen] = (0, import_react.useState)(false);
3858
+ const [pendingStep, setPendingStep] = (0, import_react.useState)(null);
3859
+ const [switchError, setSwitchError] = (0, import_react.useState)(null);
3381
3860
  (0, import_react.useEffect)(() => {
3382
3861
  fetch(`${iqAuthBaseUrl.replace(/\/$/, "")}/api/v1/auth/me`, { credentials: "include" }).then((r) => r.json()).then((p) => {
3383
3862
  if (p?.data?.tenantId) setActiveTenantId(p.data.tenantId);
@@ -3387,18 +3866,48 @@ function OrganizationSwitcher({ iqAuthBaseUrl, onSwitched, appearance: _appearan
3387
3866
  });
3388
3867
  }, [iqAuthBaseUrl]);
3389
3868
  const switchTo = async (tenantId) => {
3869
+ setSwitchError(null);
3870
+ setPendingStep(null);
3390
3871
  try {
3391
- await jsonFetch(`${iqAuthBaseUrl.replace(/\/$/, "")}/api/v1/auth/select-tenant`, {
3392
- method: "POST",
3393
- headers: { "Content-Type": "application/json" },
3394
- body: JSON.stringify({ tenantId })
3395
- });
3872
+ const result = await performTenantSwitch(manager, iqAuthBaseUrl, tenantId);
3873
+ if (result.kind === "mfa_required") {
3874
+ setPendingStep({
3875
+ kind: "mfa_required",
3876
+ tenantId: result.tenantId,
3877
+ mfaChallengeToken: result.mfaChallengeToken,
3878
+ availableMethods: result.availableMethods
3879
+ });
3880
+ return;
3881
+ }
3882
+ if (result.kind === "scope_selection_required") {
3883
+ setPendingStep({
3884
+ kind: "scope_selection_required",
3885
+ tenantId: result.tenantId,
3886
+ scopeSelectionToken: result.scopeSelectionToken
3887
+ });
3888
+ return;
3889
+ }
3396
3890
  setActiveTenantId(tenantId);
3397
3891
  setOpen(false);
3398
3892
  onSwitched?.(tenantId);
3399
- } catch {
3893
+ } catch (err) {
3894
+ setSwitchError(err instanceof Error ? err.message : "Failed to switch organization");
3400
3895
  }
3401
3896
  };
3897
+ const hostedSignInUrl = () => {
3898
+ const baseHosted = `${iqAuthBaseUrl.replace(/\/$/, "")}/sign-in`;
3899
+ if (!pendingStep) return baseHosted;
3900
+ const params = new URLSearchParams();
3901
+ if (pendingStep.kind === "mfa_required") {
3902
+ params.set("mfaChallengeToken", pendingStep.mfaChallengeToken);
3903
+ if (pendingStep.availableMethods.length) {
3904
+ params.set("availableMethods", pendingStep.availableMethods.join(","));
3905
+ }
3906
+ } else {
3907
+ params.set("prompt", "login");
3908
+ }
3909
+ return `${baseHosted}?${params.toString()}`;
3910
+ };
3402
3911
  const active = memberships.find((m) => m.tenantId === activeTenantId);
3403
3912
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
3404
3913
  "div",
@@ -3419,44 +3928,274 @@ function OrganizationSwitcher({ iqAuthBaseUrl, onSwitched, appearance: _appearan
3419
3928
  children: active?.tenantName || active?.tenantSlug || t2("orgSwitcher.label")
3420
3929
  }
3421
3930
  ),
3422
- open ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { role: "menu", style: {
3931
+ open ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "menu", style: {
3423
3932
  position: "absolute",
3424
3933
  left: 0,
3425
3934
  top: 36,
3426
- minWidth: 220,
3935
+ minWidth: 260,
3427
3936
  background: "#fff",
3428
3937
  border: "1px solid rgba(15,23,42,0.12)",
3429
3938
  borderRadius: 8,
3430
3939
  boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
3431
3940
  padding: 8,
3432
3941
  zIndex: 100
3433
- }, children: memberships.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: 13, opacity: 0.6, padding: "4px 6px" }, children: t2("orgSwitcher.noOrgs") }) : memberships.map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
3942
+ }, children: [
3943
+ memberships.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: 13, opacity: 0.6, padding: "4px 6px" }, children: t2("orgSwitcher.noOrgs") }) : memberships.map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
3944
+ "button",
3945
+ {
3946
+ role: "menuitem",
3947
+ type: "button",
3948
+ onClick: () => switchTo(m.tenantId),
3949
+ style: {
3950
+ display: "block",
3951
+ width: "100%",
3952
+ textAlign: "left",
3953
+ padding: "8px 10px",
3954
+ background: m.tenantId === activeTenantId ? `${accent}14` : "transparent",
3955
+ border: "none",
3956
+ borderRadius: 4,
3957
+ cursor: "pointer",
3958
+ fontSize: 13,
3959
+ color: "#0f172a"
3960
+ },
3961
+ children: [
3962
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontWeight: 500 }, children: m.tenantName || m.tenantSlug || m.tenantId }),
3963
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 11, opacity: 0.6 }, children: m.roles.join(", ") })
3964
+ ]
3965
+ },
3966
+ m.tenantId
3967
+ )),
3968
+ pendingStep ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
3969
+ "div",
3970
+ {
3971
+ "data-testid": pendingStep.kind === "mfa_required" ? "prompt-org-switch-mfa-required" : "prompt-org-switch-scope-required",
3972
+ role: "alert",
3973
+ style: {
3974
+ marginTop: 8,
3975
+ padding: "10px 12px",
3976
+ background: `${accent}10`,
3977
+ border: `1px solid ${accent}55`,
3978
+ borderRadius: 6
3979
+ },
3980
+ children: [
3981
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, fontWeight: 600, color: "#0f172a", marginBottom: 4 }, children: pendingStep.kind === "mfa_required" ? t2("orgSwitcher.mfaRequiredTitle") : t2("orgSwitcher.scopeSelectionRequiredTitle") }),
3982
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: "#334155", marginBottom: 8 }, children: pendingStep.kind === "mfa_required" ? t2("orgSwitcher.mfaRequiredBody") : t2("orgSwitcher.scopeSelectionRequiredBody") }),
3983
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
3984
+ "a",
3985
+ {
3986
+ href: hostedSignInUrl(),
3987
+ "data-testid": "link-org-switch-continue-hosted",
3988
+ style: {
3989
+ display: "inline-block",
3990
+ fontSize: 12,
3991
+ fontWeight: 600,
3992
+ color: branding?.accentColor || accent,
3993
+ textDecoration: "none"
3994
+ },
3995
+ children: t2("orgSwitcher.continueInHostedSignIn")
3996
+ }
3997
+ )
3998
+ ]
3999
+ }
4000
+ ) : null,
4001
+ switchError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { "data-testid": "text-org-switch-error", style: { fontSize: 12, color: "#b91c1c", padding: "6px 8px 0" }, children: switchError }) : null
4002
+ ] }) : null
4003
+ ]
4004
+ }
4005
+ );
4006
+ }
4007
+ function useMemberships() {
4008
+ const { manager, snapshot } = useCtx();
4009
+ const [memberships, setMemberships] = (0, import_react.useState)([]);
4010
+ const [isLoading, setIsLoading] = (0, import_react.useState)(true);
4011
+ const [error, setError] = (0, import_react.useState)(null);
4012
+ const base = manager.issuerUrl.replace(/\/$/, "");
4013
+ const refresh = (0, import_react.useCallback)(async () => {
4014
+ setIsLoading(true);
4015
+ setError(null);
4016
+ try {
4017
+ const res = await manager.fetch(`${base}/api/v1/auth/available-scopes`);
4018
+ const json = await res.json().catch(() => ({}));
4019
+ if (!res.ok) throw new Error(json?.error?.message || `HTTP ${res.status}`);
4020
+ const tree = json.data || {};
4021
+ const flat = [];
4022
+ for (const v of tree.vendors || []) {
4023
+ flat.push({
4024
+ membershipId: v.membershipId,
4025
+ scopeType: "vendor",
4026
+ scopeId: v.id,
4027
+ scopeName: v.name,
4028
+ role: v.role,
4029
+ grantedVia: v.grantedVia || "direct"
4030
+ });
4031
+ }
4032
+ for (const s of tree.sources || []) {
4033
+ flat.push({
4034
+ membershipId: s.membershipId,
4035
+ scopeType: "source",
4036
+ scopeId: s.id,
4037
+ scopeName: s.name,
4038
+ role: s.role,
4039
+ grantedVia: s.grantedVia || "direct"
4040
+ });
4041
+ }
4042
+ for (const c of tree.clients || []) {
4043
+ flat.push({
4044
+ membershipId: c.membershipId,
4045
+ scopeType: "client",
4046
+ scopeId: c.id,
4047
+ scopeName: c.name,
4048
+ role: c.role,
4049
+ grantedVia: c.grantedVia || "direct"
4050
+ });
4051
+ }
4052
+ setMemberships(flat);
4053
+ } catch (err) {
4054
+ setError(err.message);
4055
+ setMemberships([]);
4056
+ } finally {
4057
+ setIsLoading(false);
4058
+ }
4059
+ }, [manager, base]);
4060
+ (0, import_react.useEffect)(() => {
4061
+ if (snapshot.status !== "authenticated") {
4062
+ setMemberships([]);
4063
+ setIsLoading(false);
4064
+ return;
4065
+ }
4066
+ void refresh();
4067
+ }, [snapshot.status, snapshot.tenantId, refresh]);
4068
+ const switchScope = (0, import_react.useCallback)(
4069
+ (target) => performScopeSwitch(manager, base, target),
4070
+ [manager, base]
4071
+ );
4072
+ const active = (0, import_react.useMemo)(() => {
4073
+ const ctx = snapshot.user?.scopeContext;
4074
+ if (!ctx) return null;
4075
+ return {
4076
+ type: ctx.type,
4077
+ id: ctx.id,
4078
+ role: ctx.role,
4079
+ membershipId: ctx.membershipId
4080
+ };
4081
+ }, [snapshot.user, snapshot.version]);
4082
+ return { isLoading, error, memberships, active, refresh, switchScope };
4083
+ }
4084
+ function ScopeSwitcher({ onSwitched, include, className }) {
4085
+ const { memberships, active, isLoading, error, switchScope } = useMemberships();
4086
+ const [open, setOpen] = (0, import_react.useState)(false);
4087
+ const [busy, setBusy] = (0, import_react.useState)(null);
4088
+ const [switchError, setSwitchError] = (0, import_react.useState)(null);
4089
+ const visible = (0, import_react.useMemo)(
4090
+ () => include ? memberships.filter((m) => include.includes(m.scopeType)) : memberships,
4091
+ [memberships, include]
4092
+ );
4093
+ if (isLoading) {
4094
+ return (0, import_react.createElement)(
4095
+ "div",
4096
+ { className, "data-testid": "scope-switcher-loading", style: { fontSize: 13, opacity: 0.6 } },
4097
+ "Loading scopes\u2026"
4098
+ );
4099
+ }
4100
+ if (error) {
4101
+ return (0, import_react.createElement)(
4102
+ "div",
4103
+ { className, "data-testid": "scope-switcher-error", style: { fontSize: 13, color: "#b91c1c" } },
4104
+ error
4105
+ );
4106
+ }
4107
+ if (!visible.length) return null;
4108
+ const activeLabel = active ? visible.find((m) => m.scopeType === active.type && m.scopeId === active.id)?.scopeName || `${active.type}:${active.id}` : "Select a scope";
4109
+ return (0, import_react.createElement)(
4110
+ "div",
4111
+ { className, "data-testid": "scope-switcher", style: { position: "relative", display: "inline-block" } },
4112
+ (0, import_react.createElement)(
4113
+ "button",
4114
+ {
4115
+ type: "button",
4116
+ "data-testid": "scope-switcher-trigger",
4117
+ onClick: () => setOpen((v) => !v),
4118
+ style: {
4119
+ padding: "6px 10px",
4120
+ border: "1px solid #e5e7eb",
4121
+ borderRadius: 6,
4122
+ background: "#fff",
4123
+ cursor: "pointer",
4124
+ fontSize: 13
4125
+ }
4126
+ },
4127
+ active ? `${active.type}: ${activeLabel}` : activeLabel
4128
+ ),
4129
+ switchError ? (0, import_react.createElement)(
4130
+ "div",
4131
+ {
4132
+ "data-testid": "scope-switcher-switch-error",
4133
+ style: { marginTop: 4, fontSize: 12, color: "#b91c1c" }
4134
+ },
4135
+ switchError
4136
+ ) : null,
4137
+ open ? (0, import_react.createElement)(
4138
+ "div",
4139
+ {
4140
+ "data-testid": "scope-switcher-menu",
4141
+ style: {
4142
+ position: "absolute",
4143
+ top: "100%",
4144
+ left: 0,
4145
+ marginTop: 4,
4146
+ minWidth: 220,
4147
+ background: "#fff",
4148
+ border: "1px solid #e5e7eb",
4149
+ borderRadius: 6,
4150
+ boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
4151
+ padding: 4,
4152
+ zIndex: 50
4153
+ }
4154
+ },
4155
+ visible.map((m) => {
4156
+ const isActive = active?.type === m.scopeType && active?.id === m.scopeId;
4157
+ const key = `${m.scopeType}:${m.scopeId}`;
4158
+ return (0, import_react.createElement)(
3434
4159
  "button",
3435
4160
  {
3436
- role: "menuitem",
4161
+ key,
3437
4162
  type: "button",
3438
- onClick: () => switchTo(m.tenantId),
4163
+ "data-testid": `scope-switcher-option-${m.scopeType}-${m.scopeId}`,
4164
+ disabled: busy === key,
4165
+ onClick: async () => {
4166
+ setBusy(key);
4167
+ setSwitchError(null);
4168
+ try {
4169
+ await switchScope({ type: m.scopeType, id: m.scopeId });
4170
+ setOpen(false);
4171
+ onSwitched?.({ type: m.scopeType, id: m.scopeId });
4172
+ } catch (err) {
4173
+ setSwitchError(err.message || "Scope switch failed");
4174
+ } finally {
4175
+ setBusy(null);
4176
+ }
4177
+ },
3439
4178
  style: {
3440
4179
  display: "block",
3441
4180
  width: "100%",
3442
4181
  textAlign: "left",
3443
4182
  padding: "8px 10px",
3444
- background: m.tenantId === activeTenantId ? `${accent}14` : "transparent",
3445
4183
  border: "none",
4184
+ background: isActive ? "#f3f4f6" : "transparent",
4185
+ cursor: busy ? "wait" : "pointer",
3446
4186
  borderRadius: 4,
3447
- cursor: "pointer",
3448
- fontSize: 13,
3449
- color: "#0f172a"
3450
- },
3451
- children: [
3452
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontWeight: 500 }, children: m.tenantName || m.tenantSlug || m.tenantId }),
3453
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 11, opacity: 0.6 }, children: m.roles.join(", ") })
3454
- ]
4187
+ fontSize: 13
4188
+ }
3455
4189
  },
3456
- m.tenantId
3457
- )) }) : null
3458
- ]
3459
- }
4190
+ (0, import_react.createElement)("div", { style: { fontWeight: 500 } }, m.scopeName),
4191
+ (0, import_react.createElement)(
4192
+ "div",
4193
+ { style: { fontSize: 11, opacity: 0.6 } },
4194
+ `${m.scopeType} \xB7 ${m.role}${m.grantedVia && m.grantedVia !== "direct" ? ` \xB7 via ${m.grantedVia}` : ""}`
4195
+ )
4196
+ );
4197
+ })
4198
+ ) : null
3460
4199
  );
3461
4200
  }
3462
4201
  function useImpersonation() {
@@ -4069,10 +4808,13 @@ function OrganizationList({ iqAuthBaseUrl, onSelect, showCreate = true, createRe
4069
4808
  const branding = useResolvedSdkBranding(iqAuthBaseUrl);
4070
4809
  const baseUrl = iqAuthBaseUrl.replace(/\/$/, "");
4071
4810
  const accent = branding?.accentColor || "#6366f1";
4811
+ const t2 = useT();
4812
+ const { manager } = useCtx();
4072
4813
  const [memberships, setMemberships] = (0, import_react.useState)([]);
4073
4814
  const [activeTenantId, setActiveTenantId] = (0, import_react.useState)(null);
4074
4815
  const [loading, setLoading] = (0, import_react.useState)(true);
4075
4816
  const [error, setError] = (0, import_react.useState)(null);
4817
+ const [pendingStep, setPendingStep] = (0, import_react.useState)(null);
4076
4818
  (0, import_react.useEffect)(() => {
4077
4819
  let cancelled = false;
4078
4820
  Promise.all([
@@ -4096,21 +4838,81 @@ function OrganizationList({ iqAuthBaseUrl, onSelect, showCreate = true, createRe
4096
4838
  onSelect?.(tenantId);
4097
4839
  return;
4098
4840
  }
4841
+ setError(null);
4842
+ setPendingStep(null);
4099
4843
  try {
4100
- await jsonFetch(`${baseUrl}/api/v1/auth/select-tenant`, {
4101
- method: "POST",
4102
- headers: { "Content-Type": "application/json" },
4103
- credentials: "include",
4104
- body: JSON.stringify({ tenantId })
4105
- });
4844
+ const result = await performTenantSwitch(manager, iqAuthBaseUrl, tenantId);
4845
+ if (result.kind === "mfa_required") {
4846
+ setPendingStep({
4847
+ kind: "mfa_required",
4848
+ tenantId: result.tenantId,
4849
+ mfaChallengeToken: result.mfaChallengeToken,
4850
+ availableMethods: result.availableMethods
4851
+ });
4852
+ return;
4853
+ }
4854
+ if (result.kind === "scope_selection_required") {
4855
+ setPendingStep({
4856
+ kind: "scope_selection_required",
4857
+ tenantId: result.tenantId,
4858
+ scopeSelectionToken: result.scopeSelectionToken
4859
+ });
4860
+ return;
4861
+ }
4106
4862
  setActiveTenantId(tenantId);
4107
4863
  onSelect?.(tenantId);
4108
4864
  } catch (err) {
4109
4865
  setError(err instanceof Error ? err.message : "Failed to switch organization");
4110
4866
  }
4111
4867
  };
4868
+ const hostedSignInUrl = () => {
4869
+ const baseHosted = `${baseUrl}/sign-in`;
4870
+ if (!pendingStep) return baseHosted;
4871
+ const params = new URLSearchParams();
4872
+ if (pendingStep.kind === "mfa_required") {
4873
+ params.set("mfaChallengeToken", pendingStep.mfaChallengeToken);
4874
+ if (pendingStep.availableMethods.length) {
4875
+ params.set("availableMethods", pendingStep.availableMethods.join(","));
4876
+ }
4877
+ } else {
4878
+ params.set("prompt", "login");
4879
+ }
4880
+ return `${baseHosted}?${params.toString()}`;
4881
+ };
4112
4882
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Shell, { appearance, branding, className, title: "Your organizations", subtitle: "Select an organization to make it active.", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-iqauth-sdk-org-list": "", style: { display: "flex", flexDirection: "column", gap: 8 }, children: [
4113
4883
  error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorBanner, { message: error }) : null,
4884
+ pendingStep ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
4885
+ "div",
4886
+ {
4887
+ "data-testid": pendingStep.kind === "mfa_required" ? "prompt-org-list-mfa-required" : "prompt-org-list-scope-required",
4888
+ role: "alert",
4889
+ style: {
4890
+ padding: "10px 12px",
4891
+ background: `${accent}10`,
4892
+ border: `1px solid ${accent}55`,
4893
+ borderRadius: 6
4894
+ },
4895
+ children: [
4896
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 13, fontWeight: 600, color: "#0f172a", marginBottom: 4 }, children: pendingStep.kind === "mfa_required" ? t2("orgSwitcher.mfaRequiredTitle") : t2("orgSwitcher.scopeSelectionRequiredTitle") }),
4897
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: "#334155", marginBottom: 8 }, children: pendingStep.kind === "mfa_required" ? t2("orgSwitcher.mfaRequiredBody") : t2("orgSwitcher.scopeSelectionRequiredBody") }),
4898
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
4899
+ "a",
4900
+ {
4901
+ href: hostedSignInUrl(),
4902
+ "data-testid": "link-org-list-continue-hosted",
4903
+ style: {
4904
+ display: "inline-block",
4905
+ fontSize: 12,
4906
+ fontWeight: 600,
4907
+ color: branding?.accentColor || accent,
4908
+ textDecoration: "none"
4909
+ },
4910
+ children: t2("orgSwitcher.continueInHostedSignIn")
4911
+ }
4912
+ )
4913
+ ]
4914
+ }
4915
+ ) : null,
4114
4916
  loading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: 13 }, children: "Loading\u2026" }) : memberships.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: 13, opacity: 0.6 }, children: "You don\u2019t belong to any organizations yet." }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: { listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }, children: memberships.map((m) => {
4115
4917
  const active = m.tenantId === activeTenantId;
4116
4918
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -4148,8 +4950,8 @@ function OrganizationList({ iqAuthBaseUrl, onSelect, showCreate = true, createRe
4148
4950
  iqAuthBaseUrl,
4149
4951
  unstyled: true,
4150
4952
  appearance,
4151
- onCreated: (t2) => {
4152
- onSelect?.(t2.id);
4953
+ onCreated: (t3) => {
4954
+ onSelect?.(t3.id);
4153
4955
  reloadList();
4154
4956
  },
4155
4957
  redirectUrl: createRedirectUrl
@@ -4402,6 +5204,7 @@ var __version__ = "phase-bc-1.0.0";
4402
5204
  Protect,
4403
5205
  RedirectToSignIn,
4404
5206
  RedirectToSignedIn,
5207
+ ScopeSwitcher,
4405
5208
  SignIn,
4406
5209
  SignUp,
4407
5210
  SignedIn,
@@ -4409,10 +5212,15 @@ var __version__ = "phase-bc-1.0.0";
4409
5212
  UserButton,
4410
5213
  UserProfile,
4411
5214
  Waitlist,
5215
+ __useIQAuthInternal,
4412
5216
  __version__,
5217
+ claimSatisfiesScope,
4413
5218
  isReturnToAllowed,
4414
5219
  isSilentSsoEligible,
5220
+ performScopeSwitch,
5221
+ performTenantSwitch,
4415
5222
  preflightReturnTo,
5223
+ resolveAfterSignInDestination,
4416
5224
  revokeSession,
4417
5225
  sanitizeBrandCss,
4418
5226
  sanitizeReturnTo,
@@ -4426,6 +5234,7 @@ var __version__ = "phase-bc-1.0.0";
4426
5234
  useLinkedIdentities,
4427
5235
  useLocale,
4428
5236
  useMagicLink,
5237
+ useMemberships,
4429
5238
  useOrganization,
4430
5239
  usePasskey,
4431
5240
  useResolvedSdkBranding,