portal-mojo 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/auth.md CHANGED
@@ -28,6 +28,12 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
28
28
  - JWT pair (`access_token`/`refresh_token`); single-flight refresh (3
29
29
  concurrent 401s → ONE refresh POST); pre-request gate throws a synthetic
30
30
  `AuthRequiredError` instead of firing doomed requests.
31
+ - A temporary refresh failure (network, timeout or server unavailable) blocks
32
+ the protected request and rejects with the original transport error. It
33
+ keeps the session and emits `refresh-failed`, so cached workspaces can stay
34
+ open. Only expired/unusable credentials or confirmed refresh 401/403 emit
35
+ `unauthorized` and become `AuthRequiredError`. `refreshTokens()` still
36
+ resolves a boolean, and concurrent gated calls share one refresh POST.
31
37
  - `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
32
38
  - `logout()` is client-side (django-mojo has no logout endpoint) and emits
33
39
  `'logout'`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -10,8 +10,8 @@
10
10
  // 10 min, or older than 60 min, with a valid refresh token → refresh)
11
11
  // · pre-request gate: bypass /api/token/refresh exactly (recursion guard)
12
12
  // and requests made with no stored token (pre-login / public); otherwise
13
- // ensureValidToken() — throws AuthRequiredError, which the transport
14
- // turns into a synthetic 401 REJECT without calling fetch
13
+ // ensureValidToken() — blocks fetch on any refresh failure; only a
14
+ // confirmed denial becomes AuthRequiredError (synthetic 401)
15
15
  // · auth-code / magic-token URL handoff: the param is scrubbed from the URL
16
16
  // *synchronously, before any network call*, so a slow exchange cannot
17
17
  // leak the one-time code to third-party scripts reading location.search
@@ -174,13 +174,18 @@ export function checkTokenStatus(): { action: TokenAction; reason: string } {
174
174
  return { action: 'none', reason: 'All tokens valid and not expiring soon' };
175
175
  }
176
176
 
177
- let refreshPromise: Promise<boolean> | null = null;
177
+ type RefreshOutcome = { ok: true } | { ok: false; error?: unknown };
178
+ let refreshPromise: Promise<RefreshOutcome> | null = null;
178
179
 
179
180
  /**
180
181
  * Refresh the access token. Single-flight: concurrent callers share one
181
182
  * in-flight POST. Always resolves to a boolean; never throws.
182
183
  */
183
184
  export function refreshTokens(): Promise<boolean> {
185
+ return refreshOutcome().then(result => result.ok);
186
+ }
187
+
188
+ function refreshOutcome(): Promise<RefreshOutcome> {
184
189
  if (!refreshPromise) {
185
190
  refreshPromise = doRefresh().finally(() => {
186
191
  refreshPromise = null;
@@ -189,13 +194,13 @@ export function refreshTokens(): Promise<boolean> {
189
194
  return refreshPromise;
190
195
  }
191
196
 
192
- async function doRefresh(): Promise<boolean> {
197
+ async function doRefresh(): Promise<RefreshOutcome> {
193
198
  const refresh = getRefreshToken();
194
199
  const info = tokenInfo(refresh);
195
200
  if (!refresh || !info?.isValid || info.isExpired) {
196
201
  emitAuth('unauthorized');
197
202
  stopAutoRefresh();
198
- return false;
203
+ return { ok: false };
199
204
  }
200
205
  const persistent = sessionIsPersistent();
201
206
  // The answer is for the session whose refresh token was sent. When the
@@ -208,30 +213,31 @@ async function doRefresh(): Promise<boolean> {
208
213
  const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
209
214
  try {
210
215
  const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
211
- if (superseded()) return sessionUsable();
216
+ if (superseded()) return { ok: sessionUsable() };
212
217
  const pkg = body.data as { access_token: string; refresh_token?: string };
213
218
  setTokens(pkg.access_token, pkg.refresh_token, persistent);
214
219
  emitAuth('refreshed');
215
- return true;
220
+ return { ok: true };
216
221
  } catch (error) {
217
- if (superseded()) return sessionUsable();
222
+ if (superseded()) return { ok: sessionUsable() };
218
223
  const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : 0;
219
224
  if (status === 401 || status === 403) {
220
225
  emitAuth('unauthorized');
221
226
  stopAutoRefresh();
227
+ return { ok: false };
222
228
  } else {
223
229
  // Transient (network/5xx): keep the session, report, let the
224
230
  // watcher or the next gated request retry.
225
231
  emitAuth('refresh-failed', error);
232
+ return { ok: false, error };
226
233
  }
227
- return false;
228
234
  }
229
235
  }
230
236
 
231
237
  /**
232
238
  * Pre-request gate (TokenManager.ensureValidToken). Throws AuthRequiredError
233
- * when the request must not be sent; the transport short-circuits it to a
234
- * synthetic-401 reject without touching the network.
239
+ * on confirmed denial. A temporary refresh failure preserves its original
240
+ * error; both failures block the protected request without touching fetch.
235
241
  */
236
242
  export async function ensureValidToken(): Promise<void> {
237
243
  const status = checkTokenStatus();
@@ -241,8 +247,11 @@ export async function ensureValidToken(): Promise<void> {
241
247
  throw new AuthRequiredError('Both access and refresh tokens are invalid');
242
248
  }
243
249
  if (status.action === 'refresh') {
244
- const ok = await refreshTokens();
245
- if (!ok) throw new AuthRequiredError('Token refresh failed');
250
+ const result = await refreshOutcome();
251
+ if (!result.ok) {
252
+ if ('error' in result) throw result.error;
253
+ throw new AuthRequiredError('Token refresh failed');
254
+ }
246
255
  }
247
256
  }
248
257