@rebasepro/auth 0.5.0 → 0.6.1

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.
@@ -1,504 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import type { User } from "@rebasepro/types";
3
-
4
- /**
5
- * UserManagement interface - compatible with @rebasepro/user_management
6
- * Defined inline to avoid dependency on that package
7
- */
8
- export interface UserManagement<USER extends User = User> {
9
- loading: boolean;
10
- hasAdminUsers?: boolean;
11
-
12
- users: USER[];
13
- saveUser: (user: USER) => Promise<USER>;
14
- createUser?: (user: USER) => Promise<{
15
- user: USER;
16
- invitationSent: boolean;
17
- temporaryPassword?: string;
18
- }>;
19
- resetPassword?: (user: USER) => Promise<{
20
- user: USER;
21
- invitationSent: boolean;
22
- temporaryPassword?: string;
23
- }>;
24
- deleteUser: (user: USER) => Promise<void>;
25
-
26
- isAdmin?: boolean;
27
- allowDefaultRolesCreation?: boolean;
28
- defineRolesFor: (user: User) => Promise<string[] | undefined> | string[] | undefined;
29
- getUser: (uid: string) => User | null;
30
-
31
- /**
32
- * Search users with server-side pagination.
33
- * When provided, the CMS will use this for the users table
34
- * instead of loading all users into memory.
35
- */
36
- searchUsers?: (options: {
37
- search?: string;
38
- limit?: number;
39
- offset?: number;
40
- orderBy?: string;
41
- orderDir?: "asc" | "desc";
42
- roleId?: string;
43
- }) => Promise<{ users: USER[]; total: number }>;
44
-
45
- usersError?: Error;
46
- bootstrapAdmin?: () => Promise<void>;
47
- }
48
-
49
- export interface BackendUserManagementConfig {
50
- /**
51
- * The Rebase Client instance
52
- */
53
- client?: { baseUrl?: string; resolveToken?: () => Promise<string | null> };
54
-
55
- /**
56
- * Base API URL for the backend (optional, extracted from client if not provided)
57
- */
58
- apiUrl?: string;
59
-
60
- /**
61
- * Function to get the current auth token (optional, extracted from client if not provided)
62
- */
63
- getAuthToken?: () => Promise<string>;
64
-
65
- /**
66
- * Current logged-in user
67
- */
68
- currentUser?: User | null;
69
- }
70
-
71
- interface ApiUser {
72
- uid: string;
73
- email: string;
74
- displayName?: string | null;
75
- photoURL?: string | null;
76
- roles: string[];
77
- createdAt?: string;
78
- updatedAt?: string;
79
- }
80
-
81
- /** Response shapes from the admin API */
82
- interface ApiUsersResponse { users: ApiUser[]; total: number }
83
- interface ApiUserResponse { user: ApiUser; invitationSent?: boolean; temporaryPassword?: string }
84
-
85
- /**
86
- * Convert API user to Rebase User
87
- * @param apiUser - The API user object
88
- */
89
- function convertUser(apiUser: ApiUser): User {
90
- return {
91
- uid: apiUser.uid,
92
- email: apiUser.email,
93
- displayName: apiUser.displayName || null,
94
- photoURL: apiUser.photoURL || null,
95
- providerId: "custom",
96
- isAnonymous: false,
97
- roles: apiUser.roles,
98
- createdAt: apiUser.createdAt ? new Date(apiUser.createdAt) : null
99
- } as User;
100
- }
101
-
102
- /**
103
- * Hook to manage users and roles via backend API
104
- * Compatible with Rebase UserManagement interface
105
- */
106
- export function useBackendUserManagement(config: BackendUserManagementConfig): UserManagement {
107
- const { client, apiUrl, getAuthToken, currentUser } = config;
108
-
109
- // Lazy user cache — populated on demand from search results, saves, and
110
- // individual API lookups. We never load ALL users into memory.
111
- const [userCache, setUserCache] = useState<Map<string, User>>(new Map());
112
- const [hasAdminUsers, setHasAdminUsers] = useState(false);
113
- const userRoles = currentUser?.roles ?? [];
114
- const isUserAdmin = userRoles.some(r => r === "admin" || r === "schema-admin");
115
-
116
- const [loading, setLoading] = useState(() => {
117
- if (!currentUser) return false;
118
- if (!isUserAdmin) return false;
119
- return true;
120
- });
121
- const [usersError, setUsersError] = useState<Error | undefined>();
122
-
123
- // Tracks the UID for which roles+users were last successfully loaded.
124
- // Prevents redundant refetches on React StrictMode double-mounts.
125
- const lastLoadedUidRef = useRef<string | null>(null);
126
-
127
- const effectiveLoading = loading || !!(currentUser && isUserAdmin && lastLoadedUidRef.current !== currentUser.uid);
128
-
129
- /** Merge one or more users into the cache without replacing the whole Map. */
130
- const mergeIntoCache = useCallback((incoming: User[]) => {
131
- setUserCache(prev => {
132
- const next = new Map(prev);
133
- for (const u of incoming) {
134
- next.set(u.uid, u);
135
- }
136
- return next;
137
- });
138
- }, []);
139
-
140
- // Ref to hold the latest apiRequest so the initial-load effect doesn't
141
- // re-trigger every time the callback identity changes.
142
- const apiRequestRef = useRef<typeof apiRequest | null>(null);
143
-
144
- /**
145
- * Make authenticated API request
146
- */
147
- const apiRequest = useCallback(async <T = Record<string, unknown>>(
148
- endpoint: string,
149
- method = "GET",
150
- body?: Record<string, unknown>,
151
- retryCount = 6,
152
- signal?: AbortSignal
153
- ): Promise<T> => {
154
- let lastError: Error | null = null;
155
- for (let attempt = 0; attempt < retryCount; attempt++) {
156
- if (signal?.aborted) {
157
- const error = new Error("Request aborted");
158
- error.name = "AbortError";
159
- throw error;
160
- }
161
-
162
- try {
163
- // Determine token provider
164
- const token = getAuthToken ? await getAuthToken() : (client?.resolveToken ? await client.resolveToken() : null);
165
- const baseUrl = apiUrl || (client?.baseUrl ? client.baseUrl : "");
166
-
167
- // Use /api/admin prefix for admin endpoints
168
- const response = await fetch(`${baseUrl}/api/admin${endpoint}`, {
169
- method,
170
- headers: {
171
- "Content-Type": "application/json",
172
- ...(token ? { "Authorization": `Bearer ${token}` } : {})
173
- },
174
- body: body ? JSON.stringify(body) : undefined,
175
- signal
176
- });
177
-
178
- if (!response.ok) {
179
- const errorText = await response.text();
180
- let errorMessage = "API request failed";
181
- try {
182
- const errorJson = JSON.parse(errorText);
183
- errorMessage = errorJson.error?.message || errorMessage;
184
- } catch (e) {
185
- errorMessage = errorText || `HTTP error ${response.status}`;
186
- }
187
-
188
- const error = Object.assign(new Error(errorMessage), { status: response.status });
189
- throw error;
190
- }
191
-
192
- return await response.json();
193
- } catch (error: unknown) {
194
- if (error instanceof Error && error.name === "AbortError" || signal?.aborted) {
195
- throw error;
196
- }
197
-
198
- lastError = error instanceof Error ? error : new Error(String(error));
199
-
200
- // Retry conditions: Network errors (TypeError) OR 5xx Server Errors (Backend rebooting)
201
- const isNetworkError = error instanceof TypeError;
202
- const isServerError = typeof (error as { status?: number }).status === "number" && (error as { status: number }).status >= 500 && (error as { status: number }).status < 600;
203
-
204
- if (attempt < retryCount - 1 && (isNetworkError || isServerError)) {
205
- const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // 1s, 2s, 4s...
206
- console.warn(`Admin API request to ${endpoint} failed, retrying in ${delay}ms...`);
207
-
208
- // Wait for delay or abort
209
- await new Promise<void>((resolve, reject) => {
210
- if (signal?.aborted) return reject(new Error("AbortError"));
211
- const timer = setTimeout(resolve, delay);
212
- if (signal) {
213
- signal.addEventListener("abort", () => {
214
- clearTimeout(timer);
215
- reject(new Error("AbortError"));
216
- }, { once: true });
217
- }
218
- }).catch(() => {}); // Catch AbortError from wait
219
-
220
- if (signal?.aborted) {
221
- const abortError = new Error("Request aborted");
222
- abortError.name = "AbortError";
223
- throw abortError;
224
- }
225
- continue;
226
- }
227
-
228
- console.error("Admin API error after retries:", error);
229
- throw error;
230
- }
231
- }
232
- throw lastError;
233
- }, [apiUrl, getAuthToken]);
234
-
235
- // Keep the ref in sync after every render.
236
- apiRequestRef.current = apiRequest;
237
-
238
-
239
-
240
- /**
241
- * Lightweight admin-existence check: fetch a single admin user.
242
- * Used by the BootstrapAdminBanner to decide whether to show.
243
- */
244
- const checkAdminExists = useCallback(async (signal?: AbortSignal) => {
245
- try {
246
- const data = await apiRequest<ApiUsersResponse>("/users?role=admin&limit=1", "GET", undefined, 6, signal);
247
- const adminUsers: User[] = data.users.map((u: ApiUser) => convertUser(u));
248
- setHasAdminUsers(adminUsers.length > 0);
249
- // Also cache these admin users for getUser lookups
250
- if (adminUsers.length > 0) {
251
- mergeIntoCache(adminUsers);
252
- }
253
- setUsersError(undefined);
254
- } catch (error: unknown) {
255
- if (error instanceof Error && error.name === "AbortError") return;
256
- console.error("Failed to check admin users:", error);
257
- setUsersError(error instanceof Error ? error : new Error(String(error)));
258
- }
259
- }, [apiRequest, mergeIntoCache]);
260
-
261
- /**
262
- * Initial data load - only when user is logged in
263
- * Load roles first, then admin users.
264
- *
265
- * Dependencies are intentionally limited to `currentUser?.uid` so the
266
- * effect does NOT re-run when callback identities change. The latest
267
- * `apiRequest` is read via `apiRequestRef`.
268
- */
269
- useEffect(() => {
270
- // Don't load if no user is logged in
271
- if (!currentUser) {
272
- setLoading(false);
273
- return;
274
- }
275
-
276
- // Skip admin API calls for non-admin users — they'd get 403 anyway.
277
- // This avoids a spurious warning in backend logs on every non-admin login.
278
- const userRoles = currentUser.roles ?? [];
279
- const isUserAdmin = userRoles.some(r => r === "admin" || r === "schema-admin");
280
- if (!isUserAdmin) {
281
- setLoading(false);
282
- return;
283
- }
284
-
285
- // Skip refetch if we already loaded data for this same UID
286
- // (e.g. React StrictMode unmounts and re-mounts with the same user).
287
- if (lastLoadedUidRef.current === currentUser.uid) {
288
- setLoading(false);
289
- return;
290
- }
291
-
292
- const abortController = new AbortController();
293
-
294
- const load = async () => {
295
- setLoading(true);
296
- const request = apiRequestRef.current!;
297
-
298
- // Lightweight admin-existence check (NOT loading all users)
299
- if (!abortController.signal.aborted) {
300
- try {
301
- const data = await request<ApiUsersResponse>("/users?role=admin&limit=1", "GET", undefined, 6, abortController.signal);
302
- const adminUsers: User[] = data.users.map((u: ApiUser) => convertUser(u));
303
- setHasAdminUsers(adminUsers.length > 0);
304
- if (adminUsers.length > 0) {
305
- mergeIntoCache(adminUsers);
306
- }
307
- setUsersError(undefined);
308
- } catch (error: unknown) {
309
- if (error instanceof Error && error.name === "AbortError") return;
310
- console.error("Failed to check admin users:", error);
311
- setUsersError(error instanceof Error ? error : new Error(String(error)));
312
- }
313
- }
314
-
315
- if (!abortController.signal.aborted) {
316
- lastLoadedUidRef.current = currentUser.uid;
317
- setLoading(false);
318
- }
319
- };
320
- load();
321
-
322
- return () => {
323
- abortController.abort();
324
- };
325
- // eslint-disable-next-line react-hooks/exhaustive-deps
326
- }, [currentUser?.uid]);
327
-
328
- /**
329
- * Search users with server-side pagination.
330
- * This is the primary method used by the UsersView table.
331
- * Results are also merged into the cache for getUser lookups.
332
- */
333
- const searchUsers = useCallback(async (options: {
334
- search?: string;
335
- limit?: number;
336
- offset?: number;
337
- orderBy?: string;
338
- orderDir?: "asc" | "desc";
339
- roleId?: string;
340
- }): Promise<{ users: User[]; total: number }> => {
341
- const params = new URLSearchParams();
342
- if (options.limit !== undefined) params.set("limit", String(options.limit));
343
- if (options.offset !== undefined) params.set("offset", String(options.offset));
344
- if (options.search) params.set("search", options.search);
345
- if (options.orderBy) params.set("orderBy", options.orderBy);
346
- if (options.orderDir) params.set("orderDir", options.orderDir);
347
- if (options.roleId) params.set("role", options.roleId);
348
- const qs = params.toString();
349
-
350
- const data = await apiRequest<ApiUsersResponse>("/users" + (qs ? "?" + qs : ""), "GET");
351
- const converted = data.users.map((u: ApiUser) => convertUser(u));
352
- // Feed search results into cache for getUser/defineRolesFor
353
- mergeIntoCache(converted);
354
- return {
355
- users: converted,
356
- total: data.total
357
- };
358
- }, [apiRequest, mergeIntoCache]);
359
-
360
- /**
361
- * Save user (update existing user)
362
- */
363
- const saveUser = useCallback(async (user: User): Promise<User> => {
364
- const roleIds = user.roles ?? [];
365
-
366
- const data = await apiRequest<ApiUserResponse>(`/users/${user.uid}`, "PUT", {
367
- email: user.email,
368
- displayName: user.displayName,
369
- roles: roleIds
370
- });
371
- const updated = convertUser(data.user);
372
- mergeIntoCache([updated]);
373
- return updated;
374
- }, [apiRequest, mergeIntoCache]);
375
-
376
- /**
377
- * Create a new user with invitation/password generation support.
378
- * Returns additional info about how credentials were delivered.
379
- */
380
- const createUser = useCallback(async (user: User): Promise<{
381
- user: User;
382
- invitationSent: boolean;
383
- temporaryPassword?: string;
384
- }> => {
385
- const roleIds = user.roles ?? [];
386
-
387
- const data = await apiRequest<ApiUserResponse>("/users", "POST", {
388
- email: user.email,
389
- displayName: user.displayName,
390
- roles: roleIds
391
- });
392
- const created = convertUser(data.user);
393
- mergeIntoCache([created]);
394
- return {
395
- user: created,
396
- invitationSent: data.invitationSent ?? false,
397
- temporaryPassword: data.temporaryPassword
398
- };
399
- }, [apiRequest, mergeIntoCache]);
400
-
401
- /**
402
- * Reset the password for an existing user
403
- */
404
- const resetPassword = useCallback(async (user: User): Promise<{
405
- user: User;
406
- invitationSent: boolean;
407
- temporaryPassword?: string;
408
- }> => {
409
- const data = await apiRequest<ApiUserResponse>(`/users/${user.uid}/reset-password`, "POST");
410
- const updatedUser = convertUser(data.user);
411
- mergeIntoCache([updatedUser]);
412
- return {
413
- user: updatedUser,
414
- invitationSent: data.invitationSent ?? false,
415
- temporaryPassword: data.temporaryPassword
416
- };
417
- }, [apiRequest, mergeIntoCache]);
418
-
419
- /**
420
- * Delete user
421
- */
422
- const deleteUser = useCallback(async (user: User): Promise<void> => {
423
- await apiRequest(`/users/${user.uid}`, "DELETE");
424
- setUserCache(prev => {
425
- const next = new Map(prev);
426
- next.delete(user.uid);
427
- return next;
428
- });
429
- }, [apiRequest]);
430
-
431
-
432
-
433
- /**
434
- * Get user by uid
435
- */
436
- const getUser = useCallback((uid: string): User | null => {
437
- return userCache.get(uid) ?? null;
438
- }, [userCache]);
439
-
440
- /**
441
- * Define roles for a given user (for authController)
442
- */
443
- const defineRolesFor = useCallback(async (user: User): Promise<string[] | undefined> => {
444
- // Check cache first
445
- let existingUser = userCache.get(user.uid)
446
- ?? Array.from(userCache.values()).find(u => u.email === user.email);
447
-
448
- // If not cached, fetch from API
449
- if (!existingUser) {
450
- try {
451
- const data = await apiRequest<ApiUserResponse>(`/users/${user.uid}`, "GET");
452
- existingUser = convertUser(data.user);
453
- mergeIntoCache([existingUser]);
454
- } catch {
455
- return undefined;
456
- }
457
- }
458
-
459
- // Return role IDs as simple strings
460
- const userRoleIds = existingUser.roles ?? [];
461
- return userRoleIds;
462
- }, [userCache, apiRequest, mergeIntoCache]);
463
-
464
- /**
465
- * Check if current user is admin
466
- */
467
- const isAdmin = currentUser?.roles?.includes("admin") ?? false;
468
-
469
-
470
- /**
471
- * Bootstrap default admin
472
- */
473
- const bootstrapAdmin = useCallback(async (): Promise<void> => {
474
- try {
475
- await apiRequest("/bootstrap", "POST");
476
- // Re-check admin existence after successful bootstrap
477
- await checkAdminExists();
478
- } catch (error) {
479
- console.error("Failed to bootstrap admin:", error);
480
- throw error;
481
- }
482
- }, [apiRequest, checkAdminExists]);
483
-
484
- // Expose cached users as an array for backward compat (BootstrapAdminBanner,
485
- // UsersView fallback). This is NOT the full user list — just the cache.
486
- const users = Array.from(userCache.values());
487
-
488
- return {
489
- loading: effectiveLoading,
490
- users,
491
- hasAdminUsers,
492
- saveUser,
493
- createUser,
494
- resetPassword,
495
- deleteUser,
496
- isAdmin,
497
- allowDefaultRolesCreation: isAdmin,
498
- defineRolesFor,
499
- getUser,
500
- searchUsers,
501
- usersError,
502
- bootstrapAdmin
503
- };
504
- }