@rebasepro/admin 0.2.4 → 0.2.5

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.
Files changed (53) hide show
  1. package/dist/{CollectionEditorDialog-D0VqpLPO.js → CollectionEditorDialog-Cn8-tGyL.js} +22 -5
  2. package/dist/CollectionEditorDialog-Cn8-tGyL.js.map +1 -0
  3. package/dist/{CollectionsStudioView-Bc3Rxxc2.js → CollectionsStudioView-C-Ts1rZt.js} +4 -4
  4. package/dist/{CollectionsStudioView-Bc3Rxxc2.js.map → CollectionsStudioView-C-Ts1rZt.js.map} +1 -1
  5. package/dist/{ExportCollectionAction-Ckc-09BQ.js → ExportCollectionAction-BRdKM3DF.js} +2 -2
  6. package/dist/{ExportCollectionAction-Ckc-09BQ.js.map → ExportCollectionAction-BRdKM3DF.js.map} +1 -1
  7. package/dist/{ImportCollectionAction-BqjIrC3Z.js → ImportCollectionAction-U-v7lGxO.js} +2 -2
  8. package/dist/{ImportCollectionAction-BqjIrC3Z.js.map → ImportCollectionAction-U-v7lGxO.js.map} +1 -1
  9. package/dist/{PropertyEditView-CvRSV-A2.js → PropertyEditView-BDNYkfNf.js} +2 -2
  10. package/dist/{PropertyEditView-CvRSV-A2.js.map → PropertyEditView-BDNYkfNf.js.map} +1 -1
  11. package/dist/collection_editor_ui.js +3 -3
  12. package/dist/components/RebaseRouteDefs.d.ts +1 -1
  13. package/dist/components/admin/index.d.ts +1 -3
  14. package/dist/hooks/navigation/useBuildNavigationStateController.d.ts +1 -1
  15. package/dist/hooks/navigation/useResolvedViews.d.ts +2 -5
  16. package/dist/{index-DY2k5TtG.js → index-DHaOV-7A.js} +3 -3
  17. package/dist/index-DHaOV-7A.js.map +1 -0
  18. package/dist/{index-UQOMHwt1.js → index-DJSL_SCr.js} +3 -3
  19. package/dist/index-DJSL_SCr.js.map +1 -0
  20. package/dist/{index-BCcLwgfe.js → index-XMII4H3d.js} +2 -2
  21. package/dist/{index-BCcLwgfe.js.map → index-XMII4H3d.js.map} +1 -1
  22. package/dist/index.d.ts +0 -2
  23. package/dist/index.js +90 -295
  24. package/dist/index.js.map +1 -1
  25. package/dist/{util-ZM9gQuCv.js → util-0GYaJqL_.js} +153 -644
  26. package/dist/util-0GYaJqL_.js.map +1 -0
  27. package/package.json +8 -8
  28. package/src/collection_editor/pgColumnToProperty.ts +19 -2
  29. package/src/components/DefaultDrawer.tsx +2 -2
  30. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +4 -4
  31. package/src/components/EntityCollectionView/EntityCollectionListView.tsx +7 -0
  32. package/src/components/EntityCollectionView/EntityCollectionView.tsx +4 -1
  33. package/src/components/RebaseRouteDefs.tsx +4 -6
  34. package/src/components/admin/index.ts +1 -3
  35. package/src/components/index.ts +1 -3
  36. package/src/hooks/navigation/useBuildNavigationStateController.tsx +2 -3
  37. package/src/hooks/navigation/useResolvedViews.tsx +6 -48
  38. package/src/index.ts +2 -3
  39. package/src/util/previews.ts +9 -1
  40. package/dist/CollectionEditorDialog-D0VqpLPO.js.map +0 -1
  41. package/dist/components/admin/RoleChip.d.ts +0 -4
  42. package/dist/components/admin/RolesFilterSelect.d.ts +0 -2
  43. package/dist/components/admin/RolesView.d.ts +0 -4
  44. package/dist/components/admin/UserRolesSelectField.d.ts +0 -2
  45. package/dist/components/admin/UsersView.d.ts +0 -4
  46. package/dist/index-DY2k5TtG.js.map +0 -1
  47. package/dist/index-UQOMHwt1.js.map +0 -1
  48. package/dist/util-ZM9gQuCv.js.map +0 -1
  49. package/src/components/admin/RoleChip.tsx +0 -23
  50. package/src/components/admin/RolesFilterSelect.tsx +0 -45
  51. package/src/components/admin/RolesView.tsx +0 -470
  52. package/src/components/admin/UserRolesSelectField.tsx +0 -50
  53. package/src/components/admin/UsersView.tsx +0 -693
