@authhero/react-admin 0.10.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.
Files changed (110) hide show
  1. package/.eslintrc.js +21 -0
  2. package/.vercelignore +4 -0
  3. package/CHANGELOG.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +50 -0
  6. package/index.html +125 -0
  7. package/package.json +61 -0
  8. package/prettier.config.js +1 -0
  9. package/public/favicon.ico +0 -0
  10. package/public/manifest.json +15 -0
  11. package/src/App.spec.tsx +42 -0
  12. package/src/App.tsx +232 -0
  13. package/src/AuthCallback.tsx +138 -0
  14. package/src/Layout.tsx +12 -0
  15. package/src/TenantsApp.tsx +115 -0
  16. package/src/auth0DataProvider.ts +1242 -0
  17. package/src/authProvider.ts +521 -0
  18. package/src/components/CertificateErrorDialog.tsx +116 -0
  19. package/src/components/DomainSelector.tsx +401 -0
  20. package/src/components/TenantAppBar.tsx +83 -0
  21. package/src/components/TenantLayout.tsx +25 -0
  22. package/src/components/TenantsAppBar.tsx +21 -0
  23. package/src/components/TenantsLayout.tsx +28 -0
  24. package/src/components/activity/ActivityDashboard.tsx +381 -0
  25. package/src/components/activity/index.ts +1 -0
  26. package/src/components/branding/BrandingList.tsx +0 -0
  27. package/src/components/branding/BrandingShow.tsx +0 -0
  28. package/src/components/branding/ThemesTab.tsx +286 -0
  29. package/src/components/branding/edit.tsx +149 -0
  30. package/src/components/branding/hooks/useThemesData.ts +123 -0
  31. package/src/components/branding/index.ts +2 -0
  32. package/src/components/branding/list.tsx +12 -0
  33. package/src/components/clients/create.tsx +12 -0
  34. package/src/components/clients/edit.tsx +1285 -0
  35. package/src/components/clients/index.ts +3 -0
  36. package/src/components/clients/list.tsx +37 -0
  37. package/src/components/common/DateAgo.tsx +6 -0
  38. package/src/components/common/JsonOutput.tsx +26 -0
  39. package/src/components/common/index.ts +1 -0
  40. package/src/components/connections/create.tsx +35 -0
  41. package/src/components/connections/edit.tsx +212 -0
  42. package/src/components/connections/index.ts +3 -0
  43. package/src/components/connections/list.tsx +15 -0
  44. package/src/components/custom-domains/create.tsx +26 -0
  45. package/src/components/custom-domains/edit.tsx +101 -0
  46. package/src/components/custom-domains/index.ts +3 -0
  47. package/src/components/custom-domains/list.tsx +16 -0
  48. package/src/components/flows/create.tsx +30 -0
  49. package/src/components/flows/edit.tsx +238 -0
  50. package/src/components/flows/index.ts +3 -0
  51. package/src/components/flows/list.tsx +15 -0
  52. package/src/components/forms/FlowEditor.tsx +1363 -0
  53. package/src/components/forms/NodeEditor.tsx +1119 -0
  54. package/src/components/forms/RichTextEditor.tsx +145 -0
  55. package/src/components/forms/create.tsx +30 -0
  56. package/src/components/forms/edit.tsx +256 -0
  57. package/src/components/forms/index.ts +3 -0
  58. package/src/components/forms/list.tsx +16 -0
  59. package/src/components/hooks/create.tsx +96 -0
  60. package/src/components/hooks/edit.tsx +114 -0
  61. package/src/components/hooks/index.ts +3 -0
  62. package/src/components/hooks/list.tsx +17 -0
  63. package/src/components/listActions/PostListActions.tsx +10 -0
  64. package/src/components/logs/LogIcon.tsx +32 -0
  65. package/src/components/logs/LogShow.tsx +82 -0
  66. package/src/components/logs/LogType.tsx +38 -0
  67. package/src/components/logs/index.ts +4 -0
  68. package/src/components/logs/list.tsx +41 -0
  69. package/src/components/organizations/create.tsx +13 -0
  70. package/src/components/organizations/edit.tsx +682 -0
  71. package/src/components/organizations/index.ts +3 -0
  72. package/src/components/organizations/list.tsx +21 -0
  73. package/src/components/resource-servers/create.tsx +87 -0
  74. package/src/components/resource-servers/edit.tsx +121 -0
  75. package/src/components/resource-servers/index.ts +3 -0
  76. package/src/components/resource-servers/list.tsx +47 -0
  77. package/src/components/roles/create.tsx +12 -0
  78. package/src/components/roles/edit.tsx +426 -0
  79. package/src/components/roles/index.ts +3 -0
  80. package/src/components/roles/list.tsx +24 -0
  81. package/src/components/sessions/edit.tsx +101 -0
  82. package/src/components/sessions/index.ts +3 -0
  83. package/src/components/sessions/list.tsx +20 -0
  84. package/src/components/sessions/show.tsx +113 -0
  85. package/src/components/settings/edit.tsx +236 -0
  86. package/src/components/settings/index.ts +2 -0
  87. package/src/components/settings/list.tsx +14 -0
  88. package/src/components/tenants/create.tsx +20 -0
  89. package/src/components/tenants/edit.tsx +54 -0
  90. package/src/components/tenants/index.ts +2 -0
  91. package/src/components/tenants/list.tsx +67 -0
  92. package/src/components/themes/edit.tsx +200 -0
  93. package/src/components/themes/index.ts +2 -0
  94. package/src/components/themes/list.tsx +12 -0
  95. package/src/components/users/create.tsx +144 -0
  96. package/src/components/users/edit.tsx +1711 -0
  97. package/src/components/users/index.ts +3 -0
  98. package/src/components/users/list.tsx +35 -0
  99. package/src/data.json +121 -0
  100. package/src/dataProvider.ts +97 -0
  101. package/src/index.tsx +106 -0
  102. package/src/lib/logs.ts +21 -0
  103. package/src/types/reactflow.d.ts +86 -0
  104. package/src/utils/domainUtils.ts +169 -0
  105. package/src/utils/tokenUtils.ts +75 -0
  106. package/src/vite-env.d.ts +1 -0
  107. package/tsconfig.json +37 -0
  108. package/tsconfig.node.json +10 -0
  109. package/vercel.json +17 -0
  110. package/vite.config.ts +30 -0
