@oxyhq/core 3.4.1 → 3.4.3

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 (174) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +91 -319
  3. package/dist/cjs/CrossDomainAuth.js +19 -106
  4. package/dist/cjs/HttpService.js +49 -73
  5. package/dist/cjs/OxyServices.base.js +2 -2
  6. package/dist/cjs/i18n/index.js +7 -1
  7. package/dist/cjs/i18n/locales/ar-SA.json +18 -2
  8. package/dist/cjs/i18n/locales/ca-ES.json +18 -2
  9. package/dist/cjs/i18n/locales/de-DE.json +18 -2
  10. package/dist/cjs/i18n/locales/en-US.json +16 -2
  11. package/dist/cjs/i18n/locales/es-ES.json +16 -2
  12. package/dist/cjs/i18n/locales/fr-FR.json +18 -2
  13. package/dist/cjs/i18n/locales/it-IT.json +18 -2
  14. package/dist/cjs/i18n/locales/ja-JP.json +18 -2
  15. package/dist/cjs/i18n/locales/ko-KR.json +18 -2
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +18 -2
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +18 -2
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +18 -2
  19. package/dist/cjs/i18n/locales/locales/en-US.json +17 -3
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -2
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +18 -2
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +18 -2
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +18 -2
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +18 -2
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +18 -2
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +18 -2
  27. package/dist/cjs/i18n/locales/pt-PT.json +18 -2
  28. package/dist/cjs/i18n/locales/zh-CN.json +18 -2
  29. package/dist/cjs/mixins/OxyServices.auth.js +20 -63
  30. package/dist/cjs/mixins/OxyServices.fedcm.js +10 -12
  31. package/dist/cjs/mixins/OxyServices.popup.js +50 -299
  32. package/dist/cjs/mixins/OxyServices.redirect.js +84 -348
  33. package/dist/cjs/mixins/OxyServices.silent.js +204 -0
  34. package/dist/cjs/mixins/OxyServices.sso.js +4 -5
  35. package/dist/cjs/mixins/OxyServices.utility.js +6 -15
  36. package/dist/cjs/mixins/index.js +5 -6
  37. package/dist/cjs/server/index.js +21 -0
  38. package/dist/cjs/server/rateLimit.js +77 -0
  39. package/dist/cjs/shared/utils/debugUtils.js +1 -1
  40. package/dist/cjs/utils/accountUtils.js +4 -4
  41. package/dist/cjs/utils/authHelpers.js +21 -15
  42. package/dist/cjs/utils/coldBoot.js +3 -3
  43. package/dist/cjs/utils/fapiAutoDetect.js +1 -1
  44. package/dist/esm/.tsbuildinfo +1 -1
  45. package/dist/esm/AuthManager.js +91 -319
  46. package/dist/esm/CrossDomainAuth.js +19 -106
  47. package/dist/esm/HttpService.js +49 -73
  48. package/dist/esm/OxyServices.base.js +2 -2
  49. package/dist/esm/i18n/index.js +7 -1
  50. package/dist/esm/i18n/locales/ar-SA.json +18 -2
  51. package/dist/esm/i18n/locales/ca-ES.json +18 -2
  52. package/dist/esm/i18n/locales/de-DE.json +18 -2
  53. package/dist/esm/i18n/locales/en-US.json +16 -2
  54. package/dist/esm/i18n/locales/es-ES.json +16 -2
  55. package/dist/esm/i18n/locales/fr-FR.json +18 -2
  56. package/dist/esm/i18n/locales/it-IT.json +18 -2
  57. package/dist/esm/i18n/locales/ja-JP.json +18 -2
  58. package/dist/esm/i18n/locales/ko-KR.json +18 -2
  59. package/dist/esm/i18n/locales/locales/ar-SA.json +18 -2
  60. package/dist/esm/i18n/locales/locales/ca-ES.json +18 -2
  61. package/dist/esm/i18n/locales/locales/de-DE.json +18 -2
  62. package/dist/esm/i18n/locales/locales/en-US.json +17 -3
  63. package/dist/esm/i18n/locales/locales/es-ES.json +16 -2
  64. package/dist/esm/i18n/locales/locales/fr-FR.json +18 -2
  65. package/dist/esm/i18n/locales/locales/it-IT.json +18 -2
  66. package/dist/esm/i18n/locales/locales/ja-JP.json +18 -2
  67. package/dist/esm/i18n/locales/locales/ko-KR.json +18 -2
  68. package/dist/esm/i18n/locales/locales/pt-PT.json +18 -2
  69. package/dist/esm/i18n/locales/locales/zh-CN.json +18 -2
  70. package/dist/esm/i18n/locales/pt-PT.json +18 -2
  71. package/dist/esm/i18n/locales/zh-CN.json +18 -2
  72. package/dist/esm/mixins/OxyServices.auth.js +20 -63
  73. package/dist/esm/mixins/OxyServices.fedcm.js +10 -12
  74. package/dist/esm/mixins/OxyServices.popup.js +52 -301
  75. package/dist/esm/mixins/OxyServices.redirect.js +84 -349
  76. package/dist/esm/mixins/OxyServices.silent.js +202 -0
  77. package/dist/esm/mixins/OxyServices.sso.js +4 -5
  78. package/dist/esm/mixins/OxyServices.utility.js +6 -15
  79. package/dist/esm/mixins/index.js +5 -6
  80. package/dist/esm/server/index.js +17 -0
  81. package/dist/esm/server/rateLimit.js +71 -0
  82. package/dist/esm/shared/utils/debugUtils.js +1 -1
  83. package/dist/esm/utils/accountUtils.js +4 -4
  84. package/dist/esm/utils/authHelpers.js +21 -15
  85. package/dist/esm/utils/coldBoot.js +3 -3
  86. package/dist/esm/utils/fapiAutoDetect.js +1 -1
  87. package/dist/types/.tsbuildinfo +1 -1
  88. package/dist/types/AuthManager.d.ts +26 -53
  89. package/dist/types/AuthManagerTypes.d.ts +5 -9
  90. package/dist/types/CrossDomainAuth.d.ts +13 -52
  91. package/dist/types/HttpService.d.ts +9 -8
  92. package/dist/types/OxyServices.base.d.ts +1 -1
  93. package/dist/types/OxyServices.d.ts +4 -10
  94. package/dist/types/index.d.ts +1 -1
  95. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
  96. package/dist/types/mixins/OxyServices.appData.d.ts +1 -1
  97. package/dist/types/mixins/OxyServices.applications.d.ts +1 -1
  98. package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
  99. package/dist/types/mixins/OxyServices.auth.d.ts +10 -31
  100. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -1
  101. package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
  102. package/dist/types/mixins/OxyServices.features.d.ts +1 -1
  103. package/dist/types/mixins/OxyServices.fedcm.d.ts +5 -5
  104. package/dist/types/mixins/OxyServices.language.d.ts +1 -1
  105. package/dist/types/mixins/OxyServices.location.d.ts +1 -1
  106. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -1
  107. package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
  108. package/dist/types/mixins/OxyServices.popup.d.ts +18 -120
  109. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
  110. package/dist/types/mixins/OxyServices.redirect.d.ts +13 -174
  111. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -1
  112. package/dist/types/mixins/OxyServices.security.d.ts +1 -1
  113. package/dist/types/mixins/OxyServices.silent.d.ts +131 -0
  114. package/dist/types/mixins/OxyServices.sso.d.ts +4 -5
  115. package/dist/types/mixins/OxyServices.topics.d.ts +1 -1
  116. package/dist/types/mixins/OxyServices.user.d.ts +1 -1
  117. package/dist/types/mixins/OxyServices.utility.d.ts +3 -8
  118. package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -1
  119. package/dist/types/mixins/index.d.ts +3 -3
  120. package/dist/types/models/interfaces.d.ts +5 -16
  121. package/dist/types/models/session.d.ts +0 -2
  122. package/dist/types/server/index.d.ts +18 -0
  123. package/dist/types/server/rateLimit.d.ts +40 -0
  124. package/dist/types/shared/utils/debugUtils.d.ts +1 -1
  125. package/dist/types/utils/authHelpers.d.ts +4 -3
  126. package/dist/types/utils/coldBoot.d.ts +2 -2
  127. package/dist/types/utils/fapiAutoDetect.d.ts +1 -1
  128. package/package.json +24 -2
  129. package/src/AuthManager.ts +100 -370
  130. package/src/AuthManagerTypes.ts +5 -9
  131. package/src/CrossDomainAuth.ts +22 -129
  132. package/src/HttpService.ts +55 -73
  133. package/src/OxyServices.base.ts +2 -3
  134. package/src/OxyServices.ts +9 -11
  135. package/src/__tests__/authManager.cookiePath.test.ts +19 -17
  136. package/src/__tests__/authManager.security.test.ts +7 -3
  137. package/src/__tests__/crossDomainAuth.test.ts +26 -118
  138. package/src/i18n/index.ts +7 -1
  139. package/src/i18n/locales/ar-SA.json +18 -2
  140. package/src/i18n/locales/ca-ES.json +18 -2
  141. package/src/i18n/locales/de-DE.json +18 -2
  142. package/src/i18n/locales/en-US.json +17 -3
  143. package/src/i18n/locales/es-ES.json +16 -2
  144. package/src/i18n/locales/fr-FR.json +18 -2
  145. package/src/i18n/locales/it-IT.json +18 -2
  146. package/src/i18n/locales/ja-JP.json +18 -2
  147. package/src/i18n/locales/ko-KR.json +18 -2
  148. package/src/i18n/locales/pt-PT.json +18 -2
  149. package/src/i18n/locales/zh-CN.json +18 -2
  150. package/src/index.ts +1 -1
  151. package/src/mixins/OxyServices.auth.ts +23 -75
  152. package/src/mixins/OxyServices.fedcm.ts +10 -12
  153. package/src/mixins/OxyServices.redirect.ts +82 -371
  154. package/src/mixins/OxyServices.silent.ts +272 -0
  155. package/src/mixins/OxyServices.sso.ts +5 -6
  156. package/src/mixins/OxyServices.utility.ts +9 -22
  157. package/src/mixins/__tests__/appData.test.ts +1 -1
  158. package/src/mixins/__tests__/onTokensChanged.test.ts +1 -1
  159. package/src/mixins/__tests__/reputation.test.ts +1 -1
  160. package/src/mixins/__tests__/serviceAuth.test.ts +7 -5
  161. package/src/mixins/__tests__/silent.test.ts +102 -0
  162. package/src/mixins/__tests__/verifyChallenge.test.ts +9 -14
  163. package/src/mixins/index.ts +6 -8
  164. package/src/models/interfaces.ts +5 -16
  165. package/src/models/session.ts +1 -3
  166. package/src/server/index.ts +19 -0
  167. package/src/server/rateLimit.ts +170 -0
  168. package/src/shared/utils/debugUtils.ts +1 -1
  169. package/src/utils/accountUtils.ts +4 -4
  170. package/src/utils/authHelpers.ts +23 -15
  171. package/src/utils/coldBoot.ts +4 -4
  172. package/src/utils/fapiAutoDetect.ts +1 -1
  173. package/src/mixins/OxyServices.popup.ts +0 -631
  174. package/src/mixins/__tests__/popup.test.ts +0 -374
