@rebasepro/auth 0.0.1-canary.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.
- package/LICENSE +6 -0
- package/dist/api.d.ts +119 -0
- package/dist/components/AdminViews.d.ts +20 -0
- package/dist/components/RebaseLoginView.d.ts +52 -0
- package/dist/hooks/useBackendUserManagement.d.ts +41 -0
- package/dist/hooks/useRebaseAuthController.d.ts +9 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.es.js +1883 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +1883 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/types.d.ts +95 -0
- package/package.json +48 -0
- package/src/api.ts +328 -0
- package/src/components/AdminViews.tsx +795 -0
- package/src/components/RebaseLoginView.tsx +570 -0
- package/src/hooks/useBackendUserManagement.ts +407 -0
- package/src/hooks/useRebaseAuthController.ts +692 -0
- package/src/index.ts +28 -0
- package/src/types.ts +102 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useRef } from "react";
|
|
2
|
+
import { User } from "@rebasepro/core";
|
|
3
|
+
import * as authApi from "../api";
|
|
4
|
+
import { AuthConfigResponse } from "../api";
|
|
5
|
+
import {
|
|
6
|
+
RebaseAuthController,
|
|
7
|
+
RebaseAuthControllerProps,
|
|
8
|
+
AuthTokens,
|
|
9
|
+
UserInfo
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = "rebase_auth";
|
|
13
|
+
|
|
14
|
+
// Buffer time before expiry to trigger refresh (2 minutes)
|
|
15
|
+
const TOKEN_REFRESH_BUFFER_MS = 2 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert UserInfo from API to Rebase User type
|
|
19
|
+
*/
|
|
20
|
+
function convertToUser(userInfo: UserInfo): User {
|
|
21
|
+
return {
|
|
22
|
+
uid: userInfo.uid,
|
|
23
|
+
email: userInfo.email,
|
|
24
|
+
displayName: userInfo.displayName || null,
|
|
25
|
+
photoURL: userInfo.photoURL || null,
|
|
26
|
+
providerId: "custom",
|
|
27
|
+
isAnonymous: false,
|
|
28
|
+
roles: userInfo.roles || []
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Storage data structure
|
|
34
|
+
*/
|
|
35
|
+
interface StoredAuthData {
|
|
36
|
+
tokens: AuthTokens;
|
|
37
|
+
user: UserInfo;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save auth data to localStorage
|
|
42
|
+
*/
|
|
43
|
+
function saveAuthToStorage(tokens: AuthTokens, user: UserInfo): void {
|
|
44
|
+
try {
|
|
45
|
+
const data: StoredAuthData = { tokens, user };
|
|
46
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
47
|
+
const expiryDate = new Date(tokens.accessTokenExpiresAt);
|
|
48
|
+
const expiryStr = Number.isFinite(tokens.accessTokenExpiresAt) ? expiryDate.toISOString() : "invalid";
|
|
49
|
+
} catch (e) {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load auth data from localStorage
|
|
55
|
+
*/
|
|
56
|
+
function loadAuthFromStorage(): StoredAuthData | null {
|
|
57
|
+
try {
|
|
58
|
+
const data = localStorage.getItem(STORAGE_KEY);
|
|
59
|
+
if (data) {
|
|
60
|
+
const parsed = JSON.parse(data);
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn("Failed to load auth from storage:", e);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clear auth data from localStorage
|
|
71
|
+
*/
|
|
72
|
+
function clearAuthFromStorage(): void {
|
|
73
|
+
try {
|
|
74
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.warn("Failed to clear auth from storage:", e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if token is expired or about to expire
|
|
82
|
+
*/
|
|
83
|
+
function isTokenExpiredOrNearExpiry(expiresAt: number, bufferMs: number = TOKEN_REFRESH_BUFFER_MS): boolean {
|
|
84
|
+
return Date.now() + bufferMs >= expiresAt;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Auth controller hook for JWT-based authentication
|
|
89
|
+
* with @rebasepro/backend
|
|
90
|
+
*
|
|
91
|
+
* @param props Configuration options
|
|
92
|
+
* @returns RebaseAuthController instance
|
|
93
|
+
*/
|
|
94
|
+
export function useRebaseAuthController(
|
|
95
|
+
props: RebaseAuthControllerProps = {}
|
|
96
|
+
): RebaseAuthController {
|
|
97
|
+
const { apiUrl, onSignOut, defineRolesFor } = props;
|
|
98
|
+
|
|
99
|
+
const [user, setUser] = useState<User | null>(null);
|
|
100
|
+
const [authLoading, setAuthLoading] = useState(false);
|
|
101
|
+
const [initialLoading, setInitialLoading] = useState(true);
|
|
102
|
+
const [authError, setAuthError] = useState<Error | null>(null);
|
|
103
|
+
const [authProviderError, setAuthProviderError] = useState<Error | null>(null);
|
|
104
|
+
const [loginSkipped, setLoginSkipped] = useState(false);
|
|
105
|
+
const [extra, setExtra] = useState<unknown>(null);
|
|
106
|
+
const [authConfig, setAuthConfig] = useState<AuthConfigResponse | null>(null);
|
|
107
|
+
|
|
108
|
+
// Store tokens in ref for quick access, but also persist to localStorage
|
|
109
|
+
const tokensRef = useRef<AuthTokens | null>(null);
|
|
110
|
+
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
|
+
// Track if a refresh is currently in progress to avoid concurrent refreshes
|
|
112
|
+
const refreshPromiseRef = useRef<Promise<AuthTokens | null> | null>(null);
|
|
113
|
+
// Track if component is mounted
|
|
114
|
+
const isMountedRef = useRef(true);
|
|
115
|
+
|
|
116
|
+
// Configure API URL on mount
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (apiUrl) {
|
|
119
|
+
authApi.setApiUrl(apiUrl);
|
|
120
|
+
}
|
|
121
|
+
}, [apiUrl]);
|
|
122
|
+
|
|
123
|
+
// Clear session and sign out
|
|
124
|
+
const clearSessionAndSignOut = useCallback(() => {
|
|
125
|
+
tokensRef.current = null;
|
|
126
|
+
clearAuthFromStorage();
|
|
127
|
+
if (refreshTimeoutRef.current) {
|
|
128
|
+
clearTimeout(refreshTimeoutRef.current);
|
|
129
|
+
refreshTimeoutRef.current = null;
|
|
130
|
+
}
|
|
131
|
+
setUser(null);
|
|
132
|
+
setLoginSkipped(false);
|
|
133
|
+
onSignOut?.();
|
|
134
|
+
}, [onSignOut]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Refresh the access token using the stored refresh token.
|
|
138
|
+
* Returns the new tokens or null if refresh failed.
|
|
139
|
+
*/
|
|
140
|
+
const refreshAccessToken = useCallback(async (): Promise<AuthTokens | null> => {
|
|
141
|
+
// Prevent concurrent refreshes
|
|
142
|
+
if (refreshPromiseRef.current) {
|
|
143
|
+
// Wait for the current refresh to complete
|
|
144
|
+
return refreshPromiseRef.current;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const executeRefresh = async (): Promise<AuthTokens | null> => {
|
|
148
|
+
// Check if another tab has already refreshed the token
|
|
149
|
+
const storedData = loadAuthFromStorage();
|
|
150
|
+
if (storedData?.tokens?.accessTokenExpiresAt) {
|
|
151
|
+
const storedTokens = storedData.tokens;
|
|
152
|
+
// If stored token is newer and not expired
|
|
153
|
+
if (!isTokenExpiredOrNearExpiry(storedTokens.accessTokenExpiresAt) && storedTokens.accessToken !== tokensRef.current?.accessToken) {
|
|
154
|
+
tokensRef.current = storedTokens;
|
|
155
|
+
return storedTokens;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const currentTokens = tokensRef.current;
|
|
160
|
+
if (!currentTokens?.refreshToken) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const response = await authApi.refreshAccessToken(currentTokens.refreshToken);
|
|
167
|
+
const newTokens = response.tokens;
|
|
168
|
+
|
|
169
|
+
// Update tokens immediately
|
|
170
|
+
tokensRef.current = newTokens;
|
|
171
|
+
|
|
172
|
+
// Persist to storage
|
|
173
|
+
const latestStoredData = loadAuthFromStorage();
|
|
174
|
+
if (latestStoredData) {
|
|
175
|
+
saveAuthToStorage(newTokens, latestStoredData.user);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const newExpiryStr = Number.isFinite(newTokens.accessTokenExpiresAt) ? new Date(newTokens.accessTokenExpiresAt).toISOString() : "invalid";
|
|
179
|
+
return newTokens;
|
|
180
|
+
} catch (error: unknown) {
|
|
181
|
+
|
|
182
|
+
// If it's a network error (e.g., backend restarting), we throw so callers can retry
|
|
183
|
+
// instead of immediately assuming the refresh token is invalid and signing out.
|
|
184
|
+
if (error instanceof Error && (error as { code?: string }).code === "NETWORK_ERROR") {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
} finally {
|
|
189
|
+
refreshPromiseRef.current = null;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
refreshPromiseRef.current = executeRefresh();
|
|
194
|
+
return refreshPromiseRef.current;
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
// Schedule token refresh before expiry
|
|
198
|
+
const scheduleTokenRefresh = useCallback((tokens: AuthTokens) => {
|
|
199
|
+
if (refreshTimeoutRef.current) {
|
|
200
|
+
clearTimeout(refreshTimeoutRef.current);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Calculate when to refresh (2 minutes before expiry)
|
|
204
|
+
const expiresAt = tokens.accessTokenExpiresAt;
|
|
205
|
+
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
206
|
+
const timeUntilRefresh = refreshAt - Date.now();
|
|
207
|
+
|
|
208
|
+
if (timeUntilRefresh <= 0) {
|
|
209
|
+
// Token already expired or about to expire - refresh now
|
|
210
|
+
refreshAccessToken().then(newTokens => {
|
|
211
|
+
if (newTokens && isMountedRef.current) {
|
|
212
|
+
scheduleTokenRefresh(newTokens);
|
|
213
|
+
} else if (!newTokens && isMountedRef.current) {
|
|
214
|
+
clearSessionAndSignOut();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
refreshTimeoutRef.current = setTimeout(async () => {
|
|
222
|
+
if (!isMountedRef.current) return;
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const newTokens = await refreshAccessToken();
|
|
226
|
+
|
|
227
|
+
if (newTokens && isMountedRef.current) {
|
|
228
|
+
scheduleTokenRefresh(newTokens);
|
|
229
|
+
} else if (!newTokens && isMountedRef.current) {
|
|
230
|
+
clearSessionAndSignOut();
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// Network error - try again shortly instead of logging out
|
|
234
|
+
if (isMountedRef.current) {
|
|
235
|
+
refreshTimeoutRef.current = setTimeout(() => {
|
|
236
|
+
scheduleTokenRefresh(tokens);
|
|
237
|
+
}, 10000);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}, timeUntilRefresh);
|
|
241
|
+
}, [refreshAccessToken, clearSessionAndSignOut]);
|
|
242
|
+
|
|
243
|
+
// Get auth token for API requests (with automatic refresh if needed)
|
|
244
|
+
const getAuthToken = useCallback(async (): Promise<string> => {
|
|
245
|
+
// If still loading, throw - the UI should show a spinner
|
|
246
|
+
if (initialLoading) {
|
|
247
|
+
throw new Error("Auth is still loading");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const currentTokens = tokensRef.current;
|
|
251
|
+
if (!currentTokens) {
|
|
252
|
+
throw new Error("User is not logged in");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check if token is expired or about to expire
|
|
256
|
+
if (isTokenExpiredOrNearExpiry(currentTokens.accessTokenExpiresAt)) {
|
|
257
|
+
try {
|
|
258
|
+
const newTokens = await refreshAccessToken();
|
|
259
|
+
if (!newTokens) {
|
|
260
|
+
clearSessionAndSignOut();
|
|
261
|
+
throw new Error("Session expired. Please login again.");
|
|
262
|
+
}
|
|
263
|
+
return newTokens.accessToken;
|
|
264
|
+
} catch (error: unknown) {
|
|
265
|
+
// If the error was a network error during refresh, just re-throw it
|
|
266
|
+
// so the user isn't logged out locally and the network request fails naturally.
|
|
267
|
+
if (error instanceof Error && (error as { code?: string }).code === "NETWORK_ERROR") {
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
clearSessionAndSignOut();
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return currentTokens.accessToken;
|
|
276
|
+
}, [initialLoading, refreshAccessToken, clearSessionAndSignOut]);
|
|
277
|
+
|
|
278
|
+
// Handle successful authentication
|
|
279
|
+
const handleAuthSuccess = useCallback(async (userInfo: UserInfo, tokens: AuthTokens) => {
|
|
280
|
+
tokensRef.current = tokens;
|
|
281
|
+
let convertedUser = convertToUser(userInfo);
|
|
282
|
+
|
|
283
|
+
// Apply custom roles if defineRolesFor provided
|
|
284
|
+
if (defineRolesFor) {
|
|
285
|
+
const customRoles = await defineRolesFor(convertedUser);
|
|
286
|
+
if (customRoles) {
|
|
287
|
+
convertedUser = { ...convertedUser, roles: customRoles.map(r => r.id) };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Save to localStorage for persistence
|
|
292
|
+
saveAuthToStorage(tokens, userInfo);
|
|
293
|
+
|
|
294
|
+
setUser(convertedUser);
|
|
295
|
+
setAuthError(null);
|
|
296
|
+
setAuthProviderError(null);
|
|
297
|
+
setLoginSkipped(false);
|
|
298
|
+
scheduleTokenRefresh(tokens);
|
|
299
|
+
}, [scheduleTokenRefresh, defineRolesFor]);
|
|
300
|
+
|
|
301
|
+
// Email/password login
|
|
302
|
+
const emailPasswordLogin = useCallback(async (email: string, password: string) => {
|
|
303
|
+
setAuthLoading(true);
|
|
304
|
+
setAuthProviderError(null);
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const response = await authApi.login(email, password);
|
|
308
|
+
await handleAuthSuccess(response.user, response.tokens);
|
|
309
|
+
} catch (error: unknown) {
|
|
310
|
+
setAuthProviderError(error as Error);
|
|
311
|
+
throw error;
|
|
312
|
+
} finally {
|
|
313
|
+
setAuthLoading(false);
|
|
314
|
+
}
|
|
315
|
+
}, [handleAuthSuccess]);
|
|
316
|
+
|
|
317
|
+
// Register new user
|
|
318
|
+
const register = useCallback(async (email: string, password: string, displayName?: string) => {
|
|
319
|
+
setAuthLoading(true);
|
|
320
|
+
setAuthProviderError(null);
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const response = await authApi.register(email, password, displayName);
|
|
324
|
+
await handleAuthSuccess(response.user, response.tokens);
|
|
325
|
+
} catch (error: unknown) {
|
|
326
|
+
setAuthProviderError(error as Error);
|
|
327
|
+
throw error;
|
|
328
|
+
} finally {
|
|
329
|
+
setAuthLoading(false);
|
|
330
|
+
}
|
|
331
|
+
}, [handleAuthSuccess]);
|
|
332
|
+
|
|
333
|
+
// Google login with ID token
|
|
334
|
+
const googleLogin = useCallback(async (idToken: string) => {
|
|
335
|
+
setAuthLoading(true);
|
|
336
|
+
setAuthProviderError(null);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const response = await authApi.googleLogin(idToken);
|
|
340
|
+
await handleAuthSuccess(response.user, response.tokens);
|
|
341
|
+
} catch (error: unknown) {
|
|
342
|
+
setAuthProviderError(error as Error);
|
|
343
|
+
throw error;
|
|
344
|
+
} finally {
|
|
345
|
+
setAuthLoading(false);
|
|
346
|
+
}
|
|
347
|
+
}, [handleAuthSuccess]);
|
|
348
|
+
|
|
349
|
+
// Sign out
|
|
350
|
+
const signOut = useCallback(async () => {
|
|
351
|
+
try {
|
|
352
|
+
if (tokensRef.current) {
|
|
353
|
+
await authApi.logout(tokensRef.current.refreshToken);
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
console.error("Logout error:", error);
|
|
357
|
+
} finally {
|
|
358
|
+
clearSessionAndSignOut();
|
|
359
|
+
}
|
|
360
|
+
}, [clearSessionAndSignOut]);
|
|
361
|
+
|
|
362
|
+
// Skip login
|
|
363
|
+
const skipLogin = useCallback(() => {
|
|
364
|
+
setLoginSkipped(true);
|
|
365
|
+
setUser(null);
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
368
|
+
// Forgot password - request reset email
|
|
369
|
+
const forgotPassword = useCallback(async (email: string) => {
|
|
370
|
+
setAuthLoading(true);
|
|
371
|
+
setAuthProviderError(null);
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
await authApi.forgotPassword(email);
|
|
375
|
+
} catch (error: unknown) {
|
|
376
|
+
setAuthProviderError(error as Error);
|
|
377
|
+
throw error;
|
|
378
|
+
} finally {
|
|
379
|
+
setAuthLoading(false);
|
|
380
|
+
}
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
// Reset password using token
|
|
384
|
+
const resetPassword = useCallback(async (token: string, password: string) => {
|
|
385
|
+
setAuthLoading(true);
|
|
386
|
+
setAuthProviderError(null);
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
await authApi.resetPassword(token, password);
|
|
390
|
+
} catch (error: unknown) {
|
|
391
|
+
setAuthProviderError(error as Error);
|
|
392
|
+
throw error;
|
|
393
|
+
} finally {
|
|
394
|
+
setAuthLoading(false);
|
|
395
|
+
}
|
|
396
|
+
}, []);
|
|
397
|
+
|
|
398
|
+
// Change password for authenticated user
|
|
399
|
+
const changePassword = useCallback(async (oldPassword: string, newPassword: string) => {
|
|
400
|
+
setAuthLoading(true);
|
|
401
|
+
setAuthProviderError(null);
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
if (!tokensRef.current) {
|
|
405
|
+
throw new Error("User is not logged in");
|
|
406
|
+
}
|
|
407
|
+
await authApi.changePassword(tokensRef.current.accessToken, oldPassword, newPassword);
|
|
408
|
+
// After password change, user needs to log in again (all sessions invalidated)
|
|
409
|
+
clearSessionAndSignOut();
|
|
410
|
+
} catch (error: unknown) {
|
|
411
|
+
setAuthProviderError(error as Error);
|
|
412
|
+
throw error;
|
|
413
|
+
} finally {
|
|
414
|
+
setAuthLoading(false);
|
|
415
|
+
}
|
|
416
|
+
}, [clearSessionAndSignOut]);
|
|
417
|
+
|
|
418
|
+
// Update user profile
|
|
419
|
+
const updateProfile = useCallback(async (displayName?: string, photoURL?: string) => {
|
|
420
|
+
setAuthLoading(true);
|
|
421
|
+
setAuthProviderError(null);
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
if (!tokensRef.current) {
|
|
425
|
+
throw new Error("User is not logged in");
|
|
426
|
+
}
|
|
427
|
+
const response = await authApi.updateProfile(tokensRef.current.accessToken, displayName, photoURL);
|
|
428
|
+
|
|
429
|
+
// Update local user state
|
|
430
|
+
let convertedUser = convertToUser(response.user);
|
|
431
|
+
if (defineRolesFor) {
|
|
432
|
+
const customRoles = await defineRolesFor(convertedUser);
|
|
433
|
+
if (customRoles) {
|
|
434
|
+
convertedUser = { ...convertedUser, roles: customRoles.map(r => r.id) };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Update storage
|
|
439
|
+
const storedData = loadAuthFromStorage();
|
|
440
|
+
if (storedData) {
|
|
441
|
+
saveAuthToStorage(storedData.tokens, response.user);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
setUser(convertedUser);
|
|
445
|
+
return convertedUser;
|
|
446
|
+
} catch (error: unknown) {
|
|
447
|
+
setAuthProviderError(error as Error);
|
|
448
|
+
throw error;
|
|
449
|
+
} finally {
|
|
450
|
+
setAuthLoading(false);
|
|
451
|
+
}
|
|
452
|
+
}, [defineRolesFor]);
|
|
453
|
+
|
|
454
|
+
// Fetch active sessions
|
|
455
|
+
const fetchSessions = useCallback(async () => {
|
|
456
|
+
try {
|
|
457
|
+
if (!tokensRef.current) {
|
|
458
|
+
throw new Error("User is not logged in");
|
|
459
|
+
}
|
|
460
|
+
const response = await authApi.fetchSessions(tokensRef.current.accessToken, tokensRef.current.refreshToken);
|
|
461
|
+
return response.sessions;
|
|
462
|
+
} catch (error: unknown) {
|
|
463
|
+
setAuthProviderError(error as Error);
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}, []);
|
|
467
|
+
|
|
468
|
+
// Revoke a session
|
|
469
|
+
const revokeSession = useCallback(async (sessionId: string) => {
|
|
470
|
+
try {
|
|
471
|
+
if (!tokensRef.current) {
|
|
472
|
+
throw new Error("User is not logged in");
|
|
473
|
+
}
|
|
474
|
+
await authApi.revokeSession(tokensRef.current.accessToken, sessionId);
|
|
475
|
+
// If the revoked session is the current one, the next API request will fail with 401
|
|
476
|
+
// and trigger an auto-logout. Otherwise, it just removes it from the DB.
|
|
477
|
+
} catch (error: unknown) {
|
|
478
|
+
setAuthProviderError(error as Error);
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
// Restore auth state from localStorage on mount
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
isMountedRef.current = true;
|
|
486
|
+
|
|
487
|
+
const restoreAuth = async () => {
|
|
488
|
+
|
|
489
|
+
// Fetch auth config (needsSetup, registrationEnabled, etc.)
|
|
490
|
+
try {
|
|
491
|
+
const config = await authApi.fetchAuthConfig();
|
|
492
|
+
if (isMountedRef.current) {
|
|
493
|
+
setAuthConfig(config);
|
|
494
|
+
}
|
|
495
|
+
} catch (e) {
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const stored = loadAuthFromStorage();
|
|
499
|
+
|
|
500
|
+
if (!stored) {
|
|
501
|
+
setInitialLoading(false);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!stored.tokens?.refreshToken) {
|
|
506
|
+
clearAuthFromStorage();
|
|
507
|
+
setInitialLoading(false);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
// Validate accessTokenExpiresAt is a valid number
|
|
513
|
+
const expiresAt = stored.tokens.accessTokenExpiresAt;
|
|
514
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
|
|
515
|
+
clearAuthFromStorage();
|
|
516
|
+
setInitialLoading(false);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
// Check if access token is still valid
|
|
522
|
+
if (!isTokenExpiredOrNearExpiry(stored.tokens.accessTokenExpiresAt)) {
|
|
523
|
+
// Token is still valid - use it directly
|
|
524
|
+
tokensRef.current = stored.tokens;
|
|
525
|
+
|
|
526
|
+
let userToSet = convertToUser(stored.user);
|
|
527
|
+
if (defineRolesFor) {
|
|
528
|
+
const customRoles = await defineRolesFor(userToSet);
|
|
529
|
+
if (customRoles) {
|
|
530
|
+
userToSet = { ...userToSet, roles: customRoles.map(r => r.id) };
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
setUser(userToSet);
|
|
535
|
+
scheduleTokenRefresh(stored.tokens);
|
|
536
|
+
setInitialLoading(false);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Token is expired or near expiry - refresh it
|
|
541
|
+
tokensRef.current = stored.tokens; // Set so refreshAccessToken can use it
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
const newTokens = await refreshAccessToken();
|
|
545
|
+
|
|
546
|
+
if (!newTokens) {
|
|
547
|
+
clearAuthFromStorage();
|
|
548
|
+
tokensRef.current = null;
|
|
549
|
+
setInitialLoading(false);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!isMountedRef.current) return;
|
|
554
|
+
|
|
555
|
+
// Fetch fresh user data from the server
|
|
556
|
+
let userToSet: User;
|
|
557
|
+
try {
|
|
558
|
+
const meResponse = await authApi.getCurrentUser(newTokens.accessToken);
|
|
559
|
+
|
|
560
|
+
if (!isMountedRef.current) return;
|
|
561
|
+
|
|
562
|
+
const freshUserInfo = meResponse.user;
|
|
563
|
+
|
|
564
|
+
// Update stored data with fresh user info
|
|
565
|
+
saveAuthToStorage(newTokens, freshUserInfo);
|
|
566
|
+
|
|
567
|
+
userToSet = convertToUser(freshUserInfo);
|
|
568
|
+
|
|
569
|
+
if (defineRolesFor) {
|
|
570
|
+
const customRoles = await defineRolesFor(userToSet);
|
|
571
|
+
if (!isMountedRef.current) return;
|
|
572
|
+
if (customRoles) {
|
|
573
|
+
userToSet = { ...userToSet, roles: customRoles.map(r => r.id) };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} catch (meError: unknown) {
|
|
577
|
+
if (!isMountedRef.current) return;
|
|
578
|
+
userToSet = convertToUser(stored.user);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!isMountedRef.current) return;
|
|
582
|
+
|
|
583
|
+
setUser(userToSet);
|
|
584
|
+
scheduleTokenRefresh(newTokens);
|
|
585
|
+
} catch (error: unknown) {
|
|
586
|
+
if (!isMountedRef.current) return;
|
|
587
|
+
|
|
588
|
+
// Do not clear the session entirely if it's just a temporary network outage
|
|
589
|
+
if (!(error instanceof Error && (error as { code?: string }).code === "NETWORK_ERROR")) {
|
|
590
|
+
clearAuthFromStorage();
|
|
591
|
+
tokensRef.current = null;
|
|
592
|
+
}
|
|
593
|
+
} finally {
|
|
594
|
+
if (isMountedRef.current) {
|
|
595
|
+
setInitialLoading(false);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
restoreAuth();
|
|
601
|
+
|
|
602
|
+
return () => {
|
|
603
|
+
isMountedRef.current = false;
|
|
604
|
+
};
|
|
605
|
+
}, [scheduleTokenRefresh, defineRolesFor, refreshAccessToken]);
|
|
606
|
+
|
|
607
|
+
// Handle visibility change - refresh token when user returns to tab
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
const handleVisibilityChange = async () => {
|
|
610
|
+
if (initialLoading) return;
|
|
611
|
+
|
|
612
|
+
if (document.visibilityState === "visible" && tokensRef.current) {
|
|
613
|
+
// Check if token needs refreshing
|
|
614
|
+
if (isTokenExpiredOrNearExpiry(tokensRef.current.accessTokenExpiresAt)) {
|
|
615
|
+
try {
|
|
616
|
+
const newTokens = await refreshAccessToken();
|
|
617
|
+
|
|
618
|
+
if (newTokens && isMountedRef.current) {
|
|
619
|
+
scheduleTokenRefresh(newTokens);
|
|
620
|
+
} else if (!newTokens && isMountedRef.current) {
|
|
621
|
+
clearSessionAndSignOut();
|
|
622
|
+
}
|
|
623
|
+
} catch (error) {
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
630
|
+
|
|
631
|
+
return () => {
|
|
632
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
633
|
+
};
|
|
634
|
+
}, [initialLoading, refreshAccessToken, scheduleTokenRefresh, clearSessionAndSignOut]);
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
// Get currently configured API URL
|
|
638
|
+
const getApiUrl = useCallback(() => {
|
|
639
|
+
return authApi.getApiUrl();
|
|
640
|
+
}, []);
|
|
641
|
+
|
|
642
|
+
// Cleanup on unmount
|
|
643
|
+
useEffect(() => {
|
|
644
|
+
return () => {
|
|
645
|
+
isMountedRef.current = false;
|
|
646
|
+
if (refreshTimeoutRef.current) {
|
|
647
|
+
clearTimeout(refreshTimeoutRef.current);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}, []);
|
|
651
|
+
|
|
652
|
+
// Revoke all sessions
|
|
653
|
+
const revokeAllSessions = useCallback(async () => {
|
|
654
|
+
try {
|
|
655
|
+
if (!tokensRef.current) {
|
|
656
|
+
throw new Error("User is not logged in");
|
|
657
|
+
}
|
|
658
|
+
await authApi.revokeAllSessions(tokensRef.current.accessToken);
|
|
659
|
+
clearSessionAndSignOut();
|
|
660
|
+
} catch (error: unknown) {
|
|
661
|
+
setAuthProviderError(error as Error);
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}, [clearSessionAndSignOut]);
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
user,
|
|
668
|
+
authLoading,
|
|
669
|
+
initialLoading,
|
|
670
|
+
authError,
|
|
671
|
+
authProviderError,
|
|
672
|
+
loginSkipped,
|
|
673
|
+
needsSetup: authConfig?.needsSetup ?? false,
|
|
674
|
+
registrationEnabled: authConfig?.registrationEnabled ?? false,
|
|
675
|
+
getAuthToken,
|
|
676
|
+
getApiUrl,
|
|
677
|
+
signOut,
|
|
678
|
+
emailPasswordLogin,
|
|
679
|
+
register,
|
|
680
|
+
googleLogin,
|
|
681
|
+
skipLogin,
|
|
682
|
+
forgotPassword,
|
|
683
|
+
resetPassword,
|
|
684
|
+
changePassword,
|
|
685
|
+
updateProfile,
|
|
686
|
+
fetchSessions,
|
|
687
|
+
revokeSession,
|
|
688
|
+
revokeAllSessions,
|
|
689
|
+
extra,
|
|
690
|
+
setExtra
|
|
691
|
+
};
|
|
692
|
+
}
|