@@ -0,0 +1,1711 @@
1
+ import React from "react";
2
+ import {
3
+ Datagrid,
4
+ DateField,
5
+ Edit,
6
+ FieldTitle,
7
+ Labeled,
8
+ Pagination,
9
+ ReferenceManyField,
10
+ TabbedForm,
11
+ TextField,
12
+ TextInput,
13
+ FunctionField,
14
+ BooleanField,
15
+ ArrayField,
16
+ SimpleShowLayout,
17
+ useNotify,
18
+ useDataProvider,
19
+ useRecordContext,
20
+ useRefresh,
21
+ FormDataConsumer,
22
+ } from "react-admin";
23
+ import { LogType, LogIcon } from "../logs";
24
+ import { DateAgo } from "../common";
25
+ import {
26
+ Stack,
27
+ Button,
28
+ Dialog,
29
+ DialogTitle,
30
+ DialogContent,
31
+ DialogActions,
32
+ List,
33
+ ListItem,
34
+ ListItemText,
35
+ TextField as MuiTextField,
36
+ Box,
37
+ Typography,
38
+ CircularProgress,
39
+ IconButton,
40
+ Tooltip,
41
+ DialogContentText,
42
+ Autocomplete,
43
+ } from "@mui/material";
44
+ import { JsonOutput } from "../common/JsonOutput";
45
+ import { useState } from "react";
46
+ import LinkIcon from "@mui/icons-material/Link";
47
+ import LinkOffIcon from "@mui/icons-material/LinkOff";
48
+ import SearchIcon from "@mui/icons-material/Search";
49
+ import AddIcon from "@mui/icons-material/Add";
50
+ import DeleteIcon from "@mui/icons-material/Delete";
51
+ import { useParams } from "react-router-dom";
52
+ import { useEffect } from "react";
53
+
54
+ const LinkUserButton = () => {
55
+ const [open, setOpen] = useState(false);
56
+ const [searchText, setSearchText] = useState("");
57
+ const [searching, setSearching] = useState(false);
58
+ const [searchResults, setSearchResults] = useState<any[]>([]);
59
+ const record = useRecordContext();
60
+ const dataProvider = useDataProvider();
61
+ const notify = useNotify();
62
+
63
+ const handleOpen = () => setOpen(true);
64
+ const handleClose = () => {
65
+ setOpen(false);
66
+ setSearchText("");
67
+ setSearchResults([]);
68
+ };
69
+
70
+ const handleSearch = async () => {
71
+ if (!searchText.trim()) return;
72
+
73
+ setSearching(true);
74
+ try {
75
+ // Search for users by email
76
+ const { data } = await dataProvider.getList("users", {
77
+ pagination: { page: 1, perPage: 10 },
78
+ sort: { field: "email", order: "ASC" },
79
+ filter: { q: searchText },
80
+ });
81
+
82
+ // Filter out the current user from results
83
+ const filteredData = data.filter((user) => user.id !== record?.id);
84
+ setSearchResults(filteredData);
85
+ } catch (error) {
86
+ console.error("Error searching for users:", error);
87
+ notify("Error searching for users", { type: "error" });
88
+ } finally {
89
+ setSearching(false);
90
+ }
91
+ };
92
+
93
+ const handleKeyPress = (e) => {
94
+ if (e.key === "Enter") {
95
+ handleSearch();
96
+ }
97
+ };
98
+
99
+ // Removed the condition that might hide the button
100
+ // if (!record) {
101
+ // return null;
102
+ // }
103
+
104
+ const handleLinkUser = async (userId) => {
105
+ if (!record) {
106
+ notify("Error: No user selected", { type: "error" });
107
+ return;
108
+ }
109
+
110
+ try {
111
+ // Changed API endpoint to link the current user TO the selected user instead
112
+ await dataProvider.create(`users/${userId}/identities`, {
113
+ data: { link_with: record.id },
114
+ });
115
+ notify("User linked successfully", { type: "success" });
116
+ handleClose();
117
+ // Refresh the current view
118
+ window.location.reload();
119
+ } catch (error) {
120
+ console.error("Error linking users:", error);
121
+ notify("Error linking users", { type: "error" });
122
+ }
123
+ };
124
+
125
+ return (
126
+ <>
127
+ <Button
128
+ variant="contained"
129
+ color="primary"
130
+ startIcon={<LinkIcon />}
131
+ onClick={handleOpen}
132
+ sx={{ mt: 2 }}
133
+ >
134
+ Link User
135
+ </Button>
136
+
137
+ <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
138
+ <DialogTitle>Link User</DialogTitle>
139
+ <DialogContent>
140
+ <Typography variant="body2" sx={{ mb: 2 }}>
141
+ Search for a user to link this user to
142
+ </Typography>
143
+
144
+ <Box sx={{ display: "flex", mb: 2 }}>
145
+ <MuiTextField
146
+ label="Search by email"
147
+ variant="outlined"
148
+ fullWidth
149
+ value={searchText}
150
+ onChange={(e) => setSearchText(e.target.value)}
151
+ onKeyPress={handleKeyPress}
152
+ sx={{ mr: 1 }}
153
+ />
154
+ <Button
155
+ variant="contained"
156
+ onClick={handleSearch}
157
+ startIcon={<SearchIcon />}
158
+ disabled={searching || !searchText.trim()}
159
+ >
160
+ Search
161
+ </Button>
162
+ </Box>
163
+
164
+ {searching ? (
165
+ <Box sx={{ display: "flex", justifyContent: "center", p: 2 }}>
166
+ <CircularProgress />
167
+ </Box>
168
+ ) : searchResults.length > 0 ? (
169
+ <List sx={{ width: "100%" }}>
170
+ {searchResults.map((user) => (
171
+ <ListItem
172
+ component="button"
173
+ key={user.id}
174
+ onClick={() => handleLinkUser(user.id)}
175
+ divider
176
+ >
177
+ <ListItemText
178
+ primary={user.email || user.phone_number || user.id}
179
+ secondary={`ID: ${user.id} | Connection: ${user.connection}`}
180
+ />
181
+ </ListItem>
182
+ ))}
183
+ </List>
184
+ ) : searchText && !searching ? (
185
+ <Typography color="textSecondary" align="center" sx={{ p: 2 }}>
186
+ No users found
187
+ </Typography>
188
+ ) : null}
189
+ </DialogContent>
190
+ <DialogActions>
191
+ <Button onClick={handleClose}>Cancel</Button>
192
+ </DialogActions>
193
+ </Dialog>
194
+ </>
195
+ );
196
+ };
197
+
198
+ const UnlinkButton = () => {
199
+ const [open, setOpen] = useState(false);
200
+ const dataProvider = useDataProvider();
201
+ const notify = useNotify();
202
+ const refresh = useRefresh();
203
+ const identity = useRecordContext();
204
+
205
+ // Get the user ID from the route params
206
+ const { id: userId } = useParams();
207
+
208
+ if (!identity || !userId || identity.provider === "auth0") {
209
+ // Don't allow unlinking the primary identity
210
+ return null;
211
+ }
212
+
213
+ const handleOpen = () => setOpen(true);
214
+ const handleClose = () => setOpen(false);
215
+
216
+ const handleUnlink = async () => {
217
+ try {
218
+ // Create the endpoint URL with proper parameters
219
+ const endpoint = `users/${userId}/identities/${identity.provider}/${identity.user_id}`;
220
+
221
+ await dataProvider.delete(endpoint, {
222
+ id: "stuff",
223
+ });
224
+ notify("Identity unlinked successfully", { type: "success" });
225
+ handleClose();
226
+ refresh();
227
+ } catch (error) {
228
+ console.error("Error unlinking identity:", error);
229
+ notify("Error unlinking identity", { type: "error" });
230
+ }
231
+ };
232
+
233
+ return (
234
+ <>
235
+ <Tooltip title="Unlink identity">
236
+ <IconButton onClick={handleOpen} color="error" size="small">
237
+ <LinkOffIcon />
238
+ </IconButton>
239
+ </Tooltip>
240
+
241
+ <Dialog open={open} onClose={handleClose}>
242
+ <DialogTitle>Unlink Identity</DialogTitle>
243
+ <DialogContent>
244
+ <DialogContentText>
245
+ Are you sure you want to unlink this identity ({identity.provider}/
246
+ {identity.user_id})? This action cannot be undone.
247
+ </DialogContentText>
248
+ </DialogContent>
249
+ <DialogActions>
250
+ <Button onClick={handleClose}>Cancel</Button>
251
+ <Button onClick={handleUnlink} color="error" autoFocus>
252
+ Unlink
253
+ </Button>
254
+ </DialogActions>
255
+ </Dialog>
256
+ </>
257
+ );
258
+ };
259
+
260
+ // Password change component for users with password connection
261
+ const PasswordChangeSection = () => {
262
+ const [open, setOpen] = useState(false);
263
+ const [newPassword, setNewPassword] = useState("");
264
+ const [confirmPassword, setConfirmPassword] = useState("");
265
+ const [saving, setSaving] = useState(false);
266
+ const record = useRecordContext();
267
+ const dataProvider = useDataProvider();
268
+ const notify = useNotify();
269
+ const refresh = useRefresh();
270
+
271
+ // Check if the user has a password identity (Username-Password-Authentication connection)
272
+ const hasPasswordIdentity =
273
+ record?.connection === "Username-Password-Authentication" ||
274
+ record?.identities?.some(
275
+ (i: any) => i.connection === "Username-Password-Authentication",
276
+ );
277
+
278
+ if (!hasPasswordIdentity || !record) {
279
+ return null;
280
+ }
281
+
282
+ const handleOpen = () => setOpen(true);
283
+ const handleClose = () => {
284
+ setOpen(false);
285
+ setNewPassword("");
286
+ setConfirmPassword("");
287
+ };
288
+
289
+ const handleSave = async () => {
290
+ if (!newPassword) {
291
+ notify("Please enter a new password", { type: "warning" });
292
+ return;
293
+ }
294
+
295
+ if (newPassword !== confirmPassword) {
296
+ notify("Passwords do not match", { type: "warning" });
297
+ return;
298
+ }
299
+
300
+ if (newPassword.length < 8) {
301
+ notify("Password must be at least 8 characters", { type: "warning" });
302
+ return;
303
+ }
304
+
305
+ setSaving(true);
306
+ try {
307
+ await dataProvider.update("users", {
308
+ id: record.id,
309
+ data: {
310
+ password: newPassword,
311
+ connection: "Username-Password-Authentication",
312
+ },
313
+ previousData: record,
314
+ });
315
+ notify("Password updated successfully", { type: "success" });
316
+ handleClose();
317
+ refresh();
318
+ } catch (error) {
319
+ console.error("Error updating password:", error);
320
+ notify("Error updating password", { type: "error" });
321
+ } finally {
322
+ setSaving(false);
323
+ }
324
+ };
325
+
326
+ return (
327
+ <Box sx={{ mt: 3, p: 2, border: "1px solid #e0e0e0", borderRadius: 1 }}>
328
+ <Typography variant="h6" fontWeight="bold" sx={{ mb: 2 }}>
329
+ Password
330
+ </Typography>
331
+ <Typography variant="body2" sx={{ mb: 2 }}>
332
+ This user has a password connection. You can update their password here.
333
+ </Typography>
334
+ <Button variant="contained" onClick={handleOpen}>
335
+ Change Password
336
+ </Button>
337
+
338
+ <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
339
+ <DialogTitle>Change Password</DialogTitle>
340
+ <DialogContent>
341
+ <Typography variant="body2" sx={{ mb: 2 }}>
342
+ Enter a new password for this user.
343
+ </Typography>
344
+ <MuiTextField
345
+ label="New Password"
346
+ type="password"
347
+ fullWidth
348
+ value={newPassword}
349
+ onChange={(e) => setNewPassword(e.target.value)}
350
+ sx={{ mb: 2, mt: 1 }}
351
+ autoComplete="new-password"
352
+ />
353
+ <MuiTextField
354
+ label="Confirm Password"
355
+ type="password"
356
+ fullWidth
357
+ value={confirmPassword}
358
+ onChange={(e) => setConfirmPassword(e.target.value)}
359
+ error={confirmPassword !== "" && newPassword !== confirmPassword}
360
+ helperText={
361
+ confirmPassword !== "" && newPassword !== confirmPassword
362
+ ? "Passwords do not match"
363
+ : ""
364
+ }
365
+ autoComplete="new-password"
366
+ />
367
+ </DialogContent>
368
+ <DialogActions>
369
+ <Button onClick={handleClose} disabled={saving}>
370
+ Cancel
371
+ </Button>
372
+ <Button
373
+ onClick={handleSave}
374
+ variant="contained"
375
+ disabled={saving || !newPassword || newPassword !== confirmPassword}
376
+ >
377
+ {saving ? <CircularProgress size={20} /> : "Save Password"}
378
+ </Button>
379
+ </DialogActions>
380
+ </Dialog>
381
+ </Box>
382
+ );
383
+ };
384
+
385
+ const AddPermissionButton = () => {
386
+ const [open, setOpen] = useState(false);
387
+ const [resourceServers, setResourceServers] = useState<any[]>([]);
388
+ const [selectedResourceServer, setSelectedResourceServer] =
389
+ useState<any>(null);
390
+ const [availablePermissions, setAvailablePermissions] = useState<any[]>([]);
391
+ const [selectedPermissions, setSelectedPermissions] = useState<any[]>([]);
392
+ const [loading, setLoading] = useState(false);
393
+ const [loadingPermissions, setLoadingPermissions] = useState(false);
394
+ const dataProvider = useDataProvider();
395
+ const notify = useNotify();
396
+ const refresh = useRefresh();
397
+
398
+ // Get the user ID from the URL path
399
+ const urlPath = window.location.pathname;
400
+ const matches = urlPath.match(/\/([^/]+)\/users\/([^/]+)/);
401
+ const userId = matches ? matches[2] : null;
402
+
403
+ const handleOpen = async () => {
404
+ setOpen(true);
405
+ await loadResourceServers();
406
+ };
407
+
408
+ const handleClose = () => {
409
+ setOpen(false);
410
+ setSelectedResourceServer(null);
411
+ setAvailablePermissions([]);
412
+ setSelectedPermissions([]);
413
+ };
414
+
415
+ const loadResourceServers = async () => {
416
+ setLoading(true);
417
+ try {
418
+ const { data } = await dataProvider.getList("resource-servers", {
419
+ pagination: { page: 1, perPage: 100 },
420
+ sort: { field: "name", order: "ASC" },
421
+ filter: {},
422
+ });
423
+ setResourceServers(data);
424
+ } catch (error) {
425
+ console.error("Error loading resource servers:", error);
426
+ notify("Error loading resource servers", { type: "error" });
427
+ } finally {
428
+ setLoading(false);
429
+ }
430
+ };
431
+
432
+ const loadPermissions = async (resourceServer: any) => {
433
+ setLoadingPermissions(true);
434
+ try {
435
+ // Build available scopes from the selected resource server's property
436
+ const allScopes = (resourceServer?.scopes || []).map((s: any) => ({
437
+ permission_name: s?.permission_name ?? s?.value ?? s,
438
+ description: s?.description ?? "",
439
+ }));
440
+
441
+ // Fetch the user's existing permissions from /users/:id/permissions
442
+ const existingRes = await dataProvider.getList(
443
+ `users/${userId}/permissions`,
444
+ {
445
+ pagination: { page: 1, perPage: 200 },
446
+ sort: { field: "permission_name", order: "ASC" },
447
+ filter: {},
448
+ },
449
+ );
450
+
451
+ const existingAll = existingRes.data ?? [];
452
+ // Narrow to the selected resource server
453
+ const existingForServer = existingAll.filter((p: any) => {
454
+ const identifier =
455
+ p.resource_server_identifier ?? p.resource_server_id ?? p.audience;
456
+ return identifier === resourceServer?.identifier;
457
+ });
458
+
459
+ const existingSet = new Set(
460
+ existingForServer.map((p: any) => p.permission_name),
461
+ );
462
+
463
+ // Filter out scopes the user already has
464
+ const filtered = allScopes.filter(
465
+ (p: any) => p.permission_name && !existingSet.has(p.permission_name),
466
+ );
467
+
468
+ setAvailablePermissions(filtered);
469
+ } catch (error) {
470
+ console.error("Error loading permissions:", error);
471
+ notify("Error loading permissions", { type: "error" });
472
+ } finally {
473
+ setLoadingPermissions(false);
474
+ }
475
+ };
476
+
477
+ const handleResourceServerChange = (resourceServer: any) => {
478
+ setSelectedResourceServer(resourceServer);
479
+ setSelectedPermissions([]);
480
+ if (resourceServer) {
481
+ loadPermissions(resourceServer);
482
+ } else {
483
+ setAvailablePermissions([]);
484
+ }
485
+ };
486
+
487
+ const handleAddPermissions = async () => {
488
+ if (!userId || selectedPermissions.length === 0) {
489
+ notify("Please select at least one permission", { type: "warning" });
490
+ return;
491
+ }
492
+
493
+ try {
494
+ // Send permissions in a single payload as an array
495
+ const payload = {
496
+ permissions: selectedPermissions.map((permission: any) => ({
497
+ permission_name: permission.permission_name,
498
+ resource_server_identifier: selectedResourceServer.identifier,
499
+ })),
500
+ };
501
+
502
+ await dataProvider.create(`users/${userId}/permissions`, {
503
+ data: payload,
504
+ });
505
+
506
+ notify(`${selectedPermissions.length} permission(s) added successfully`, {
507
+ type: "success",
508
+ });
509
+ handleClose();
510
+ refresh();
511
+ } catch (error) {
512
+ console.error("Error adding permissions:", error);
513
+ notify("Error adding permissions", { type: "error" });
514
+ }
515
+ };
516
+
517
+ if (!userId) {
518
+ return null;
519
+ }
520
+
521
+ return (
522
+ <>
523
+ <Button
524
+ variant="contained"
525
+ color="primary"
526
+ startIcon={<AddIcon />}
527
+ onClick={handleOpen}
528
+ sx={{ mb: 2 }}
529
+ >
530
+ Add Permission
531
+ </Button>
532
+
533
+ <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
534
+ <DialogTitle>Add Permissions</DialogTitle>
535
+ <DialogContent>
536
+ <Typography variant="body2" sx={{ mb: 3 }}>
537
+ Select a resource server and permissions to assign to this user
538
+ </Typography>
539
+
540
+ <Box sx={{ mb: 3 }}>
541
+ <Autocomplete
542
+ options={resourceServers}
543
+ getOptionLabel={(option) => option.name || option.identifier}
544
+ value={selectedResourceServer}
545
+ onChange={(_, value) => handleResourceServerChange(value)}
546
+ loading={loading}
547
+ isOptionEqualToValue={(option, value) =>
548
+ !!option &&
549
+ !!value &&
550
+ (option.id === value.id ||
551
+ option.identifier === value.identifier ||
552
+ option.audience === value.audience)
553
+ }
554
+ renderInput={(params) => (
555
+ <MuiTextField
556
+ {...params}
557
+ label="Resource Server"
558
+ variant="outlined"
559
+ fullWidth
560
+ InputProps={{
561
+ ...params.InputProps,
562
+ endAdornment: (
563
+ <>
564
+ {loading ? (
565
+ <CircularProgress color="inherit" size={20} />
566
+ ) : null}
567
+ {params.InputProps.endAdornment}
568
+ </>
569
+ ),
570
+ }}
571
+ />
572
+ )}
573
+ />
574
+ </Box>
575
+
576
+ {selectedResourceServer && (
577
+ <>
578
+ <Box sx={{ mb: 3 }}>
579
+ <Autocomplete
580
+ multiple
581
+ options={availablePermissions}
582
+ getOptionLabel={(option) =>
583
+ `${option.permission_name} - ${option.description || "No description"}`
584
+ }
585
+ value={selectedPermissions}
586
+ onChange={(_, value) => setSelectedPermissions(value)}
587
+ loading={loadingPermissions}
588
+ isOptionEqualToValue={(option, value) =>
589
+ option?.permission_name === value?.permission_name
590
+ }
591
+ renderInput={(params) => (
592
+ <MuiTextField
593
+ {...params}
594
+ label="Permissions"
595
+ variant="outlined"
596
+ fullWidth
597
+ InputProps={{
598
+ ...params.InputProps,
599
+ endAdornment: (
600
+ <>
601
+ {loadingPermissions ? (
602
+ <CircularProgress color="inherit" size={20} />
603
+ ) : null}
604
+ {params.InputProps.endAdornment}
605
+ </>
606
+ ),
607
+ }}
608
+ />
609
+ )}
610
+ renderOption={(props, option) => (
611
+ <li {...props} key={option.permission_name}>
612
+ <Box>
613
+ <Typography variant="body2" fontWeight="medium">
614
+ {option.permission_name}
615
+ </Typography>
616
+ {option.description && (
617
+ <Typography variant="caption" color="text.secondary">
618
+ {option.description}
619
+ </Typography>
620
+ )}
621
+ </Box>
622
+ </li>
623
+ )}
624
+ />
625
+ </Box>
626
+
627
+ {!loadingPermissions && availablePermissions.length === 0 && (
628
+ <Typography
629
+ variant="body2"
630
+ color="text.secondary"
631
+ sx={{ mb: 2 }}
632
+ >
633
+ This user already has all available scopes for the selected
634
+ resource server.
635
+ </Typography>
636
+ )}
637
+
638
+ {selectedPermissions.length > 0 && (
639
+ <Box sx={{ mt: 2 }}>
640
+ <Typography variant="subtitle2" sx={{ mb: 1 }}>
641
+ Selected Permissions ({selectedPermissions.length}):
642
+ </Typography>
643
+ <Box sx={{ maxHeight: 200, overflow: "auto" }}>
644
+ {selectedPermissions.map((permission, index) => (
645
+ <Typography key={index} variant="body2" sx={{ ml: 2 }}>
646
+ • {permission.permission_name}
647
+ </Typography>
648
+ ))}
649
+ </Box>
650
+ </Box>
651
+ )}
652
+ </>
653
+ )}
654
+ </DialogContent>
655
+ <DialogActions>
656
+ <Button onClick={handleClose}>Cancel</Button>
657
+ <Button
658
+ onClick={handleAddPermissions}
659
+ variant="contained"
660
+ disabled={selectedPermissions.length === 0}
661
+ >
662
+ Add {selectedPermissions.length} Permission(s)
663
+ </Button>
664
+ </DialogActions>
665
+ </Dialog>
666
+ </>
667
+ );
668
+ };
669
+
670
+ const RemovePermissionButton = () => {
671
+ const [open, setOpen] = useState(false);
672
+ const permission = useRecordContext();
673
+ const dataProvider = useDataProvider();
674
+ const notify = useNotify();
675
+ const refresh = useRefresh();
676
+
677
+ // Get the user id from the route params (e.g. /:tenantId/users/:id)
678
+ const { id: userId } = useParams();
679
+
680
+ if (!permission || !userId) {
681
+ return null;
682
+ }
683
+
684
+ const handleOpen = () => setOpen(true);
685
+ const handleClose = () => setOpen(false);
686
+
687
+ const handleRemove = async () => {
688
+ try {
689
+ // Build the permission id and URL-encode it for safe path usage
690
+ const permissionId = encodeURIComponent(
691
+ `${permission.resource_server_identifier}:${permission.permission_name}`,
692
+ );
693
+
694
+ await dataProvider.delete(`users/${userId}/permissions`, {
695
+ id: permissionId,
696
+ previousData: permission,
697
+ });
698
+ notify("Permission removed successfully", { type: "success" });
699
+ handleClose();
700
+ refresh();
701
+ } catch (error) {
702
+ console.error("Error removing permission:", error);
703
+ notify("Error removing permission", { type: "error" });
704
+ }
705
+ };
706
+
707
+ return (
708
+ <>
709
+ <Tooltip title="Remove permission">
710
+ <IconButton onClick={handleOpen} color="error" size="small">
711
+ <DeleteIcon />
712
+ </IconButton>
713
+ </Tooltip>
714
+
715
+ <Dialog open={open} onClose={handleClose}>
716
+ <DialogTitle>Remove Permission</DialogTitle>
717
+ <DialogContent>
718
+ <DialogContentText>
719
+ Are you sure you want to remove the permission "
720
+ {permission.permission_name}" from resource server "
721
+ {permission.resource_server_name ||
722
+ permission.resource_server_identifier}
723
+ "? This action cannot be undone.
724
+ </DialogContentText>
725
+ </DialogContent>
726
+ <DialogActions>
727
+ <Button onClick={handleClose}>Cancel</Button>
728
+ <Button onClick={handleRemove} color="error" autoFocus>
729
+ Remove
730
+ </Button>
731
+ </DialogActions>
732
+ </Dialog>
733
+ </>
734
+ );
735
+ };
736
+
737
+ // Metadata card for editing user and app metadata
738
+ const MetadataCard = () => {
739
+ return (
740
+ <FormDataConsumer>
741
+ {() => (
742
+ <Box sx={{ mt: 3, p: 2, border: "1px solid #e0e0e0", borderRadius: 1 }}>
743
+ <Typography variant="h6" fontWeight="bold" sx={{ mb: 2 }}>
744
+ Metadata
745
+ </Typography>
746
+
747
+ <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
748
+ <Box>
749
+ <Typography
750
+ variant="subtitle2"
751
+ sx={{ mb: 1, fontWeight: "bold" }}
752
+ >
753
+ User Metadata
754
+ </Typography>
755
+ <TextInput
756
+ source="user_metadata"
757
+ multiline
758
+ fullWidth
759
+ rows={8}
760
+ variant="outlined"
761
+ size="small"
762
+ sx={{ fontFamily: "monospace", fontSize: "0.85em" }}
763
+ parse={(v) => {
764
+ if (!v || v.trim() === "") return {};
765
+ if (typeof v === "string") {
766
+ try {
767
+ return JSON.parse(v);
768
+ } catch (e) {
769
+ console.warn(
770
+ "Invalid JSON in user_metadata, keeping as string:",
771
+ e,
772
+ );
773
+ return v; // Keep as string if invalid JSON
774
+ }
775
+ }
776
+ return v || {};
777
+ }}
778
+ format={(v) => {
779
+ if (
780
+ !v ||
781
+ (typeof v === "object" && Object.keys(v).length === 0)
782
+ ) {
783
+ return "{}";
784
+ }
785
+ if (typeof v === "string") {
786
+ // Try to format if it's valid JSON
787
+ try {
788
+ const parsed = JSON.parse(v);
789
+ return JSON.stringify(parsed, null, 2);
790
+ } catch {
791
+ return v; // Keep as-is if not valid JSON
792
+ }
793
+ }
794
+ return JSON.stringify(v, null, 2);
795
+ }}
796
+ />
797
+ </Box>
798
+
799
+ <Box>
800
+ <Typography
801
+ variant="subtitle2"
802
+ sx={{ mb: 1, fontWeight: "bold" }}
803
+ >
804
+ App Metadata
805
+ </Typography>
806
+ <TextInput
807
+ source="app_metadata"
808
+ multiline
809
+ fullWidth
810
+ rows={8}
811
+ variant="outlined"
812
+ size="small"
813
+ sx={{ fontFamily: "monospace", fontSize: "0.85em" }}
814
+ parse={(v) => {
815
+ if (!v || v.trim() === "") return {};
816
+ if (typeof v === "string") {
817
+ try {
818
+ return JSON.parse(v);
819
+ } catch (e) {
820
+ console.warn(
821
+ "Invalid JSON in app_metadata, keeping as string:",
822
+ e,
823
+ );
824
+ return v; // Keep as string if invalid JSON
825
+ }
826
+ }
827
+ return v || {};
828
+ }}
829
+ format={(v) => {
830
+ if (
831
+ !v ||
832
+ (typeof v === "object" && Object.keys(v).length === 0)
833
+ ) {
834
+ return "{}";
835
+ }
836
+ if (typeof v === "string") {
837
+ // Try to format if it's valid JSON
838
+ try {
839
+ const parsed = JSON.parse(v);
840
+ return JSON.stringify(parsed, null, 2);
841
+ } catch {
842
+ return v; // Keep as-is if not valid JSON
843
+ }
844
+ }
845
+ return JSON.stringify(v, null, 2);
846
+ }}
847
+ />
848
+ </Box>
849
+ </Box>
850
+ </Box>
851
+ )}
852
+ </FormDataConsumer>
853
+ );
854
+ };
855
+
856
+ // Custom component to display user roles with organization context
857
+ const UserRolesTable = ({
858
+ onRolesChanged,
859
+ }: {
860
+ onRolesChanged?: () => void;
861
+ }) => {
862
+ const [roles, setRoles] = useState<any[]>([]);
863
+ const [loading, setLoading] = useState(true);
864
+ const dataProvider = useDataProvider();
865
+ const notify = useNotify();
866
+ const refresh = useRefresh();
867
+ const { id: userId } = useParams();
868
+
869
+ const loadRolesAndOrganizations = async () => {
870
+ if (!userId) return;
871
+
872
+ setLoading(true);
873
+ try {
874
+ // Load user organizations first
875
+ const orgsRes = await dataProvider.getList(
876
+ `users/${userId}/organizations`,
877
+ {
878
+ pagination: { page: 1, perPage: 1000 },
879
+ sort: { field: "name", order: "ASC" },
880
+ filter: {},
881
+ },
882
+ );
883
+ const userOrganizations = orgsRes?.data ?? [];
884
+
885
+ // Load global roles
886
+ const globalRolesRes = await dataProvider.getList(
887
+ `users/${userId}/roles`,
888
+ {
889
+ pagination: { page: 1, perPage: 1000 },
890
+ sort: { field: "name", order: "ASC" },
891
+ filter: {},
892
+ },
893
+ );
894
+ const globalRoles = (globalRolesRes?.data ?? []).map((role: any) => ({
895
+ ...role,
896
+ organization_id: null,
897
+ organization_name: "Global",
898
+ role_context: "global",
899
+ }));
900
+
901
+ // Load organization-specific roles
902
+ const orgRoles: any[] = [];
903
+ for (const org of userOrganizations) {
904
+ try {
905
+ const orgRolesRes = await dataProvider.getList(
906
+ `organizations/${org.id}/members/${userId}/roles`,
907
+ {
908
+ pagination: { page: 1, perPage: 1000 },
909
+ sort: { field: "name", order: "ASC" },
910
+ filter: {},
911
+ },
912
+ );
913
+ const rolesWithOrg = (orgRolesRes?.data ?? []).map((role: any) => ({
914
+ ...role,
915
+ organization_id: org.id,
916
+ organization_name: org.display_name || org.name || org.id,
917
+ role_context: "organization",
918
+ }));
919
+ orgRoles.push(...rolesWithOrg);
920
+ } catch (error) {
921
+ console.error(
922
+ `Error loading roles for organization ${org.id}:`,
923
+ error,
924
+ );
925
+ }
926
+ }
927
+
928
+ // Combine global and organization roles and deduplicate
929
+ const allRoles = [...globalRoles, ...orgRoles];
930
+
931
+ // Deduplicate roles by creating a unique key combining role ID and organization context
932
+ const roleMap = new Map();
933
+ allRoles.forEach((role) => {
934
+ const key = `${role.id}-${role.organization_id || "global"}`;
935
+ roleMap.set(key, role);
936
+ });
937
+ const deduplicatedRoles = Array.from(roleMap.values());
938
+
939
+ setRoles(deduplicatedRoles);
940
+ } catch (error) {
941
+ console.error("Error loading roles and organizations:", error);
942
+ notify("Error loading roles", { type: "error" });
943
+ } finally {
944
+ setLoading(false);
945
+ }
946
+ };
947
+
948
+ const handleRemoveRole = async (role: any) => {
949
+ try {
950
+ if (role.role_context === "global") {
951
+ // Remove global role
952
+ await dataProvider.delete(`users/${userId}/roles`, {
953
+ id: role.id,
954
+ previousData: role,
955
+ });
956
+ } else {
957
+ // Remove organization-specific role using the dataProvider
958
+ await dataProvider.delete(
959
+ `organizations/${role.organization_id}/members/${userId}/roles`,
960
+ {
961
+ id: role.id,
962
+ previousData: { id: role.id, roles: [role.id] },
963
+ },
964
+ );
965
+ }
966
+ notify("Role removed successfully", { type: "success" });
967
+ loadRolesAndOrganizations(); // Refresh the table
968
+ refresh();
969
+ onRolesChanged?.();
970
+ } catch (error) {
971
+ console.error("Error removing role:", error);
972
+ notify("Error removing role", { type: "error" });
973
+ }
974
+ };
975
+
976
+ useEffect(() => {
977
+ loadRolesAndOrganizations();
978
+ }, [userId]);
979
+
980
+ if (loading) {
981
+ return (
982
+ <Box display="flex" justifyContent="center" p={2}>
983
+ <CircularProgress />
984
+ </Box>
985
+ );
986
+ }
987
+
988
+ return (
989
+ <Box sx={{ mt: 2 }}>
990
+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
991
+ <thead>
992
+ <tr style={{ backgroundColor: "#f5f5f5" }}>
993
+ <th
994
+ style={{
995
+ padding: "12px",
996
+ textAlign: "left",
997
+ borderBottom: "1px solid #ddd",
998
+ }}
999
+ >
1000
+ Role
1001
+ </th>
1002
+ <th
1003
+ style={{
1004
+ padding: "12px",
1005
+ textAlign: "left",
1006
+ borderBottom: "1px solid #ddd",
1007
+ }}
1008
+ >
1009
+ Description
1010
+ </th>
1011
+ <th
1012
+ style={{
1013
+ padding: "12px",
1014
+ textAlign: "left",
1015
+ borderBottom: "1px solid #ddd",
1016
+ }}
1017
+ >
1018
+ Organization
1019
+ </th>
1020
+ <th
1021
+ style={{
1022
+ padding: "12px",
1023
+ textAlign: "left",
1024
+ borderBottom: "1px solid #ddd",
1025
+ }}
1026
+ >
1027
+ ID
1028
+ </th>
1029
+ <th
1030
+ style={{
1031
+ padding: "12px",
1032
+ textAlign: "left",
1033
+ borderBottom: "1px solid #ddd",
1034
+ }}
1035
+ >
1036
+ Actions
1037
+ </th>
1038
+ </tr>
1039
+ </thead>
1040
+ <tbody>
1041
+ {roles.map((role) => (
1042
+ <tr
1043
+ key={`${role.id}-${role.organization_id || "global"}`}
1044
+ style={{ borderBottom: "1px solid #eee" }}
1045
+ >
1046
+ <td style={{ padding: "12px" }}>{role.name}</td>
1047
+ <td style={{ padding: "12px" }}>{role.description || "-"}</td>
1048
+ <td style={{ padding: "12px" }}>
1049
+ <span
1050
+ style={{
1051
+ color: role.role_context === "global" ? "#666" : "#1976d2",
1052
+ fontStyle:
1053
+ role.role_context === "global" ? "italic" : "normal",
1054
+ }}
1055
+ >
1056
+ {role.organization_name}
1057
+ </span>
1058
+ </td>
1059
+ <td
1060
+ style={{
1061
+ padding: "12px",
1062
+ fontFamily: "monospace",
1063
+ fontSize: "0.9em",
1064
+ }}
1065
+ >
1066
+ {role.id}
1067
+ </td>
1068
+ <td style={{ padding: "12px" }}>
1069
+ <IconButton
1070
+ onClick={() => handleRemoveRole(role)}
1071
+ color="error"
1072
+ size="small"
1073
+ title="Remove Role"
1074
+ >
1075
+ <DeleteIcon />
1076
+ </IconButton>
1077
+ </td>
1078
+ </tr>
1079
+ ))}
1080
+ {roles.length === 0 && (
1081
+ <tr>
1082
+ <td
1083
+ colSpan={5}
1084
+ style={{ padding: "24px", textAlign: "center", color: "#666" }}
1085
+ >
1086
+ No roles assigned
1087
+ </td>
1088
+ </tr>
1089
+ )}
1090
+ </tbody>
1091
+ </table>
1092
+ </Box>
1093
+ );
1094
+ };
1095
+
1096
+ // Add roles management: add roles and remove roles for a user
1097
+ const AddRoleButton = ({ onRolesChanged }: { onRolesChanged?: () => void }) => {
1098
+ const [open, setOpen] = useState(false);
1099
+ const [availableRoles, setAvailableRoles] = useState<any[]>([]);
1100
+ const [selectedRoles, setSelectedRoles] = useState<any[]>([]);
1101
+ const [userOrganizations, setUserOrganizations] = useState<any[]>([]);
1102
+ const [selectedOrganization, setSelectedOrganization] = useState<any>(null);
1103
+ const [loading, setLoading] = useState(false);
1104
+ const dataProvider = useDataProvider();
1105
+ const notify = useNotify();
1106
+ const refresh = useRefresh();
1107
+
1108
+ const { id: userId } = useParams();
1109
+
1110
+ // Global option for the organization dropdown
1111
+ const globalOption = {
1112
+ id: "global",
1113
+ name: "Global (No Organization)",
1114
+ display_name: "Global Roles",
1115
+ };
1116
+
1117
+ const handleOpen = async () => {
1118
+ setOpen(true);
1119
+ await loadUserOrganizations();
1120
+ setSelectedOrganization(globalOption); // Default to global
1121
+ await loadRoles(null); // Load global roles by default
1122
+ };
1123
+
1124
+ const handleClose = () => {
1125
+ setOpen(false);
1126
+ setSelectedRoles([]);
1127
+ setSelectedOrganization(null);
1128
+ setUserOrganizations([]);
1129
+ };
1130
+
1131
+ const loadUserOrganizations = async () => {
1132
+ if (!userId) return;
1133
+ try {
1134
+ const res = await dataProvider.getList(`users/${userId}/organizations`, {
1135
+ pagination: { page: 1, perPage: 1000 },
1136
+ sort: { field: "name", order: "ASC" },
1137
+ filter: {},
1138
+ });
1139
+ setUserOrganizations(res?.data ?? []);
1140
+ } catch (error) {
1141
+ console.error("Error loading user organizations:", error);
1142
+ // Don't show error notification as this is not critical
1143
+ }
1144
+ };
1145
+
1146
+ const loadRoles = async (organizationId: string | null = null) => {
1147
+ if (!userId) return;
1148
+ setLoading(true);
1149
+ try {
1150
+ let allRoles: any[] = [];
1151
+ let assignedRoles: any[] = [];
1152
+
1153
+ if (organizationId && organizationId !== "global") {
1154
+ // Load organization roles
1155
+ const allRes = await dataProvider.getList(
1156
+ `organizations/${organizationId}/roles`,
1157
+ {
1158
+ pagination: { page: 1, perPage: 1000 },
1159
+ sort: { field: "name", order: "ASC" },
1160
+ filter: {},
1161
+ },
1162
+ );
1163
+ allRoles = allRes?.data ?? [];
1164
+
1165
+ // Load roles already assigned to user in this organization
1166
+ const assignedRes = await dataProvider.getList(
1167
+ `organizations/${organizationId}/members/${userId}/roles`,
1168
+ {
1169
+ pagination: { page: 1, perPage: 1000 },
1170
+ sort: { field: "name", order: "ASC" },
1171
+ filter: {},
1172
+ },
1173
+ );
1174
+ assignedRoles = assignedRes?.data ?? [];
1175
+ } else {
1176
+ // Load global roles
1177
+ const allRes = await dataProvider.getList("roles", {
1178
+ pagination: { page: 1, perPage: 1000 },
1179
+ sort: { field: "name", order: "ASC" },
1180
+ filter: {},
1181
+ });
1182
+ allRoles = allRes?.data ?? [];
1183
+
1184
+ // Load roles already assigned to user globally
1185
+ const assignedRes = await dataProvider.getList(
1186
+ `users/${userId}/roles`,
1187
+ {
1188
+ pagination: { page: 1, perPage: 1000 },
1189
+ sort: { field: "name", order: "ASC" },
1190
+ filter: {},
1191
+ },
1192
+ );
1193
+ assignedRoles = assignedRes?.data ?? [];
1194
+ }
1195
+
1196
+ const assignedSet = new Set(assignedRoles.map((r: any) => r.id));
1197
+ const available = allRoles.filter((r: any) => !assignedSet.has(r.id));
1198
+ setAvailableRoles(available);
1199
+ } catch (error) {
1200
+ console.error("Error loading roles:", error);
1201
+ notify("Error loading roles", { type: "error" });
1202
+ } finally {
1203
+ setLoading(false);
1204
+ }
1205
+ };
1206
+
1207
+ const handleOrganizationChange = (organization: any) => {
1208
+ setSelectedOrganization(organization);
1209
+ setSelectedRoles([]);
1210
+ const orgId = organization?.id === "global" ? null : organization?.id;
1211
+ loadRoles(orgId);
1212
+ };
1213
+
1214
+ const handleAddRoles = async () => {
1215
+ if (!userId || selectedRoles.length === 0) {
1216
+ notify("Please select at least one role", { type: "warning" });
1217
+ return;
1218
+ }
1219
+
1220
+ try {
1221
+ const payload = { roles: selectedRoles.map((r: any) => r.id) };
1222
+
1223
+ if (selectedOrganization?.id === "global") {
1224
+ // Add global roles
1225
+ await dataProvider.create(`users/${userId}/roles`, { data: payload });
1226
+ } else {
1227
+ // Add organization-specific roles
1228
+ await dataProvider.create(
1229
+ `organizations/${selectedOrganization.id}/members/${userId}/roles`,
1230
+ {
1231
+ data: payload,
1232
+ },
1233
+ );
1234
+ }
1235
+
1236
+ const context =
1237
+ selectedOrganization?.id === "global"
1238
+ ? "globally"
1239
+ : `to organization "${selectedOrganization?.name}"`;
1240
+ notify(`${selectedRoles.length} role(s) added ${context} successfully`, {
1241
+ type: "success",
1242
+ });
1243
+ handleClose();
1244
+ refresh();
1245
+ onRolesChanged?.();
1246
+ } catch (error) {
1247
+ console.error("Error adding roles:", error);
1248
+ notify("Error adding roles", { type: "error" });
1249
+ }
1250
+ };
1251
+
1252
+ if (!userId) return null;
1253
+
1254
+ // Create organization options: global + user's organizations
1255
+ const organizationOptions = [globalOption, ...userOrganizations];
1256
+ const showOrganizationDropdown = userOrganizations.length > 0;
1257
+
1258
+ return (
1259
+ <>
1260
+ <Button
1261
+ variant="contained"
1262
+ color="primary"
1263
+ startIcon={<AddIcon />}
1264
+ onClick={handleOpen}
1265
+ sx={{ mb: 2 }}
1266
+ >
1267
+ Add Role
1268
+ </Button>
1269
+
1270
+ <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
1271
+ <DialogTitle>Add Roles</DialogTitle>
1272
+ <DialogContent>
1273
+ <Typography variant="body2" sx={{ mb: 3 }}>
1274
+ Select one or more roles to assign to this user
1275
+ </Typography>
1276
+
1277
+ {showOrganizationDropdown && (
1278
+ <Box sx={{ mb: 3 }}>
1279
+ <Autocomplete
1280
+ options={organizationOptions}
1281
+ getOptionLabel={(option) =>
1282
+ option.display_name || option.name || option.id
1283
+ }
1284
+ value={selectedOrganization}
1285
+ onChange={(_, value) => handleOrganizationChange(value)}
1286
+ isOptionEqualToValue={(option, value) =>
1287
+ option?.id === value?.id
1288
+ }
1289
+ renderInput={(params) => (
1290
+ <MuiTextField
1291
+ {...params}
1292
+ label="Organization"
1293
+ variant="outlined"
1294
+ fullWidth
1295
+ />
1296
+ )}
1297
+ renderOption={(props, option) => (
1298
+ <li {...props} key={option.id}>
1299
+ <Box>
1300
+ <Typography variant="body2" fontWeight="medium">
1301
+ {option.display_name || option.name || option.id}
1302
+ </Typography>
1303
+ {option.id === "global" ? (
1304
+ <Typography variant="caption" color="text.secondary">
1305
+ Roles that apply across all organizations
1306
+ </Typography>
1307
+ ) : (
1308
+ <Typography variant="caption" color="text.secondary">
1309
+ Organization ID: {option.id}
1310
+ </Typography>
1311
+ )}
1312
+ </Box>
1313
+ </li>
1314
+ )}
1315
+ />
1316
+ </Box>
1317
+ )}
1318
+
1319
+ <Autocomplete
1320
+ multiple
1321
+ options={availableRoles}
1322
+ getOptionLabel={(option) => option.name || option.id}
1323
+ value={selectedRoles}
1324
+ onChange={(_, value) => setSelectedRoles(value)}
1325
+ loading={loading}
1326
+ isOptionEqualToValue={(option, value) => option?.id === value?.id}
1327
+ renderInput={(params) => (
1328
+ <MuiTextField
1329
+ {...params}
1330
+ label="Roles"
1331
+ variant="outlined"
1332
+ fullWidth
1333
+ InputProps={{
1334
+ ...params.InputProps,
1335
+ endAdornment: (
1336
+ <>
1337
+ {loading ? (
1338
+ <CircularProgress color="inherit" size={20} />
1339
+ ) : null}
1340
+ {params.InputProps.endAdornment}
1341
+ </>
1342
+ ),
1343
+ }}
1344
+ />
1345
+ )}
1346
+ renderOption={(props, option) => (
1347
+ <li {...props} key={option.id}>
1348
+ <Box>
1349
+ <Typography variant="body2" fontWeight="medium">
1350
+ {option.name || option.id}
1351
+ </Typography>
1352
+ {option.description && (
1353
+ <Typography variant="caption" color="text.secondary">
1354
+ {option.description}
1355
+ </Typography>
1356
+ )}
1357
+ </Box>
1358
+ </li>
1359
+ )}
1360
+ />
1361
+
1362
+ {!loading && availableRoles.length === 0 && selectedOrganization && (
1363
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
1364
+ This user already has all available roles{" "}
1365
+ {selectedOrganization.id === "global"
1366
+ ? "globally"
1367
+ : `in "${selectedOrganization.name}"`}
1368
+ .
1369
+ </Typography>
1370
+ )}
1371
+
1372
+ {selectedOrganization && (
1373
+ <Typography
1374
+ variant="caption"
1375
+ color="text.secondary"
1376
+ sx={{ mt: 1, display: "block" }}
1377
+ >
1378
+ {selectedOrganization.id === "global"
1379
+ ? "These roles will be assigned globally (not tied to any specific organization)"
1380
+ : `These roles will be assigned within the "${selectedOrganization.name}" organization`}
1381
+ </Typography>
1382
+ )}
1383
+ </DialogContent>
1384
+ <DialogActions>
1385
+ <Button onClick={handleClose}>Cancel</Button>
1386
+ <Button
1387
+ onClick={handleAddRoles}
1388
+ variant="contained"
1389
+ disabled={selectedRoles.length === 0 || !selectedOrganization}
1390
+ >
1391
+ Add {selectedRoles.length} Role(s)
1392
+ </Button>
1393
+ </DialogActions>
1394
+ </Dialog>
1395
+ </>
1396
+ );
1397
+ };
1398
+
1399
+ // Combined component for roles management
1400
+ const RolesManagement = () => {
1401
+ const [refreshKey, setRefreshKey] = useState(0);
1402
+
1403
+ const handleRolesChanged = () => {
1404
+ setRefreshKey((prev) => prev + 1);
1405
+ };
1406
+
1407
+ return (
1408
+ <>
1409
+ <AddRoleButton onRolesChanged={handleRolesChanged} />
1410
+ <UserRolesTable key={refreshKey} onRolesChanged={handleRolesChanged} />
1411
+ </>
1412
+ );
1413
+ };
1414
+
1415
+ export function UserEdit() {
1416
+ return (
1417
+ <Edit>
1418
+ <SimpleShowLayout>
1419
+ <TextField source="email" />
1420
+ <TextField source="id" />
1421
+ </SimpleShowLayout>
1422
+ <TabbedForm>
1423
+ <TabbedForm.Tab label="details">
1424
+ <Stack spacing={2} direction="row">
1425
+ <TextInput source="email" sx={{ mb: 4 }} />
1426
+ <TextInput source="phone_number" sx={{ mb: 4 }} />
1427
+ <Labeled
1428
+ label={React.createElement(FieldTitle as any, { source: "id" })}
1429
+ >
1430
+ <TextField source="id" sx={{ mb: 4 }} />
1431
+ </Labeled>
1432
+ </Stack>
1433
+ <Stack spacing={2} direction="row">
1434
+ <TextInput source="given_name" />
1435
+ <TextInput source="family_name" />
1436
+ <TextInput source="nickname" />
1437
+ </Stack>
1438
+ <Stack spacing={2} direction="row">
1439
+ <TextInput source="name" />
1440
+ <Labeled
1441
+ label={React.createElement(FieldTitle as any, {
1442
+ source: "connection",
1443
+ })}
1444
+ >
1445
+ <TextField source="connection" />
1446
+ </Labeled>
1447
+ </Stack>
1448
+ <TextInput source="picture" />
1449
+ <ArrayField source="identities">
1450
+ {React.createElement(
1451
+ Datagrid as any,
1452
+ { bulkActionButtons: false, sx: { my: 4 }, rowClick: "" },
1453
+ React.createElement(TextField, { source: "connection" }),
1454
+ React.createElement(TextField, { source: "provider" }),
1455
+ React.createElement(TextField, { source: "user_id" }),
1456
+ React.createElement(BooleanField, { source: "isSocial" }),
1457
+ React.createElement(UnlinkButton),
1458
+ )}
1459
+ </ArrayField>
1460
+
1461
+ <LinkUserButton />
1462
+
1463
+ <MetadataCard />
1464
+
1465
+ <PasswordChangeSection />
1466
+
1467
+ <Labeled
1468
+ label={React.createElement(FieldTitle as any, {
1469
+ source: "created_at",
1470
+ })}
1471
+ >
1472
+ <DateField source="created_at" showTime={true} />
1473
+ </Labeled>
1474
+ <Labeled
1475
+ label={React.createElement(FieldTitle as any, {
1476
+ source: "updated_at",
1477
+ })}
1478
+ >
1479
+ <DateField source="updated_at" showTime={true} />
1480
+ </Labeled>
1481
+ </TabbedForm.Tab>
1482
+ <TabbedForm.Tab label="sessions">
1483
+ <ReferenceManyField
1484
+ reference="sessions"
1485
+ target="user_id"
1486
+ pagination={React.createElement(Pagination as any)}
1487
+ perPage={10}
1488
+ sort={{ field: "used_at", order: "DESC" }}
1489
+ >
1490
+ {React.createElement(
1491
+ Datagrid as any,
1492
+ {
1493
+ sx: {
1494
+ width: "100%",
1495
+ "& .column-comment": {
1496
+ maxWidth: "20em",
1497
+ overflow: "hidden",
1498
+ textOverflow: "ellipsis",
1499
+ whiteSpace: "nowrap",
1500
+ },
1501
+ },
1502
+ rowClick: "edit",
1503
+ empty: React.createElement(
1504
+ "div",
1505
+ null,
1506
+ "No active sessions found",
1507
+ ),
1508
+ },
1509
+ React.createElement(TextField, { source: "id", sortable: true }),
1510
+ React.createElement(DateField, {
1511
+ source: "used_at",
1512
+ showTime: true,
1513
+ emptyText: "-",
1514
+ sortable: true,
1515
+ }),
1516
+ React.createElement(DateField, {
1517
+ source: "idle_expires_at",
1518
+ showTime: true,
1519
+ sortable: true,
1520
+ }),
1521
+ React.createElement(TextField, {
1522
+ source: "device.last_ip",
1523
+ label: "IP Address",
1524
+ emptyText: "-",
1525
+ sortable: false,
1526
+ }),
1527
+ React.createElement(TextField, {
1528
+ source: "device.last_user_agent",
1529
+ label: "User Agent",
1530
+ emptyText: "-",
1531
+ sortable: false,
1532
+ }),
1533
+ React.createElement(FunctionField, {
1534
+ label: "Client IDs",
1535
+ render: (record) =>
1536
+ record.clients ? record.clients.join(", ") : "-",
1537
+ sortable: false,
1538
+ }),
1539
+ React.createElement(DateField, {
1540
+ source: "created_at",
1541
+ showTime: true,
1542
+ sortable: true,
1543
+ }),
1544
+ React.createElement(FunctionField, {
1545
+ label: "Status",
1546
+ render: (record) => (record.revoked_at ? "Revoked" : "Active"),
1547
+ sortable: false,
1548
+ }),
1549
+ )}
1550
+ </ReferenceManyField>
1551
+ </TabbedForm.Tab>
1552
+ <TabbedForm.Tab label="logs">
1553
+ <ReferenceManyField
1554
+ reference="logs"
1555
+ target="user_id"
1556
+ pagination={React.createElement(Pagination as any)}
1557
+ sort={{ field: "date", order: "DESC" }}
1558
+ >
1559
+ {React.createElement(
1560
+ Datagrid as any,
1561
+ {
1562
+ sx: {
1563
+ width: "100%",
1564
+ "& .column-comment": {
1565
+ maxWidth: "20em",
1566
+ overflow: "hidden",
1567
+ textOverflow: "ellipsis",
1568
+ whiteSpace: "nowrap",
1569
+ },
1570
+ },
1571
+ rowClick: "show",
1572
+ },
1573
+ React.createElement(FunctionField, {
1574
+ source: "success",
1575
+ render: (record) =>
1576
+ React.createElement(LogIcon, { type: record.type }),
1577
+ }),
1578
+ React.createElement(FunctionField, {
1579
+ source: "type",
1580
+ render: (record) =>
1581
+ React.createElement(LogType, { type: record.type }),
1582
+ }),
1583
+ React.createElement(FunctionField, {
1584
+ source: "date",
1585
+ render: (record) =>
1586
+ React.createElement(DateAgo, { date: record.date }),
1587
+ sortable: true,
1588
+ }),
1589
+ React.createElement(TextField, { source: "description" }),
1590
+ )}
1591
+ </ReferenceManyField>
1592
+ </TabbedForm.Tab>
1593
+ <TabbedForm.Tab label="permissions">
1594
+ <AddPermissionButton />
1595
+ <ReferenceManyField
1596
+ reference="permissions"
1597
+ target="user_id"
1598
+ pagination={React.createElement(Pagination as any)}
1599
+ sort={{ field: "permission_name", order: "ASC" }}
1600
+ >
1601
+ {React.createElement(
1602
+ Datagrid as any,
1603
+ {
1604
+ sx: {
1605
+ width: "100%",
1606
+ "& .column-comment": {
1607
+ maxWidth: "20em",
1608
+ overflow: "hidden",
1609
+ textOverflow: "ellipsis",
1610
+ whiteSpace: "nowrap",
1611
+ },
1612
+ },
1613
+ rowClick: "",
1614
+ bulkActionButtons: false,
1615
+ },
1616
+ React.createElement(TextField, {
1617
+ source: "resource_server_identifier",
1618
+ label: "Resource Server",
1619
+ }),
1620
+ React.createElement(TextField, {
1621
+ source: "resource_server_name",
1622
+ label: "Resource Name",
1623
+ }),
1624
+ React.createElement(TextField, {
1625
+ source: "permission_name",
1626
+ label: "Permission",
1627
+ }),
1628
+ React.createElement(TextField, {
1629
+ source: "description",
1630
+ label: "Description",
1631
+ }),
1632
+ React.createElement(FunctionField, {
1633
+ source: "created_at",
1634
+ render: (record) =>
1635
+ record.created_at
1636
+ ? React.createElement(DateAgo, { date: record.created_at })
1637
+ : "-",
1638
+ label: "Assigned",
1639
+ }),
1640
+ React.createElement(RemovePermissionButton),
1641
+ )}
1642
+ </ReferenceManyField>
1643
+ </TabbedForm.Tab>
1644
+ <TabbedForm.Tab label="roles">
1645
+ <RolesManagement />
1646
+ </TabbedForm.Tab>
1647
+ <TabbedForm.Tab label="organizations">
1648
+ <ReferenceManyField
1649
+ reference="user-organizations"
1650
+ target="user_id"
1651
+ pagination={React.createElement(Pagination as any)}
1652
+ sort={{ field: "name", order: "ASC" }}
1653
+ >
1654
+ {React.createElement(
1655
+ Datagrid as any,
1656
+ {
1657
+ sx: {
1658
+ width: "100%",
1659
+ "& .column-comment": {
1660
+ maxWidth: "20em",
1661
+ overflow: "hidden",
1662
+ textOverflow: "ellipsis",
1663
+ whiteSpace: "nowrap",
1664
+ },
1665
+ },
1666
+ rowClick: (_, __, record) => `/organizations/${record.id}`,
1667
+ bulkActionButtons: false,
1668
+ },
1669
+ React.createElement(TextField, {
1670
+ source: "name",
1671
+ label: "Organization Name",
1672
+ }),
1673
+ React.createElement(TextField, {
1674
+ source: "display_name",
1675
+ label: "Display Name",
1676
+ }),
1677
+ React.createElement(TextField, {
1678
+ source: "id",
1679
+ label: "Organization ID",
1680
+ }),
1681
+ React.createElement(FunctionField, {
1682
+ source: "metadata",
1683
+ render: (record) => {
1684
+ const metadata = record.metadata || {};
1685
+ const metadataCount = Object.keys(metadata).length;
1686
+ return metadataCount > 0
1687
+ ? `${metadataCount} properties`
1688
+ : "-";
1689
+ },
1690
+ label: "Metadata",
1691
+ }),
1692
+ React.createElement(FunctionField, {
1693
+ label: "Joined",
1694
+ render: (record) =>
1695
+ record.created_at
1696
+ ? React.createElement(DateAgo, { date: record.created_at })
1697
+ : "-",
1698
+ }),
1699
+ )}
1700
+ </ReferenceManyField>
1701
+ </TabbedForm.Tab>
1702
+ <TabbedForm.Tab label="Raw JSON">
1703
+ <FunctionField
1704
+ source="date"
1705
+ render={(record: any) => <JsonOutput data={record} />}
1706
+ />
1707
+ </TabbedForm.Tab>
1708
+ </TabbedForm>
1709
+ </Edit>
1710
+ );
1711
+ }