@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,682 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Edit,
|
|
3
|
+
TextInput,
|
|
4
|
+
required,
|
|
5
|
+
TabbedForm,
|
|
6
|
+
ReferenceManyField,
|
|
7
|
+
Datagrid,
|
|
8
|
+
Pagination,
|
|
9
|
+
TextField,
|
|
10
|
+
FunctionField,
|
|
11
|
+
useDataProvider,
|
|
12
|
+
useNotify,
|
|
13
|
+
useRefresh,
|
|
14
|
+
useRecordContext,
|
|
15
|
+
useRedirect,
|
|
16
|
+
} from "react-admin";
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import {
|
|
19
|
+
Box,
|
|
20
|
+
Button,
|
|
21
|
+
Dialog,
|
|
22
|
+
DialogActions,
|
|
23
|
+
DialogContent,
|
|
24
|
+
DialogContentText,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
Typography,
|
|
27
|
+
CircularProgress,
|
|
28
|
+
IconButton,
|
|
29
|
+
TextField as MuiTextField,
|
|
30
|
+
List,
|
|
31
|
+
ListItem,
|
|
32
|
+
ListItemButton,
|
|
33
|
+
ListItemText,
|
|
34
|
+
Checkbox,
|
|
35
|
+
Chip,
|
|
36
|
+
Stack,
|
|
37
|
+
} from "@mui/material";
|
|
38
|
+
import AddIcon from "@mui/icons-material/Add";
|
|
39
|
+
import DeleteIcon from "@mui/icons-material/Delete";
|
|
40
|
+
import { useParams } from "react-router-dom";
|
|
41
|
+
|
|
42
|
+
const AddOrganizationMemberButton = () => {
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const [users, setUsers] = useState<any[]>([]);
|
|
45
|
+
const [selectedUsers, setSelectedUsers] = useState<any[]>([]);
|
|
46
|
+
const [searchText, setSearchText] = useState("");
|
|
47
|
+
const [searchLoading, setSearchLoading] = useState(false);
|
|
48
|
+
const dataProvider = useDataProvider();
|
|
49
|
+
const notify = useNotify();
|
|
50
|
+
const refresh = useRefresh();
|
|
51
|
+
|
|
52
|
+
// Get organization id from the route params (/:tenantId/organizations/:id)
|
|
53
|
+
const { id: organizationId } = useParams();
|
|
54
|
+
|
|
55
|
+
const toggleUserSelection = (user: any) => {
|
|
56
|
+
const isSelected = selectedUsers.some(
|
|
57
|
+
(selected) => selected.user_id === user.user_id,
|
|
58
|
+
);
|
|
59
|
+
if (isSelected) {
|
|
60
|
+
setSelectedUsers(
|
|
61
|
+
selectedUsers.filter((selected) => selected.user_id !== user.user_id),
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
setSelectedUsers([...selectedUsers, user]);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const removeSelectedUser = (userToRemove: any) => {
|
|
69
|
+
setSelectedUsers(
|
|
70
|
+
selectedUsers.filter((user) => user.user_id !== userToRemove.user_id),
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const selectAllUsers = () => {
|
|
75
|
+
setSelectedUsers([...users]);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const clearAllUsers = () => {
|
|
79
|
+
setSelectedUsers([]);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const searchUsers = async (query: string) => {
|
|
83
|
+
if (!query.trim()) {
|
|
84
|
+
setUsers([]);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setSearchLoading(true);
|
|
89
|
+
try {
|
|
90
|
+
// Search users by email, name, or user_id
|
|
91
|
+
const { data } = await dataProvider.getList("users", {
|
|
92
|
+
pagination: { page: 1, perPage: 50 },
|
|
93
|
+
sort: { field: "email", order: "ASC" },
|
|
94
|
+
filter: { q: query },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Get existing organization members to filter them out
|
|
98
|
+
if (organizationId) {
|
|
99
|
+
try {
|
|
100
|
+
// Use getManyReference to get organization members
|
|
101
|
+
const existingMembers = await dataProvider.getManyReference(
|
|
102
|
+
"organization-members",
|
|
103
|
+
{
|
|
104
|
+
target: "organization_id",
|
|
105
|
+
id: organizationId,
|
|
106
|
+
pagination: { page: 1, perPage: 1000 },
|
|
107
|
+
sort: { field: "user_id", order: "ASC" },
|
|
108
|
+
filter: {},
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const existingUserIds = new Set(
|
|
113
|
+
existingMembers.data.map((member: any) => member.user_id),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Filter out users who are already members
|
|
117
|
+
const availableUsers = data.filter(
|
|
118
|
+
(user: any) => !existingUserIds.has(user.user_id),
|
|
119
|
+
);
|
|
120
|
+
setUsers(availableUsers);
|
|
121
|
+
} catch (memberError) {
|
|
122
|
+
// If we can't get members, just show all users
|
|
123
|
+
console.warn("Could not fetch existing members:", memberError);
|
|
124
|
+
setUsers(data);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
setUsers(data);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
notify("Error searching users", { type: "error" });
|
|
131
|
+
setUsers([]);
|
|
132
|
+
} finally {
|
|
133
|
+
setSearchLoading(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Debounced search function
|
|
138
|
+
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(
|
|
139
|
+
null,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleSearchChange = (value: string) => {
|
|
143
|
+
setSearchText(value);
|
|
144
|
+
|
|
145
|
+
// Clear previous timeout
|
|
146
|
+
if (searchTimeout) {
|
|
147
|
+
clearTimeout(searchTimeout);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Set new timeout for debounced search
|
|
151
|
+
const timeout = setTimeout(() => {
|
|
152
|
+
searchUsers(value);
|
|
153
|
+
}, 300); // 300ms delay
|
|
154
|
+
|
|
155
|
+
setSearchTimeout(timeout);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleOpen = () => {
|
|
159
|
+
setOpen(true);
|
|
160
|
+
setUsers([]);
|
|
161
|
+
setSearchText("");
|
|
162
|
+
setSelectedUsers([]);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleClose = () => {
|
|
166
|
+
setOpen(false);
|
|
167
|
+
setSelectedUsers([]);
|
|
168
|
+
setUsers([]);
|
|
169
|
+
setSearchText("");
|
|
170
|
+
if (searchTimeout) {
|
|
171
|
+
clearTimeout(searchTimeout);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const handleAddMembers = async () => {
|
|
176
|
+
if (!organizationId || selectedUsers.length === 0) return;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Use the bulk add members endpoint with all selected users
|
|
180
|
+
await dataProvider.create("organization-members", {
|
|
181
|
+
data: {
|
|
182
|
+
organization_id: organizationId,
|
|
183
|
+
user_ids: selectedUsers.map((user) => user.user_id), // Send all user IDs at once
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
notify(`Added ${selectedUsers.length} member(s) to organization`, {
|
|
188
|
+
type: "success",
|
|
189
|
+
});
|
|
190
|
+
refresh();
|
|
191
|
+
handleClose();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
notify("Error adding members to organization", { type: "error" });
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
<Button
|
|
200
|
+
variant="contained"
|
|
201
|
+
startIcon={<AddIcon />}
|
|
202
|
+
onClick={handleOpen}
|
|
203
|
+
sx={{ mb: 2 }}
|
|
204
|
+
>
|
|
205
|
+
Add Members
|
|
206
|
+
</Button>
|
|
207
|
+
|
|
208
|
+
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
|
209
|
+
<DialogTitle>Add Members to Organization</DialogTitle>
|
|
210
|
+
<DialogContent>
|
|
211
|
+
<DialogContentText sx={{ mb: 2 }}>
|
|
212
|
+
Search and select users to add as members of this organization.
|
|
213
|
+
</DialogContentText>
|
|
214
|
+
|
|
215
|
+
{/* Search Field */}
|
|
216
|
+
<MuiTextField
|
|
217
|
+
fullWidth
|
|
218
|
+
label="Search Users"
|
|
219
|
+
placeholder="Search by email, name, or user ID..."
|
|
220
|
+
value={searchText}
|
|
221
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
222
|
+
sx={{ mb: 2 }}
|
|
223
|
+
InputProps={{
|
|
224
|
+
endAdornment: searchLoading && <CircularProgress size={20} />,
|
|
225
|
+
}}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
{/* Selected Users Display */}
|
|
229
|
+
{selectedUsers.length > 0 && (
|
|
230
|
+
<Box sx={{ mb: 2 }}>
|
|
231
|
+
<Typography variant="subtitle2" gutterBottom>
|
|
232
|
+
Selected Users ({selectedUsers.length})
|
|
233
|
+
</Typography>
|
|
234
|
+
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
235
|
+
{selectedUsers.map((user) => (
|
|
236
|
+
<Chip
|
|
237
|
+
key={user.user_id}
|
|
238
|
+
label={`${user.name || user.email || user.user_id}`}
|
|
239
|
+
onDelete={() => removeSelectedUser(user)}
|
|
240
|
+
size="small"
|
|
241
|
+
variant="outlined"
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</Stack>
|
|
245
|
+
</Box>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
{/* User Search Results */}
|
|
249
|
+
{users.length > 0 ? (
|
|
250
|
+
<>
|
|
251
|
+
<Box
|
|
252
|
+
sx={{
|
|
253
|
+
display: "flex",
|
|
254
|
+
justifyContent: "space-between",
|
|
255
|
+
alignItems: "center",
|
|
256
|
+
mb: 1,
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
<Typography variant="body2" color="text.secondary">
|
|
260
|
+
Found {users.length} available user
|
|
261
|
+
{users.length !== 1 ? "s" : ""}
|
|
262
|
+
</Typography>
|
|
263
|
+
<Box sx={{ display: "flex", gap: 1 }}>
|
|
264
|
+
<Button
|
|
265
|
+
size="small"
|
|
266
|
+
onClick={selectAllUsers}
|
|
267
|
+
disabled={selectedUsers.length === users.length}
|
|
268
|
+
>
|
|
269
|
+
Select All
|
|
270
|
+
</Button>
|
|
271
|
+
<Button
|
|
272
|
+
size="small"
|
|
273
|
+
onClick={clearAllUsers}
|
|
274
|
+
disabled={selectedUsers.length === 0}
|
|
275
|
+
>
|
|
276
|
+
Clear All
|
|
277
|
+
</Button>
|
|
278
|
+
</Box>
|
|
279
|
+
</Box>
|
|
280
|
+
<Box
|
|
281
|
+
sx={{
|
|
282
|
+
maxHeight: 300,
|
|
283
|
+
overflow: "auto",
|
|
284
|
+
border: 1,
|
|
285
|
+
borderColor: "divider",
|
|
286
|
+
borderRadius: 1,
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<List dense>
|
|
290
|
+
{users.map((user) => {
|
|
291
|
+
const isSelected = selectedUsers.some(
|
|
292
|
+
(selected) => selected.user_id === user.user_id,
|
|
293
|
+
);
|
|
294
|
+
return (
|
|
295
|
+
<ListItem key={user.user_id} disablePadding>
|
|
296
|
+
<ListItemButton
|
|
297
|
+
onClick={() => toggleUserSelection(user)}
|
|
298
|
+
sx={{
|
|
299
|
+
"&:hover": { backgroundColor: "action.hover" },
|
|
300
|
+
backgroundColor: isSelected
|
|
301
|
+
? "action.selected"
|
|
302
|
+
: "transparent",
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<Checkbox
|
|
306
|
+
checked={isSelected}
|
|
307
|
+
tabIndex={-1}
|
|
308
|
+
disableRipple
|
|
309
|
+
sx={{ mr: 1 }}
|
|
310
|
+
/>
|
|
311
|
+
<ListItemText
|
|
312
|
+
primary={
|
|
313
|
+
<Typography variant="body2" fontWeight="medium">
|
|
314
|
+
{user.name || user.email || user.user_id}
|
|
315
|
+
</Typography>
|
|
316
|
+
}
|
|
317
|
+
secondary={
|
|
318
|
+
<Typography
|
|
319
|
+
variant="caption"
|
|
320
|
+
color="text.secondary"
|
|
321
|
+
>
|
|
322
|
+
{user.email} • {user.user_id}
|
|
323
|
+
</Typography>
|
|
324
|
+
}
|
|
325
|
+
/>
|
|
326
|
+
</ListItemButton>
|
|
327
|
+
</ListItem>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</List>
|
|
331
|
+
</Box>
|
|
332
|
+
</>
|
|
333
|
+
) : searchText.trim() ? (
|
|
334
|
+
<Box textAlign="center" py={3}>
|
|
335
|
+
{searchLoading ? (
|
|
336
|
+
<CircularProgress />
|
|
337
|
+
) : (
|
|
338
|
+
<Typography color="text.secondary">
|
|
339
|
+
No users found matching "{searchText}"
|
|
340
|
+
</Typography>
|
|
341
|
+
)}
|
|
342
|
+
</Box>
|
|
343
|
+
) : (
|
|
344
|
+
<Box textAlign="center" py={3}>
|
|
345
|
+
<Typography color="text.secondary">
|
|
346
|
+
Start typing to search for users
|
|
347
|
+
</Typography>
|
|
348
|
+
</Box>
|
|
349
|
+
)}
|
|
350
|
+
</DialogContent>
|
|
351
|
+
<DialogActions>
|
|
352
|
+
<Button onClick={handleClose}>Cancel</Button>
|
|
353
|
+
<Button
|
|
354
|
+
onClick={handleAddMembers}
|
|
355
|
+
variant="contained"
|
|
356
|
+
disabled={selectedUsers.length === 0}
|
|
357
|
+
>
|
|
358
|
+
Add Members
|
|
359
|
+
</Button>
|
|
360
|
+
</DialogActions>
|
|
361
|
+
</Dialog>
|
|
362
|
+
</>
|
|
363
|
+
);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const RemoveMemberButton = ({ record }: { record: any }) => {
|
|
367
|
+
const dataProvider = useDataProvider();
|
|
368
|
+
const notify = useNotify();
|
|
369
|
+
const refresh = useRefresh();
|
|
370
|
+
const { id: organizationId } = useParams();
|
|
371
|
+
|
|
372
|
+
const handleRemoveMember = async () => {
|
|
373
|
+
if (!organizationId || !record?.user_id) return;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// Use the bulk remove members endpoint
|
|
377
|
+
await dataProvider.delete("organization-members", {
|
|
378
|
+
id: organizationId,
|
|
379
|
+
previousData: {
|
|
380
|
+
id: organizationId,
|
|
381
|
+
members: [record.user_id],
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
notify("Member removed from organization", { type: "success" });
|
|
386
|
+
refresh();
|
|
387
|
+
} catch (error) {
|
|
388
|
+
notify("Error removing member from organization", { type: "error" });
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<IconButton
|
|
394
|
+
onClick={handleRemoveMember}
|
|
395
|
+
color="error"
|
|
396
|
+
size="small"
|
|
397
|
+
title="Remove from organization"
|
|
398
|
+
>
|
|
399
|
+
<DeleteIcon />
|
|
400
|
+
</IconButton>
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const OrganizationGeneralTab = () => (
|
|
405
|
+
<Box>
|
|
406
|
+
<TextInput source="name" validate={[required()]} fullWidth />
|
|
407
|
+
<TextInput source="display_name" fullWidth />
|
|
408
|
+
<TextInput source="description" multiline fullWidth />
|
|
409
|
+
</Box>
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const OrganizationMembersTab = () => {
|
|
413
|
+
const record = useRecordContext();
|
|
414
|
+
const redirect = useRedirect();
|
|
415
|
+
|
|
416
|
+
if (!record?.id) {
|
|
417
|
+
return (
|
|
418
|
+
<Typography>Save the organization first to manage members.</Typography>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<Box>
|
|
424
|
+
<Typography variant="h6" gutterBottom>
|
|
425
|
+
Organization Members
|
|
426
|
+
</Typography>
|
|
427
|
+
|
|
428
|
+
<AddOrganizationMemberButton />
|
|
429
|
+
|
|
430
|
+
<ReferenceManyField
|
|
431
|
+
reference="organization-members"
|
|
432
|
+
target="organization_id"
|
|
433
|
+
pagination={<Pagination />}
|
|
434
|
+
>
|
|
435
|
+
<Datagrid bulkActionButtons={false}>
|
|
436
|
+
<FunctionField
|
|
437
|
+
label="User ID"
|
|
438
|
+
render={(record) => (
|
|
439
|
+
<Button
|
|
440
|
+
variant="text"
|
|
441
|
+
onClick={() => redirect("edit", "users", record.user_id)}
|
|
442
|
+
sx={{
|
|
443
|
+
textTransform: "none",
|
|
444
|
+
p: 0,
|
|
445
|
+
minWidth: "auto",
|
|
446
|
+
color: "primary.main",
|
|
447
|
+
textDecoration: "underline",
|
|
448
|
+
"&:hover": {
|
|
449
|
+
textDecoration: "underline",
|
|
450
|
+
backgroundColor: "transparent",
|
|
451
|
+
},
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
{record.user_id}
|
|
455
|
+
</Button>
|
|
456
|
+
)}
|
|
457
|
+
/>
|
|
458
|
+
<TextField source="email" label="Email" />
|
|
459
|
+
<FunctionField
|
|
460
|
+
label="Actions"
|
|
461
|
+
render={(record) => <RemoveMemberButton record={record} />}
|
|
462
|
+
/>
|
|
463
|
+
</Datagrid>
|
|
464
|
+
</ReferenceManyField>
|
|
465
|
+
</Box>
|
|
466
|
+
);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const CreateInviteButton = () => {
|
|
470
|
+
const [open, setOpen] = useState(false);
|
|
471
|
+
const [inviteData, setInviteData] = useState({
|
|
472
|
+
inviterName: "",
|
|
473
|
+
inviteeEmail: "",
|
|
474
|
+
clientId: "",
|
|
475
|
+
});
|
|
476
|
+
const dataProvider = useDataProvider();
|
|
477
|
+
const notify = useNotify();
|
|
478
|
+
const refresh = useRefresh();
|
|
479
|
+
const { id: organizationId } = useParams();
|
|
480
|
+
|
|
481
|
+
const handleOpen = () => {
|
|
482
|
+
setOpen(true);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const handleClose = () => {
|
|
486
|
+
setOpen(false);
|
|
487
|
+
setInviteData({
|
|
488
|
+
inviterName: "",
|
|
489
|
+
inviteeEmail: "",
|
|
490
|
+
clientId: "",
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const handleCreateInvite = async () => {
|
|
495
|
+
if (!organizationId) return;
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
await dataProvider.create("organization-invitations", {
|
|
499
|
+
data: {
|
|
500
|
+
organization_id: organizationId,
|
|
501
|
+
inviter: { name: inviteData.inviterName },
|
|
502
|
+
invitee: { email: inviteData.inviteeEmail },
|
|
503
|
+
client_id: inviteData.clientId,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
notify("Invitation created successfully", { type: "success" });
|
|
508
|
+
refresh();
|
|
509
|
+
handleClose();
|
|
510
|
+
} catch (error) {
|
|
511
|
+
notify("Error creating invitation", { type: "error" });
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<>
|
|
517
|
+
<Button
|
|
518
|
+
variant="contained"
|
|
519
|
+
startIcon={<AddIcon />}
|
|
520
|
+
onClick={handleOpen}
|
|
521
|
+
sx={{ mb: 2 }}
|
|
522
|
+
>
|
|
523
|
+
Create Invitation
|
|
524
|
+
</Button>
|
|
525
|
+
|
|
526
|
+
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
|
527
|
+
<DialogTitle>Create Organization Invitation</DialogTitle>
|
|
528
|
+
<DialogContent>
|
|
529
|
+
<DialogContentText sx={{ mb: 2 }}>
|
|
530
|
+
Create an invitation to invite a user to join this organization.
|
|
531
|
+
</DialogContentText>
|
|
532
|
+
|
|
533
|
+
<MuiTextField
|
|
534
|
+
fullWidth
|
|
535
|
+
label="Inviter Name"
|
|
536
|
+
value={inviteData.inviterName}
|
|
537
|
+
onChange={(e) =>
|
|
538
|
+
setInviteData({ ...inviteData, inviterName: e.target.value })
|
|
539
|
+
}
|
|
540
|
+
sx={{ mb: 2, mt: 1 }}
|
|
541
|
+
required
|
|
542
|
+
/>
|
|
543
|
+
|
|
544
|
+
<MuiTextField
|
|
545
|
+
fullWidth
|
|
546
|
+
label="Invitee Email"
|
|
547
|
+
type="email"
|
|
548
|
+
value={inviteData.inviteeEmail}
|
|
549
|
+
onChange={(e) =>
|
|
550
|
+
setInviteData({ ...inviteData, inviteeEmail: e.target.value })
|
|
551
|
+
}
|
|
552
|
+
sx={{ mb: 2 }}
|
|
553
|
+
required
|
|
554
|
+
/>
|
|
555
|
+
|
|
556
|
+
<MuiTextField
|
|
557
|
+
fullWidth
|
|
558
|
+
label="Client ID"
|
|
559
|
+
value={inviteData.clientId}
|
|
560
|
+
onChange={(e) =>
|
|
561
|
+
setInviteData({ ...inviteData, clientId: e.target.value })
|
|
562
|
+
}
|
|
563
|
+
required
|
|
564
|
+
/>
|
|
565
|
+
</DialogContent>
|
|
566
|
+
<DialogActions>
|
|
567
|
+
<Button onClick={handleClose}>Cancel</Button>
|
|
568
|
+
<Button
|
|
569
|
+
onClick={handleCreateInvite}
|
|
570
|
+
variant="contained"
|
|
571
|
+
disabled={
|
|
572
|
+
!inviteData.inviterName ||
|
|
573
|
+
!inviteData.inviteeEmail ||
|
|
574
|
+
!inviteData.clientId
|
|
575
|
+
}
|
|
576
|
+
>
|
|
577
|
+
Create Invitation
|
|
578
|
+
</Button>
|
|
579
|
+
</DialogActions>
|
|
580
|
+
</Dialog>
|
|
581
|
+
</>
|
|
582
|
+
);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const DeleteInviteButton = ({ record }: { record: any }) => {
|
|
586
|
+
const dataProvider = useDataProvider();
|
|
587
|
+
const notify = useNotify();
|
|
588
|
+
const refresh = useRefresh();
|
|
589
|
+
const { id: organizationId } = useParams();
|
|
590
|
+
|
|
591
|
+
const handleDeleteInvite = async () => {
|
|
592
|
+
if (!organizationId || !record?.id) return;
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
await dataProvider.delete("organization-invitations", {
|
|
596
|
+
id: record.id,
|
|
597
|
+
previousData: record,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
notify("Invitation deleted successfully", { type: "success" });
|
|
601
|
+
refresh();
|
|
602
|
+
} catch (error) {
|
|
603
|
+
notify("Error deleting invitation", { type: "error" });
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
return (
|
|
608
|
+
<IconButton
|
|
609
|
+
onClick={handleDeleteInvite}
|
|
610
|
+
color="error"
|
|
611
|
+
size="small"
|
|
612
|
+
title="Delete invitation"
|
|
613
|
+
>
|
|
614
|
+
<DeleteIcon />
|
|
615
|
+
</IconButton>
|
|
616
|
+
);
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const OrganizationInvitesTab = () => {
|
|
620
|
+
const record = useRecordContext();
|
|
621
|
+
|
|
622
|
+
if (!record?.id) {
|
|
623
|
+
return (
|
|
624
|
+
<Typography>
|
|
625
|
+
Save the organization first to manage invitations.
|
|
626
|
+
</Typography>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<Box>
|
|
632
|
+
<Typography variant="h6" gutterBottom>
|
|
633
|
+
Organization Invitations
|
|
634
|
+
</Typography>
|
|
635
|
+
|
|
636
|
+
<CreateInviteButton />
|
|
637
|
+
|
|
638
|
+
<ReferenceManyField
|
|
639
|
+
reference="organization-invitations"
|
|
640
|
+
target="organization_id"
|
|
641
|
+
pagination={<Pagination />}
|
|
642
|
+
>
|
|
643
|
+
<Datagrid bulkActionButtons={false}>
|
|
644
|
+
<TextField source="id" label="Invitation ID" />
|
|
645
|
+
<FunctionField
|
|
646
|
+
label="Invitee Email"
|
|
647
|
+
render={(record) => record.invitee?.email || "-"}
|
|
648
|
+
/>
|
|
649
|
+
<FunctionField
|
|
650
|
+
label="Inviter Name"
|
|
651
|
+
render={(record) => record.inviter?.name || "-"}
|
|
652
|
+
/>
|
|
653
|
+
<TextField source="client_id" label="Client ID" />
|
|
654
|
+
<TextField source="created_at" label="Created At" />
|
|
655
|
+
<TextField source="expires_at" label="Expires At" />
|
|
656
|
+
<FunctionField
|
|
657
|
+
label="Actions"
|
|
658
|
+
render={(record) => <DeleteInviteButton record={record} />}
|
|
659
|
+
/>
|
|
660
|
+
</Datagrid>
|
|
661
|
+
</ReferenceManyField>
|
|
662
|
+
</Box>
|
|
663
|
+
);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
export function OrganizationEdit() {
|
|
667
|
+
return (
|
|
668
|
+
<Edit>
|
|
669
|
+
<TabbedForm>
|
|
670
|
+
<TabbedForm.Tab label="General">
|
|
671
|
+
<OrganizationGeneralTab />
|
|
672
|
+
</TabbedForm.Tab>
|
|
673
|
+
<TabbedForm.Tab label="Members">
|
|
674
|
+
<OrganizationMembersTab />
|
|
675
|
+
</TabbedForm.Tab>
|
|
676
|
+
<TabbedForm.Tab label="Invites">
|
|
677
|
+
<OrganizationInvitesTab />
|
|
678
|
+
</TabbedForm.Tab>
|
|
679
|
+
</TabbedForm>
|
|
680
|
+
</Edit>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { List, Datagrid, TextField, DateField, TextInput } from "react-admin";
|
|
2
|
+
import { PostListActions } from "../listActions/PostListActions";
|
|
3
|
+
|
|
4
|
+
export function OrganizationList() {
|
|
5
|
+
const filters = [
|
|
6
|
+
<TextInput key="search" label="Search" source="q" alwaysOn />,
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<List actions={<PostListActions />} filters={filters}>
|
|
11
|
+
<Datagrid rowClick="edit" bulkActionButtons={false}>
|
|
12
|
+
<TextField source="id" />
|
|
13
|
+
<TextField source="name" />
|
|
14
|
+
<TextField source="display_name" />
|
|
15
|
+
<TextField source="description" />
|
|
16
|
+
<DateField source="created_at" showTime={true} />
|
|
17
|
+
<DateField source="updated_at" showTime={true} />
|
|
18
|
+
</Datagrid>
|
|
19
|
+
</List>
|
|
20
|
+
);
|
|
21
|
+
}
|