@@ -5,8 +5,7 @@
5
5
  * selects the best authentication method based on browser capabilities:
6
6
  *
7
7
  * 1. FedCM (if supported) - Modern, Google-style browser-native auth
8
- * 2. Popup (fallback) - OAuth2-style popup window
9
- * 3. Redirect (final fallback) - Traditional full-page redirect
8
+ * 2. Redirect (fallback) - Tokenless central SSO full-page redirect
10
9
  *
11
10
  * Usage:
12
11
  * ```typescript
@@ -17,8 +16,8 @@
17
16
  * // Automatic method selection
18
17
  * const session = await auth.signIn();
19
18
  *
20
- * // Or use specific method
21
- * const session = await auth.signInWithPopup();
19
+ * // Or use a specific method
20
+ * auth.signInWithRedirect();
22
21
  * ```
23
22
  */
24
23
  import { logger } from './utils/loggerUtils.js';
@@ -31,53 +30,23 @@ export class CrossDomainAuth {
31
30
  *
32
31
  * Tries methods in this order:
33
32
  * 1. FedCM (if supported and not in private browsing)
34
- * 2. Popup (if not blocked)
35
- * 3. Redirect (always works)
33
+ * 2. Redirect (always works)
36
34
  *
37
35
  * @param options - Authentication options
38
36
  * @returns Session with user data and access token
39
37
  */
40
38
  async signIn(options = {}) {
41
39
  const method = options.method || 'auto';
42
- // If specific method requested, use it directly. The caller MAY have
43
- // pre-opened a popup on the raw click (the standard pattern in
44
- // WebOxyProvider / services useAuth). For the FedCM and redirect paths
45
- // that popup is unused — close it so it doesn't linger as an orphaned
46
- // blank window. Close in both success and failure paths.
47
40
  if (method === 'fedcm') {
48
- try {
49
- const session = await this.signInWithFedCM(options);
50
- this.closeOrphanPopup(options.popup);
51
- return session;
52
- }
53
- catch (error) {
54
- this.closeOrphanPopup(options.popup);
55
- throw error;
56
- }
57
- }
58
- if (method === 'popup') {
59
- return this.signInWithPopup(options);
41
+ return this.signInWithFedCM(options);
60
42
  }
61
43
  if (method === 'redirect') {
62
- this.closeOrphanPopup(options.popup);
63
44
  this.signInWithRedirect(options);
64
45
  return null; // Redirect doesn't return immediately
65
46
  }
66
- // Auto mode: Try methods in order of preference
47
+ // Auto mode: try methods in order of preference.
67
48
  return this.autoSignIn(options);
68
49
  }
69
- /**
70
- * Close a caller-supplied popup window that is no longer needed (e.g. the
71
- * resolved auth method didn't end up using it). Safe against null / already
72
- * closed handles.
73
- *
74
- * @private
75
- */
76
- closeOrphanPopup(popup) {
77
- if (popup && !popup.closed) {
78
- popup.close();
79
- }
80
- }
81
50
  /**
82
51
  * Automatic sign-in with progressive enhancement
83
52
  *
@@ -88,27 +57,13 @@ export class CrossDomainAuth {
88
57
  if (this.isFedCMSupported()) {
89
58
  try {
90
59
  options.onMethodSelected?.('fedcm');
91
- const session = await this.signInWithFedCM(options);
92
- // FedCM succeeded — close the pre-opened popup so it doesn't linger
93
- // as an orphaned blank window.
94
- this.closeOrphanPopup(options.popup);
95
- return session;
60
+ return await this.signInWithFedCM(options);
96
61
  }
97
62
  catch (error) {
98
- logger.warn('FedCM failed, trying popup', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
63
+ logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
99
64
  }
100
65
  }
101
- // 2. Try popup (good UX, widely supported)
102
- try {
103
- options.onMethodSelected?.('popup');
104
- return await this.signInWithPopup(options);
105
- }
106
- catch (error) {
107
- logger.warn('Popup failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
108
- // Popup path failed — close the pre-opened popup before redirecting.
109
- this.closeOrphanPopup(options.popup);
110
- }
111
- // 3. Fallback to redirect (always works)
66
+ // 2. Fallback to redirect (always works)
112
67
  options.onMethodSelected?.('redirect');
113
68
  this.signInWithRedirect(options);
114
69
  return null;
@@ -116,26 +71,13 @@ export class CrossDomainAuth {
116
71
  /**
117
72
  * Sign in using FedCM (Federated Credential Management)
118
73
  *
119
- * Best method - browser-native, no popups, Google-like experience
74
+ * Best method - browser-native, Google-like experience
120
75
  */
121
76
  async signInWithFedCM(options = {}) {
122
77
  return this.oxyServices.signInWithFedCM({
123
78
  context: options.isSignup ? 'signup' : 'signin',
124
79
  });
125
80
  }
126
- /**
127
- * Sign in using popup window
128
- *
129
- * Good method - preserves app state, no full page reload
130
- */
131
- async signInWithPopup(options = {}) {
132
- return this.oxyServices.signInWithPopup({
133
- mode: options.isSignup ? 'signup' : 'login',
134
- width: options.popupDimensions?.width,
135
- height: options.popupDimensions?.height,
136
- popup: options.popup ?? undefined,
137
- });
138
- }
139
81
  /**
140
82
  * Sign in using full-page redirect
141
83
  *
@@ -159,7 +101,7 @@ export class CrossDomainAuth {
159
101
  * Silent sign-in (check for existing session)
160
102
  *
161
103
  * Tries to automatically sign in without user interaction.
162
- * Works with both FedCM and popup/iframe methods.
104
+ * Works with FedCM and iframe-based silent auth.
163
105
  *
164
106
  * @returns Session if user is already signed in, null otherwise
165
107
  */
@@ -186,22 +128,13 @@ export class CrossDomainAuth {
186
128
  }
187
129
  }
