@robosystems/core 0.11.2 → 0.11.4
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/auth-components/AuthProvider.js +166 -27
- package/auth-components/SessionWarningDialog.js +2 -2
- package/auth-components/SignInForm.d.ts +2 -1
- package/auth-components/SignInForm.js +8 -2
- package/auth-components/SignUpForm.js +4 -1
- package/auth-core/client.d.ts +8 -1
- package/auth-core/client.js +142 -100
- package/auth-core/sso.d.ts +4 -9
- package/auth-core/sso.js +24 -75
- package/auth-core/token-storage.d.ts +12 -0
- package/auth-core/token-storage.js +42 -16
- package/auth-core/types.d.ts +1 -0
- package/components/console/ConsoleContent.js +30 -4
- package/components/console/graphAwareConfig.js +27 -17
- package/components/repositories/ActiveSubscriptions.js +18 -6
- package/components/repositories/BrowseRepositories.js +17 -5
- package/components/search/SearchContent.js +30 -4
- package/contexts/graph-context.js +23 -13
- package/hooks/use-streaming-query.js +43 -28
- package/index.d.ts +5 -2
- package/index.js +6 -2
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/sdk-errors.d.ts +79 -0
- package/lib/sdk-errors.js +148 -0
- package/package.json +1 -1
- package/task-monitoring/hooks.js +23 -13
- package/task-monitoring/operationHooks.d.ts +21 -2
- package/task-monitoring/operationHooks.js +67 -17
- package/task-monitoring/operationMonitor.d.ts +10 -1
- package/task-monitoring/operationMonitor.js +109 -57
- package/task-monitoring/taskMonitor.d.ts +15 -1
- package/task-monitoring/taskMonitor.js +120 -73
- package/task-monitoring/types.d.ts +2 -1
|
@@ -4,17 +4,20 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, }
|
|
|
4
4
|
import { clearEntitySelection } from '../actions/entity-actions';
|
|
5
5
|
import { clearGraphSelection } from '../actions/graph-actions';
|
|
6
6
|
import { performLogoutCleanup } from '../auth-core/cleanup';
|
|
7
|
-
import { RoboSystemsAuthClient } from '../auth-core/client';
|
|
7
|
+
import { isTransientError, RoboSystemsAuthClient } from '../auth-core/client';
|
|
8
8
|
import { CURRENT_APP, isLoginHome } from '../auth-core/config';
|
|
9
9
|
import { useTokenExpiryHandler } from '../auth-core/hooks';
|
|
10
10
|
import { buildLoginHomeUrl, buildReturnTo } from '../auth-core/login-home';
|
|
11
|
-
import { getTimeUntilExpiry, getTokenStatus } from '../auth-core/token-storage';
|
|
11
|
+
import { getRefreshToken, getTimeUntilExpiry, getTokenStatus, TOKEN_REFRESH_GRACE_MS, } from '../auth-core/token-storage';
|
|
12
12
|
// Configuration constants
|
|
13
13
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
14
14
|
const TOKEN_REFRESH_INTERVAL_MS = 25 * 60 * 1000; // 25 minutes (5 min before 30 min expiry)
|
|
15
15
|
const TOKEN_WARNING_CHECK_INTERVAL_MS = 30 * 1000; // 30 seconds - reduced for better battery life
|
|
16
16
|
const ACTIVITY_THROTTLE_MS = 1000; // 1 second
|
|
17
17
|
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes - server validation heartbeat
|
|
18
|
+
const HEARTBEAT_BACKOFF_BASE_MS = 30 * 1000; // first retry after a transient heartbeat failure
|
|
19
|
+
const SESSION_CHECK_MAX_RETRIES = 4; // page-load /me retries on a transient failure
|
|
20
|
+
const SESSION_CHECK_BACKOFF_BASE_MS = 1000;
|
|
18
21
|
const REFRESH_COOLDOWN_MS = 60 * 1000; // 60 seconds - prevent duplicate background refreshes
|
|
19
22
|
const CACHE_VERSION = '1'; // Increment to invalidate all cached auth data
|
|
20
23
|
// Debug logging helper
|
|
@@ -34,6 +37,18 @@ const logStorageError = (operation, error) => {
|
|
|
34
37
|
console.error(message, error);
|
|
35
38
|
}
|
|
36
39
|
};
|
|
40
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
/**
|
|
42
|
+
* Whether a failure is the server's final answer about this session: any
|
|
43
|
+
* 4xx but 429 (401 and 403 in practice). Outages, rate limits, network
|
|
44
|
+
* drops and malformed bodies are transient.
|
|
45
|
+
*/
|
|
46
|
+
const endsSession = (error) => !isTransientError(error);
|
|
47
|
+
/**
|
|
48
|
+
* Seconds the warning dialog counts down: to the end of the refresh grace,
|
|
49
|
+
* the last moment a renewal can still succeed.
|
|
50
|
+
*/
|
|
51
|
+
const warningSeconds = (timeLeftMs) => Math.ceil((timeLeftMs + TOKEN_REFRESH_GRACE_MS) / 1000);
|
|
37
52
|
const AuthContext = createContext(null);
|
|
38
53
|
export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSYSTEMS_API_URL ||
|
|
39
54
|
'http://localhost:8000', }) {
|
|
@@ -64,8 +79,14 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
|
-
catch (
|
|
68
|
-
//
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Only the server refusing the session ends it. An outage, a rate
|
|
84
|
+
// limit or a network drop keeps the cached user; the heartbeat
|
|
85
|
+
// re-validates once the API answers again.
|
|
86
|
+
if (!endsSession(error)) {
|
|
87
|
+
debugLog('Cached user validation deferred (transient error)', error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
69
90
|
setUser(null);
|
|
70
91
|
if (typeof window !== 'undefined') {
|
|
71
92
|
try {
|
|
@@ -80,7 +101,24 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
80
101
|
}, [authClient]);
|
|
81
102
|
const checkSession = useCallback(async () => {
|
|
82
103
|
try {
|
|
83
|
-
|
|
104
|
+
// A transient failure at page load (deploy, 5xx, 429, network) is
|
|
105
|
+
// retried with backoff instead of being read as "logged out".
|
|
106
|
+
let user = null;
|
|
107
|
+
for (let attempt = 0;; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
user = await authClient.getCurrentUser();
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!isTransientError(error) ||
|
|
114
|
+
attempt >= SESSION_CHECK_MAX_RETRIES ||
|
|
115
|
+
!mounted.current) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
authClient.clearAuthCache();
|
|
119
|
+
await sleep(SESSION_CHECK_BACKOFF_BASE_MS * 2 ** attempt);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
84
122
|
setUser(user);
|
|
85
123
|
// Cache the user data
|
|
86
124
|
if (typeof window !== 'undefined') {
|
|
@@ -112,7 +150,15 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
112
150
|
}, [authClient]);
|
|
113
151
|
const logout = useCallback(async (reason, options) => {
|
|
114
152
|
try {
|
|
115
|
-
|
|
153
|
+
// An automatic logout (the server already refused the session)
|
|
154
|
+
// skips the server call: there is nothing left to revoke.
|
|
155
|
+
if (!(options === null || options === void 0 ? void 0 : options.skipServerLogout)) {
|
|
156
|
+
await authClient.logout();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const { clearToken } = await import('../auth-core/token-storage');
|
|
160
|
+
clearToken();
|
|
161
|
+
}
|
|
116
162
|
}
|
|
117
163
|
catch (error) {
|
|
118
164
|
// Logout error - continue with local logout regardless
|
|
@@ -213,6 +259,9 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
213
259
|
const response = await authClient.refreshSession();
|
|
214
260
|
if (response.success) {
|
|
215
261
|
setUser(response.user);
|
|
262
|
+
// A renewed session has nothing left to warn about ("Stay Logged
|
|
263
|
+
// In" relies on this to dismiss the dialog).
|
|
264
|
+
setSessionWarning({ show: false, timeLeft: 0 });
|
|
216
265
|
// Cache the refreshed user data
|
|
217
266
|
if (typeof window !== 'undefined') {
|
|
218
267
|
try {
|
|
@@ -282,21 +331,81 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
282
331
|
checkSession();
|
|
283
332
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
284
333
|
}, []);
|
|
285
|
-
// Heartbeat - Server-side validation and background tab refresh
|
|
334
|
+
// Heartbeat - Server-side validation and background tab refresh.
|
|
335
|
+
//
|
|
336
|
+
// Only the server refusing the session (401, or 403 for a deactivated
|
|
337
|
+
// account) ends it, and then locally: the token is already dead, so there
|
|
338
|
+
// is no server logout to call. A network drop, a 5xx or a 429 keeps the
|
|
339
|
+
// session and backs off; focus events inside the backoff window are
|
|
340
|
+
// skipped so a flapping API is not hammered from every tab.
|
|
286
341
|
useEffect(() => {
|
|
287
342
|
if (!isAuthenticated)
|
|
288
343
|
return;
|
|
289
344
|
let isMounted = true;
|
|
290
|
-
|
|
291
|
-
|
|
345
|
+
let inFlight = false;
|
|
346
|
+
let failures = 0;
|
|
347
|
+
let backoffUntil = 0;
|
|
348
|
+
let retryTimer = null;
|
|
349
|
+
let sentToken = null;
|
|
350
|
+
let recheckNow = false;
|
|
351
|
+
const tokenRotatedSince = (sent) => {
|
|
352
|
+
const current = getRefreshToken();
|
|
353
|
+
return current !== null && current !== sent;
|
|
354
|
+
};
|
|
355
|
+
const endSession = (reason) => logout(reason, { skipServerLogout: true });
|
|
356
|
+
const scheduleRetry = (error) => {
|
|
357
|
+
failures++;
|
|
358
|
+
const delay = Math.min(HEARTBEAT_BACKOFF_BASE_MS * 2 ** (failures - 1), HEARTBEAT_INTERVAL_MS);
|
|
359
|
+
debugLog(`Heartbeat: transient failure, retrying in ${Math.round(delay / 1000)}s`, error);
|
|
360
|
+
backoffUntil = Date.now() + delay;
|
|
361
|
+
if (retryTimer)
|
|
362
|
+
clearTimeout(retryTimer);
|
|
363
|
+
retryTimer = setTimeout(() => {
|
|
364
|
+
retryTimer = null;
|
|
365
|
+
performHeartbeat(true);
|
|
366
|
+
}, delay);
|
|
367
|
+
};
|
|
368
|
+
const performHeartbeat = async (scheduled = false) => {
|
|
369
|
+
if (!isMounted || inFlight)
|
|
370
|
+
return;
|
|
371
|
+
if (!scheduled && Date.now() < backoffUntil)
|
|
292
372
|
return;
|
|
373
|
+
inFlight = true;
|
|
293
374
|
debugLog('Heartbeat: Checking server authentication status');
|
|
294
375
|
try {
|
|
376
|
+
// A token that expired while the tab slept cannot authenticate
|
|
377
|
+
// /me, but the refresh endpoint still renews it within its grace
|
|
378
|
+
// window — so renew first.
|
|
379
|
+
if (getTokenStatus() === 'expired') {
|
|
380
|
+
try {
|
|
381
|
+
await refreshSession(true);
|
|
382
|
+
}
|
|
383
|
+
catch (refreshError) {
|
|
384
|
+
if (!isMounted)
|
|
385
|
+
return;
|
|
386
|
+
if (endsSession(refreshError)) {
|
|
387
|
+
await endSession('session_expired');
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
scheduleRetry(refreshError);
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// A refresh already in flight elsewhere skips this one; /me would
|
|
395
|
+
// then go out unauthenticated and read as a refusal. Wait instead.
|
|
396
|
+
if (getTokenStatus() === 'expired') {
|
|
397
|
+
scheduleRetry(new Error('Session refresh still pending'));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
295
401
|
// Make real HTTP call to validate session server-side
|
|
296
402
|
// This works even in background tabs (not throttled like timers)
|
|
403
|
+
sentToken = getRefreshToken();
|
|
297
404
|
await authClient.getCurrentUser();
|
|
298
405
|
if (!isMounted)
|
|
299
406
|
return;
|
|
407
|
+
failures = 0;
|
|
408
|
+
backoffUntil = 0;
|
|
300
409
|
debugLog('Heartbeat: Server validation successful');
|
|
301
410
|
// After successful server check, verify local token status
|
|
302
411
|
const tokenStatus = getTokenStatus();
|
|
@@ -311,17 +420,19 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
311
420
|
if (!isMounted)
|
|
312
421
|
return;
|
|
313
422
|
debugLog('Heartbeat: Token refresh successful');
|
|
314
|
-
setSessionWarning({ show: false, timeLeft: 0 });
|
|
315
423
|
}
|
|
316
424
|
catch (refreshError) {
|
|
317
425
|
if (!isMounted)
|
|
318
426
|
return;
|
|
319
427
|
debugLog('Heartbeat: Token refresh failed', refreshError);
|
|
320
|
-
|
|
321
|
-
|
|
428
|
+
if (endsSession(refreshError)) {
|
|
429
|
+
await endSession('session_expired');
|
|
430
|
+
}
|
|
431
|
+
else if (timeLeft > 0) {
|
|
432
|
+
// Show warning if refresh failed but token not expired
|
|
322
433
|
setSessionWarning({
|
|
323
434
|
show: true,
|
|
324
|
-
timeLeft:
|
|
435
|
+
timeLeft: warningSeconds(timeLeft),
|
|
325
436
|
});
|
|
326
437
|
}
|
|
327
438
|
}
|
|
@@ -330,13 +441,33 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
330
441
|
catch (error) {
|
|
331
442
|
if (!isMounted)
|
|
332
443
|
return;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
444
|
+
if (endsSession(error) && tokenRotatedSince(sentToken)) {
|
|
445
|
+
// Another tab renewed the session while this check was in flight;
|
|
446
|
+
// the refusal was for the token it replaced. Re-check at once.
|
|
447
|
+
authClient.clearAuthCache();
|
|
448
|
+
recheckNow = true;
|
|
449
|
+
}
|
|
450
|
+
else if (endsSession(error)) {
|
|
451
|
+
debugLog('Heartbeat: Server refused the session', error);
|
|
452
|
+
await endSession('session_invalid');
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
scheduleRetry(error);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
inFlight = false;
|
|
460
|
+
if (recheckNow && isMounted) {
|
|
461
|
+
recheckNow = false;
|
|
462
|
+
retryTimer = setTimeout(() => {
|
|
463
|
+
retryTimer = null;
|
|
464
|
+
performHeartbeat(true);
|
|
465
|
+
}, 0);
|
|
466
|
+
}
|
|
336
467
|
}
|
|
337
468
|
};
|
|
338
469
|
// Regular heartbeat interval
|
|
339
|
-
const heartbeatInterval = setInterval(performHeartbeat, HEARTBEAT_INTERVAL_MS); // Every 5 minutes
|
|
470
|
+
const heartbeatInterval = setInterval(() => performHeartbeat(), HEARTBEAT_INTERVAL_MS); // Every 5 minutes
|
|
340
471
|
// Also check immediately when tab regains focus
|
|
341
472
|
const handleFocus = () => {
|
|
342
473
|
if (!isMounted)
|
|
@@ -357,6 +488,8 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
357
488
|
return () => {
|
|
358
489
|
isMounted = false;
|
|
359
490
|
clearInterval(heartbeatInterval);
|
|
491
|
+
if (retryTimer)
|
|
492
|
+
clearTimeout(retryTimer);
|
|
360
493
|
window.removeEventListener('focus', handleFocus);
|
|
361
494
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
362
495
|
};
|
|
@@ -383,19 +516,21 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
383
516
|
if (!isMounted)
|
|
384
517
|
return;
|
|
385
518
|
debugLog('Token refresh failed', error);
|
|
386
|
-
// Show warning instead of silent logout
|
|
387
519
|
const timeLeft = getTimeUntilExpiry();
|
|
388
|
-
if (
|
|
520
|
+
if (endsSession(error)) {
|
|
521
|
+
// The server refused the renewal: the session is over.
|
|
522
|
+
debugLog('Refresh refused, redirecting to login');
|
|
523
|
+
await logout('session_expired', { skipServerLogout: true });
|
|
524
|
+
}
|
|
525
|
+
else if (timeLeft > 0) {
|
|
526
|
+
// Show warning instead of silent logout
|
|
389
527
|
setSessionWarning({
|
|
390
528
|
show: true,
|
|
391
|
-
timeLeft:
|
|
529
|
+
timeLeft: warningSeconds(timeLeft),
|
|
392
530
|
});
|
|
393
531
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
debugLog('Token expired, redirecting to login');
|
|
397
|
-
await logout('session_expired');
|
|
398
|
-
}
|
|
532
|
+
// A transient failure past expiry is left to the heartbeat,
|
|
533
|
+
// which retries the renewal with backoff.
|
|
399
534
|
}
|
|
400
535
|
}
|
|
401
536
|
}, TOKEN_REFRESH_INTERVAL_MS); // Check every 25 minutes
|
|
@@ -418,11 +553,15 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
418
553
|
catch (error) {
|
|
419
554
|
if (!isMounted)
|
|
420
555
|
return;
|
|
556
|
+
if (endsSession(error)) {
|
|
557
|
+
await logout('session_expired', { skipServerLogout: true });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
421
560
|
// Auto-refresh failed, show warning modal
|
|
422
561
|
debugLog('Auto-refresh failed, showing warning modal', error);
|
|
423
562
|
setSessionWarning({
|
|
424
563
|
show: true,
|
|
425
|
-
timeLeft:
|
|
564
|
+
timeLeft: warningSeconds(timeLeft),
|
|
426
565
|
});
|
|
427
566
|
}
|
|
428
567
|
}
|
|
@@ -431,7 +570,7 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
431
570
|
return;
|
|
432
571
|
setSessionWarning({ show: false, timeLeft: 0 });
|
|
433
572
|
}
|
|
434
|
-
}, TOKEN_WARNING_CHECK_INTERVAL_MS); // Check every
|
|
573
|
+
}, TOKEN_WARNING_CHECK_INTERVAL_MS); // Check every 30 seconds
|
|
435
574
|
return () => {
|
|
436
575
|
isMounted = false;
|
|
437
576
|
clearInterval(refreshInterval);
|
|
@@ -17,8 +17,8 @@ export function SessionWarningDialog() {
|
|
|
17
17
|
const timer = setInterval(() => {
|
|
18
18
|
setCountdown((prev) => {
|
|
19
19
|
if (prev <= 1) {
|
|
20
|
-
// Time's up,
|
|
21
|
-
logout();
|
|
20
|
+
// Time's up: the session lapsed, it was not ended by the user
|
|
21
|
+
logout('session_expired');
|
|
22
22
|
return 0;
|
|
23
23
|
}
|
|
24
24
|
return prev - 1;
|
|
@@ -14,7 +14,8 @@ export interface SignInFormProps {
|
|
|
14
14
|
* request never reached the server — `fetch` throws a `TypeError`) must NOT be
|
|
15
15
|
* reported as bad credentials: that sends users to reset a password that is
|
|
16
16
|
* actually correct. Reached-server auth rejection (401/403, or an empty/invalid
|
|
17
|
-
* auth body) stays "Invalid email or password"; 5xx
|
|
17
|
+
* auth body) stays "Invalid email or password"; 429 and 5xx get their own
|
|
18
|
+
* messages.
|
|
18
19
|
*/
|
|
19
20
|
export declare function loginErrorMessage(error: unknown): string;
|
|
20
21
|
/**
|
|
@@ -4,23 +4,29 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
4
4
|
import { RoboSystemsAuthClient } from '../auth-core/client';
|
|
5
5
|
import { getAppConfig } from '../auth-core/config';
|
|
6
6
|
import { useSSO } from '../auth-core/sso';
|
|
7
|
+
import { ApiError } from '../lib/sdk-errors';
|
|
7
8
|
import { LogoBadge, Spinner } from '../ui-components';
|
|
8
9
|
/**
|
|
9
10
|
* Map a login failure to a user-facing message. A connectivity failure (the
|
|
10
11
|
* request never reached the server — `fetch` throws a `TypeError`) must NOT be
|
|
11
12
|
* reported as bad credentials: that sends users to reset a password that is
|
|
12
13
|
* actually correct. Reached-server auth rejection (401/403, or an empty/invalid
|
|
13
|
-
* auth body) stays "Invalid email or password"; 5xx
|
|
14
|
+
* auth body) stays "Invalid email or password"; 429 and 5xx get their own
|
|
15
|
+
* messages.
|
|
14
16
|
*/
|
|
15
17
|
export function loginErrorMessage(error) {
|
|
16
18
|
var _a, _b, _c;
|
|
17
19
|
const err = error;
|
|
18
20
|
const status = (_a = err === null || err === void 0 ? void 0 : err.status) !== null && _a !== void 0 ? _a : (_b = err === null || err === void 0 ? void 0 : err.response) === null || _b === void 0 ? void 0 : _b.status;
|
|
19
21
|
const message = String((_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : '');
|
|
20
|
-
if (error instanceof
|
|
22
|
+
if ((error instanceof ApiError && error.isNetworkError) ||
|
|
23
|
+
error instanceof TypeError ||
|
|
21
24
|
/failed to fetch|networkerror|load failed|fetch failed|err_(connection|network|name_not_resolved)/i.test(message)) {
|
|
22
25
|
return 'Unable to reach the server. Check your connection and try again.';
|
|
23
26
|
}
|
|
27
|
+
if (status === 429) {
|
|
28
|
+
return 'Too many sign-in attempts. Please wait a few minutes and try again.';
|
|
29
|
+
}
|
|
24
30
|
if (typeof status === 'number' && status >= 500) {
|
|
25
31
|
return 'The server ran into a problem. Please try again in a moment.';
|
|
26
32
|
}
|
|
@@ -21,7 +21,10 @@ export function SignUpForm({ onSuccess, onRedirect, redirectTo = '/login', class
|
|
|
21
21
|
const [invitation, setInvitation] = useState(null);
|
|
22
22
|
const [inviteChecked, setInviteChecked] = useState(!inviteToken);
|
|
23
23
|
const debounceRef = useRef(null);
|
|
24
|
-
|
|
24
|
+
// `useState`'s initializer runs once; `useRef(new …)` would construct a
|
|
25
|
+
// client on every render.
|
|
26
|
+
const [authClient] = useState(() => new RoboSystemsAuthClient(apiUrl));
|
|
27
|
+
const authClientRef = useRef(authClient);
|
|
25
28
|
// Resolve the invitation before the form is usable. A token that no longer
|
|
26
29
|
// resolves (revoked, expired, already accepted) degrades to ordinary
|
|
27
30
|
// registration with a notice rather than a dead end.
|
package/auth-core/client.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { isSessionRejection, isTransientError } from '../lib/sdk-errors';
|
|
1
2
|
import type { APIKey, AuthProviders, AuthResponse, AuthUser, CreateAPIKeyRequest, PasskeyEnrollmentResult, SSOTokenResponse } from './types';
|
|
3
|
+
export { isSessionRejection, isTransientError };
|
|
2
4
|
export declare class TokenExpiredError extends Error {
|
|
3
5
|
constructor(message?: string);
|
|
4
6
|
}
|
|
@@ -55,7 +57,12 @@ export declare class RoboSystemsAuthClient {
|
|
|
55
57
|
private performAuthCheck;
|
|
56
58
|
refreshSession(): Promise<AuthResponse>;
|
|
57
59
|
/**
|
|
58
|
-
* Refresh session with exponential backoff retry logic
|
|
60
|
+
* Refresh session with exponential backoff retry logic.
|
|
61
|
+
*
|
|
62
|
+
* The request presents the stored token even when it has just expired:
|
|
63
|
+
* the API renews a recently-expired token within its grace window, and
|
|
64
|
+
* the data-call path deliberately stops sending it at expiry. A refusal
|
|
65
|
+
* (401/403) is final; only network errors, 429 and 5xx are retried.
|
|
59
66
|
*/
|
|
60
67
|
private refreshSessionWithRetry;
|
|
61
68
|
createAPIKey(request: CreateAPIKeyRequest): Promise<APIKey>;
|