@authhero/react-admin 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.eslintrc.js +21 -0
  2. package/.vercelignore +4 -0
  3. package/CHANGELOG.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +50 -0
  6. package/index.html +125 -0
  7. package/package.json +61 -0
  8. package/prettier.config.js +1 -0
  9. package/public/favicon.ico +0 -0
  10. package/public/manifest.json +15 -0
  11. package/src/App.spec.tsx +42 -0
  12. package/src/App.tsx +232 -0
  13. package/src/AuthCallback.tsx +138 -0
  14. package/src/Layout.tsx +12 -0
  15. package/src/TenantsApp.tsx +115 -0
  16. package/src/auth0DataProvider.ts +1242 -0
  17. package/src/authProvider.ts +521 -0
  18. package/src/components/CertificateErrorDialog.tsx +116 -0
  19. package/src/components/DomainSelector.tsx +401 -0
  20. package/src/components/TenantAppBar.tsx +83 -0
  21. package/src/components/TenantLayout.tsx +25 -0
  22. package/src/components/TenantsAppBar.tsx +21 -0
  23. package/src/components/TenantsLayout.tsx +28 -0
  24. package/src/components/activity/ActivityDashboard.tsx +381 -0
  25. package/src/components/activity/index.ts +1 -0
  26. package/src/components/branding/BrandingList.tsx +0 -0
  27. package/src/components/branding/BrandingShow.tsx +0 -0
  28. package/src/components/branding/ThemesTab.tsx +286 -0
  29. package/src/components/branding/edit.tsx +149 -0
  30. package/src/components/branding/hooks/useThemesData.ts +123 -0
  31. package/src/components/branding/index.ts +2 -0
  32. package/src/components/branding/list.tsx +12 -0
  33. package/src/components/clients/create.tsx +12 -0
  34. package/src/components/clients/edit.tsx +1285 -0
  35. package/src/components/clients/index.ts +3 -0
  36. package/src/components/clients/list.tsx +37 -0
  37. package/src/components/common/DateAgo.tsx +6 -0
  38. package/src/components/common/JsonOutput.tsx +26 -0
  39. package/src/components/common/index.ts +1 -0
  40. package/src/components/connections/create.tsx +35 -0
  41. package/src/components/connections/edit.tsx +212 -0
  42. package/src/components/connections/index.ts +3 -0
  43. package/src/components/connections/list.tsx +15 -0
  44. package/src/components/custom-domains/create.tsx +26 -0
  45. package/src/components/custom-domains/edit.tsx +101 -0
  46. package/src/components/custom-domains/index.ts +3 -0
  47. package/src/components/custom-domains/list.tsx +16 -0
  48. package/src/components/flows/create.tsx +30 -0
  49. package/src/components/flows/edit.tsx +238 -0
  50. package/src/components/flows/index.ts +3 -0
  51. package/src/components/flows/list.tsx +15 -0
  52. package/src/components/forms/FlowEditor.tsx +1363 -0
  53. package/src/components/forms/NodeEditor.tsx +1119 -0
  54. package/src/components/forms/RichTextEditor.tsx +145 -0
  55. package/src/components/forms/create.tsx +30 -0
  56. package/src/components/forms/edit.tsx +256 -0
  57. package/src/components/forms/index.ts +3 -0
  58. package/src/components/forms/list.tsx +16 -0
  59. package/src/components/hooks/create.tsx +96 -0
  60. package/src/components/hooks/edit.tsx +114 -0
  61. package/src/components/hooks/index.ts +3 -0
  62. package/src/components/hooks/list.tsx +17 -0
  63. package/src/components/listActions/PostListActions.tsx +10 -0
  64. package/src/components/logs/LogIcon.tsx +32 -0
  65. package/src/components/logs/LogShow.tsx +82 -0
  66. package/src/components/logs/LogType.tsx +38 -0
  67. package/src/components/logs/index.ts +4 -0
  68. package/src/components/logs/list.tsx +41 -0
  69. package/src/components/organizations/create.tsx +13 -0
  70. package/src/components/organizations/edit.tsx +682 -0
  71. package/src/components/organizations/index.ts +3 -0
  72. package/src/components/organizations/list.tsx +21 -0
  73. package/src/components/resource-servers/create.tsx +87 -0
  74. package/src/components/resource-servers/edit.tsx +121 -0
  75. package/src/components/resource-servers/index.ts +3 -0
  76. package/src/components/resource-servers/list.tsx +47 -0
  77. package/src/components/roles/create.tsx +12 -0
  78. package/src/components/roles/edit.tsx +426 -0
  79. package/src/components/roles/index.ts +3 -0
  80. package/src/components/roles/list.tsx +24 -0
  81. package/src/components/sessions/edit.tsx +101 -0
  82. package/src/components/sessions/index.ts +3 -0
  83. package/src/components/sessions/list.tsx +20 -0
  84. package/src/components/sessions/show.tsx +113 -0
  85. package/src/components/settings/edit.tsx +236 -0
  86. package/src/components/settings/index.ts +2 -0
  87. package/src/components/settings/list.tsx +14 -0
  88. package/src/components/tenants/create.tsx +20 -0
  89. package/src/components/tenants/edit.tsx +54 -0
  90. package/src/components/tenants/index.ts +2 -0
  91. package/src/components/tenants/list.tsx +67 -0
  92. package/src/components/themes/edit.tsx +200 -0
  93. package/src/components/themes/index.ts +2 -0
  94. package/src/components/themes/list.tsx +12 -0
  95. package/src/components/users/create.tsx +144 -0
  96. package/src/components/users/edit.tsx +1711 -0
  97. package/src/components/users/index.ts +3 -0
  98. package/src/components/users/list.tsx +35 -0
  99. package/src/data.json +121 -0
  100. package/src/dataProvider.ts +97 -0
  101. package/src/index.tsx +106 -0
  102. package/src/lib/logs.ts +21 -0
  103. package/src/types/reactflow.d.ts +86 -0
  104. package/src/utils/domainUtils.ts +169 -0
  105. package/src/utils/tokenUtils.ts +75 -0
  106. package/src/vite-env.d.ts +1 -0
  107. package/tsconfig.json +37 -0
  108. package/tsconfig.node.json +10 -0
  109. package/vercel.json +17 -0
  110. package/vite.config.ts +30 -0