188
130
  /**
189
- * Restore session from storage
131
+ * Restore session from storage.
190
132
  *
191
- * For redirect method - restores previously authenticated session from localStorage
133
+ * Access tokens are no longer persisted in browser storage; providers restore
134
+ * through refresh cookies / SSO code exchange instead.
192
135
  */
193
136
  restoreSession() {
194
- return this.oxyServices.restoreSession?.() || false;
195
- }
196
- /**
197
- * Open a blank popup SYNCHRONOUSLY (call from a raw user-gesture handler
198
- * BEFORE any `await`). Returns `null` if the popup was blocked. Pass the
199
- * handle into `signIn({ popup })` / `signInWithPopup({ popup })` so the
200
- * popup is not blocked by Chrome after any prior `await` consumed the
201
- * transient user activation. Delegates to `OxyServices.openBlankPopup`.
202
- */
203
- openBlankPopup(width, height) {
204
- return this.oxyServices.openBlankPopup(width, height);
137
+ return false;
205
138
  }
206
139
  /**
207
140
  * Check if FedCM is supported in current browser
@@ -225,8 +158,8 @@ export class CrossDomainAuth {
225
158
  }
226
159
  if (typeof window !== 'undefined') {
227
160
  return {
228
- method: 'popup',
229
- reason: 'Browser environment - popup preserves app state',
161
+ method: 'redirect',
162
+ reason: 'Browser environment - redirect SSO works without token callback URLs',
230
163
  };
231
164
  }
232
165
  return {
@@ -239,8 +172,7 @@ export class CrossDomainAuth {
239
172
  *
240
173
  * This handles:
241
174
  * 1. Redirect callback (if returning from auth.oxy.so)
242
- * 2. Session restoration (from localStorage)
243
- * 3. Silent sign-in (check for existing SSO session)
175
+ * 2. Silent sign-in (check for existing SSO session)
244
176
  *
245
177
  * @returns Session if user is authenticated, null otherwise
246
178
  */
