@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,1119 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ Box,
4
+ Typography,
5
+ Drawer,
6
+ TextField,
7
+ Button,
8
+ IconButton,
9
+ FormControl,
10
+ InputLabel,
11
+ Select,
12
+ MenuItem,
13
+ Divider,
14
+ FormHelperText,
15
+ Switch,
16
+ FormControlLabel,
17
+ Tab,
18
+ Tabs,
19
+ Paper,
20
+ Dialog,
21
+ DialogTitle,
22
+ DialogContent,
23
+ DialogActions,
24
+ Menu,
25
+ ListItemIcon,
26
+ ListItemText,
27
+ Autocomplete,
28
+ } from "@mui/material";
29
+ import CloseIcon from "@mui/icons-material/Close";
30
+ import EditIcon from "@mui/icons-material/Edit";
31
+ import CodeIcon from "@mui/icons-material/Code";
32
+ import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
33
+ import TextFieldsIcon from "@mui/icons-material/TextFields";
34
+ import GavelIcon from "@mui/icons-material/Gavel";
35
+ import SmartButtonIcon from "@mui/icons-material/SmartButton";
36
+ import ShortTextIcon from "@mui/icons-material/ShortText";
37
+ import EmailIcon from "@mui/icons-material/Email";
38
+ import NumbersIcon from "@mui/icons-material/Numbers";
39
+ import PhoneIcon from "@mui/icons-material/Phone";
40
+ import { Node } from "@xyflow/react";
41
+
42
+ import DeleteIcon from "@mui/icons-material/Delete";
43
+ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
44
+ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
45
+
46
+ import RichTextEditor from "./RichTextEditor";
47
+
48
+ // Available fields for router condition rules
49
+ // These are the user context fields that can be used in router conditions
50
+ const ROUTER_FIELD_OPTIONS = [
51
+ {
52
+ value: "{{context.user.email}}",
53
+ label: "User Email",
54
+ description: "The user's email address",
55
+ },
56
+ {
57
+ value: "{{context.user.email_verified}}",
58
+ label: "Email Verified",
59
+ description: "Whether the user's email is verified (true/false)",
60
+ },
61
+ {
62
+ value: "{{context.user.name}}",
63
+ label: "Name",
64
+ description: "The user's full name",
65
+ },
66
+ {
67
+ value: "{{context.user.given_name}}",
68
+ label: "Given Name",
69
+ description: "The user's first/given name",
70
+ },
71
+ {
72
+ value: "{{context.user.family_name}}",
73
+ label: "Family Name",
74
+ description: "The user's last/family name",
75
+ },
76
+ {
77
+ value: "{{context.user.nickname}}",
78
+ label: "Nickname",
79
+ description: "The user's nickname",
80
+ },
81
+ {
82
+ value: "{{context.user.picture}}",
83
+ label: "Picture URL",
84
+ description: "URL to the user's profile picture",
85
+ },
86
+ {
87
+ value: "{{context.user.locale}}",
88
+ label: "Locale",
89
+ description: "The user's locale/language preference",
90
+ },
91
+ {
92
+ value: "{{context.user.username}}",
93
+ label: "Username",
94
+ description: "The user's username",
95
+ },
96
+ {
97
+ value: "{{context.user.phone_number}}",
98
+ label: "Phone Number",
99
+ description: "The user's phone number",
100
+ },
101
+ {
102
+ value: "{{context.user.connection}}",
103
+ label: "Connection",
104
+ description: "The authentication connection used",
105
+ },
106
+ {
107
+ value: "{{context.user.provider}}",
108
+ label: "Provider",
109
+ description: "The authentication provider (e.g., auth0, google)",
110
+ },
111
+ {
112
+ value: "{{context.user.is_social}}",
113
+ label: "Is Social",
114
+ description: "Whether the user logged in via social provider (true/false)",
115
+ },
116
+ {
117
+ value: "{{context.user.user_id}}",
118
+ label: "User ID",
119
+ description: "The unique user identifier",
120
+ },
121
+ ];
122
+
123
+ import type {
124
+ ComponentConfig,
125
+ FlowNodeData,
126
+ FlowChoice,
127
+ RouterRule,
128
+ StartNode,
129
+ EndingNode,
130
+ } from "./FlowEditor";
131
+
132
+ interface NodeEditorProps {
133
+ open: boolean;
134
+ selectedNode: Node | null;
135
+ nodes: FlowNodeData[];
136
+ start?: StartNode;
137
+ ending?: EndingNode;
138
+ flows?: FlowChoice[];
139
+ onClose: () => void;
140
+ onNodeUpdate: (
141
+ nodeId: string,
142
+ updates: Partial<FlowNodeData> | Partial<StartNode> | Partial<EndingNode>,
143
+ ) => void;
144
+ }
145
+
146
+ interface TabPanelProps {
147
+ children?: React.ReactNode;
148
+ index: number;
149
+ value: number;
150
+ }
151
+
152
+ const TabPanel = (props: TabPanelProps) => {
153
+ const { children, value, index, ...other } = props;
154
+
155
+ return (
156
+ <div
157
+ role="tabpanel"
158
+ hidden={value !== index}
159
+ id={`node-editor-tabpanel-${index}`}
160
+ aria-labelledby={`node-editor-tab-${index}`}
161
+ {...other}
162
+ style={{ padding: "16px 0" }}
163
+ >
164
+ {value === index && children}
165
+ </div>
166
+ );
167
+ };
168
+
169
+ export const NodeEditor: React.FC<NodeEditorProps> = ({
170
+ open,
171
+ selectedNode,
172
+ nodes,
173
+ start,
174
+ ending,
175
+ flows,
176
+ onClose,
177
+ onNodeUpdate,
178
+ }) => {
179
+ const [tabValue, setTabValue] = useState(0);
180
+ const [formData, setFormData] = useState<any>({});
181
+ const [editingComponent, setEditingComponent] =
182
+ useState<ComponentConfig | null>(null);
183
+ const [componentDialogOpen, setComponentDialogOpen] = useState(false);
184
+ const [addComponentAnchor, setAddComponentAnchor] =
185
+ useState<null | HTMLElement>(null);
186
+
187
+ // Initialize form data when selected node changes
188
+ useEffect(() => {
189
+ if (!selectedNode) {
190
+ setFormData({});
191
+ return;
192
+ }
193
+
194
+ if (selectedNode.id === "start" && start) {
195
+ setFormData({
196
+ next_node: start.next_node || "",
197
+ });
198
+ } else if (selectedNode.id === "end" && ending) {
199
+ setFormData({
200
+ resume_flow: ending.resume_flow || false,
201
+ });
202
+ } else {
203
+ // Find the node data in the nodes array
204
+ const nodeData = nodes.find((node) => node.id === selectedNode.id);
205
+ if (nodeData) {
206
+ setFormData({
207
+ id: nodeData.id,
208
+ alias: nodeData.alias || "",
209
+ type: nodeData.type,
210
+ next_node: nodeData.config?.next_node || "",
211
+ components: nodeData.config?.components || [],
212
+ flow_id: nodeData.config?.flow_id || "",
213
+ rules: nodeData.config?.rules || [],
214
+ fallback: nodeData.config?.fallback || "",
215
+ });
216
+ }
217
+ }
218
+ }, [selectedNode, nodes, start, ending]);
219
+
220
+ const handleInputChange = useCallback(
221
+ (
222
+ e:
223
+ | React.ChangeEvent<HTMLInputElement>
224
+ | {
225
+ target: { name?: string; value: unknown };
226
+ },
227
+ ) => {
228
+ const { name, value } = e.target;
229
+ if (name) {
230
+ setFormData((prev: any) => ({
231
+ ...prev,
232
+ [name]: value,
233
+ }));
234
+ }
235
+ },
236
+ [],
237
+ );
238
+
239
+ const handleSwitchChange = useCallback(
240
+ (e: React.ChangeEvent<HTMLInputElement>) => {
241
+ const { name, checked } = e.target;
242
+ if (name) {
243
+ setFormData((prev: any) => ({
244
+ ...prev,
245
+ [name]: checked,
246
+ }));
247
+ }
248
+ },
249
+ [],
250
+ );
251
+
252
+ // Component editing handlers
253
+ const handleEditComponent = useCallback((component: ComponentConfig) => {
254
+ setEditingComponent({ ...component });
255
+ setComponentDialogOpen(true);
256
+ }, []);
257
+
258
+ const handleCloseComponentDialog = useCallback(() => {
259
+ setComponentDialogOpen(false);
260
+ setEditingComponent(null);
261
+ }, []);
262
+
263
+ const handleSaveComponent = useCallback(() => {
264
+ if (!editingComponent) return;
265
+
266
+ setFormData((prev: any) => ({
267
+ ...prev,
268
+ components: prev.components.map((c: ComponentConfig) =>
269
+ c.id === editingComponent.id ? editingComponent : c,
270
+ ),
271
+ }));
272
+ handleCloseComponentDialog();
273
+ }, [editingComponent, handleCloseComponentDialog]);
274
+
275
+ const handleComponentFieldChange = useCallback(
276
+ (field: string, value: string) => {
277
+ setEditingComponent((prev) => {
278
+ if (!prev) return null;
279
+ return {
280
+ ...prev,
281
+ config: {
282
+ ...prev.config,
283
+ [field]: value,
284
+ },
285
+ };
286
+ });
287
+ },
288
+ [],
289
+ );
290
+
291
+ const handleAddComponent = useCallback((type: ComponentConfig["type"]) => {
292
+ const getDefaultConfig = () => {
293
+ switch (type) {
294
+ case "RICH_TEXT":
295
+ return { content: "" };
296
+ case "LEGAL":
297
+ return { text: "" };
298
+ case "NEXT_BUTTON":
299
+ return { text: "Continue" };
300
+ case "TEXT":
301
+ return { label: "Text Field", placeholder: "" };
302
+ case "EMAIL":
303
+ return { label: "Email", placeholder: "Enter your email" };
304
+ case "NUMBER":
305
+ return { label: "Number", placeholder: "" };
306
+ case "PHONE":
307
+ return { label: "Phone", placeholder: "Enter your phone number" };
308
+ default:
309
+ return {};
310
+ }
311
+ };
312
+
313
+ const newComponent: ComponentConfig = {
314
+ id: `component_${Date.now()}`,
315
+ type,
316
+ config: getDefaultConfig(),
317
+ };
318
+ setFormData((prev: any) => ({
319
+ ...prev,
320
+ components: [...(prev.components || []), newComponent],
321
+ }));
322
+ // Open the editor for the new component
323
+ setEditingComponent(newComponent);
324
+ setComponentDialogOpen(true);
325
+ }, []);
326
+
327
+ const handleMoveComponent = useCallback(
328
+ (index: number, direction: "up" | "down") => {
329
+ setFormData((prev: any) => {
330
+ const components = [...(prev.components || [])];
331
+ const newIndex = direction === "up" ? index - 1 : index + 1;
332
+ if (newIndex < 0 || newIndex >= components.length) return prev;
333
+
334
+ // Swap components
335
+ [components[index], components[newIndex]] = [
336
+ components[newIndex],
337
+ components[index],
338
+ ];
339
+ return { ...prev, components };
340
+ });
341
+ },
342
+ [],
343
+ );
344
+
345
+ const handleDeleteComponent = useCallback((componentId: string) => {
346
+ setFormData((prev: any) => ({
347
+ ...prev,
348
+ components: (prev.components || []).filter(
349
+ (c: ComponentConfig) => c.id !== componentId,
350
+ ),
351
+ }));
352
+ }, []);
353
+
354
+ const handleSave = useCallback(() => {
355
+ if (!selectedNode) return;
356
+
357
+ if (selectedNode.id === "start") {
358
+ onNodeUpdate("start", { next_node: formData.next_node || undefined });
359
+ } else if (selectedNode.id === "end") {
360
+ onNodeUpdate("end", { resume_flow: formData.resume_flow || false });
361
+ } else {
362
+ const updates: Partial<FlowNodeData> = {
363
+ alias: formData.alias,
364
+ config: {
365
+ next_node: formData.next_node || undefined,
366
+ components: formData.components || [],
367
+ },
368
+ };
369
+
370
+ if (selectedNode.type === "flow") {
371
+ updates.config!.flow_id = formData.flow_id;
372
+ }
373
+
374
+ if (selectedNode.type === "router") {
375
+ updates.config!.rules = formData.rules;
376
+ updates.config!.fallback = formData.fallback || undefined;
377
+ }
378
+
379
+ onNodeUpdate(selectedNode.id, updates);
380
+ }
381
+ onClose();
382
+ }, [selectedNode, formData, onNodeUpdate, onClose]);
383
+
384
+ // Render the proper editor based on node type
385
+ const renderEditor = () => {
386
+ if (!selectedNode) return null;
387
+
388
+ switch (selectedNode.type) {
389
+ case "start":
390
+ return renderStartNodeEditor();
391
+ case "end":
392
+ return renderEndNodeEditor();
393
+ case "step":
394
+ return renderStepNodeEditor();
395
+ case "flow":
396
+ return renderFlowNodeEditor();
397
+ case "router":
398
+ return renderRouterNodeEditor();
399
+ default:
400
+ return (
401
+ <Typography color="text.secondary">
402
+ Unknown node type: {selectedNode.type}
403
+ </Typography>
404
+ );
405
+ }
406
+ };
407
+
408
+ const renderStartNodeEditor = () => (
409
+ <Box>
410
+ <Typography variant="h6" sx={{ mb: 2 }}>
411
+ Start Node
412
+ </Typography>
413
+ <FormControl fullWidth sx={{ mb: 2 }}>
414
+ <InputLabel id="next-node-label">Next Node</InputLabel>
415
+ <Select
416
+ labelId="next-node-label"
417
+ id="next-node"
418
+ name="next_node"
419
+ value={formData.next_node || ""}
420
+ label="Next Node"
421
+ onChange={handleInputChange}
422
+ >
423
+ <MenuItem value="">
424
+ <em>None</em>
425
+ </MenuItem>
426
+ {nodes.map((node) => (
427
+ <MenuItem key={node.id} value={node.id}>
428
+ {node.alias || node.id}
429
+ </MenuItem>
430
+ ))}
431
+ </Select>
432
+ <FormHelperText>Select the first node in the flow</FormHelperText>
433
+ </FormControl>
434
+ </Box>
435
+ );
436
+
437
+ const renderEndNodeEditor = () => (
438
+ <Box>
439
+ <Typography variant="h6" sx={{ mb: 2 }}>
440
+ End Node
441
+ </Typography>
442
+ <FormControlLabel
443
+ control={
444
+ <Switch
445
+ name="resume_flow"
446
+ checked={!!formData.resume_flow}
447
+ onChange={handleSwitchChange}
448
+ />
449
+ }
450
+ label="Resume authentication flow"
451
+ />
452
+ <FormHelperText>
453
+ When enabled, the flow will resume the authentication process after
454
+ completion
455
+ </FormHelperText>
456
+ </Box>
457
+ );
458
+
459
+ const renderStepNodeEditor = () => (
460
+ <Box>
461
+ <TextField
462
+ fullWidth
463
+ label="Node Alias"
464
+ name="alias"
465
+ value={formData.alias || ""}
466
+ onChange={handleInputChange}
467
+ margin="normal"
468
+ helperText="A friendly name for this step"
469
+ />
470
+
471
+ <Divider sx={{ my: 3 }} />
472
+
473
+ <Typography variant="h6" gutterBottom>
474
+ Components
475
+ </Typography>
476
+
477
+ {formData.components && formData.components.length > 0 ? (
478
+ <Box sx={{ mt: 2 }}>
479
+ {formData.components.map(
480
+ (component: ComponentConfig, index: number) => (
481
+ <Paper key={component.id} sx={{ p: 2, mb: 1 }}>
482
+ <Box
483
+ sx={{
484
+ display: "flex",
485
+ justifyContent: "space-between",
486
+ alignItems: "center",
487
+ mb: 1,
488
+ }}
489
+ >
490
+ <Typography variant="subtitle2">{component.type}</Typography>
491
+ <Box sx={{ display: "flex", gap: 0.5 }}>
492
+ <IconButton
493
+ size="small"
494
+ aria-label="move up"
495
+ onClick={() => handleMoveComponent(index, "up")}
496
+ disabled={index === 0}
497
+ >
498
+ <ArrowUpwardIcon fontSize="small" />
499
+ </IconButton>
500
+ <IconButton
501
+ size="small"
502
+ aria-label="move down"
503
+ onClick={() => handleMoveComponent(index, "down")}
504
+ disabled={index === formData.components.length - 1}
505
+ >
506
+ <ArrowDownwardIcon fontSize="small" />
507
+ </IconButton>
508
+ <IconButton
509
+ size="small"
510
+ aria-label="edit component"
511
+ onClick={() => handleEditComponent(component)}
512
+ >
513
+ <EditIcon fontSize="small" />
514
+ </IconButton>
515
+ <IconButton
516
+ size="small"
517
+ aria-label="delete component"
518
+ onClick={() => handleDeleteComponent(component.id)}
519
+ color="error"
520
+ >
521
+ <DeleteIcon fontSize="small" />
522
+ </IconButton>
523
+ </Box>
524
+ </Box>
525
+ <Typography variant="caption" color="text.secondary">
526
+ {component.type === "RICH_TEXT" && component.config?.content
527
+ ? `Content: ${component.config.content.substring(0, 30)}...`
528
+ : component.type === "LEGAL" && component.config?.text
529
+ ? `Text: ${component.config.text}`
530
+ : component.type === "NEXT_BUTTON" &&
531
+ component.config?.text
532
+ ? `Button text: ${component.config.text}`
533
+ : (component.type === "TEXT" ||
534
+ component.type === "EMAIL" ||
535
+ component.type === "NUMBER" ||
536
+ component.type === "PHONE") &&
537
+ component.config?.label
538
+ ? `Label: ${component.config.label}`
539
+ : component.id}
540
+ </Typography>
541
+ </Paper>
542
+ ),
543
+ )}
544
+ </Box>
545
+ ) : (
546
+ <Typography variant="body2" color="text.secondary">
547
+ No components added to this step.
548
+ </Typography>
549
+ )}
550
+
551
+ <Button
552
+ variant="outlined"
553
+ startIcon={<AddCircleOutlineIcon />}
554
+ fullWidth
555
+ sx={{ mt: 2 }}
556
+ onClick={(e) => setAddComponentAnchor(e.currentTarget)}
557
+ >
558
+ Add Component
559
+ </Button>
560
+ <Menu
561
+ anchorEl={addComponentAnchor}
562
+ open={Boolean(addComponentAnchor)}
563
+ onClose={() => setAddComponentAnchor(null)}
564
+ >
565
+ <Typography
566
+ variant="caption"
567
+ sx={{ px: 2, py: 0.5, color: "text.secondary", display: "block" }}
568
+ >
569
+ Content
570
+ </Typography>
571
+ <MenuItem
572
+ onClick={() => {
573
+ handleAddComponent("RICH_TEXT");
574
+ setAddComponentAnchor(null);
575
+ }}
576
+ >
577
+ <ListItemIcon>
578
+ <TextFieldsIcon fontSize="small" />
579
+ </ListItemIcon>
580
+ <ListItemText>Rich Text</ListItemText>
581
+ </MenuItem>
582
+ <Divider />
583
+ <Typography
584
+ variant="caption"
585
+ sx={{ px: 2, py: 0.5, color: "text.secondary", display: "block" }}
586
+ >
587
+ Fields
588
+ </Typography>
589
+ <MenuItem
590
+ onClick={() => {
591
+ handleAddComponent("TEXT");
592
+ setAddComponentAnchor(null);
593
+ }}
594
+ >
595
+ <ListItemIcon>
596
+ <ShortTextIcon fontSize="small" />
597
+ </ListItemIcon>
598
+ <ListItemText>Text Field</ListItemText>
599
+ </MenuItem>
600
+ <MenuItem
601
+ onClick={() => {
602
+ handleAddComponent("EMAIL");
603
+ setAddComponentAnchor(null);
604
+ }}
605
+ >
606
+ <ListItemIcon>
607
+ <EmailIcon fontSize="small" />
608
+ </ListItemIcon>
609
+ <ListItemText>Email Field</ListItemText>
610
+ </MenuItem>
611
+ <MenuItem
612
+ onClick={() => {
613
+ handleAddComponent("NUMBER");
614
+ setAddComponentAnchor(null);
615
+ }}
616
+ >
617
+ <ListItemIcon>
618
+ <NumbersIcon fontSize="small" />
619
+ </ListItemIcon>
620
+ <ListItemText>Number Field</ListItemText>
621
+ </MenuItem>
622
+ <MenuItem
623
+ onClick={() => {
624
+ handleAddComponent("PHONE");
625
+ setAddComponentAnchor(null);
626
+ }}
627
+ >
628
+ <ListItemIcon>
629
+ <PhoneIcon fontSize="small" />
630
+ </ListItemIcon>
631
+ <ListItemText>Phone Field</ListItemText>
632
+ </MenuItem>
633
+ <MenuItem
634
+ onClick={() => {
635
+ handleAddComponent("LEGAL");
636
+ setAddComponentAnchor(null);
637
+ }}
638
+ >
639
+ <ListItemIcon>
640
+ <GavelIcon fontSize="small" />
641
+ </ListItemIcon>
642
+ <ListItemText>Legal Checkbox</ListItemText>
643
+ </MenuItem>
644
+ <Divider />
645
+ <Typography
646
+ variant="caption"
647
+ sx={{ px: 2, py: 0.5, color: "text.secondary", display: "block" }}
648
+ >
649
+ Actions
650
+ </Typography>
651
+ <MenuItem
652
+ onClick={() => {
653
+ handleAddComponent("NEXT_BUTTON");
654
+ setAddComponentAnchor(null);
655
+ }}
656
+ >
657
+ <ListItemIcon>
658
+ <SmartButtonIcon fontSize="small" />
659
+ </ListItemIcon>
660
+ <ListItemText>Next Button</ListItemText>
661
+ </MenuItem>
662
+ </Menu>
663
+ </Box>
664
+ );
665
+
666
+ const renderFlowNodeEditor = () => (
667
+ <Box>
668
+ <FormControl fullWidth margin="normal">
669
+ <InputLabel id="flow-id-label">Flow</InputLabel>
670
+ <Select
671
+ labelId="flow-id-label"
672
+ name="flow_id"
673
+ value={formData.flow_id || ""}
674
+ onChange={handleInputChange}
675
+ label="Flow"
676
+ >
677
+ <MenuItem value="">
678
+ <em>None</em>
679
+ </MenuItem>
680
+ {flows?.map((flow) => (
681
+ <MenuItem key={flow.id} value={flow.id}>
682
+ {flow.name} ({flow.id})
683
+ </MenuItem>
684
+ ))}
685
+ </Select>
686
+ <FormHelperText>Select the flow to execute</FormHelperText>
687
+ </FormControl>
688
+ </Box>
689
+ );
690
+
691
+ const handleRuleChange = useCallback(
692
+ (ruleId: string, field: keyof RouterRule, value: any) => {
693
+ setFormData((prev: any) => ({
694
+ ...prev,
695
+ rules: prev.rules.map((rule: RouterRule) =>
696
+ rule.id === ruleId ? { ...rule, [field]: value } : rule,
697
+ ),
698
+ }));
699
+ },
700
+ [],
701
+ );
702
+
703
+ const handleAddRule = useCallback(() => {
704
+ const newRule: RouterRule = {
705
+ id: `rule_${Date.now()}`,
706
+ alias: "",
707
+ condition: {},
708
+ next_node: "",
709
+ };
710
+ setFormData((prev: any) => ({
711
+ ...prev,
712
+ rules: [...(prev.rules || []), newRule],
713
+ }));
714
+ }, []);
715
+
716
+ const handleDeleteRule = useCallback((ruleId: string) => {
717
+ setFormData((prev: any) => ({
718
+ ...prev,
719
+ rules: prev.rules.filter((rule: RouterRule) => rule.id !== ruleId),
720
+ }));
721
+ }, []);
722
+
723
+ const renderRouterNodeEditor = () => (
724
+ <Box>
725
+ <TextField
726
+ fullWidth
727
+ label="Router Alias"
728
+ name="alias"
729
+ value={formData.alias || ""}
730
+ onChange={handleInputChange}
731
+ margin="normal"
732
+ helperText="A friendly name for this router"
733
+ />
734
+
735
+ <Divider sx={{ my: 3 }} />
736
+
737
+ <Typography variant="h6" gutterBottom>
738
+ Rules
739
+ </Typography>
740
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
741
+ Rules are evaluated in order. The first matching rule determines the
742
+ next node.
743
+ </Typography>
744
+
745
+ {formData.rules && formData.rules.length > 0 ? (
746
+ <Box sx={{ mt: 2 }}>
747
+ {formData.rules.map((rule: RouterRule, index: number) => (
748
+ <Paper key={rule.id} sx={{ p: 2, mb: 2 }}>
749
+ <Box
750
+ sx={{
751
+ display: "flex",
752
+ justifyContent: "space-between",
753
+ alignItems: "center",
754
+ mb: 1,
755
+ }}
756
+ >
757
+ <Typography variant="subtitle2">Rule {index + 1}</Typography>
758
+ <IconButton
759
+ size="small"
760
+ onClick={() => handleDeleteRule(rule.id)}
761
+ aria-label="delete rule"
762
+ >
763
+ <DeleteIcon fontSize="small" />
764
+ </IconButton>
765
+ </Box>
766
+
767
+ <TextField
768
+ fullWidth
769
+ label="Rule Alias"
770
+ value={rule.alias || ""}
771
+ onChange={(e) =>
772
+ handleRuleChange(rule.id, "alias", e.target.value)
773
+ }
774
+ margin="dense"
775
+ size="small"
776
+ />
777
+
778
+ <Typography
779
+ variant="caption"
780
+ color="text.secondary"
781
+ sx={{ display: "block", mt: 1, mb: 0.5 }}
782
+ >
783
+ Condition
784
+ </Typography>
785
+ <Box
786
+ sx={{
787
+ display: "flex",
788
+ gap: 1,
789
+ flexWrap: "wrap",
790
+ alignItems: "flex-start",
791
+ }}
792
+ >
793
+ <Autocomplete
794
+ freeSolo
795
+ size="small"
796
+ options={ROUTER_FIELD_OPTIONS}
797
+ getOptionLabel={(option) =>
798
+ typeof option === "string" ? option : option.value
799
+ }
800
+ value={rule.condition?.field || ""}
801
+ onChange={(_, newValue) => {
802
+ const fieldValue =
803
+ typeof newValue === "string"
804
+ ? newValue
805
+ : newValue?.value || "";
806
+ handleRuleChange(rule.id, "condition", {
807
+ ...rule.condition,
808
+ field: fieldValue,
809
+ });
810
+ }}
811
+ onInputChange={(_, inputValue) => {
812
+ handleRuleChange(rule.id, "condition", {
813
+ ...rule.condition,
814
+ field: inputValue,
815
+ });
816
+ }}
817
+ renderOption={(props, option) => (
818
+ <Box component="li" {...props}>
819
+ <Box>
820
+ <Typography variant="body2">{option.label}</Typography>
821
+ <Typography variant="caption" color="text.secondary">
822
+ {option.value}
823
+ </Typography>
824
+ </Box>
825
+ </Box>
826
+ )}
827
+ renderInput={(params) => (
828
+ <TextField
829
+ {...params}
830
+ label="Field"
831
+ placeholder="Select or type a field"
832
+ helperText="e.g., {{context.user.email}}"
833
+ />
834
+ )}
835
+ sx={{ flex: 1, minWidth: "200px" }}
836
+ />
837
+ <FormControl size="small" sx={{ minWidth: "120px" }}>
838
+ <InputLabel id={`rule-operator-${rule.id}-label`}>
839
+ Operator
840
+ </InputLabel>
841
+ <Select
842
+ labelId={`rule-operator-${rule.id}-label`}
843
+ value={rule.condition?.operator || ""}
844
+ label="Operator"
845
+ onChange={(e) =>
846
+ handleRuleChange(rule.id, "condition", {
847
+ ...rule.condition,
848
+ operator: e.target.value,
849
+ })
850
+ }
851
+ >
852
+ <MenuItem value="equals">equals</MenuItem>
853
+ <MenuItem value="not_equals">not equals</MenuItem>
854
+ <MenuItem value="contains">contains</MenuItem>
855
+ <MenuItem value="not_contains">not contains</MenuItem>
856
+ <MenuItem value="starts_with">starts with</MenuItem>
857
+ <MenuItem value="ends_with">ends with</MenuItem>
858
+ <MenuItem value="exists">exists</MenuItem>
859
+ <MenuItem value="not_exists">not exists</MenuItem>
860
+ </Select>
861
+ </FormControl>
862
+ <TextField
863
+ label="Value"
864
+ value={rule.condition?.value || ""}
865
+ onChange={(e) =>
866
+ handleRuleChange(rule.id, "condition", {
867
+ ...rule.condition,
868
+ value: e.target.value,
869
+ })
870
+ }
871
+ size="small"
872
+ sx={{ flex: 1, minWidth: "100px" }}
873
+ placeholder="e.g., admin"
874
+ disabled={
875
+ rule.condition?.operator === "exists" ||
876
+ rule.condition?.operator === "not_exists"
877
+ }
878
+ />
879
+ </Box>
880
+ </Paper>
881
+ ))}
882
+ </Box>
883
+ ) : (
884
+ <Typography variant="body2" color="text.secondary">
885
+ No rules defined. Add a rule to route to different nodes based on
886
+ conditions.
887
+ </Typography>
888
+ )}
889
+
890
+ <Button
891
+ variant="outlined"
892
+ startIcon={<AddCircleOutlineIcon />}
893
+ fullWidth
894
+ sx={{ mt: 2 }}
895
+ onClick={handleAddRule}
896
+ >
897
+ Add Rule
898
+ </Button>
899
+ </Box>
900
+ );
901
+
902
+ return (
903
+ <>
904
+ <Drawer
905
+ anchor="right"
906
+ open={open}
907
+ onClose={onClose}
908
+ sx={{
909
+ flexShrink: 0,
910
+ width: 350,
911
+ "& .MuiDrawer-paper": {
912
+ width: 350,
913
+ boxSizing: "border-box",
914
+ padding: 2,
915
+ },
916
+ }}
917
+ >
918
+ <Box
919
+ sx={{
920
+ display: "flex",
921
+ justifyContent: "space-between",
922
+ alignItems: "center",
923
+ mb: 2,
924
+ }}
925
+ >
926
+ <Box>
927
+ <Typography variant="h5" sx={{ display: "block" }}>
928
+ {selectedNode
929
+ ? selectedNode.id === "start"
930
+ ? "Start Node"
931
+ : selectedNode.id === "end"
932
+ ? "End Node"
933
+ : String(selectedNode?.data?.label || "Node")
934
+ : "Node"}
935
+ </Typography>
936
+ <Typography variant="caption" color="text.secondary">
937
+ ID: {selectedNode?.id || ""}
938
+ </Typography>
939
+ </Box>
940
+ <IconButton onClick={onClose} size="small">
941
+ <CloseIcon />
942
+ </IconButton>
943
+ </Box>
944
+
945
+ <Divider sx={{ mb: 2 }} />
946
+
947
+ <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
948
+ <Tabs
949
+ value={tabValue}
950
+ onChange={(_, newValue) => setTabValue(newValue)}
951
+ >
952
+ <Tab label="Properties" />
953
+ <Tab label="JSON" />
954
+ </Tabs>
955
+ </Box>
956
+
957
+ <Box sx={{ mt: 2, mb: 2 }}>
958
+ <TabPanel value={tabValue} index={0}>
959
+ {renderEditor()}
960
+ </TabPanel>
961
+ <TabPanel value={tabValue} index={1}>
962
+ <Box sx={{ position: "relative" }}>
963
+ <IconButton
964
+ size="small"
965
+ sx={{ position: "absolute", top: 0, right: 0 }}
966
+ title="Copy JSON"
967
+ >
968
+ <CodeIcon fontSize="small" />
969
+ </IconButton>
970
+ <Typography
971
+ component="pre"
972
+ sx={{
973
+ p: 1,
974
+ backgroundColor: (theme) =>
975
+ theme.palette.mode === "dark"
976
+ ? theme.palette.grey[900]
977
+ : "#f5f5f5",
978
+ color: "text.primary",
979
+ borderRadius: 1,
980
+ overflow: "auto",
981
+ fontSize: "0.8rem",
982
+ maxHeight: "400px",
983
+ }}
984
+ >
985
+ {JSON.stringify(
986
+ selectedNode?.id === "start"
987
+ ? { next_node: formData.next_node || null, id: "start" }
988
+ : selectedNode?.id === "end"
989
+ ? {
990
+ resume_flow: formData.resume_flow || false,
991
+ id: "end",
992
+ }
993
+ : formData,
994
+ null,
995
+ 2,
996
+ )}
997
+ </Typography>
998
+ </Box>
999
+ </TabPanel>
1000
+ </Box>
1001
+
1002
+ <Box
1003
+ sx={{
1004
+ position: "sticky",
1005
+ bottom: 0,
1006
+ backgroundColor: "background.paper",
1007
+ pt: 2,
1008
+ pb: 2,
1009
+ px: 2,
1010
+ mx: -2,
1011
+ borderTop: 1,
1012
+ borderColor: "divider",
1013
+ display: "flex",
1014
+ justifyContent: "flex-end",
1015
+ gap: 1,
1016
+ }}
1017
+ >
1018
+ <Button variant="outlined" onClick={onClose}>
1019
+ Cancel
1020
+ </Button>
1021
+ <Button variant="contained" color="primary" onClick={handleSave}>
1022
+ Save
1023
+ </Button>
1024
+ </Box>
1025
+ </Drawer>
1026
+
1027
+ {/* Component Edit Dialog */}
1028
+ <Dialog
1029
+ open={componentDialogOpen}
1030
+ onClose={handleCloseComponentDialog}
1031
+ maxWidth="sm"
1032
+ fullWidth
1033
+ >
1034
+ <DialogTitle>Edit {editingComponent?.type} Component</DialogTitle>
1035
+ <DialogContent>
1036
+ {editingComponent?.type === "RICH_TEXT" && (
1037
+ <Box sx={{ mt: 2 }}>
1038
+ <Typography variant="subtitle2" sx={{ mb: 1 }}>
1039
+ Content
1040
+ </Typography>
1041
+ <RichTextEditor
1042
+ value={editingComponent?.config?.content || ""}
1043
+ onChange={(value) =>
1044
+ handleComponentFieldChange("content", value)
1045
+ }
1046
+ />
1047
+ <FormHelperText>Rich text content with formatting</FormHelperText>
1048
+ </Box>
1049
+ )}
1050
+ {editingComponent?.type === "LEGAL" && (
1051
+ <TextField
1052
+ fullWidth
1053
+ label="Legal Text"
1054
+ value={editingComponent?.config?.text || ""}
1055
+ onChange={(e) =>
1056
+ handleComponentFieldChange("text", e.target.value)
1057
+ }
1058
+ margin="normal"
1059
+ multiline
1060
+ rows={3}
1061
+ helperText="Text for the legal checkbox"
1062
+ />
1063
+ )}
1064
+ {editingComponent?.type === "NEXT_BUTTON" && (
1065
+ <TextField
1066
+ fullWidth
1067
+ label="Button Text"
1068
+ value={editingComponent?.config?.text || ""}
1069
+ onChange={(e) =>
1070
+ handleComponentFieldChange("text", e.target.value)
1071
+ }
1072
+ margin="normal"
1073
+ helperText="Text to display on the button"
1074
+ />
1075
+ )}
1076
+ {(editingComponent?.type === "TEXT" ||
1077
+ editingComponent?.type === "EMAIL" ||
1078
+ editingComponent?.type === "NUMBER" ||
1079
+ editingComponent?.type === "PHONE") && (
1080
+ <Box>
1081
+ <TextField
1082
+ fullWidth
1083
+ label="Label"
1084
+ value={editingComponent?.config?.label || ""}
1085
+ onChange={(e) =>
1086
+ handleComponentFieldChange("label", e.target.value)
1087
+ }
1088
+ margin="normal"
1089
+ helperText="Label displayed above the field"
1090
+ />
1091
+ <TextField
1092
+ fullWidth
1093
+ label="Placeholder"
1094
+ value={editingComponent?.config?.placeholder || ""}
1095
+ onChange={(e) =>
1096
+ handleComponentFieldChange("placeholder", e.target.value)
1097
+ }
1098
+ margin="normal"
1099
+ helperText="Placeholder text shown when field is empty"
1100
+ />
1101
+ </Box>
1102
+ )}
1103
+ </DialogContent>
1104
+ <DialogActions>
1105
+ <Button onClick={handleCloseComponentDialog}>Cancel</Button>
1106
+ <Button
1107
+ onClick={handleSaveComponent}
1108
+ variant="contained"
1109
+ color="primary"
1110
+ >
1111
+ Save
1112
+ </Button>
1113
+ </DialogActions>
1114
+ </Dialog>
1115
+ </>
1116
+ );
1117
+ };
1118
+
1119
+ export default NodeEditor;