@@ -1,693 +0,0 @@
1
-
2
- import React, { useState, useEffect, useCallback, useRef } from "react";
3
- import { User } from "@rebasepro/types";
4
- import { useSnackbarController, useAuthController, useTranslation, useInternalUserManagementController } from "@rebasepro/core";
5
- import { useBreadcrumbsController } from "../../index";
6
- import {
7
- Alert,
8
- Button,
9
- CenteredView,
10
- CheckCircleIcon,
11
- ChevronLeftIcon,
12
- ChevronRightIcon,
13
- CircularProgress,
14
- Container,
15
- CopyIcon,
16
- Dialog,
17
- DialogActions,
18
- DialogContent,
19
- DialogTitle,
20
- IconButton,
21
- iconSize,
22
- KeyRoundIcon,
23
- LoadingButton,
24
- MailIcon,
25
- MultiSelect,
26
- MultiSelectItem,
27
- PlusIcon,
28
- SearchBar,
29
- Select,
30
- SelectItem,
31
- Skeleton,
32
- Table,
33
- TableBody,
34
- TableCell,
35
- TableHeader,
36
- TableRow,
37
- TextField,
38
- Tooltip,
39
- Trash2Icon,
40
- Typography
41
- } from "@rebasepro/ui";
42
- import { RoleChip } from "./RoleChip";
43
- import { UserManagementDelegate, Role, UserCreationResult } from "@rebasepro/types";
44
- import { ConfirmationDialog, BootstrapAdminBanner } from "@rebasepro/core";
45
- import { CreationResultDialog } from "./CreationResultDialog";
46
-
47
-
48
- const PAGE_SIZE = 25;
49
-
50
- // ============================================
51
- // UsersView Component
52
- // ============================================
53
- export function UsersView({ userManagement: userManagementProp }: {
54
- userManagement?: UserManagementDelegate;
55
- }) {
56
- const userManagementContext = useInternalUserManagementController();
57
- const userManagement = userManagementProp ?? userManagementContext;
58
- if (!userManagement) {
59
- return null;
60
- }
61
- const { roles, saveUser, createUser, deleteUser, resetPassword, loading: delegateLoading, bootstrapAdmin, usersError } = userManagement;
62
- const snackbarController = useSnackbarController();
63
- const { user: loggedInUser } = useAuthController();
64
- const { t } = useTranslation();
65
- const breadcrumbs = useBreadcrumbsController();
66
-
67
- React.useEffect(() => {
68
- breadcrumbs.set({
69
- breadcrumbs: [{ title: t("users"),
70
- url: "/users" }]
71
- });
72
-
73
- }, []);
74
-
75
- const [dialogOpen, setDialogOpen] = useState(false);
76
- const [selectedUser, setSelectedUser] = useState<User | undefined>();
77
- const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
78
- const [userToDelete, setUserToDelete] = useState<User | undefined>();
79
- const [deleteInProgress, setDeleteInProgress] = useState(false);
80
- const [formKey, setFormKey] = useState(0);
81
- const [bootstrapping, setBootstrapping] = useState(false);
82
-
83
- // Creation result state
84
- const [creationResult, setCreationResult] = useState<UserCreationResult | null>(null);
85
-
86
- // Reset password
87
- const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
88
- const [userToReset, setUserToReset] = useState<User | undefined>();
89
- const [resetInProgress, setResetInProgress] = useState(false);
90
-
91
- // Check if server-side search is available
92
- const hasServerSearch = !!userManagement.searchUsers;
93
-
94
- // ---- Server-side pagination state ----
95
- const [searchQuery, setSearchQuery] = useState("");
96
- const [roleFilter, setRoleFilter] = useState<string>("");
97
- const [page, setPage] = useState(0);
98
- const [paginatedUsers, setPaginatedUsers] = useState<User[]>([]);
99
- const [totalUsers, setTotalUsers] = useState(0);
100
- const [tableLoading, setTableLoading] = useState(hasServerSearch);
101
-
102
- // Debounce timer ref for search
103
- const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
104
-
105
- // Fallback: use in-memory users if no searchUsers
106
- const allUsers = userManagement.users;
107
-
108
- /**
109
- * Fetch a page of users from the server.
110
- * Only shows the loading skeleton when we have no data yet (initial load).
111
- * Subsequent re-fetches (pagination, search) update in-place without flashing.
112
- */
113
- const fetchPage = useCallback(async (pageNum: number, search: string, filterRole: string, forceLoading = false) => {
114
- if (!userManagement.searchUsers) return;
115
-
116
- // Only show skeleton on initial load or explicit requests (search/filter/page change).
117
- // This avoids flashing skeletons when the effect re-fires from dep changes.
118
- if (forceLoading) {
119
- setTableLoading(true);
120
- }
121
- try {
122
- const result = await userManagement.searchUsers({
123
- search: search || undefined,
124
- roleId: filterRole || undefined,
125
- limit: PAGE_SIZE,
126
- offset: pageNum * PAGE_SIZE,
127
- orderBy: "createdAt",
128
- orderDir: "desc"
129
- });
130
- setPaginatedUsers(result.users);
131
- setTotalUsers(result.total);
132
- } catch (error: unknown) {
133
- console.error("Failed to fetch users:", error);
134
- snackbarController.open({ type: "error",
135
- message: error instanceof Error ? error.message : "Failed to load users" });
136
- } finally {
137
- setTableLoading(false);
138
- }
139
- }, [userManagement.searchUsers, snackbarController]);
140
-
141
- // Stable ref for fetchPage so the initial-load effect doesn't re-fire
142
- // every time fetchPage's reference changes (which happens on parent re-renders).
143
- const fetchPageRef = useRef(fetchPage);
144
- fetchPageRef.current = fetchPage;
145
- const initialFetchDone = useRef(false);
146
-
147
- // Load initial page when delegate finishes loading — runs exactly once.
148
- useEffect(() => {
149
- if (!delegateLoading && !usersError && hasServerSearch && !initialFetchDone.current) {
150
- initialFetchDone.current = true;
151
- fetchPageRef.current(0, "", roleFilter, true);
152
- }
153
- }, [delegateLoading, usersError, hasServerSearch, roleFilter]);
154
-
155
- // Handle search changes (debounced)
156
- const handleSearch = useCallback((value: string) => {
157
- setSearchQuery(value);
158
- setPage(0);
159
-
160
- if (searchTimerRef.current) {
161
- clearTimeout(searchTimerRef.current);
162
- }
163
-
164
- if (hasServerSearch) {
165
- searchTimerRef.current = setTimeout(() => {
166
- fetchPage(0, value, roleFilter, true);
167
- }, 300);
168
- }
169
- }, [hasServerSearch, fetchPage, roleFilter]);
170
-
171
- const handleRoleFilterChange = useCallback((newRole: string) => {
172
- setRoleFilter(newRole);
173
- setPage(0);
174
- if (hasServerSearch) {
175
- fetchPage(0, searchQuery, newRole, true);
176
- }
177
- }, [hasServerSearch, fetchPage, searchQuery]);
178
-
179
- // Handle page change
180
- const handlePageChange = useCallback((newPage: number) => {
181
- setPage(newPage);
182
- if (hasServerSearch) {
183
- fetchPage(newPage, searchQuery, roleFilter, true);
184
- }
185
- }, [hasServerSearch, fetchPage, searchQuery, roleFilter]);
186
-
187
- // Refresh current page (after create/update/delete)
188
- const refreshCurrentPage = useCallback(() => {
189
- if (hasServerSearch) {
190
- fetchPage(page, searchQuery, roleFilter);
191
- }
192
- }, [hasServerSearch, fetchPage, page, searchQuery, roleFilter]);
193
-
194
- // Determine which users to show
195
- let displayUsers: User[];
196
- let displayTotal: number;
197
-
198
- if (hasServerSearch) {
199
- displayUsers = paginatedUsers;
200
- displayTotal = totalUsers;
201
- } else {
202
- // Fallback: local filtering for backward compat
203
- const filtered = allUsers.filter(u => {
204
- let matches = true;
205
- if (searchQuery) {
206
- const q = searchQuery.toLowerCase();
207
- matches = !!(u.email?.toLowerCase().includes(q) || u.displayName?.toLowerCase().includes(q));
208
- }
209
- if (matches && roleFilter) {
210
- matches = !!u.roles?.includes(roleFilter);
211
- }
212
- return matches;
213
- });
214
- displayTotal = filtered.length;
215
- displayUsers = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
216
- }
217
-
218
- const totalPages = Math.max(1, Math.ceil(displayTotal / PAGE_SIZE));
219
-
220
- // Check if any admin exists (use dedicated flag from delegate, or fallback to array scan)
221
- const hasAdmin = userManagement.hasAdminUsers ?? allUsers.some(u => u.roles?.includes("admin"));
222
-
223
- const handleBootstrap = async () => {
224
- if (!bootstrapAdmin) return;
225
- setBootstrapping(true);
226
- try {
227
- await bootstrapAdmin();
228
- snackbarController.open({ type: "success",
229
- message: t("bootstrap_admin_success") });
230
- window.location.reload();
231
- } catch (error: unknown) {
232
- snackbarController.open({ type: "error",
233
- message: error instanceof Error ? error.message : t("failed_to_bootstrap_admin") });
234
- } finally {
235
- setBootstrapping(false);
236
- }
237
- };
238
-
239
- const handleAddUser = () => {
240
- setSelectedUser(undefined);
241
- setFormKey(k => k + 1);
242
- setDialogOpen(true);
243
- };
244
-
245
- const handleEditUser = (user: User) => {
246
- setSelectedUser(user);
247
- setDialogOpen(true);
248
- };
249
-
250
- const handleClose = () => {
251
- setDialogOpen(false);
252
- setSelectedUser(undefined);
253
- };
254
-
255
- const handleDelete = async () => {
256
- if (!userToDelete || !deleteUser) return;
257
- setDeleteInProgress(true);
258
- try {
259
- await deleteUser(userToDelete);
260
- snackbarController.open({ type: "success",
261
- message: t("user_deleted_successfully") });
262
- setDeleteConfirmOpen(false);
263
- setUserToDelete(undefined);
264
- refreshCurrentPage();
265
- } catch (error: unknown) {
266
- snackbarController.open({ type: "error",
267
- message: error instanceof Error ? error.message : t("error_deleting_user") });
268
- } finally {
269
- setDeleteInProgress(false);
270
- }
271
- };
272
-
273
- const handleResetPassword = async () => {
274
- if (!userToReset || !resetPassword) return;
275
- setResetInProgress(true);
276
- try {
277
- const result = await resetPassword(userToReset);
278
- setResetConfirmOpen(false);
279
- setUserToReset(undefined);
280
- setCreationResult(result);
281
- snackbarController.open({ type: "success",
282
- message: t("reset_password_success") });
283
- } catch (error: unknown) {
284
- snackbarController.open({ type: "error",
285
- message: error instanceof Error ? error.message : t("error_resetting_password") });
286
- } finally {
287
- setResetInProgress(false);
288
- }
289
- };
290
-
291
- return (
292
- <Container className="w-full flex flex-col py-4 gap-4" maxWidth={"6xl"}>
293
- <BootstrapAdminBanner className="mb-4" />
294
-
295
- <div className="flex items-center mt-12 mb-4 gap-4">
296
- <Typography gutterBottom variant="h4" className="grow mb-0" component="h4">
297
- {t("users")}
298
- </Typography>
299
- {roles && roles.length > 0 && (
300
- <Select
301
- value={roleFilter || "__all__"}
302
- onValueChange={(v) => handleRoleFilterChange(v === "__all__" ? "" : v)}
303
- placeholder={t("all_roles") || "All Roles"}
304
- size="small"
305
- className="w-48"
306
- >
307
- <SelectItem value="__all__">{t("all_roles") || "All Roles"}</SelectItem>
308
- {roles.map(role => (
309
- <SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
310
- ))}
311
- </Select>
312
- )}
313
- <SearchBar
314
- placeholder={t("search_users")}
315
- onTextSearch={(v) => handleSearch(v || "")}
316
- size="small"
317
- expandable
318
- />
319
- <Button startIcon={<PlusIcon/>} onClick={handleAddUser} disabled={!saveUser}>
320
- {t("add_user")}
321
- </Button>
322
- </div>
323
-
324
- <div className="overflow-auto">
325
- <Table className="w-full">
326
- <TableHeader>
327
- <TableCell header className="w-48">{t("id") || "ID"}</TableCell>
328
- <TableCell header>{t("email")}</TableCell>
329
- <TableCell header>{t("name")}</TableCell>
330
- <TableCell header>{t("roles")}</TableCell>
331
- <TableCell header className="whitespace-nowrap">{t("created")}</TableCell>
332
- <TableCell header className="w-24 text-right">{t("actions")}</TableCell>
333
- </TableHeader>
334
- <TableBody>
335
- {(tableLoading || delegateLoading) ? (
336
- [
337
- { email: "w-48",
338
- name: "w-32",
339
- roles: ["w-16", "w-20"] },
340
- { email: "w-32",
341
- name: "w-24",
342
- roles: ["w-24"] },
343
- { email: "w-40",
344
- name: "w-36",
345
- roles: ["w-16", "w-16"] }
346
- ].map((row, i) => (
347
- <TableRow key={`skeleton-${i}`}>
348
- <TableCell className="font-mono text-xs"><Skeleton className="h-3 w-40"/></TableCell>
349
- <TableCell><Skeleton className={`h-4 ${row.email}`}/></TableCell>
350
- <TableCell className="font-medium"><Skeleton className={`h-4 ${row.name}`}/></TableCell>
351
- <TableCell>
352
- <div className="flex flex-wrap gap-2">
353
- {row.roles.map((w, j) => (
354
- <Skeleton key={j} className={`h-6 ${w} rounded-full`}/>
355
- ))}
356
- </div>
357
- </TableCell>
358
- <TableCell className="whitespace-nowrap text-sm">
359
- <Skeleton className="h-4 w-20"/>
360
- </TableCell>
361
- <TableCell className="text-right whitespace-nowrap">
362
- <div className="flex justify-end items-center gap-1">
363
- <Skeleton className="h-7 w-7 rounded-md"/>
364
- <Skeleton className="h-7 w-7 rounded-md"/>
365
- </div>
366
- </TableCell>
367
- </TableRow>
368
- ))
369
- ) : (
370
- displayUsers.map(user => (
371
- <TableRow key={user.uid} onClick={() => saveUser && handleEditUser(user)}>
372
- <TableCell className="font-mono text-xs">{user.uid}</TableCell>
373
- <TableCell>{user.email}</TableCell>
374
- <TableCell className="font-medium">{user.displayName}</TableCell>
375
- <TableCell>
376
- <div className="flex flex-wrap gap-2">
377
- {user.roles?.map((roleId: string) => {
378
- const role = roles?.find(r => r.id === roleId);
379
- return role ? <RoleChip key={roleId} role={role}/> : <span key={roleId}>{roleId}</span>;
380
- })}
381
- </div>
382
- </TableCell>
383
- <TableCell className="whitespace-nowrap text-sm text-surface-accent-600 dark:text-surface-accent-400">
384
- {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : "-"}
385
- </TableCell>
386
- <TableCell className="text-right whitespace-nowrap">
387
- <div className="flex justify-end items-center gap-1">
388
- {resetPassword && (
389
- <Tooltip asChild title={t("reset_password")}>
390
- <IconButton
391
- size="small"
392
- onClick={(e) => {
393
- e.stopPropagation();
394
- setUserToReset(user);
395
- setResetConfirmOpen(true);
396
- }}>
397
- <KeyRoundIcon size={iconSize.small}/>
398
- </IconButton>
399
- </Tooltip>
400
- )}
401
- {deleteUser && (
402
- <Tooltip asChild title={loggedInUser?.uid === user.uid ? (t("cannot_delete_own_account") || "Cannot delete your own account") : t("delete_this_user")}>
403
- <IconButton
404
- size="small"
405
- disabled={loggedInUser?.uid === user.uid}
406
- onClick={(e) => {
407
- e.stopPropagation();
408
- setUserToDelete(user);
409
- setDeleteConfirmOpen(true);
410
- }}>
411
- <Trash2Icon size={iconSize.small}/>
412
- </IconButton>
413
- </Tooltip>
414
- )}
415
- </div>
416
- </TableCell>
417
- </TableRow>
418
- )))}
419
-
420
- {displayUsers.length === 0 && !tableLoading && !delegateLoading && (
421
- <TableRow>
422
- <TableCell colspan={6}>
423
- <CenteredView className="flex flex-col gap-4 my-8 items-center">
424
- <Typography variant="label">
425
- {usersError
426
- ? t("no_permission_to_view_users")
427
- : searchQuery ? t("no_users_found") : t("no_users_yet")}
428
- </Typography>
429
- {usersError && (
430
- <Typography variant="caption" color="secondary">
431
- {t("no_permission_description")}
432
- </Typography>
433
- )}
434
- </CenteredView>
435
- </TableCell>
436
- </TableRow>
437
- )}
438
- </TableBody>
439
- </Table>
440
- </div>
441
-
442
- {/* Pagination */}
443
- {displayTotal > PAGE_SIZE && (
444
- <div className="flex items-center justify-between px-2 py-3">
445
- <Typography variant="body2" className="text-surface-accent-500 dark:text-surface-accent-400">
446
- {`${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, displayTotal)} / ${displayTotal}`}
447
- </Typography>
448
- <div className="flex items-center gap-1">
449
- <IconButton
450
- size="small"
451
- disabled={page === 0}
452
- onClick={() => handlePageChange(page - 1)}>
453
- <ChevronLeftIcon size={iconSize.smallest}/>
454
- </IconButton>
455
- <Typography variant="body2" className="px-3 text-surface-accent-600 dark:text-surface-accent-300">
456
- {page + 1} / {totalPages}
457
- </Typography>
458
- <IconButton
459
- size="small"
460
- disabled={page >= totalPages - 1}
461
- onClick={() => handlePageChange(page + 1)}>
462
- <ChevronRightIcon size={iconSize.smallest}/>
463
- </IconButton>
464
- </div>
465
- </div>
466
- )}
467
-
468
- {/* User Edit Dialog */}
469
- {saveUser && (
470
- <UserDetailsForm
471
- key={selectedUser?.uid ?? `new-${formKey}`}
472
- open={dialogOpen}
473
- user={selectedUser}
474
- roles={roles}
475
- saveUser={saveUser}
476
- createUser={createUser}
477
- handleClose={handleClose}
478
- onCreationResult={setCreationResult}
479
- onSaved={refreshCurrentPage}
480
- />
481
- )}
482
-
483
- {/* Creation Result Dialog */}
484
- {creationResult && (
485
- <CreationResultDialog
486
- result={creationResult}
487
- onClose={() => setCreationResult(null)}
488
- />
489
- )}
490
-
491
- {/* Delete Confirmation */}
492
- <ConfirmationDialog
493
- open={deleteConfirmOpen}
494
- loading={deleteInProgress}
495
- onAccept={handleDelete}
496
- onCancel={() => { setDeleteConfirmOpen(false); setUserToDelete(undefined); }}
497
- title={<>{t("delete_confirmation_title")}</>}
498
- body={<>{t("delete_user_confirmation")}</>}
499
- />
500
-
501
- {/* Reset Password Confirmation */}
502
- <ConfirmationDialog
503
- open={resetConfirmOpen}
504
- loading={resetInProgress}
505
- onAccept={handleResetPassword}
506
- onCancel={() => { setResetConfirmOpen(false); setUserToReset(undefined); }}
507
- title={<>{t("reset_password")}</>}
508
- body={<>{t("reset_password_confirmation")}</>}
509
- />
510
- </Container>
511
- );
512
- }
513
-
514
- // ============================================
515
- // UserDetailsForm Component
516
- // ============================================
517
- function UserDetailsForm({
518
- open,
519
- user: userProp,
520
- roles,
521
- saveUser,
522
- createUser,
523
- handleClose,
524
- onCreationResult,
525
- onSaved
526
- }: {
527
- open: boolean;
528
- user?: User;
529
- roles?: Role[];
530
- saveUser: (user: User) => Promise<User>;
531
- createUser?: (user: User) => Promise<UserCreationResult>;
532
- handleClose: () => void;
533
- onCreationResult?: (result: UserCreationResult) => void;
534
- onSaved?: () => void;
535
- }) {
536
- const snackbarController = useSnackbarController();
537
- const { t } = useTranslation();
538
- const isNewUser = !userProp;
539
-
540
- const [displayName, setDisplayName] = useState(userProp?.displayName || "");
541
- const [email, setEmail] = useState(userProp?.email || "");
542
- const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(
543
- userProp?.roles || []
544
- );
545
- const [isSubmitting, setIsSubmitting] = useState(false);
546
- const [errors, setErrors] = useState<{ displayName?: string; email?: string; roles?: string }>({});
547
- const [submitCount, setSubmitCount] = useState(0);
548
-
549
- const validate = () => {
550
- const newErrors: typeof errors = {};
551
- if (!displayName) newErrors.displayName = "Required";
552
- if (!email) newErrors.email = "Required";
553
- else if (!/\S+@\S+\.\S+/.test(email)) newErrors.email = "Invalid email";
554
- setErrors(newErrors);
555
- return Object.keys(newErrors).length === 0;
556
- };
557
-
558
- const handleSubmit = async (e: React.FormEvent) => {
559
- e.preventDefault();
560
- setSubmitCount(c => c + 1);
561
-
562
- if (!validate()) return;
563
-
564
- setIsSubmitting(true);
565
- try {
566
- const userRoles = selectedRoleIds;
567
- const userToSave: User = {
568
- uid: userProp?.uid || crypto.randomUUID(),
569
- email,
570
- displayName: displayName || null,
571
- photoURL: userProp?.photoURL || null,
572
- providerId: userProp?.providerId || "custom",
573
- isAnonymous: userProp?.isAnonymous || false,
574
- roles: userRoles
575
- };
576
-
577
- if (isNewUser && createUser && onCreationResult) {
578
- // Use createUser for new users to get invitation/password info
579
- const result = await createUser(userToSave);
580
- handleClose();
581
- onCreationResult(result);
582
- } else {
583
- await saveUser(userToSave);
584
- handleClose();
585
- }
586
- onSaved?.();
587
- } catch (error: unknown) {
588
- snackbarController.open({ type: "error",
589
- message: error instanceof Error ? error.message : "Failed to save user" });
590
- } finally {
591
- setIsSubmitting(false);
592
- }
593
- };
594
-
595
- const dirty = isNewUser ||
596
- displayName !== (userProp?.displayName || "") ||
597
- email !== (userProp?.email || "") ||
598
- (() => {
599
- const prev = userProp?.roles || [];
600
- if (selectedRoleIds.length !== prev.length) return true;
601
- const set = new Set(prev);
602
- return selectedRoleIds.some(id => !set.has(id));
603
- })();
604
-
605
- return (
606
- <Dialog open={open} onOpenChange={(open) => !open ? handleClose() : undefined} maxWidth="4xl">
607
- <form onSubmit={handleSubmit} autoComplete="off" noValidate
608
- style={{ display: "flex",
609
- flexDirection: "column",
610
- position: "relative",
611
- height: "100%" }}>
612
-
613
- <DialogTitle variant="h4" gutterBottom={false}>
614
- {t("user")}
615
- </DialogTitle>
616
-
617
- <DialogContent className="h-full grow">
618
- <div className="grid grid-cols-12 gap-4">
619
- {!isNewUser && (
620
- <div className="col-span-12">
621
- <TextField
622
- name="uid"
623
- value={userProp?.uid || ""}
624
- label={t("id") || "ID"}
625
- disabled
626
- />
627
- </div>
628
- )}
629
- <div className="col-span-12">
630
- <TextField
631
- name="displayName"
632
- required
633
- error={submitCount > 0 && Boolean(errors.displayName)}
634
- value={displayName}
635
- onChange={(e) => setDisplayName(e.target.value)}
636
- label={t("name")}
637
- />
638
- {submitCount > 0 && errors.displayName && (
639
- <Typography variant="caption" color="error">{errors.displayName}</Typography>
640
- )}
641
- </div>
642
-
643
- <div className="col-span-12">
644
- <TextField
645
- required
646
- error={submitCount > 0 && Boolean(errors.email)}
647
- name="email"
648
- value={email}
649
- onChange={(e) => setEmail(e.target.value)}
650
- label={t("email")}
651
- disabled={!isNewUser}
652
- />
653
- {submitCount > 0 && errors.email && (
654
- <Typography variant="caption" color="error">{errors.email}</Typography>
655
- )}
656
- </div>
657
-
658
- {roles && roles.length > 0 && (
659
- <div className="col-span-12">
660
- <MultiSelect
661
- className="w-full"
662
- label={t("roles")}
663
- value={selectedRoleIds}
664
- onValueChange={(value: string[]) => setSelectedRoleIds(value)}
665
- >
666
- {roles.map(role => (
667
- <MultiSelectItem key={role.id} value={role.id}>
668
- <RoleChip role={role}/>
669
- </MultiSelectItem>
670
- ))}
671
- </MultiSelect>
672
- </div>
673
- )}
674
- </div>
675
- </DialogContent>
676
-
677
- <DialogActions>
678
- <Button variant="text" onClick={handleClose}>
679
- {t("cancel")}
680
- </Button>
681
- <LoadingButton
682
- variant="filled"
683
- type="submit"
684
- disabled={!dirty}
685
- loading={isSubmitting}
686
- >
687
- {isNewUser ? t("create_user") : t("update")}
688
- </LoadingButton>
689
- </DialogActions>
690
- </form>
691
- </Dialog>
692
- );
693
- }