@@ -250,26 +182,7 @@ export class CrossDomainAuth {
250
182
  if (callbackSession) {
251
183
  return callbackSession;
252
184
  }
253
- // 2. Try to restore existing session from storage
254
- const restored = this.restoreSession();
255
- if (restored) {
256
- // Verify session is still valid by fetching user
257
- try {
258
- const user = await this.oxyServices.getCurrentUser();
259
- if (user) {
260
- return {
261
- sessionId: this.oxyServices.getStoredSessionId?.() || '',
262
- deviceId: '',
263
- expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
264
- user,
265
- };
266
- }
267
- }
268
- catch (error) {
269
- logger.debug('stored session invalid', { component: 'CrossDomainAuth', method: 'initialize' }, error);
270
- }
271
- }
272
- // 3. Try silent sign-in (check for SSO session at auth.oxy.so)
185
+ // 2. Try silent sign-in (check for SSO session at auth.oxy.so)
273
186
  return await this.silentSignIn();
274
187
  }
275
188
  }
@@ -57,23 +57,17 @@ function fnv1a32(str) {
57
57
  class TokenStore {
58
58
  constructor() {
59
59
  this.accessToken = null;
60
- this.refreshToken = null;
61
60
  this.csrfToken = null;
62
61
  this.csrfTokenFetchPromise = null;
63
62
  }
64
- setTokens(accessToken, refreshToken = '') {
63
+ setTokens(accessToken) {
65
64
  this.accessToken = accessToken;
66
- this.refreshToken = refreshToken;
67
65
  }
68
66
  getAccessToken() {
69
67
  return this.accessToken;
70
68
  }
71
- getRefreshToken() {
72
- return this.refreshToken;
73
- }
74
69
  clearTokens() {
75
70
  this.accessToken = null;
76
- this.refreshToken = null;
77
71
  }
78
72
  hasAccessToken() {
79
73
  return !!this.accessToken;
@@ -105,13 +99,12 @@ export class HttpService {
105
99
  constructor(config) {
106
100
  this.tokenRefreshPromise = null;
107
101
  this.tokenRefreshCooldownUntil = 0;
108
- this._onTokenRefreshed = null;
102
+ this.authRefreshHandler = null;
109
103
  /**
110
104
  * Fan-out listeners notified on EVERY access-token change on this instance:
111
- * explicit `setTokens`, `clearTokens`, a successful silent refresh, and the
112
- * internal 401-driven clear. Unlike the single-slot `_onTokenRefreshed`
113
- * (owned by AuthManager for the refresh path only), this is a Set so multiple
114
- * independent observers can mirror token state without clobbering each other.
105
+ * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
106
+ * internal 401-driven clear. This is a Set so multiple independent observers
107
+ * can mirror token state without clobbering each other.
115
108
  *
116
109
  * Each listener receives the resulting access token, or `null` when cleared.
117
110
  */
@@ -298,23 +291,13 @@ export class HttpService {
298
291
  clearTimeout(timeoutId);
299
292
  // Handle response
300
293
  if (!response.ok) {
301
- // On 401, attempt token refresh and retry once before giving up
294
+ // On 401, delegate refresh to AuthManager and retry once before
295
+ // giving up. HttpService deliberately does not know any session
296
+ // routes; the AuthManager is the single session authority.
302
297
  if (response.status === 401 && !config._isAuthRetry) {
303
- const currentToken = this.tokenStore.getAccessToken();
304
- if (currentToken) {
305
- try {
306
- const decoded = jwtDecode(currentToken);
307
- if (decoded.sessionId) {
308
- const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
309
- if (refreshResult) {
310
- // Retry the request with the new token
311
- return this.request({ ...config, _isAuthRetry: true, retry: false });
312
- }
313
- }
314
- }
315
- catch {
316
- // Token decode failed, fall through to clear
317
- }
298
+ const refreshed = await this.refreshAccessToken('response-401');
299
+ if (refreshed) {
300
+ return this.request({ ...config, _isAuthRetry: true, retry: false });
318
301
  }
319
302
  // Refresh failed or no token — clear tokens and stale CSRF
320
303
  this.tokenStore.clearTokens();
@@ -341,7 +324,7 @@ export class HttpService {
341
324
  if (contentType && contentType.includes('application/json')) {
342
325
  try {
343
326
  const errorData = await response.json();
344
- // Check both 'message' and 'error' fields for backwards compatibility
327
+ // Accept either structured error field from API responses.
345
328
  if (errorData?.message) {
346
329
  errorMessage = errorData.message;
347
330
  }
@@ -708,26 +691,10 @@ export class HttpService {
708
691
  const decoded = jwtDecode(accessToken);
709
692
  const currentTime = Math.floor(Date.now() / 1000);
710
693
  // If token expires in less than 60 seconds, refresh it
711
- if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
712
- // Skip if we recently failed a refresh (15s cooldown to prevent storms)
713
- if (Date.now() < this.tokenRefreshCooldownUntil) {
714
- return `Bearer ${accessToken}`;
715
- }
716
- // Deduplicate concurrent refresh attempts. The promise is shared
717
- // across all concurrent callers and cleared only after it settles,
718
- // so every awaiter receives the same result.
719
- if (!this.tokenRefreshPromise) {
720
- this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId)
721
- .then((result) => {
722
- if (!result)
723
- this.tokenRefreshCooldownUntil = Date.now() + 15000;
724
- return result;
725
- })
726
- .finally(() => { this.tokenRefreshPromise = null; });
727
- }
728
- const result = await this.tokenRefreshPromise;
729
- if (result)
730
- return result;
694
+ if (decoded.exp && decoded.exp - currentTime < 60) {
695
+ const refreshed = await this.refreshAccessToken('preflight');
696
+ if (refreshed)
697
+ return `Bearer ${refreshed}`;
731
698
  // Refresh failed — don't use the expired token (would cause 401 loop)
732
699
  return null;
733
700
  }
@@ -738,28 +705,37 @@ export class HttpService {
738
705
  return null;
739
706
  }
740
707
  }
741
- async _refreshTokenFromSession(sessionId) {
742
- try {
743
- const refreshUrl = `${this.baseURL}/session/token/${sessionId}`;
744
- const response = await fetch(refreshUrl, {
745
- method: 'GET',
746
- headers: { 'Accept': 'application/json' },
747
- signal: AbortSignal.timeout(5000),
748
- credentials: 'include',
749
- });
750
- if (response.ok) {
751
- const { accessToken: newToken } = await response.json();
752
- this.tokenStore.setTokens(newToken);
753
- this._onTokenRefreshed?.(newToken);
754
- this.notifyTokenChange();
755
- this.logger.debug('Token refreshed');
756
- return `Bearer ${newToken}`;
757
- }
708
+ async refreshAccessToken(reason) {
709
+ if (!this.authRefreshHandler) {
710
+ return null;
711
+ }
712
+ if (Date.now() < this.tokenRefreshCooldownUntil) {
713
+ return null;
758
714
  }
759
- catch (refreshError) {
760
- this.logger.warn('Token refresh failed, using current token');
715
+ if (!this.tokenRefreshPromise) {
716
+ this.tokenRefreshPromise = this.authRefreshHandler(reason)
717
+ .then((newToken) => {
718
+ if (!newToken) {
719
+ this.tokenRefreshCooldownUntil = Date.now() + 15000;
720
+ return null;
721
+ }
722
+ if (this.tokenStore.getAccessToken() !== newToken) {
723
+ this.tokenStore.setTokens(newToken);
724
+ this.notifyTokenChange();
725
+ }
726
+ this.logger.debug('Token refreshed via AuthManager');
727
+ return newToken;
728
+ })
729
+ .catch((error) => {
730
+ this.logger.warn('Token refresh failed:', error);
731
+ this.tokenRefreshCooldownUntil = Date.now() + 15000;
732
+ return null;
733
+ })
734
+ .finally(() => {
735
+ this.tokenRefreshPromise = null;
736
+ });
761
737
  }
762
- return null;
738
+ return this.tokenRefreshPromise;
763
739
  }
764
740
  /**
765
741
  * Unwrap standardized API response format
@@ -815,12 +791,12 @@ export class HttpService {
815
791
  return this._actingAsUserId;
816
792
  }
817
793
  // Token management
818
- setTokens(accessToken, refreshToken = '') {
819
- this.tokenStore.setTokens(accessToken, refreshToken);
794
+ setTokens(accessToken) {
795
+ this.tokenStore.setTokens(accessToken);
820
796
  this.notifyTokenChange();
821
797
  }
822
- set onTokenRefreshed(callback) {
823
- this._onTokenRefreshed = callback;
798
+ setAuthRefreshHandler(handler) {
799
+ this.authRefreshHandler = handler;
824
800
  }
825
801
  clearTokens() {
826
802
  this.tokenStore.clearTokens();
@@ -127,8 +127,8 @@ export class OxyServicesBase {
127
127
  /**
128
128
  * Set authentication tokens
129
129
  */
130
- setTokens(accessToken, refreshToken = '') {
131
- this.httpService.setTokens(accessToken, refreshToken);
130
+ setTokens(accessToken) {
131
+ this.httpService.setTokens(accessToken);
132
132
  }
133
133
  /**
134
134
  * Clear stored authentication tokens
@@ -41,8 +41,14 @@ export function translate(locale, key, vars) {
41
41
  const lang = locale && DICTS[locale] ? locale : FALLBACK;
42
42
  const dict = DICTS[lang] || DICTS[FALLBACK];
43
43
  let val = getNested(dict, key);
44
+ // Per-key fallback to the English dictionary when a key is missing from the
45
+ // resolved (non-English) locale. Without this, a key present in en-US but not
46
+ // yet translated in e.g. es-ES would render the raw dotted key to users.
47
+ if (typeof val !== 'string' && lang !== FALLBACK) {
48
+ val = getNested(DICTS[FALLBACK], key);
49
+ }
44
50
  if (typeof val !== 'string')
45
- return key; // fallback to key if missing
51
+ return key; // last resort: echo the key when truly absent everywhere
46
52
  if (vars) {
47
53
  Object.keys(vars).forEach(k => {
48
54
  const token = `{{${k}}}`;
@@ -103,7 +103,9 @@
103
103
  "createAccount": "إنشاء حساب",
104
104
  "signIn": "تسجيل الدخول",
105
105
  "verify": "التحقق",
106
- "resetPassword": "إعادة تعيين كلمة المرور"
106
+ "resetPassword": "إعادة تعيين كلمة المرور",
107
+ "signedOut": "تم تسجيل الخروج",
108
+ "close": "إغلاق"
107
109
  },
108
110
  "links": {
109
111
  "recoverAccount": "استعادة حسابك",
@@ -115,7 +117,10 @@
115
117
  "password": "كلمة المرور",
116
118
  "confirmPassword": "تأكيد كلمة المرور"
117
119
  },
118
- "revoke": "Revoke"
120
+ "revoke": "Revoke",
121
+ "errors": {
122
+ "signOutAllFailed": "حدثت مشكلة أثناء تسجيل الخروج من جميع الحسابات. يرجى المحاولة مرة أخرى."
123
+ }
119
124
  },
120
125
  "notifications": {
121
126
  "title": "Notifications",
@@ -198,5 +203,16 @@
198
203
  "revoked": "Revoked access for {{name}}",
199
204
  "revokeFailed": "Failed to revoke access"
200
205
  }
206
+ },
207
+ "accountMenu": {
208
+ "label": "قائمة الحساب",
209
+ "manage": "إدارة حساب Oxy الخاص بك",
210
+ "addAnother": "إضافة حساب آخر",
211
+ "signOutAll": "تسجيل الخروج من جميع الحسابات",
212
+ "open": "قائمة الحساب",
213
+ "openHint": "يفتح قائمة الحساب",
214
+ "openWithUser": "قائمة حساب {{name}}",
215
+ "switching": "جارٍ تبديل الحساب…",
216
+ "signOutAccount": "تسجيل خروج {{name}}"
201
217
  }
202
218
  }
@@ -103,7 +103,9 @@
103
103
  "createAccount": "Crear compte",
104
104
  "signIn": "Iniciar sessió",
105
105
  "verify": "Verificar",
106
- "resetPassword": "Restablir contrasenya"
106
+ "resetPassword": "Restablir contrasenya",
107
+ "signedOut": "Sessió tancada",
108
+ "close": "Tanca"
107
109
  },
108
110
  "links": {
109
111
  "recoverAccount": "Recuperar el teu compte",
@@ -115,7 +117,10 @@
115
117
  "password": "Contrasenya",
116
118
  "confirmPassword": "Confirmar contrasenya"
117
119
  },
118
- "revoke": "Revoke"
120
+ "revoke": "Revoke",
121
+ "errors": {
122
+ "signOutAllFailed": "Hi ha hagut un problema en tancar la sessió de tots els comptes. Torna-ho a provar."
123
+ }
119
124
  },
120
125
  "notifications": {
121
126
  "title": "Notifications",
@@ -198,5 +203,16 @@
198
203
  "revoked": "Revoked access for {{name}}",
199
204
  "revokeFailed": "Failed to revoke access"
200
205
  }
206
+ },
207
+ "accountMenu": {
208
+ "label": "Menú del compte",
209
+ "manage": "Gestiona el teu compte d'Oxy",
210
+ "addAnother": "Afegeix un altre compte",
211
+ "signOutAll": "Tanca la sessió de tots els comptes",
212
+ "open": "Menú del compte",
213
+ "openHint": "Obre el menú del compte",
214
+ "openWithUser": "Menú del compte de {{name}}",
215
+ "switching": "Canviant de compte…",
216
+ "signOutAccount": "Tanca la sessió de {{name}}"
201
217
  }
202
218
  }
@@ -103,7 +103,9 @@
103
103
  "createAccount": "Konto erstellen",
104
104
  "signIn": "Anmelden",
105
105
  "verify": "Überprüfen",
106
- "resetPassword": "Passwort zurücksetzen"
106
+ "resetPassword": "Passwort zurücksetzen",
107
+ "signedOut": "Abgemeldet",
108
+ "close": "Schließen"
107
109
  },
108
110
  "links": {
109
111
  "recoverAccount": "Ihr Konto wiederherstellen",
@@ -115,7 +117,10 @@
115
117
  "password": "Passwort",
116
118
  "confirmPassword": "Passwort bestätigen"
117
119
  },
118
- "revoke": "Revoke"
120
+ "revoke": "Revoke",
121
+ "errors": {
122
+ "signOutAllFailed": "Beim Abmelden von allen Konten ist ein Problem aufgetreten. Bitte versuchen Sie es erneut."
123
+ }
119
124
  },
120
125
  "notifications": {
121
126
  "title": "Notifications",
@@ -198,5 +203,16 @@
198
203
  "revoked": "Revoked access for {{name}}",
199
204
  "revokeFailed": "Failed to revoke access"
200
205
  }
206
+ },
207
+ "accountMenu": {
208
+ "label": "Kontomenü",
209
+ "manage": "Ihr Oxy-Konto verwalten",
210
+ "addAnother": "Weiteres Konto hinzufügen",
211
+ "signOutAll": "Von allen Konten abmelden",
212
+ "open": "Kontomenü",
213
+ "openHint": "Öffnet das Kontomenü",
214
+ "openWithUser": "Kontomenü für {{name}}",
215
+ "switching": "Konto wird gewechselt…",
216
+ "signOutAccount": "{{name}} abmelden"
201
217
  }
202
218
  }
@@ -1038,7 +1038,8 @@
1038
1038
  },
1039
1039
  "common": {
1040
1040
  "errors": {
1041
- "signOutFailed": "There was a problem signing you out. Please try again."
1041
+ "signOutFailed": "There was a problem signing you out. Please try again.",
1042
+ "signOutAllFailed": "There was a problem signing out of all accounts. Please try again."
1042
1043
  },
1043
1044
  "confirms": {
1044
1045
  "signOut": "Are you sure you want to sign out?",
@@ -1057,7 +1058,9 @@
1057
1058
  "signIn": "Sign In",
1058
1059
  "signOut": "Sign Out",
1059
1060
  "verify": "Verify",
1060
- "resetPassword": "Reset Password"
1061
+ "resetPassword": "Reset Password",
1062
+ "signedOut": "Signed out",
1063
+ "close": "Close"
1061
1064
  },
1062
1065
  "cancel": "Cancel",
1063
1066
  "revoke": "Revoke",
@@ -1495,5 +1498,16 @@
1495
1498
  "revoked": "Revoked access for {{name}}",
1496
1499
  "revokeFailed": "Failed to revoke access"
1497
1500
  }
1501
+ },
1502
+ "accountMenu": {
1503
+ "label": "Account menu",
1504
+ "manage": "Manage your Oxy Account",
1505
+ "addAnother": "Add another account",
1506
+ "signOutAll": "Sign out of all accounts",
1507
+ "open": "Account menu",
1508
+ "openHint": "Opens the account menu",
1509
+ "openWithUser": "Account menu for {{name}}",
1510
+ "switching": "Switching account…",
1511
+ "signOutAccount": "Sign out {{name}}"
1498
1512
  }
1499
1513
  }
@@ -281,7 +281,9 @@
281
281
  "signIn": "Entrar",
282
282
  "signOut": "Cerrar sesión",
283
283
  "verify": "Verificar",
284
- "resetPassword": "Restablecer contraseña"
284
+ "resetPassword": "Restablecer contraseña",
285
+ "signedOut": "Sesión cerrada",
286
+ "close": "Cerrar"
285
287
  },
286
288
  "cancel": "Cancelar",
287
289
  "revoke": "Revocar",
@@ -302,7 +304,8 @@
302
304
  "confirmPassword": "Confirmar contraseña"
303
305
  },
304
306
  "errors": {
305
- "signOutFailed": "Hubo un problema al cerrar sesión. Inténtalo de nuevo."
307
+ "signOutFailed": "Hubo un problema al cerrar sesión. Inténtalo de nuevo.",
308
+ "signOutAllFailed": "Hubo un problema al cerrar sesión en todas las cuentas. Inténtalo de nuevo."
306
309
  },
307
310
  "confirms": {
308
311
  "signOut": "¿Seguro que quieres cerrar sesión?",
@@ -1495,5 +1498,16 @@
1495
1498
  "revoked": "Acceso revocado para {{name}}",
1496
1499
  "revokeFailed": "No se ha podido revocar el acceso"
1497
1500
  }
1501
+ },
1502
+ "accountMenu": {
1503
+ "label": "Menú de cuenta",
1504
+ "manage": "Gestiona tu cuenta de Oxy",
1505
+ "addAnother": "Añadir otra cuenta",
1506
+ "signOutAll": "Cerrar sesión en todas las cuentas",
1507
+ "open": "Menú de cuenta",
1508
+ "openHint": "Abre el menú de cuenta",
1509
+ "openWithUser": "Menú de cuenta de {{name}}",
1510
+ "switching": "Cambiando de cuenta…",
1511
+ "signOutAccount": "Cerrar sesión de {{name}}"
1498
1512
  }
1499
1513
  }