@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.
@@ -0,0 +1,407 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { Role, User } from "@rebasepro/core";
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
+
11
+ users: USER[];
12
+ saveUser: (user: USER) => Promise<USER>;
13
+ deleteUser: (user: USER) => Promise<void>;
14
+
15
+ roles: Role[];
16
+ saveRole: (role: Role) => Promise<void>;
17
+ deleteRole: (role: Role) => Promise<void>;
18
+
19
+ isAdmin?: boolean;
20
+ allowDefaultRolesCreation?: boolean;
21
+ includeCollectionConfigPermissions?: boolean;
22
+ defineRolesFor: (user: User) => Promise<Role[] | undefined> | Role[] | undefined;
23
+ getUser: (uid: string) => User | null;
24
+
25
+ usersError?: Error;
26
+ rolesError?: Error;
27
+ bootstrapAdmin?: () => Promise<void>;
28
+ }
29
+
30
+ export interface BackendUserManagementConfig {
31
+ /**
32
+ * Base API URL for the backend
33
+ */
34
+ apiUrl: string;
35
+
36
+ /**
37
+ * Function to get the current auth token
38
+ */
39
+ getAuthToken: () => Promise<string>;
40
+
41
+ /**
42
+ * Current logged-in user
43
+ */
44
+ currentUser?: User | null;
45
+ }
46
+
47
+ interface ApiUser {
48
+ uid: string;
49
+ email: string;
50
+ displayName?: string | null;
51
+ photoURL?: string | null;
52
+ roles: string[];
53
+ createdAt?: string;
54
+ updatedAt?: string;
55
+ }
56
+
57
+ interface ApiRole {
58
+ id: string;
59
+ name: string;
60
+ isAdmin?: boolean;
61
+ config?: Record<string, any>;
62
+ }
63
+
64
+ /**
65
+ * Convert API user to Rebase User
66
+ * @param apiUser - The API user object
67
+ * @param availableRoles - Optional array of available roles to look up names
68
+ */
69
+ function convertUser(apiUser: ApiUser): User {
70
+ return {
71
+ uid: apiUser.uid,
72
+ email: apiUser.email,
73
+ displayName: apiUser.displayName || null,
74
+ photoURL: apiUser.photoURL || null,
75
+ providerId: "custom",
76
+ isAnonymous: false,
77
+ roles: apiUser.roles
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Convert API role to Rebase Role
83
+ */
84
+ function convertRole(apiRole: ApiRole): Role {
85
+ return {
86
+ id: apiRole.id,
87
+ name: apiRole.name,
88
+ isAdmin: apiRole.isAdmin ?? false,
89
+ config: apiRole.config ?? undefined
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Hook to manage users and roles via backend API
95
+ * Compatible with Rebase UserManagement interface
96
+ */
97
+ export function useBackendUserManagement(config: BackendUserManagementConfig): UserManagement {
98
+ const { apiUrl, getAuthToken, currentUser } = config;
99
+
100
+ const [users, setUsers] = useState<User[]>([]);
101
+ const [roles, setRoles] = useState<Role[]>([]);
102
+ const [loading, setLoading] = useState(true);
103
+ const [usersError, setUsersError] = useState<Error | undefined>();
104
+ const [rolesError, setRolesError] = useState<Error | undefined>();
105
+
106
+ /**
107
+ * Make authenticated API request
108
+ */
109
+ const apiRequest = useCallback(async (
110
+ endpoint: string,
111
+ method: string = "GET",
112
+ body?: Record<string, unknown>,
113
+ retryCount: number = 6,
114
+ signal?: AbortSignal
115
+ ): Promise<any> => {
116
+ let lastError: Error | null = null;
117
+ for (let attempt = 0; attempt < retryCount; attempt++) {
118
+ if (signal?.aborted) {
119
+ const error = new Error("Request aborted");
120
+ error.name = "AbortError";
121
+ throw error;
122
+ }
123
+
124
+ try {
125
+ const token = await getAuthToken();
126
+ // Use /api/admin prefix for admin endpoints
127
+ const response = await fetch(`${apiUrl}/api/admin${endpoint}`, {
128
+ method,
129
+ headers: {
130
+ "Content-Type": "application/json",
131
+ "Authorization": `Bearer ${token}`
132
+ },
133
+ body: body ? JSON.stringify(body) : undefined,
134
+ signal
135
+ });
136
+
137
+ if (!response.ok) {
138
+ const errorText = await response.text();
139
+ let errorMessage = "API request failed";
140
+ try {
141
+ const errorJson = JSON.parse(errorText);
142
+ errorMessage = errorJson.error?.message || errorMessage;
143
+ } catch (e) {
144
+ errorMessage = errorText || `HTTP error ${response.status}`;
145
+ }
146
+
147
+ const error = Object.assign(new Error(errorMessage), { status: response.status });
148
+ throw error;
149
+ }
150
+
151
+ return await response.json();
152
+ } catch (error: unknown) {
153
+ if (error instanceof Error && error.name === "AbortError" || signal?.aborted) {
154
+ throw error;
155
+ }
156
+
157
+ lastError = error instanceof Error ? error : new Error(String(error));
158
+
159
+ // Retry conditions: Network errors (TypeError) OR 5xx Server Errors (Backend rebooting)
160
+ const isNetworkError = error instanceof TypeError;
161
+ const isServerError = typeof (error as { status?: number }).status === "number" && (error as { status: number }).status >= 500 && (error as { status: number }).status < 600;
162
+
163
+ if (attempt < retryCount - 1 && (isNetworkError || isServerError)) {
164
+ const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // 1s, 2s, 4s...
165
+ console.warn(`Admin API request to ${endpoint} failed, retrying in ${delay}ms...`);
166
+
167
+ // Wait for delay or abort
168
+ await new Promise<void>((resolve, reject) => {
169
+ if (signal?.aborted) return reject(new Error("AbortError"));
170
+ const timer = setTimeout(resolve, delay);
171
+ if (signal) {
172
+ signal.addEventListener("abort", () => {
173
+ clearTimeout(timer);
174
+ reject(new Error("AbortError"));
175
+ }, { once: true });
176
+ }
177
+ }).catch(() => {}); // Catch AbortError from wait
178
+
179
+ if (signal?.aborted) {
180
+ const abortError = new Error("Request aborted");
181
+ abortError.name = "AbortError";
182
+ throw abortError;
183
+ }
184
+ continue;
185
+ }
186
+
187
+ console.error("Admin API error after retries:", error);
188
+ throw error;
189
+ }
190
+ }
191
+ throw lastError;
192
+ }, [apiUrl, getAuthToken]);
193
+
194
+ /**
195
+ * Load users from API
196
+ * @param availableRoles - Optional roles array to resolve role names
197
+ */
198
+ const loadUsers = useCallback(async (signal?: AbortSignal) => {
199
+ try {
200
+ const data = await apiRequest("/users", "GET", undefined, 6, signal);
201
+ setUsers(data.users.map((u: ApiUser) => convertUser(u)));
202
+ setUsersError(undefined);
203
+ } catch (error: unknown) {
204
+ if (error instanceof Error && error.name === "AbortError") return;
205
+ console.error("Failed to load users:", error);
206
+ setUsersError(error instanceof Error ? error : new Error(String(error)));
207
+ }
208
+ }, [apiRequest]);
209
+
210
+ /**
211
+ * Load roles from API
212
+ */
213
+ const loadRoles = useCallback(async (signal?: AbortSignal) => {
214
+ try {
215
+ const data = await apiRequest("/roles", "GET", undefined, 6, signal);
216
+ setRoles(data.roles.map(convertRole));
217
+ setRolesError(undefined);
218
+ } catch (error: unknown) {
219
+ if (error instanceof Error && error.name === "AbortError") return;
220
+ console.error("Failed to load roles:", error);
221
+ setRolesError(error instanceof Error ? error : new Error(String(error)));
222
+ }
223
+ }, [apiRequest]);
224
+
225
+ /**
226
+ * Initial data load - only when user is logged in
227
+ * Load roles first, then users (so role names can be resolved)
228
+ */
229
+ useEffect(() => {
230
+ // Don't load if no user is logged in
231
+ if (!currentUser) {
232
+ setLoading(false);
233
+ return;
234
+ }
235
+
236
+ const abortController = new AbortController();
237
+
238
+ const load = async () => {
239
+ setLoading(true);
240
+ // Load roles first
241
+ let loadedRoles: Role[] = [];
242
+ try {
243
+ const data = await apiRequest("/roles", "GET", undefined, 6, abortController.signal);
244
+ loadedRoles = data.roles.map(convertRole);
245
+ setRoles(loadedRoles);
246
+ setRolesError(undefined);
247
+ } catch (error: unknown) {
248
+ if (error instanceof Error && error.name !== "AbortError") {
249
+ console.error("Failed to load roles:", error);
250
+ setRolesError(error);
251
+ }
252
+ }
253
+ // Then load users if not aborted
254
+ if (!abortController.signal.aborted) {
255
+ await loadUsers(abortController.signal);
256
+ }
257
+
258
+ if (!abortController.signal.aborted) {
259
+ setLoading(false);
260
+ }
261
+ };
262
+ load();
263
+
264
+ return () => {
265
+ abortController.abort();
266
+ };
267
+ }, [currentUser, apiRequest, loadUsers]);
268
+
269
+ /**
270
+ * Save user (create or update)
271
+ */
272
+ const saveUser = useCallback(async (user: User): Promise<User> => {
273
+ const roleIds = user.roles ?? [];
274
+
275
+ // Check if user exists
276
+ const existingUser = users.find(u => u.uid === user.uid);
277
+
278
+ if (existingUser) {
279
+ // Update
280
+ const data = await apiRequest(`/users/${user.uid}`, "PUT", {
281
+ email: user.email,
282
+ displayName: user.displayName,
283
+ roles: roleIds
284
+ });
285
+ const updated = convertUser(data.user);
286
+ setUsers(prev => prev.map(u => u.uid === updated.uid ? updated : u));
287
+ return updated;
288
+ } else {
289
+ // Create
290
+ const data = await apiRequest("/users", "POST", {
291
+ email: user.email,
292
+ displayName: user.displayName,
293
+ roles: roleIds
294
+ });
295
+ const created = convertUser(data.user);
296
+ setUsers(prev => [...prev, created]);
297
+ return created;
298
+ }
299
+ }, [apiRequest, users, roles]);
300
+
301
+ /**
302
+ * Delete user
303
+ */
304
+ const deleteUser = useCallback(async (user: User): Promise<void> => {
305
+ await apiRequest(`/users/${user.uid}`, "DELETE");
306
+ setUsers(prev => prev.filter(u => u.uid !== user.uid));
307
+ }, [apiRequest]);
308
+
309
+ /**
310
+ * Save role (create or update)
311
+ */
312
+ const saveRole = useCallback(async (role: Role): Promise<void> => {
313
+ // Check if role exists
314
+ const existingRole = roles.find(r => r.id === role.id);
315
+
316
+ if (existingRole) {
317
+ // Update
318
+ const data = await apiRequest(`/roles/${role.id}`, "PUT", {
319
+ name: role.name,
320
+ isAdmin: role.isAdmin,
321
+ config: role.config
322
+ });
323
+ const updated = convertRole(data.role);
324
+ setRoles(prev => prev.map(r => r.id === updated.id ? updated : r));
325
+ } else {
326
+ // Create
327
+ const data = await apiRequest("/roles", "POST", {
328
+ id: role.id,
329
+ name: role.name,
330
+ isAdmin: role.isAdmin ?? false,
331
+ config: role.config
332
+ });
333
+ const created = convertRole(data.role);
334
+ setRoles(prev => [...prev, created]);
335
+ }
336
+ }, [apiRequest, roles]);
337
+
338
+ /**
339
+ * Delete role
340
+ */
341
+ const deleteRole = useCallback(async (role: Role): Promise<void> => {
342
+ await apiRequest(`/roles/${role.id}`, "DELETE");
343
+ setRoles(prev => prev.filter(r => r.id !== role.id));
344
+ }, [apiRequest]);
345
+
346
+ /**
347
+ * Get user by uid
348
+ */
349
+ const getUser = useCallback((uid: string): User | null => {
350
+ return users.find(u => u.uid === uid) ?? null;
351
+ }, [users]);
352
+
353
+ /**
354
+ * Define roles for a given user (for authController)
355
+ */
356
+ const defineRolesFor = useCallback(async (user: User): Promise<Role[] | undefined> => {
357
+ // Find the user in our list
358
+ const existingUser = users.find(u => u.uid === user.uid || u.email === user.email);
359
+ if (!existingUser) return undefined;
360
+
361
+ // Return roles from our cached role data (string IDs → full Role objects)
362
+ const userRoleIds = existingUser.roles ?? [];
363
+ return roles.filter(r => userRoleIds.includes(r.id));
364
+ }, [users, roles]);
365
+
366
+ /**
367
+ * Check if current user is admin
368
+ */
369
+ const isAdmin = currentUser?.roles?.includes("admin") ?? false;
370
+
371
+
372
+
373
+ /**
374
+ * Bootstrap default admin
375
+ */
376
+ const bootstrapAdmin = useCallback(async (): Promise<void> => {
377
+ try {
378
+ await apiRequest("/bootstrap", "POST");
379
+ // Reload users and roles after successful bootstrap
380
+ const data = await apiRequest("/roles");
381
+ const loadedRoles = data.roles.map(convertRole);
382
+ setRoles(loadedRoles);
383
+ await loadUsers();
384
+ } catch (error) {
385
+ console.error("Failed to bootstrap admin:", error);
386
+ throw error;
387
+ }
388
+ }, [apiRequest, loadUsers]);
389
+
390
+ return {
391
+ loading,
392
+ users,
393
+ saveUser,
394
+ deleteUser,
395
+ roles,
396
+ saveRole,
397
+ deleteRole,
398
+ isAdmin,
399
+ allowDefaultRolesCreation: true,
400
+ includeCollectionConfigPermissions: true,
401
+ defineRolesFor,
402
+ getUser,
403
+ usersError,
404
+ rolesError,
405
+ bootstrapAdmin
406
+ };
407
+ }