@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,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
|
+
}
|