@@ -0,0 +1,1285 @@
1
+ import {
2
+ DateField,
3
+ Edit,
4
+ Labeled,
5
+ SelectInput,
6
+ TextInput,
7
+ BooleanInput,
8
+ SimpleShowLayout,
9
+ TextField,
10
+ TabbedForm,
11
+ SimpleFormIterator,
12
+ ArrayInput,
13
+ FunctionField,
14
+ ReferenceManyField,
15
+ Datagrid,
16
+ Pagination,
17
+ useNotify,
18
+ useDataProvider,
19
+ useRecordContext,
20
+ useRefresh,
21
+ useInput,
22
+ } from "react-admin";
23
+ // @ts-ignore - React Admin components compatibility with React 19
24
+ const PaginationComponent = Pagination as any;
25
+ // @ts-ignore - React Admin components compatibility with React 19
26
+ const DatagridComponent = Datagrid as any;
27
+ import { JsonOutput } from "../common/JsonOutput";
28
+ import { DateAgo } from "../common";
29
+ import {
30
+ Button,
31
+ Dialog,
32
+ DialogTitle,
33
+ DialogContent,
34
+ DialogActions,
35
+ TextField as MuiTextField,
36
+ Box,
37
+ Typography,
38
+ CircularProgress,
39
+ IconButton,
40
+ Tooltip,
41
+ Autocomplete,
42
+ Chip,
43
+ FormControlLabel,
44
+ Checkbox,
45
+ List,
46
+ ListItem,
47
+ ListItemText,
48
+ ListItemSecondaryAction,
49
+ Paper,
50
+ } from "@mui/material";
51
+ import { useState, useEffect, useCallback } from "react";
52
+ import AddIcon from "@mui/icons-material/Add";
53
+ import DeleteIcon from "@mui/icons-material/Delete";
54
+ import EditIcon from "@mui/icons-material/Edit";
55
+ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
56
+ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
57
+ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
58
+ import { authorizedHttpClient } from "../../authProvider";
59
+ import {
60
+ getDomainFromStorage,
61
+ getSelectedDomainFromStorage,
62
+ buildUrlWithProtocol,
63
+ } from "../../utils/domainUtils";
64
+
65
+ const AddClientGrantButton = () => {
66
+ const [open, setOpen] = useState(false);
67
+ const [resourceServers, setResourceServers] = useState<any[]>([]);
68
+ const [selectedResourceServer, setSelectedResourceServer] =
69
+ useState<any>(null);
70
+ const [availableScopes, setAvailableScopes] = useState<any[]>([]);
71
+ const [selectedScopes, setSelectedScopes] = useState<any[]>([]);
72
+ const [loading, setLoading] = useState(false);
73
+ const [loadingScopes, setLoadingScopes] = useState(false);
74
+ const dataProvider = useDataProvider();
75
+ const notify = useNotify();
76
+ const refresh = useRefresh();
77
+
78
+ // Get the client ID from the URL path
79
+ const urlPath = window.location.pathname;
80
+ const matches = urlPath.match(/\/([^/]+)\/clients\/([^/]+)/);
81
+ const clientId = matches ? matches[2] : null;
82
+
83
+ const handleOpen = async () => {
84
+ setOpen(true);
85
+ await loadResourceServers();
86
+ };
87
+
88
+ const handleClose = () => {
89
+ setOpen(false);
90
+ setSelectedResourceServer(null);
91
+ setAvailableScopes([]);
92
+ setSelectedScopes([]);
93
+ };
94
+
95
+ const loadResourceServers = async () => {
96
+ setLoading(true);
97
+ try {
98
+ const { data } = await dataProvider.getList("resource-servers", {
99
+ pagination: { page: 1, perPage: 100 },
100
+ sort: { field: "name", order: "ASC" },
101
+ filter: {},
102
+ });
103
+ setResourceServers(data);
104
+ } catch (error) {
105
+ console.error("Error loading resource servers:", error);
106
+ notify("Error loading resource servers", { type: "error" });
107
+ } finally {
108
+ setLoading(false);
109
+ }
110
+ };
111
+
112
+ const loadScopes = async (resourceServer: any) => {
113
+ setLoadingScopes(true);
114
+ try {
115
+ // Build available scopes from the selected resource server's scopes property
116
+ const allScopes = (resourceServer?.scopes || []).map((s: any) => ({
117
+ value: s?.value ?? s?.permission_name ?? s,
118
+ description: s?.description ?? "",
119
+ }));
120
+
121
+ // Fetch the client's existing grants from /client-grants
122
+ const existingRes = await dataProvider.getList("client-grants", {
123
+ pagination: { page: 1, perPage: 200 },
124
+ sort: { field: "audience", order: "ASC" },
125
+ filter: { client_id: clientId },
126
+ });
127
+
128
+ const existingAll = existingRes.data ?? [];
129
+ // Find existing grant for this resource server
130
+ const existingGrant = existingAll.find(
131
+ (g: any) => g.audience === resourceServer?.identifier,
132
+ );
133
+
134
+ const existingScopes = existingGrant?.scope || [];
135
+ const existingSet = new Set(existingScopes);
136
+
137
+ // Filter out scopes the client already has
138
+ const filtered = allScopes.filter(
139
+ (s: any) => s.value && !existingSet.has(s.value),
140
+ );
141
+
142
+ setAvailableScopes(filtered);
143
+ } catch (error) {
144
+ console.error("Error loading scopes:", error);
145
+ notify("Error loading scopes", { type: "error" });
146
+ } finally {
147
+ setLoadingScopes(false);
148
+ }
149
+ };
150
+
151
+ const handleResourceServerChange = (resourceServer: any) => {
152
+ setSelectedResourceServer(resourceServer);
153
+ setSelectedScopes([]);
154
+ if (resourceServer) {
155
+ loadScopes(resourceServer);
156
+ } else {
157
+ setAvailableScopes([]);
158
+ }
159
+ };
160
+
161
+ const handleAddClientGrant = async () => {
162
+ if (!clientId || !selectedResourceServer || selectedScopes.length === 0) {
163
+ notify("Please select a resource server and at least one scope", {
164
+ type: "warning",
165
+ });
166
+ return;
167
+ }
168
+
169
+ try {
170
+ // Check if there's already a grant for this resource server
171
+ const existingRes = await dataProvider.getList("client-grants", {
172
+ pagination: { page: 1, perPage: 200 },
173
+ sort: { field: "audience", order: "ASC" },
174
+ filter: { client_id: clientId },
175
+ });
176
+
177
+ const existingGrant = existingRes.data?.find(
178
+ (g: any) => g.audience === selectedResourceServer.identifier,
179
+ );
180
+
181
+ const newScopes = selectedScopes.map((scope: any) => scope.value);
182
+
183
+ if (existingGrant) {
184
+ // Update existing grant by merging scopes
185
+ const existingScopes = existingGrant.scope || [];
186
+ const mergedScopes = [...new Set([...existingScopes, ...newScopes])];
187
+
188
+ await dataProvider.update("client-grants", {
189
+ id: existingGrant.id,
190
+ data: {
191
+ scope: mergedScopes,
192
+ },
193
+ previousData: existingGrant,
194
+ });
195
+
196
+ notify(
197
+ `Client grant updated with ${newScopes.length} additional scope(s)`,
198
+ {
199
+ type: "success",
200
+ },
201
+ );
202
+ } else {
203
+ // Create new grant
204
+ const payload = {
205
+ client_id: clientId,
206
+ audience: selectedResourceServer.identifier,
207
+ scope: newScopes,
208
+ };
209
+
210
+ await dataProvider.create("client-grants", {
211
+ data: payload,
212
+ });
213
+
214
+ notify(`Client grant created with ${newScopes.length} scope(s)`, {
215
+ type: "success",
216
+ });
217
+ }
218
+
219
+ handleClose();
220
+ refresh();
221
+ } catch (error) {
222
+ console.error("Error creating/updating client grant:", error);
223
+ notify("Error creating/updating client grant", { type: "error" });
224
+ }
225
+ };
226
+
227
+ if (!clientId) {
228
+ return null;
229
+ }
230
+
231
+ return (
232
+ <>
233
+ <Button
234
+ variant="contained"
235
+ color="primary"
236
+ startIcon={<AddIcon />}
237
+ onClick={handleOpen}
238
+ sx={{ mb: 2 }}
239
+ >
240
+ Add Client Grant
241
+ </Button>
242
+
243
+ <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
244
+ <DialogTitle>Add Client Grant</DialogTitle>
245
+ <DialogContent>
246
+ <Typography variant="body2" sx={{ mb: 3 }}>
247
+ Select a resource server and scopes to grant access to this client
248
+ </Typography>
249
+
250
+ <Box sx={{ mb: 3 }}>
251
+ <Autocomplete
252
+ options={resourceServers}
253
+ getOptionLabel={(option) => option.name || option.identifier}
254
+ value={selectedResourceServer}
255
+ onChange={(_, value) => handleResourceServerChange(value)}
256
+ loading={loading}
257
+ isOptionEqualToValue={(option, value) =>
258
+ !!option &&
259
+ !!value &&
260
+ (option.id === value.id ||
261
+ option.identifier === value.identifier)
262
+ }
263
+ renderInput={(params) => (
264
+ <MuiTextField
265
+ {...params}
266
+ label="Resource Server"
267
+ variant="outlined"
268
+ fullWidth
269
+ InputProps={{
270
+ ...params.InputProps,
271
+ endAdornment: (
272
+ <>
273
+ {loading ? (
274
+ <CircularProgress color="inherit" size={20} />
275
+ ) : null}
276
+ {params.InputProps.endAdornment}
277
+ </>
278
+ ),
279
+ }}
280
+ />
281
+ )}
282
+ />
283
+ </Box>
284
+
285
+ {selectedResourceServer && (
286
+ <>
287
+ <Box sx={{ mb: 3 }}>
288
+ <Autocomplete
289
+ multiple
290
+ options={availableScopes}
291
+ getOptionLabel={(option) =>
292
+ `${option.value} - ${option.description || "No description"}`
293
+ }
294
+ value={selectedScopes}
295
+ onChange={(_, value) => setSelectedScopes(value)}
296
+ loading={loadingScopes}
297
+ isOptionEqualToValue={(option, value) =>
298
+ option?.value === value?.value
299
+ }
300
+ renderInput={(params) => (
301
+ <MuiTextField
302
+ {...params}
303
+ label="Scopes"
304
+ variant="outlined"
305
+ fullWidth
306
+ InputProps={{
307
+ ...params.InputProps,
308
+ endAdornment: (
309
+ <>
310
+ {loadingScopes ? (
311
+ <CircularProgress color="inherit" size={20} />
312
+ ) : null}
313
+ {params.InputProps.endAdornment}
314
+ </>
315
+ ),
316
+ }}
317
+ />
318
+ )}
319
+ renderOption={(props, option) => (
320
+ <li {...props} key={option.value}>
321
+ <Box>
322
+ <Typography variant="body2" fontWeight="medium">
323
+ {option.value}
324
+ </Typography>
325
+ {option.description && (
326
+ <Typography variant="caption" color="text.secondary">
327
+ {option.description}
328
+ </Typography>
329
+ )}
330
+ </Box>
331
+ </li>
332
+ )}
333
+ />
334
+ </Box>
335
+
336
+ {!loadingScopes && availableScopes.length === 0 && (
337
+ <Typography
338
+ variant="body2"
339
+ color="text.secondary"
340
+ sx={{ mb: 2 }}
341
+ >
342
+ This client already has access to all available scopes for the
343
+ selected resource server.
344
+ </Typography>
345
+ )}
346
+
347
+ {selectedScopes.length > 0 && (
348
+ <Box sx={{ mt: 2 }}>
349
+ <Typography variant="subtitle2" sx={{ mb: 1 }}>
350
+ Selected Scopes ({selectedScopes.length}):
351
+ </Typography>
352
+ <Box sx={{ maxHeight: 200, overflow: "auto" }}>
353
+ {selectedScopes.map((scope, index) => (
354
+ <Typography key={index} variant="body2" sx={{ ml: 2 }}>
355
+ • {scope.value}
356
+ </Typography>
357
+ ))}
358
+ </Box>
359
+ </Box>
360
+ )}
361
+ </>
362
+ )}
363
+ </DialogContent>
364
+ <DialogActions>
365
+ <Button onClick={handleClose}>Cancel</Button>
366
+ <Button
367
+ onClick={handleAddClientGrant}
368
+ variant="contained"
369
+ disabled={selectedScopes.length === 0 || !selectedResourceServer}
370
+ >
371
+ Add Grant with {selectedScopes.length} Scope(s)
372
+ </Button>
373
+ </DialogActions>
374
+ </Dialog>
375
+ </>
376
+ );
377
+ };
378
+
379
+ const EditClientGrantButton = () => {
380
+ const [open, setOpen] = useState(false);
381
+ const [loading, setLoading] = useState(false);
382
+ const [availableScopes, setAvailableScopes] = useState<any[]>([]);
383
+ const [selectedScopes, setSelectedScopes] = useState<any[]>([]);
384
+
385
+ const clientGrant = useRecordContext();
386
+ const dataProvider = useDataProvider();
387
+ const notify = useNotify();
388
+ const refresh = useRefresh();
389
+
390
+ if (!clientGrant) {
391
+ return null;
392
+ }
393
+
394
+ const handleOpen = async () => {
395
+ setOpen(true);
396
+ await loadResourceServerAndScopes();
397
+ };
398
+
399
+ const handleClose = () => {
400
+ setOpen(false);
401
+ setAvailableScopes([]);
402
+ setSelectedScopes([]);
403
+ };
404
+
405
+ const loadResourceServerAndScopes = async () => {
406
+ setLoading(true);
407
+ try {
408
+ // Find the resource server by audience
409
+ const { data: resourceServers } = await dataProvider.getList(
410
+ "resource-servers",
411
+ {
412
+ pagination: { page: 1, perPage: 100 },
413
+ sort: { field: "name", order: "ASC" },
414
+ filter: {},
415
+ },
416
+ );
417
+
418
+ const rs = resourceServers.find(
419
+ (rs: any) => rs.identifier === clientGrant.audience,
420
+ );
421
+ if (!rs) {
422
+ notify("Resource server not found", { type: "error" });
423
+ handleClose();
424
+ return;
425
+ }
426
+
427
+ // Get all scopes from the resource server
428
+ const allScopes = (rs.scopes || []).map((s: any) => ({
429
+ value: s?.value ?? s?.permission_name ?? s,
430
+ description: s?.description ?? "",
431
+ }));
432
+
433
+ // Get currently assigned scopes
434
+ const currentScopeValues = clientGrant.scope || [];
435
+
436
+ // Filter out already assigned scopes from available scopes
437
+ const unassignedScopes = allScopes.filter(
438
+ (scope: any) => !currentScopeValues.includes(scope.value),
439
+ );
440
+
441
+ setAvailableScopes(unassignedScopes);
442
+
443
+ // Set currently selected scopes (start with empty since we're only showing unassigned)
444
+ setSelectedScopes([]);
445
+ } catch (error) {
446
+ console.error("Error loading resource server and scopes:", error);
447
+ notify("Error loading scopes", { type: "error" });
448
+ } finally {
449
+ setLoading(false);
450
+ }
451
+ };
452
+
453
+ const handleSave = async () => {
454
+ if (selectedScopes.length === 0) {
455
+ notify("Please select at least one scope to add", { type: "warning" });
456
+ return;
457
+ }
458
+
459
+ try {
460
+ // Get the newly selected scope values
461
+ const newScopeValues = selectedScopes.map((scope: any) => scope.value);
462
+
463
+ // Get existing scopes and merge with new ones
464
+ const existingScopes = clientGrant.scope || [];
465
+ const mergedScopes = [...existingScopes, ...newScopeValues];
466
+
467
+ await dataProvider.update("client-grants", {
468
+ id: clientGrant.id,
469
+ data: {
470
+ scope: mergedScopes,
471
+ },
472
+ previousData: clientGrant,
473
+ });
474
+
475
+ notify(`Added ${newScopeValues.length} scope(s) to client grant`, {
476
+ type: "success",
477
+ });
478
+ handleClose();
479
+ refresh();
480
+ } catch (error) {
481
+ console.error("Error updating client grant:", error);
482
+ notify("Error updating client grant", { type: "error" });
483
+ }
484
+ };
485
+
486
+ return (
487
+ <>
488
+ <Tooltip title="Add scopes to client grant">
489
+ <IconButton onClick={handleOpen} size="small">
490
+ <EditIcon />
491
+ </IconButton>
492
+ </Tooltip>
493
+
494
+ <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
495
+ <DialogTitle>Add Scopes to Client Grant</DialogTitle>
496
+ <DialogContent>
497
+ <Typography variant="body2" sx={{ mb: 2 }}>
498
+ Add additional scopes to <strong>{clientGrant.audience}</strong>
499
+ </Typography>
500
+
501
+ {/* Show currently assigned scopes */}
502
+ {clientGrant.scope && clientGrant.scope.length > 0 && (
503
+ <Box sx={{ mb: 3 }}>
504
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
505
+ Currently assigned scopes:
506
+ </Typography>
507
+ <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
508
+ {clientGrant.scope.map((scope: string) => (
509
+ <Chip
510
+ key={scope}
511
+ label={scope}
512
+ size="small"
513
+ variant="filled"
514
+ color="primary"
515
+ />
516
+ ))}
517
+ </Box>
518
+ </Box>
519
+ )}
520
+
521
+ {loading ? (
522
+ <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
523
+ <CircularProgress />
524
+ </Box>
525
+ ) : (
526
+ <Autocomplete
527
+ multiple
528
+ options={availableScopes}
529
+ getOptionLabel={(option) => option.value}
530
+ value={selectedScopes}
531
+ onChange={(_, value) => setSelectedScopes(value)}
532
+ isOptionEqualToValue={(option, value) =>
533
+ option.value === value.value
534
+ }
535
+ renderTags={(value, getTagProps) =>
536
+ value.map((option, index) => (
537
+ <Chip
538
+ variant="outlined"
539
+ label={option.value}
540
+ {...getTagProps({ index })}
541
+ key={option.value}
542
+ />
543
+ ))
544
+ }
545
+ renderInput={(params) => (
546
+ <MuiTextField
547
+ {...params}
548
+ label="Additional Scopes"
549
+ placeholder="Select additional scopes to add"
550
+ variant="outlined"
551
+ fullWidth
552
+ />
553
+ )}
554
+ renderOption={(props, option) => (
555
+ <li {...props}>
556
+ <Box>
557
+ <Typography variant="body2" component="div">
558
+ {option.value}
559
+ </Typography>
560
+ {option.description && (
561
+ <Typography variant="caption" color="text.secondary">
562
+ {option.description}
563
+ </Typography>
564
+ )}
565
+ </Box>
566
+ </li>
567
+ )}
568
+ />
569
+ )}
570
+ </DialogContent>
571
+ <DialogActions>
572
+ <Button onClick={handleClose}>Cancel</Button>
573
+ <Button
574
+ onClick={handleSave}
575
+ variant="contained"
576
+ disabled={loading || selectedScopes.length === 0}
577
+ >
578
+ Add Scopes
579
+ </Button>
580
+ </DialogActions>
581
+ </Dialog>
582
+ </>
583
+ );
584
+ };
585
+
586
+ const RemoveClientGrantButton = () => {
587
+ const [open, setOpen] = useState(false);
588
+ const clientGrant = useRecordContext();
589
+ const dataProvider = useDataProvider();
590
+ const notify = useNotify();
591
+ const refresh = useRefresh();
592
+
593
+ if (!clientGrant) {
594
+ return null;
595
+ }
596
+
597
+ const handleOpen = () => setOpen(true);
598
+ const handleClose = () => setOpen(false);
599
+
600
+ const handleRemove = async () => {
601
+ try {
602
+ await dataProvider.delete("client-grants", {
603
+ id: clientGrant.id,
604
+ previousData: clientGrant,
605
+ });
606
+ notify("Client grant removed successfully", { type: "success" });
607
+ handleClose();
608
+ refresh();
609
+ } catch (error) {
610
+ console.error("Error removing client grant:", error);
611
+ notify("Error removing client grant", { type: "error" });
612
+ }
613
+ };
614
+
615
+ return (
616
+ <>
617
+ <Tooltip title="Remove client grant">
618
+ <IconButton onClick={handleOpen} size="small">
619
+ <DeleteIcon />
620
+ </IconButton>
621
+ </Tooltip>
622
+
623
+ <Dialog open={open} onClose={handleClose}>
624
+ <DialogTitle>Confirm Removal</DialogTitle>
625
+ <DialogContent>
626
+ <Typography>
627
+ Are you sure you want to remove this client grant for{" "}
628
+ <strong>{clientGrant.audience}</strong>?
629
+ </Typography>
630
+ </DialogContent>
631
+ <DialogActions>
632
+ <Button onClick={handleClose}>Cancel</Button>
633
+ <Button onClick={handleRemove} color="error" variant="contained">
634
+ Remove
635
+ </Button>
636
+ </DialogActions>
637
+ </Dialog>
638
+ </>
639
+ );
640
+ };
641
+
642
+ const GrantTypesInput = ({ source }: { source: string }) => {
643
+ const { field } = useInput({ source });
644
+ const { value, onChange } = field;
645
+
646
+ const grantTypeOptions = [
647
+ { value: "implicit", label: "Implicit" },
648
+ { value: "authorization_code", label: "Authorization Code" },
649
+ { value: "refresh_token", label: "Refresh Token" },
650
+ { value: "client_credentials", label: "Client Credentials" },
651
+ { value: "password", label: "Password" },
652
+ { value: "mfa", label: "MFA" },
653
+ { value: "passwordless_otp", label: "Passwordless OTP" },
654
+ ];
655
+
656
+ const handleChange = (grantType: string, checked: boolean) => {
657
+ const currentGrants = Array.isArray(value) ? value : [];
658
+ let newGrants;
659
+
660
+ if (checked) {
661
+ newGrants = [...currentGrants, grantType];
662
+ } else {
663
+ newGrants = currentGrants.filter((gt: string) => gt !== grantType);
664
+ }
665
+
666
+ onChange(newGrants);
667
+ };
668
+
669
+ const isChecked = (grantType: string) => {
670
+ return Array.isArray(value) && value.includes(grantType);
671
+ };
672
+
673
+ return (
674
+ <Box>
675
+ <Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
676
+ Grant Types
677
+ </Typography>
678
+ <Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, mb: 2 }}>
679
+ {grantTypeOptions.map((option) => (
680
+ <FormControlLabel
681
+ key={option.value}
682
+ control={
683
+ <Checkbox
684
+ checked={isChecked(option.value)}
685
+ onChange={(e) => handleChange(option.value, e.target.checked)}
686
+ />
687
+ }
688
+ label={option.label}
689
+ />
690
+ ))}
691
+ </Box>
692
+ </Box>
693
+ );
694
+ };
695
+
696
+ const ClientMetadataInput = ({ source }: { source: string }) => {
697
+ const { field } = useInput({ source });
698
+ const { value, onChange } = field;
699
+ const [metadataArray, setMetadataArray] = useState<
700
+ Array<{ key: string; value: string }>
701
+ >([]);
702
+
703
+ // Initialize metadata array from the current value
704
+ useEffect(() => {
705
+ if (value && typeof value === "object") {
706
+ // Fields managed by other inputs (BooleanInput, SelectInput, etc.)
707
+ const preservedFields = ["disable_sign_ups", "email_validation"];
708
+
709
+ const array = Object.entries(value)
710
+ .filter(([key]) => !preservedFields.includes(key))
711
+ .map(([key, val]) => ({
712
+ key,
713
+ value: String(val),
714
+ }));
715
+ setMetadataArray(array);
716
+ } else if (!value) {
717
+ setMetadataArray([]);
718
+ }
719
+ }, [value]);
720
+
721
+ const handleAdd = () => {
722
+ setMetadataArray([...metadataArray, { key: "", value: "" }]);
723
+ };
724
+
725
+ const handleRemove = (index: number) => {
726
+ const newArray = metadataArray.filter((_, i) => i !== index);
727
+ setMetadataArray(newArray);
728
+ updateFormData(newArray);
729
+ };
730
+
731
+ const handleChange = (
732
+ index: number,
733
+ field: "key" | "value",
734
+ newValue: string,
735
+ ) => {
736
+ const newArray = [...metadataArray];
737
+ const currentItem = newArray[index];
738
+ if (currentItem) {
739
+ newArray[index] = {
740
+ key: field === "key" ? newValue : currentItem.key,
741
+ value: field === "value" ? newValue : currentItem.value,
742
+ };
743
+ setMetadataArray(newArray);
744
+ updateFormData(newArray);
745
+ }
746
+ };
747
+
748
+ const updateFormData = (array: Array<{ key: string; value: string }>) => {
749
+ const newObject: Record<string, any> = {};
750
+ array.forEach((item) => {
751
+ if (item.key && item.key.trim()) {
752
+ newObject[item.key.trim()] = item.value;
753
+ }
754
+ });
755
+ onChange(newObject);
756
+ };
757
+
758
+ return (
759
+ <Box sx={{ mt: 2 }}>
760
+ <Typography variant="h6" sx={{ mb: 1 }}>
761
+ Application Metadata
762
+ </Typography>
763
+ {metadataArray.map((item, index) => (
764
+ <Box key={index} sx={{ display: "flex", alignItems: "center", mb: 1 }}>
765
+ <MuiTextField
766
+ label="Key"
767
+ value={item.key}
768
+ onChange={(e) => handleChange(index, "key", e.target.value)}
769
+ sx={{ mr: 1, minWidth: 150 }}
770
+ size="small"
771
+ />
772
+ <MuiTextField
773
+ label="Value"
774
+ value={item.value}
775
+ onChange={(e) => handleChange(index, "value", e.target.value)}
776
+ sx={{ mr: 1, minWidth: 200 }}
777
+ size="small"
778
+ />
779
+ <IconButton
780
+ onClick={() => handleRemove(index)}
781
+ size="small"
782
+ color="error"
783
+ >
784
+ <DeleteIcon />
785
+ </IconButton>
786
+ </Box>
787
+ ))}
788
+ <Button
789
+ variant="outlined"
790
+ size="small"
791
+ startIcon={<AddIcon />}
792
+ onClick={handleAdd}
793
+ sx={{ mt: 1 }}
794
+ >
795
+ Add Metadata
796
+ </Button>
797
+ </Box>
798
+ );
799
+ };
800
+
801
+ interface Connection {
802
+ id: string;
803
+ name: string;
804
+ strategy: string;
805
+ }
806
+
807
+ // Helper to get API base URL
808
+ const getApiBaseUrl = (): string => {
809
+ const selectedDomain = getSelectedDomainFromStorage();
810
+ const domains = getDomainFromStorage();
811
+ const domainConfig = domains.find((d) => d.url === selectedDomain);
812
+
813
+ if (domainConfig?.restApiUrl) {
814
+ return domainConfig.restApiUrl.replace(/\/$/, "");
815
+ }
816
+ return buildUrlWithProtocol(selectedDomain).replace(/\/$/, "");
817
+ };
818
+
819
+ // Helper to get tenant ID from URL
820
+ const getTenantIdFromUrl = (): string | null => {
821
+ const urlPath = window.location.pathname;
822
+ const matches = urlPath.match(/\/([^/]+)\/clients/);
823
+ return matches && matches[1] ? matches[1] : null;
824
+ };
825
+
826
+ const ConnectionsTab = () => {
827
+ const [loading, setLoading] = useState(true);
828
+ const [saving, setSaving] = useState(false);
829
+ const [enabledConnections, setEnabledConnections] = useState<Connection[]>(
830
+ [],
831
+ );
832
+ const [availableConnections, setAvailableConnections] = useState<
833
+ Connection[]
834
+ >([]);
835
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
836
+ const [selectedConnection, setSelectedConnection] =
837
+ useState<Connection | null>(null);
838
+
839
+ const record = useRecordContext();
840
+ const dataProvider = useDataProvider();
841
+ const notify = useNotify();
842
+
843
+ const clientId = record?.id as string | undefined;
844
+ const tenantId = getTenantIdFromUrl();
845
+
846
+ const loadConnections = useCallback(async () => {
847
+ if (!clientId || !tenantId) return;
848
+
849
+ setLoading(true);
850
+ try {
851
+ // Fetch enabled connections from the new API endpoint
852
+ const baseUrl = getApiBaseUrl();
853
+ const response = await authorizedHttpClient(
854
+ `${baseUrl}/api/v2/clients/${clientId}/connections`,
855
+ {
856
+ method: "GET",
857
+ headers: {
858
+ "tenant-id": tenantId,
859
+ },
860
+ },
861
+ );
862
+
863
+ const result = response.json as {
864
+ enabled_connections: Array<{
865
+ connection_id: string;
866
+ connection?: Connection;
867
+ }>;
868
+ };
869
+
870
+ // Get enabled connections with their details
871
+ const enabled: Connection[] = result.enabled_connections
872
+ .filter((ec) => ec.connection)
873
+ .map((ec) => ({
874
+ id: ec.connection_id,
875
+ name: ec.connection!.name,
876
+ strategy: ec.connection!.strategy,
877
+ }));
878
+
879
+ // Fetch all connections to determine available ones
880
+ const { data: allConnections } = await dataProvider.getList<Connection>(
881
+ "connections",
882
+ {
883
+ pagination: { page: 1, perPage: 100 },
884
+ sort: { field: "name", order: "ASC" },
885
+ filter: {},
886
+ },
887
+ );
888
+
889
+ // Get available connections (ones not enabled)
890
+ const enabledIds = new Set(enabled.map((c) => c.id));
891
+ const available = allConnections.filter((c) => !enabledIds.has(c.id));
892
+
893
+ setEnabledConnections(enabled);
894
+ setAvailableConnections(available);
895
+ } catch (error) {
896
+ console.error("Error loading connections:", error);
897
+ notify("Error loading connections", { type: "error" });
898
+ } finally {
899
+ setLoading(false);
900
+ }
901
+ }, [clientId, tenantId, dataProvider, notify]);
902
+
903
+ useEffect(() => {
904
+ loadConnections();
905
+ }, [loadConnections]);
906
+
907
+ const updateClientConnections = async (
908
+ newConnectionIds: string[],
909
+ ): Promise<boolean> => {
910
+ if (!clientId || !tenantId) return false;
911
+
912
+ setSaving(true);
913
+ try {
914
+ const baseUrl = getApiBaseUrl();
915
+ await authorizedHttpClient(
916
+ `${baseUrl}/api/v2/clients/${clientId}/connections`,
917
+ {
918
+ method: "PATCH",
919
+ headers: {
920
+ "tenant-id": tenantId,
921
+ "Content-Type": "application/json",
922
+ },
923
+ body: JSON.stringify(newConnectionIds),
924
+ },
925
+ );
926
+ return true;
927
+ } catch (error) {
928
+ console.error("Error updating client connections:", error);
929
+ notify("Error updating connections", { type: "error" });
930
+ return false;
931
+ } finally {
932
+ setSaving(false);
933
+ }
934
+ };
935
+
936
+ const handleAddConnection = async () => {
937
+ if (!selectedConnection || !clientId) return;
938
+
939
+ const newConnectionIds = [
940
+ ...enabledConnections.map((c) => c.id),
941
+ selectedConnection.id,
942
+ ];
943
+ const success = await updateClientConnections(newConnectionIds);
944
+
945
+ if (success) {
946
+ notify("Connection enabled for this client", { type: "success" });
947
+ setAddDialogOpen(false);
948
+ setSelectedConnection(null);
949
+
950
+ // Update local state optimistically
951
+ setEnabledConnections([...enabledConnections, selectedConnection]);
952
+ setAvailableConnections(
953
+ availableConnections.filter((c) => c.id !== selectedConnection.id),
954
+ );
955
+ }
956
+ };
957
+
958
+ const handleRemoveConnection = async (connection: Connection) => {
959
+ if (!clientId) return;
960
+
961
+ const newConnectionIds = enabledConnections
962
+ .filter((c) => c.id !== connection.id)
963
+ .map((c) => c.id);
964
+ const success = await updateClientConnections(newConnectionIds);
965
+
966
+ if (success) {
967
+ notify("Connection disabled for this client", { type: "success" });
968
+
969
+ // Update local state optimistically
970
+ setEnabledConnections(
971
+ enabledConnections.filter((c) => c.id !== connection.id),
972
+ );
973
+ setAvailableConnections([...availableConnections, connection]);
974
+ }
975
+ };
976
+
977
+ const handleMoveConnection = async (
978
+ index: number,
979
+ direction: "up" | "down",
980
+ ) => {
981
+ if (!clientId) return;
982
+
983
+ const newIndex = direction === "up" ? index - 1 : index + 1;
984
+ if (newIndex < 0 || newIndex >= enabledConnections.length) return;
985
+
986
+ // Create new order
987
+ const newOrder = [...enabledConnections];
988
+ const movedConnection = newOrder.splice(index, 1)[0];
989
+ if (!movedConnection) return;
990
+ newOrder.splice(newIndex, 0, movedConnection);
991
+
992
+ // Update local state optimistically
993
+ setEnabledConnections(newOrder);
994
+
995
+ // Update the client's connections array
996
+ const newConnectionIds = newOrder.map((c) => c.id);
997
+ const success = await updateClientConnections(newConnectionIds);
998
+
999
+ if (success) {
1000
+ notify("Connection order updated", { type: "success" });
1001
+ } else {
1002
+ // Revert on failure
1003
+ loadConnections();
1004
+ }
1005
+ };
1006
+
1007
+ if (loading) {
1008
+ return (
1009
+ <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
1010
+ <CircularProgress />
1011
+ </Box>
1012
+ );
1013
+ }
1014
+
1015
+ return (
1016
+ <Box sx={{ width: "100%", maxWidth: 800 }}>
1017
+ <Typography variant="h6" sx={{ mb: 2 }}>
1018
+ Enabled Connections
1019
+ </Typography>
1020
+
1021
+ {enabledConnections.length === 0 ? (
1022
+ <Typography color="text.secondary" sx={{ mb: 2 }}>
1023
+ No connections enabled for this client. Click "Add Connection" to
1024
+ enable one.
1025
+ </Typography>
1026
+ ) : (
1027
+ <Paper variant="outlined" sx={{ mb: 2 }}>
1028
+ <List>
1029
+ {enabledConnections.map((connection, index) => (
1030
+ <ListItem
1031
+ key={connection.id}
1032
+ divider={index < enabledConnections.length - 1}
1033
+ sx={{
1034
+ "&:hover": { bgcolor: "action.hover" },
1035
+ }}
1036
+ >
1037
+ <Box sx={{ display: "flex", alignItems: "center", mr: 2 }}>
1038
+ <DragIndicatorIcon color="disabled" />
1039
+ </Box>
1040
+ <ListItemText
1041
+ primary={connection.name}
1042
+ secondary={`Strategy: ${connection.strategy}`}
1043
+ />
1044
+ <ListItemSecondaryAction>
1045
+ <Tooltip title="Move up">
1046
+ <span>
1047
+ <IconButton
1048
+ size="small"
1049
+ onClick={() => handleMoveConnection(index, "up")}
1050
+ disabled={index === 0 || saving}
1051
+ >
1052
+ <ArrowUpwardIcon />
1053
+ </IconButton>
1054
+ </span>
1055
+ </Tooltip>
1056
+ <Tooltip title="Move down">
1057
+ <span>
1058
+ <IconButton
1059
+ size="small"
1060
+ onClick={() => handleMoveConnection(index, "down")}
1061
+ disabled={
1062
+ index === enabledConnections.length - 1 || saving
1063
+ }
1064
+ >
1065
+ <ArrowDownwardIcon />
1066
+ </IconButton>
1067
+ </span>
1068
+ </Tooltip>
1069
+ <Tooltip title="Remove connection">
1070
+ <IconButton
1071
+ size="small"
1072
+ onClick={() => handleRemoveConnection(connection)}
1073
+ disabled={saving}
1074
+ color="error"
1075
+ >
1076
+ <DeleteIcon />
1077
+ </IconButton>
1078
+ </Tooltip>
1079
+ </ListItemSecondaryAction>
1080
+ </ListItem>
1081
+ ))}
1082
+ </List>
1083
+ </Paper>
1084
+ )}
1085
+
1086
+ <Button
1087
+ variant="contained"
1088
+ color="primary"
1089
+ startIcon={<AddIcon />}
1090
+ onClick={() => setAddDialogOpen(true)}
1091
+ disabled={availableConnections.length === 0}
1092
+ >
1093
+ Add Connection
1094
+ </Button>
1095
+
1096
+ {/* Add Connection Dialog */}
1097
+ <Dialog
1098
+ open={addDialogOpen}
1099
+ onClose={() => {
1100
+ setAddDialogOpen(false);
1101
+ setSelectedConnection(null);
1102
+ }}
1103
+ maxWidth="sm"
1104
+ fullWidth
1105
+ >
1106
+ <DialogTitle>Add Connection</DialogTitle>
1107
+ <DialogContent>
1108
+ <Typography variant="body2" sx={{ mb: 2 }}>
1109
+ Select a connection to enable for this client
1110
+ </Typography>
1111
+ <Autocomplete
1112
+ options={availableConnections}
1113
+ getOptionLabel={(option) => `${option.name} (${option.strategy})`}
1114
+ value={selectedConnection}
1115
+ onChange={(_, value) => setSelectedConnection(value)}
1116
+ isOptionEqualToValue={(option, value) => option.id === value.id}
1117
+ renderInput={(params) => (
1118
+ <MuiTextField
1119
+ {...params}
1120
+ label="Connection"
1121
+ variant="outlined"
1122
+ fullWidth
1123
+ />
1124
+ )}
1125
+ renderOption={(props, option) => (
1126
+ <li {...props} key={option.id}>
1127
+ <Box>
1128
+ <Typography variant="body2">{option.name}</Typography>
1129
+ <Typography variant="caption" color="text.secondary">
1130
+ Strategy: {option.strategy}
1131
+ </Typography>
1132
+ </Box>
1133
+ </li>
1134
+ )}
1135
+ />
1136
+ </DialogContent>
1137
+ <DialogActions>
1138
+ <Button
1139
+ onClick={() => {
1140
+ setAddDialogOpen(false);
1141
+ setSelectedConnection(null);
1142
+ }}
1143
+ >
1144
+ Cancel
1145
+ </Button>
1146
+ <Button
1147
+ onClick={handleAddConnection}
1148
+ variant="contained"
1149
+ disabled={!selectedConnection || saving}
1150
+ >
1151
+ {saving ? <CircularProgress size={20} /> : "Add Connection"}
1152
+ </Button>
1153
+ </DialogActions>
1154
+ </Dialog>
1155
+ </Box>
1156
+ );
1157
+ };
1158
+
1159
+ export function ClientEdit() {
1160
+ return (
1161
+ <Edit>
1162
+ <SimpleShowLayout>
1163
+ <TextField source="name" />
1164
+ <TextField source="id" />
1165
+ </SimpleShowLayout>
1166
+ <TabbedForm>
1167
+ <TabbedForm.Tab label="details">
1168
+ <TextInput source="id" />
1169
+ <TextInput source="name" />
1170
+ <TextInput source="client_secret" />
1171
+ <SelectInput
1172
+ source="client_metadata.email_validation"
1173
+ choices={[
1174
+ { id: "disabled", name: "Disabled" },
1175
+ { id: "enabled", name: "Enabled" },
1176
+ { id: "enforced", name: "Enforced" },
1177
+ ]}
1178
+ />
1179
+ <BooleanInput
1180
+ source="client_metadata.disable_sign_ups"
1181
+ format={(value) => value === "true" || value === true}
1182
+ parse={(value) => (value ? "true" : "false")}
1183
+ />
1184
+ <ClientMetadataInput source="client_metadata" />
1185
+ <GrantTypesInput source="grant_types" />
1186
+ <ArrayInput source="callbacks">
1187
+ <SimpleFormIterator inline>
1188
+ <TextInput source="" defaultValue="" />
1189
+ </SimpleFormIterator>
1190
+ </ArrayInput>
1191
+ <ArrayInput source="allowed_logout_urls">
1192
+ <SimpleFormIterator inline>
1193
+ <TextInput source="" defaultValue="" />
1194
+ </SimpleFormIterator>
1195
+ </ArrayInput>
1196
+ <ArrayInput source="web_origins">
1197
+ <SimpleFormIterator inline>
1198
+ <TextInput source="" defaultValue="" />
1199
+ </SimpleFormIterator>
1200
+ </ArrayInput>
1201
+ <ArrayInput source="allowed_clients">
1202
+ <SimpleFormIterator inline>
1203
+ <TextInput source="" defaultValue="" />
1204
+ </SimpleFormIterator>
1205
+ </ArrayInput>
1206
+ <Labeled label="Created At">
1207
+ <DateField source="created_at" showTime={true} />
1208
+ </Labeled>
1209
+ <Labeled label="Updated At">
1210
+ <DateField source="updated_at" showTime={true} />
1211
+ </Labeled>
1212
+ </TabbedForm.Tab>
1213
+ <TabbedForm.Tab label="SSO">
1214
+ <TextInput source="addons.samlp.audience" label="audience" />
1215
+ <TextInput source="addons.samlp.destination" label="destination" />
1216
+ <TextInput
1217
+ multiline
1218
+ source="addons.samlp.mappings"
1219
+ format={(value) => (value ? JSON.stringify(value, null, 2) : "")}
1220
+ parse={(value) => {
1221
+ try {
1222
+ return value ? JSON.parse(value) : {};
1223
+ } catch {
1224
+ throw new Error("Invalid JSON");
1225
+ }
1226
+ }}
1227
+ />
1228
+ </TabbedForm.Tab>
1229
+ <TabbedForm.Tab label="Client Grants">
1230
+ <AddClientGrantButton />
1231
+ <ReferenceManyField
1232
+ reference="client-grants"
1233
+ target="client_id"
1234
+ pagination={<PaginationComponent />}
1235
+ sort={{ field: "audience", order: "ASC" }}
1236
+ >
1237
+ <DatagridComponent
1238
+ sx={{
1239
+ width: "100%",
1240
+ "& .column-comment": {
1241
+ maxWidth: "20em",
1242
+ overflow: "hidden",
1243
+ textOverflow: "ellipsis",
1244
+ whiteSpace: "nowrap",
1245
+ },
1246
+ }}
1247
+ rowClick=""
1248
+ bulkActionButtons={false}
1249
+ >
1250
+ <TextField source="audience" label="Resource Server" />
1251
+ <FunctionField
1252
+ source="scope"
1253
+ label="Scopes"
1254
+ render={(record: any) => {
1255
+ if (!record.scope || record.scope.length === 0) {
1256
+ return "No scopes";
1257
+ }
1258
+ return record.scope.join(", ");
1259
+ }}
1260
+ />
1261
+ <FunctionField
1262
+ source="created_at"
1263
+ render={(record: any) =>
1264
+ record.created_at ? <DateAgo date={record.created_at} /> : "-"
1265
+ }
1266
+ label="Created"
1267
+ />
1268
+ <EditClientGrantButton />
1269
+ <RemoveClientGrantButton />
1270
+ </DatagridComponent>
1271
+ </ReferenceManyField>
1272
+ </TabbedForm.Tab>
1273
+ <TabbedForm.Tab label="Connections">
1274
+ <ConnectionsTab />
1275
+ </TabbedForm.Tab>
1276
+ <TabbedForm.Tab label="Raw JSON">
1277
+ <FunctionField
1278
+ source="date"
1279
+ render={(record: any) => <JsonOutput data={record} />}
1280
+ />
1281
+ </TabbedForm.Tab>
1282
+ </TabbedForm>
1283
+ </Edit>
1284
+ );
1285
+ }