@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.
- package/dist/{CollectionEditorDialog-D0VqpLPO.js → CollectionEditorDialog-Cn8-tGyL.js} +22 -5
- package/dist/CollectionEditorDialog-Cn8-tGyL.js.map +1 -0
- package/dist/{CollectionsStudioView-Bc3Rxxc2.js → CollectionsStudioView-C-Ts1rZt.js} +4 -4
- package/dist/{CollectionsStudioView-Bc3Rxxc2.js.map → CollectionsStudioView-C-Ts1rZt.js.map} +1 -1
- package/dist/{ExportCollectionAction-Ckc-09BQ.js → ExportCollectionAction-BRdKM3DF.js} +2 -2
- package/dist/{ExportCollectionAction-Ckc-09BQ.js.map → ExportCollectionAction-BRdKM3DF.js.map} +1 -1
- package/dist/{ImportCollectionAction-BqjIrC3Z.js → ImportCollectionAction-U-v7lGxO.js} +2 -2
- package/dist/{ImportCollectionAction-BqjIrC3Z.js.map → ImportCollectionAction-U-v7lGxO.js.map} +1 -1
- package/dist/{PropertyEditView-CvRSV-A2.js → PropertyEditView-BDNYkfNf.js} +2 -2
- package/dist/{PropertyEditView-CvRSV-A2.js.map → PropertyEditView-BDNYkfNf.js.map} +1 -1
- package/dist/collection_editor_ui.js +3 -3
- package/dist/components/RebaseRouteDefs.d.ts +1 -1
- package/dist/components/admin/index.d.ts +1 -3
- package/dist/hooks/navigation/useBuildNavigationStateController.d.ts +1 -1
- package/dist/hooks/navigation/useResolvedViews.d.ts +2 -5
- package/dist/{index-DY2k5TtG.js → index-DHaOV-7A.js} +3 -3
- package/dist/index-DHaOV-7A.js.map +1 -0
- package/dist/{index-UQOMHwt1.js → index-DJSL_SCr.js} +3 -3
- package/dist/index-DJSL_SCr.js.map +1 -0
- package/dist/{index-BCcLwgfe.js → index-XMII4H3d.js} +2 -2
- package/dist/{index-BCcLwgfe.js.map → index-XMII4H3d.js.map} +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +90 -295
- package/dist/index.js.map +1 -1
- package/dist/{util-ZM9gQuCv.js → util-0GYaJqL_.js} +153 -644
- package/dist/util-0GYaJqL_.js.map +1 -0
- package/package.json +8 -8
- package/src/collection_editor/pgColumnToProperty.ts +19 -2
- package/src/components/DefaultDrawer.tsx +2 -2
- package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +4 -4
- package/src/components/EntityCollectionView/EntityCollectionListView.tsx +7 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +4 -1
- package/src/components/RebaseRouteDefs.tsx +4 -6
- package/src/components/admin/index.ts +1 -3
- package/src/components/index.ts +1 -3
- package/src/hooks/navigation/useBuildNavigationStateController.tsx +2 -3
- package/src/hooks/navigation/useResolvedViews.tsx +6 -48
- package/src/index.ts +2 -3
- package/src/util/previews.ts +9 -1
- package/dist/CollectionEditorDialog-D0VqpLPO.js.map +0 -1
- package/dist/components/admin/RoleChip.d.ts +0 -4
- package/dist/components/admin/RolesFilterSelect.d.ts +0 -2
- package/dist/components/admin/RolesView.d.ts +0 -4
- package/dist/components/admin/UserRolesSelectField.d.ts +0 -2
- package/dist/components/admin/UsersView.d.ts +0 -4
- package/dist/index-DY2k5TtG.js.map +0 -1
- package/dist/index-UQOMHwt1.js.map +0 -1
- package/dist/util-ZM9gQuCv.js.map +0 -1
- package/src/components/admin/RoleChip.tsx +0 -23
- package/src/components/admin/RolesFilterSelect.tsx +0 -45
- package/src/components/admin/RolesView.tsx +0 -470
- package/src/components/admin/UserRolesSelectField.tsx +0 -50
- 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
|
-
}
|