@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.
- package/.eslintrc.js +21 -0
- package/.vercelignore +4 -0
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/index.html +125 -0
- package/package.json +61 -0
- package/prettier.config.js +1 -0
- package/public/favicon.ico +0 -0
- package/public/manifest.json +15 -0
- package/src/App.spec.tsx +42 -0
- package/src/App.tsx +232 -0
- package/src/AuthCallback.tsx +138 -0
- package/src/Layout.tsx +12 -0
- package/src/TenantsApp.tsx +115 -0
- package/src/auth0DataProvider.ts +1242 -0
- package/src/authProvider.ts +521 -0
- package/src/components/CertificateErrorDialog.tsx +116 -0
- package/src/components/DomainSelector.tsx +401 -0
- package/src/components/TenantAppBar.tsx +83 -0
- package/src/components/TenantLayout.tsx +25 -0
- package/src/components/TenantsAppBar.tsx +21 -0
- package/src/components/TenantsLayout.tsx +28 -0
- package/src/components/activity/ActivityDashboard.tsx +381 -0
- package/src/components/activity/index.ts +1 -0
- package/src/components/branding/BrandingList.tsx +0 -0
- package/src/components/branding/BrandingShow.tsx +0 -0
- package/src/components/branding/ThemesTab.tsx +286 -0
- package/src/components/branding/edit.tsx +149 -0
- package/src/components/branding/hooks/useThemesData.ts +123 -0
- package/src/components/branding/index.ts +2 -0
- package/src/components/branding/list.tsx +12 -0
- package/src/components/clients/create.tsx +12 -0
- package/src/components/clients/edit.tsx +1285 -0
- package/src/components/clients/index.ts +3 -0
- package/src/components/clients/list.tsx +37 -0
- package/src/components/common/DateAgo.tsx +6 -0
- package/src/components/common/JsonOutput.tsx +26 -0
- package/src/components/common/index.ts +1 -0
- package/src/components/connections/create.tsx +35 -0
- package/src/components/connections/edit.tsx +212 -0
- package/src/components/connections/index.ts +3 -0
- package/src/components/connections/list.tsx +15 -0
- package/src/components/custom-domains/create.tsx +26 -0
- package/src/components/custom-domains/edit.tsx +101 -0
- package/src/components/custom-domains/index.ts +3 -0
- package/src/components/custom-domains/list.tsx +16 -0
- package/src/components/flows/create.tsx +30 -0
- package/src/components/flows/edit.tsx +238 -0
- package/src/components/flows/index.ts +3 -0
- package/src/components/flows/list.tsx +15 -0
- package/src/components/forms/FlowEditor.tsx +1363 -0
- package/src/components/forms/NodeEditor.tsx +1119 -0
- package/src/components/forms/RichTextEditor.tsx +145 -0
- package/src/components/forms/create.tsx +30 -0
- package/src/components/forms/edit.tsx +256 -0
- package/src/components/forms/index.ts +3 -0
- package/src/components/forms/list.tsx +16 -0
- package/src/components/hooks/create.tsx +96 -0
- package/src/components/hooks/edit.tsx +114 -0
- package/src/components/hooks/index.ts +3 -0
- package/src/components/hooks/list.tsx +17 -0
- package/src/components/listActions/PostListActions.tsx +10 -0
- package/src/components/logs/LogIcon.tsx +32 -0
- package/src/components/logs/LogShow.tsx +82 -0
- package/src/components/logs/LogType.tsx +38 -0
- package/src/components/logs/index.ts +4 -0
- package/src/components/logs/list.tsx +41 -0
- package/src/components/organizations/create.tsx +13 -0
- package/src/components/organizations/edit.tsx +682 -0
- package/src/components/organizations/index.ts +3 -0
- package/src/components/organizations/list.tsx +21 -0
- package/src/components/resource-servers/create.tsx +87 -0
- package/src/components/resource-servers/edit.tsx +121 -0
- package/src/components/resource-servers/index.ts +3 -0
- package/src/components/resource-servers/list.tsx +47 -0
- package/src/components/roles/create.tsx +12 -0
- package/src/components/roles/edit.tsx +426 -0
- package/src/components/roles/index.ts +3 -0
- package/src/components/roles/list.tsx +24 -0
- package/src/components/sessions/edit.tsx +101 -0
- package/src/components/sessions/index.ts +3 -0
- package/src/components/sessions/list.tsx +20 -0
- package/src/components/sessions/show.tsx +113 -0
- package/src/components/settings/edit.tsx +236 -0
- package/src/components/settings/index.ts +2 -0
- package/src/components/settings/list.tsx +14 -0
- package/src/components/tenants/create.tsx +20 -0
- package/src/components/tenants/edit.tsx +54 -0
- package/src/components/tenants/index.ts +2 -0
- package/src/components/tenants/list.tsx +67 -0
- package/src/components/themes/edit.tsx +200 -0
- package/src/components/themes/index.ts +2 -0
- package/src/components/themes/list.tsx +12 -0
- package/src/components/users/create.tsx +144 -0
- package/src/components/users/edit.tsx +1711 -0
- package/src/components/users/index.ts +3 -0
- package/src/components/users/list.tsx +35 -0
- package/src/data.json +121 -0
- package/src/dataProvider.ts +97 -0
- package/src/index.tsx +106 -0
- package/src/lib/logs.ts +21 -0
- package/src/types/reactflow.d.ts +86 -0
- package/src/utils/domainUtils.ts +169 -0
- package/src/utils/tokenUtils.ts +75 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +37 -0
- package/tsconfig.node.json +10 -0
- package/vercel.json +17 -0
- 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
|
+
}
|