@oxyhq/services 13.0.3 → 13.1.0

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 (61) hide show
  1. package/lib/commonjs/index.js +18 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/ProfileButton.js +233 -0
  4. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -0
  5. package/lib/commonjs/ui/components/ProfileMenu.js +374 -0
  6. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -0
  7. package/lib/commonjs/ui/context/OxyContext.js +99 -18
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +28 -1
  10. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  11. package/lib/commonjs/ui/index.js +14 -0
  12. package/lib/commonjs/ui/index.js.map +1 -1
  13. package/lib/commonjs/ui/utils/activeAuthuser.js +76 -8
  14. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  15. package/lib/module/index.js +5 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/ProfileButton.js +228 -0
  18. package/lib/module/ui/components/ProfileButton.js.map +1 -0
  19. package/lib/module/ui/components/ProfileMenu.js +370 -0
  20. package/lib/module/ui/components/ProfileMenu.js.map +1 -0
  21. package/lib/module/ui/context/OxyContext.js +100 -19
  22. package/lib/module/ui/context/OxyContext.js.map +1 -1
  23. package/lib/module/ui/context/hooks/useAuthOperations.js +29 -2
  24. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  25. package/lib/module/ui/index.js +2 -0
  26. package/lib/module/ui/index.js.map +1 -1
  27. package/lib/module/ui/utils/activeAuthuser.js +74 -9
  28. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +4 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts +46 -0
  32. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +39 -0
  34. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  38. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +24 -0
  40. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  41. package/lib/typescript/module/index.d.ts +4 -0
  42. package/lib/typescript/module/index.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/components/ProfileButton.d.ts +46 -0
  44. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -0
  45. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +39 -0
  46. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -0
  47. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/index.d.ts +2 -0
  50. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +24 -0
  52. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  53. package/package.json +3 -3
  54. package/src/index.ts +8 -0
  55. package/src/ui/components/ProfileButton.tsx +270 -0
  56. package/src/ui/components/ProfileMenu.tsx +416 -0
  57. package/src/ui/context/OxyContext.tsx +100 -18
  58. package/src/ui/context/hooks/useAuthOperations.ts +35 -2
  59. package/src/ui/index.ts +2 -0
  60. package/src/ui/utils/__tests__/activeAuthuser.test.ts +75 -0
  61. package/src/ui/utils/activeAuthuser.ts +74 -8
@@ -9,7 +9,13 @@ import type { StorageInterface } from '../../utils/storageHelpers';
9
9
  import type { OxyServices } from '@oxyhq/core';
10
10
  import { SignatureService } from '@oxyhq/core';
11
11
  import { isWebBrowser } from '../../hooks/useWebSSO';
12
- import { clearActiveAuthuser, clearSsoBounceState } from '../../utils/activeAuthuser';
12
+ import {
13
+ clearActiveAuthuser,
14
+ clearSsoBounceState,
15
+ writeActiveAuthuser,
16
+ markSignedOut,
17
+ clearSignedOut,
18
+ } from '../../utils/activeAuthuser';
13
19
  import { isCrossApexWeb, CrossApexDirectSignInError } from '../../../utils/crossApex';
14
20
 
