@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,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
|
+
}
|