15
21
  export interface UseAuthOperationsOptions {
@@ -133,6 +139,23 @@ export const useAuthOperations = ({
133
139
  deviceFingerprint,
134
140
  );
135
141
 
142
+ // Deliberate sign-in re-enables automatic silent restore: clear the durable
143
+ // "deliberately signed out" flag so a prior sign-out no longer suppresses
144
+ // the `fedcm-silent` / per-apex iframe cold-boot steps.
145
+ clearSignedOut();
146
+
147
+ // Register this primary session in the device's first-party multi-account
148
+ // refresh-cookie set so a web key-based sign-in survives reload via
149
+ // `refresh-all` and coexists with switched accounts. `/auth/verify` mints the
150
+ // session but (unlike `/auth/login` / `/fedcm/exchange`) does NOT plant an
151
+ // `oxy_rt_<authuser>` slot, so we establish it here through the shared
152
+ // `POST /auth/session` primitive. No-op on native and cross-apex (returns
153
+ // `null`); records the active slot when one is genuinely allocated.
154
+ const verifyAuthuser = await oxyServices.establishDeviceRefreshSlot();
155
+ if (typeof verifyAuthuser === 'number') {
156
+ writeActiveAuthuser(verifyAuthuser);
157
+ }
158
+
136
159
  // Get full user data
137
160
  fullUser = await oxyServices.getUserBySession(sessionResponse.sessionId);
138
161
 
@@ -260,7 +283,14 @@ export const useAuthOperations = ({
260
283
  await switchSession(filteredSessions[0].sessionId);
261
284
  } else {
262
285
  // Genuine FULL sign-out (no sessions remain): clear the per-origin
263
- // SSO bounce state so a fresh deliberate sign-in can re-probe.
286
+ // SSO bounce state so a fresh deliberate sign-in can re-probe, drop
287
+ // the persisted active device slot so the next cold boot does not
288
+ // prioritize a now-signed-out `oxy_active_authuser`, and SET the
289
+ // deliberately-signed-out flag so the silent cold-boot steps
290
+ // (`fedcm-silent` / per-apex iframe) do not re-mint a session from a
291
+ // still-live IdP session on the next reload (mirrors `logoutAll`).
292
+ markSignedOut();
293
+ clearActiveAuthuser();
264
294
  clearSsoBounceState();
265
295
  clearPriorSessionHintSafe(clearPriorSessionHint, logger);
266
296
  await clearSessionState();
@@ -322,6 +352,9 @@ export const useAuthOperations = ({
322
352
  if (isWebBrowser()) {
323
353
  await oxyServices.logoutAllSessionsViaCookie();
324
354
  clearActiveAuthuser();
355
+ // Deliberate full sign-out: suppress automatic silent restore on the next
356
+ // cold boot so a still-live IdP session does not re-mint a session.
357
+ markSignedOut();
325
358
  }
326
359
  // logoutAll is ALWAYS a full sign-out: clear the per-origin SSO bounce
327
360
  // state (web-guarded internally) so a fresh sign-in can re-probe, and drop
package/src/ui/index.ts CHANGED
@@ -27,6 +27,8 @@ export { FontLoader, setupFonts } from './components/FontLoader';
27
27
  export { default as OxyIcon } from './components/icon/OxyIcon';
28
28
  export { default as AccountMenu } from './components/AccountMenu';
29
29
  export { default as AccountMenuButton } from './components/AccountMenuButton';
30
+ export { default as ProfileButton } from './components/ProfileButton';
31
+ export { default as ProfileMenu } from './components/ProfileMenu';
30
32
  export { default as AccountSwitcher, AccountSwitcherView } from './components/AccountSwitcher';
31
33
 
32
34
  // Context + hooks
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Fail-safe guarantees for the web `activeAuthuser` storage helpers.
3
+ *
4
+ * The PROPERTY ACCESS `window.localStorage` can throw a `SecurityError`
5
+ * synchronously in opaque-origin / sandboxed iframes or when storage is
6
+ * disabled — not just `getItem`/`setItem`. These helpers run during cold boot
7
+ * (and feed render-phase gate values in the providers), so they MUST never
8
+ * propagate that throw: reads fail safe to a benign default and writes no-op.
9
+ */
10
+
11
+ import {
12
+ readActiveAuthuser,
13
+ writeActiveAuthuser,
14
+ clearActiveAuthuser,
15
+ markSignedOut,
16
+ clearSignedOut,
17
+ isSilentRestoreSuppressed,
18
+ } from '../activeAuthuser';
19
+
20
+ describe('activeAuthuser helpers — localStorage fail-safe', () => {
21
+ const realDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
22
+
23
+ afterEach(() => {
24
+ if (realDescriptor) {
25
+ Object.defineProperty(window, 'localStorage', realDescriptor);
26
+ }
27
+ window.localStorage?.clear?.();
28
+ });
29
+
30
+ /** Replace the `localStorage` accessor so reading the property throws. */
31
+ function makeLocalStorageGetterThrow(): void {
32
+ Object.defineProperty(window, 'localStorage', {
33
+ configurable: true,
34
+ get() {
35
+ throw new DOMException('denied', 'SecurityError');
36
+ },
37
+ });
38
+ }
39
+
40
+ it('readActiveAuthuser returns null (never throws) when the getter throws', () => {
41
+ makeLocalStorageGetterThrow();
42
+ expect(() => readActiveAuthuser()).not.toThrow();
43
+ expect(readActiveAuthuser()).toBeNull();
44
+ });
45
+
46
+ it('isSilentRestoreSuppressed returns false (never throws) when the getter throws', () => {
47
+ makeLocalStorageGetterThrow();
48
+ expect(() => isSilentRestoreSuppressed()).not.toThrow();
49
+ expect(isSilentRestoreSuppressed()).toBe(false);
50
+ });
51
+
52
+ it('writeActiveAuthuser / clearActiveAuthuser / markSignedOut / clearSignedOut no-op when the getter throws', () => {
53
+ makeLocalStorageGetterThrow();
54
+ expect(() => {
55
+ writeActiveAuthuser(2);
56
+ clearActiveAuthuser();
57
+ markSignedOut();
58
+ clearSignedOut();
59
+ }).not.toThrow();
60
+ });
61
+
62
+ it('round-trips normally when storage works (zero behavior change)', () => {
63
+ writeActiveAuthuser(3);
64
+ expect(readActiveAuthuser()).toBe(3);
65
+
66
+ expect(isSilentRestoreSuppressed()).toBe(false);
67
+ markSignedOut();
68
+ expect(isSilentRestoreSuppressed()).toBe(true);
69
+ clearSignedOut();
70
+ expect(isSilentRestoreSuppressed()).toBe(false);
71
+
72
+ clearActiveAuthuser();
73
+ expect(readActiveAuthuser()).toBeNull();
74
+ });
75
+ });
@@ -24,12 +24,28 @@ import {
24
24
  ssoGuardKey,
25
25
  ssoStateKey,
26
26
  ssoDestKey,
27
+ ssoSignedOutKey,
28
+ silentRestoreSuppressed,
27
29
  } from '@oxyhq/core';
28
30
 
29
31
  const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
30
32
 
31
- function hasLocalStorage(): boolean {
32
- return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
33
+ /**
34
+ * Safely resolve `window.localStorage`, returning `null` when it is
35
+ * unavailable. The PROPERTY ACCESS itself (`window.localStorage`) can throw a
36
+ * `SecurityError` synchronously in opaque-origin / sandboxed iframes or when
37
+ * storage is disabled — even `typeof window.localStorage` evaluates the getter
38
+ * and throws. Every read/write in this module routes through here so the getter
39
+ * throw is caught once, at the source, and callers stay clean. Returns `null`
40
+ * off-web and on any access failure (fail safe).
41
+ */
42
+ function getLocalStorage(): Storage | null {
43
+ if (typeof window === 'undefined') return null;
44
+ try {
45
+ return window.localStorage ?? null;
46
+ } catch {
47
+ return null;
48
+ }
33
49
  }
34
50
 
35
51
  function getSessionStorage(): Storage | null {
@@ -49,11 +65,12 @@ function getSessionStorage(): Storage | null {
49
65
  * fall back to deterministic selection (lowest authuser).
50
66
  */
51
67
  export function readActiveAuthuser(): number | null {
52
- if (!hasLocalStorage()) {
68
+ const storage = getLocalStorage();
69
+ if (!storage) {
53
70
  return null;
54
71
  }
55
72
  try {
56
- const raw = window.localStorage.getItem(ACTIVE_AUTHUSER_KEY);
73
+ const raw = storage.getItem(ACTIVE_AUTHUSER_KEY);
57
74
  if (raw === null) return null;
58
75
  const parsed = Number.parseInt(raw, 10);
59
76
  if (!Number.isFinite(parsed) || parsed < 0) return null;
@@ -69,10 +86,11 @@ export function readActiveAuthuser(): number | null {
69
86
  * this succeeding — it is best-effort UX persistence, not authoritative.
70
87
  */
71
88
  export function writeActiveAuthuser(authuser: number): void {
72
- if (!hasLocalStorage()) return;
73
89
  if (!Number.isFinite(authuser) || authuser < 0) return;
90
+ const storage = getLocalStorage();
91
+ if (!storage) return;
74
92
  try {
75
- window.localStorage.setItem(ACTIVE_AUTHUSER_KEY, String(authuser));
93
+ storage.setItem(ACTIVE_AUTHUSER_KEY, String(authuser));
76
94
  } catch {
77
95
  // Best-effort persistence; swallow QuotaExceededError / SecurityError.
78
96
  }
@@ -84,14 +102,62 @@ export function writeActiveAuthuser(authuser: number): void {
84
102
  * cleared slot.
85
103
  */
86
104
  export function clearActiveAuthuser(): void {
87
- if (!hasLocalStorage()) return;
105
+ const storage = getLocalStorage();
106
+ if (!storage) return;
107
+ try {
108
+ storage.removeItem(ACTIVE_AUTHUSER_KEY);
109
+ } catch {
110
+ // Best-effort.
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Mark this origin as DELIBERATELY signed out (durable `localStorage`, via the
116
+ * core {@link ssoSignedOutKey}). Called ONLY on EXPLICIT full sign-out so that
117
+ * the next cold boot does NOT silently re-mint a session from a still-live IdP
118
+ * session (`fedcm-silent` / per-apex `/auth/silent` iframe). Cleared by any
119
+ * deliberate sign-in (see {@link clearSignedOut}). No-ops on native / storage
120
+ * failure (best-effort).
121
+ */
122
+ export function markSignedOut(): void {
123
+ const storage = getLocalStorage();
124
+ if (!storage) return;
88
125
  try {
89
- window.localStorage.removeItem(ACTIVE_AUTHUSER_KEY);
126
+ storage.setItem(ssoSignedOutKey(window.location.origin), '1');
127
+ } catch {
128
+ // Best-effort; swallow QuotaExceededError / SecurityError (private mode).
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Clear the durable deliberately-signed-out flag. Called on ANY deliberate
134
+ * sign-in (password, FedCM, account switch, device claim) so a real sign-in
135
+ * fully re-enables automatic silent restore — there is no "stuck signed out"
136
+ * state. No-ops on native / storage failure.
137
+ */
138
+ export function clearSignedOut(): void {
139
+ const storage = getLocalStorage();
140
+ if (!storage) return;
141
+ try {
142
+ storage.removeItem(ssoSignedOutKey(window.location.origin));
90
143
  } catch {
91
144
  // Best-effort.
92
145
  }
93
146
  }
94
147
 
148
+ /**
149
+ * Whether AUTOMATIC silent restore is suppressed for the current origin because
150
+ * the user deliberately signed out. Reads the durable flag through the core
151
+ * {@link silentRestoreSuppressed} predicate. Returns `false` off-web and on any
152
+ * storage failure (fail safe toward normal restore). Used to gate the
153
+ * `fedcm-silent` and `silent-iframe` cold-boot steps.
154
+ */
155
+ export function isSilentRestoreSuppressed(): boolean {
156
+ const storage = getLocalStorage();
157
+ if (!storage) return false;
158
+ return silentRestoreSuppressed(storage, window.location.origin);
159
+ }
160
+
95
161
  /**
96
162
  * Clear all per-origin SSO bounce sessionStorage keys. Called ONLY on EXPLICIT
97
163
  * user sign-out (logout / logoutAll) — never on a cold-boot failure path — so a