@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,1363 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import {
3
+ ReactFlow,
4
+ Node,
5
+ Edge,
6
+ MarkerType,
7
+ useNodesState,
8
+ useEdgesState,
9
+ Background,
10
+ Controls,
11
+ Panel,
12
+ NodeTypes,
13
+ Handle,
14
+ Position,
15
+ Connection,
16
+ addEdge,
17
+ } from "@xyflow/react";
18
+ import "@xyflow/react/dist/style.css";
19
+ import { Box, Typography, Alert, GlobalStyles } from "@mui/material";
20
+ import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
21
+ import { useTheme } from "@mui/material/styles";
22
+
23
+ // Import the NodeEditor component
24
+ import NodeEditor from "./NodeEditor";
25
+
26
+ // Type definitions
27
+ export interface ComponentConfig {
28
+ id: string;
29
+ type:
30
+ | "RICH_TEXT"
31
+ | "LEGAL"
32
+ | "NEXT_BUTTON"
33
+ | "TEXT"
34
+ | "EMAIL"
35
+ | "NUMBER"
36
+ | "PHONE";
37
+ required?: boolean;
38
+ config?: {
39
+ content?: string;
40
+ text?: string;
41
+ label?: string;
42
+ placeholder?: string;
43
+ };
44
+ }
45
+
46
+ export interface RouterRule {
47
+ id: string;
48
+ alias?: string;
49
+ condition: any;
50
+ next_node: string;
51
+ }
52
+
53
+ export interface FlowNodeData {
54
+ id: string;
55
+ type: "STEP" | "FLOW" | "ROUTER";
56
+ alias?: string;
57
+ coordinates?: { x: number; y: number };
58
+ config?: {
59
+ next_node?: string;
60
+ components?: ComponentConfig[];
61
+ flow_id?: string;
62
+ // Router-specific config
63
+ rules?: RouterRule[];
64
+ fallback?: string;
65
+ };
66
+ }
67
+
68
+ export interface StartNode {
69
+ next_node?: string;
70
+ coordinates?: { x: number; y: number };
71
+ }
72
+
73
+ export interface EndingNode {
74
+ resume_flow?: boolean;
75
+ coordinates?: { x: number; y: number };
76
+ }
77
+
78
+ export interface FlowChoice {
79
+ id: string;
80
+ name: string;
81
+ }
82
+
83
+ export interface FlowEditorProps {
84
+ nodes: FlowNodeData[];
85
+ start?: StartNode;
86
+ ending?: EndingNode;
87
+ flows?: FlowChoice[];
88
+ onNodeSelect?: (nodeId: string) => void;
89
+ onNodeUpdate?: (
90
+ nodeId: string,
91
+ updates: Partial<FlowNodeData> | Partial<StartNode> | Partial<EndingNode>,
92
+ ) => void;
93
+ onError?: (error: string) => void;
94
+ }
95
+
96
+ interface CustomNodeData extends Record<string, unknown> {
97
+ label?: string;
98
+ type?: string;
99
+ next?: string;
100
+ components?: ComponentConfig[];
101
+ flowId?: string;
102
+ orphaned?: boolean;
103
+ invalidConnection?: boolean;
104
+ resumeFlow?: string;
105
+ // Router-specific data
106
+ rules?: RouterRule[];
107
+ fallback?: string;
108
+ }
109
+
110
+ // Constants moved outside component
111
+ const DEFAULT_EDGE_OPTIONS = {
112
+ type: "smoothstep" as const,
113
+ animated: true,
114
+ style: { stroke: "#1976d2", strokeWidth: 2 },
115
+ markerEnd: {
116
+ type: MarkerType.ArrowClosed,
117
+ },
118
+ };
119
+
120
+ const FLOW_CONFIG = {
121
+ fitViewOptions: { padding: 0.2 },
122
+ minZoom: 0.5,
123
+ maxZoom: 1.5,
124
+ } as const;
125
+
126
+ // Utility functions
127
+ const truncateText = (text: string, maxLength: number = 20): string => {
128
+ const cleanText = text.replace(/<[^>]*>/g, "");
129
+ return cleanText.length > maxLength
130
+ ? `${cleanText.substring(0, maxLength)}...`
131
+ : cleanText;
132
+ };
133
+
134
+ const getNodePosition = (
135
+ coordinates?: { x: number; y: number },
136
+ defaultX: number = 200,
137
+ defaultY: number = 200,
138
+ ) => ({
139
+ x: coordinates?.x ?? defaultX,
140
+ y: coordinates?.y ?? defaultY,
141
+ });
142
+
143
+ // Helper to determine target handle based on node type
144
+ const getTargetHandle = (target: string, nodes: FlowNodeData[]): string => {
145
+ if (target === "end") return "end-input";
146
+ const targetNode = nodes.find((n) => n.id === target);
147
+ if (!targetNode) return "step-input";
148
+ switch (targetNode.type) {
149
+ case "FLOW":
150
+ return "flow-input";
151
+ case "ROUTER":
152
+ return "router-input";
153
+ default:
154
+ return "step-input";
155
+ }
156
+ };
157
+
158
+ // Custom Node Components
159
+ const StartNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
160
+ <Box sx={{ padding: "8px" }}>
161
+ <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
162
+ Start
163
+ </Typography>
164
+ <Box sx={{ mt: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
165
+ <Box
166
+ component="span"
167
+ sx={{
168
+ width: 18,
169
+ height: 18,
170
+ display: "flex",
171
+ alignItems: "center",
172
+ justifyContent: "center",
173
+ border: "1px solid #4CAF50",
174
+ borderRadius: "50%",
175
+ color: "#4CAF50",
176
+ fontSize: "14px",
177
+ }}
178
+ aria-label="Start node indicator"
179
+ >
180
+
181
+ </Box>
182
+ </Box>
183
+ <Handle
184
+ type="source"
185
+ position={Position.Right}
186
+ id="start-output"
187
+ style={{ background: "#4CAF50" }}
188
+ />
189
+ </Box>
190
+ ));
191
+
192
+ const ComponentRenderer = React.memo(
193
+ ({ component }: { component: ComponentConfig }) => {
194
+ switch (component.type) {
195
+ case "RICH_TEXT":
196
+ return (
197
+ <Box
198
+ sx={{
199
+ backgroundColor: "#f8f9fa",
200
+ p: 1,
201
+ borderRadius: "4px",
202
+ position: "relative",
203
+ }}
204
+ >
205
+ <Box
206
+ component="span"
207
+ sx={{
208
+ position: "absolute",
209
+ top: 0,
210
+ right: 0,
211
+ fontSize: "16px",
212
+ color: "#1976d2",
213
+ p: 0.5,
214
+ }}
215
+ >
216
+ <AddCircleOutlineIcon sx={{ fontSize: 16 }} />
217
+ </Box>
218
+ <Typography
219
+ variant="body2"
220
+ sx={{
221
+ "& u": {
222
+ textDecoration: "underline",
223
+ color: "#1976d2",
224
+ cursor: "pointer",
225
+ },
226
+ }}
227
+ >
228
+ {component.config?.content
229
+ ? truncateText(component.config.content, 60)
230
+ : "Rich text content"}
231
+ </Typography>
232
+ </Box>
233
+ );
234
+
235
+ case "LEGAL":
236
+ return (
237
+ <Typography variant="body2" color="text.secondary">
238
+ {component.config?.text
239
+ ? `Legal: ${truncateText(component.config.text)}`
240
+ : "Legal checkbox"}
241
+ </Typography>
242
+ );
243
+
244
+ case "NEXT_BUTTON":
245
+ return (
246
+ <Box
247
+ sx={{
248
+ mt: 1,
249
+ p: 1,
250
+ textAlign: "center",
251
+ bgcolor: "#1976d2",
252
+ color: "white",
253
+ borderRadius: "4px",
254
+ fontSize: "14px",
255
+ }}
256
+ >
257
+ {component.config?.text || "Continue"}
258
+ </Box>
259
+ );
260
+
261
+ default:
262
+ return (
263
+ <Typography variant="body2" color="text.secondary">
264
+ Unknown component: {component.type}
265
+ </Typography>
266
+ );
267
+ }
268
+ },
269
+ );
270
+
271
+ const StepNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
272
+ <Box sx={{ padding: "8px" }}>
273
+ <Handle
274
+ type="target"
275
+ position={Position.Left}
276
+ id="step-input"
277
+ style={{ background: "#1976d2" }}
278
+ />
279
+
280
+ <Box
281
+ sx={{
282
+ display: "flex",
283
+ justifyContent: "space-between",
284
+ alignItems: "center",
285
+ mb: 1,
286
+ }}
287
+ >
288
+ <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
289
+ {data.label || "Step"}
290
+ </Typography>
291
+ <Box sx={{ display: "flex", gap: "4px" }}>
292
+ <Box
293
+ component="span"
294
+ sx={{
295
+ width: 20,
296
+ height: 20,
297
+ display: "flex",
298
+ alignItems: "center",
299
+ justifyContent: "center",
300
+ border: "1px solid #ddd",
301
+ borderRadius: "2px",
302
+ fontSize: "12px",
303
+ color: "#666",
304
+ cursor: "pointer",
305
+ }}
306
+ title="Copy step"
307
+ role="button"
308
+ tabIndex={0}
309
+ >
310
+
311
+ </Box>
312
+ <Box
313
+ component="span"
314
+ sx={{
315
+ width: 20,
316
+ height: 20,
317
+ display: "flex",
318
+ alignItems: "center",
319
+ justifyContent: "center",
320
+ border: "1px solid #ddd",
321
+ borderRadius: "2px",
322
+ fontSize: "12px",
323
+ color: "#666",
324
+ cursor: "pointer",
325
+ }}
326
+ title="Delete step"
327
+ role="button"
328
+ tabIndex={0}
329
+ >
330
+
331
+ </Box>
332
+ </Box>
333
+ </Box>
334
+
335
+ {data.components && data.components.length > 0 && (
336
+ <Box sx={{ mt: 1 }}>
337
+ {data.components.map((comp) => (
338
+ <Box
339
+ key={comp.id}
340
+ sx={{
341
+ py: 0.5,
342
+ borderBottom:
343
+ comp.type === "NEXT_BUTTON" ? "none" : "1px solid #f0f0f0",
344
+ }}
345
+ >
346
+ <ComponentRenderer component={comp} />
347
+ </Box>
348
+ ))}
349
+ </Box>
350
+ )}
351
+
352
+ <Handle
353
+ type="source"
354
+ position={Position.Right}
355
+ id="step-output"
356
+ style={{ background: "#1976d2" }}
357
+ />
358
+ </Box>
359
+ ));
360
+
361
+ const FlowNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
362
+ <Box sx={{ padding: "8px" }}>
363
+ <Handle
364
+ type="target"
365
+ position={Position.Left}
366
+ id="flow-input"
367
+ style={{ background: "#1976d2" }}
368
+ />
369
+
370
+ <Box
371
+ sx={{
372
+ display: "flex",
373
+ justifyContent: "space-between",
374
+ alignItems: "center",
375
+ }}
376
+ >
377
+ <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
378
+ Flow
379
+ </Typography>
380
+ </Box>
381
+ <Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}>
382
+ <Box
383
+ component="span"
384
+ sx={{
385
+ width: 24,
386
+ height: 24,
387
+ display: "flex",
388
+ alignItems: "center",
389
+ justifyContent: "center",
390
+ border: "1px solid #1976d2",
391
+ borderRadius: "50%",
392
+ color: "#1976d2",
393
+ fontSize: "16px",
394
+ }}
395
+ aria-label="Flow update indicator"
396
+ >
397
+
398
+ </Box>
399
+ <Typography variant="body2" color="text.secondary">
400
+ {data.flowId ? `Update ${data.flowId}` : "Update metadata"}
401
+ </Typography>
402
+ </Box>
403
+
404
+ <Handle
405
+ type="source"
406
+ position={Position.Right}
407
+ id="flow-output"
408
+ style={{ background: "#1976d2" }}
409
+ />
410
+ </Box>
411
+ ));
412
+
413
+ const RouterNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => {
414
+ const rules = data.rules || [];
415
+ const fallback = data.fallback;
416
+
417
+ return (
418
+ <Box sx={{ padding: "8px" }}>
419
+ <Handle
420
+ type="target"
421
+ position={Position.Left}
422
+ id="router-input"
423
+ style={{ background: "#9c27b0" }}
424
+ />
425
+
426
+ <Box
427
+ sx={{
428
+ display: "flex",
429
+ justifyContent: "space-between",
430
+ alignItems: "center",
431
+ mb: 1,
432
+ }}
433
+ >
434
+ <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
435
+ {data.label || "Router"}
436
+ </Typography>
437
+ <Box
438
+ component="span"
439
+ sx={{
440
+ width: 24,
441
+ height: 24,
442
+ display: "flex",
443
+ alignItems: "center",
444
+ justifyContent: "center",
445
+ border: "1px solid #9c27b0",
446
+ borderRadius: "4px",
447
+ color: "#9c27b0",
448
+ fontSize: "14px",
449
+ }}
450
+ aria-label="Router indicator"
451
+ >
452
+
453
+ </Box>
454
+ </Box>
455
+
456
+ {/* Rules */}
457
+ {rules.length > 0 && (
458
+ <Box sx={{ mt: 1 }}>
459
+ {rules.map((rule, index) => (
460
+ <Box
461
+ key={rule.id}
462
+ sx={{
463
+ display: "flex",
464
+ alignItems: "center",
465
+ justifyContent: "space-between",
466
+ py: 0.5,
467
+ borderBottom: "1px solid #f0f0f0",
468
+ position: "relative",
469
+ }}
470
+ >
471
+ <Typography variant="body2" sx={{ fontSize: "12px", pr: 2 }}>
472
+ {rule.alias || `Rule ${index + 1}`}
473
+ </Typography>
474
+ <Handle
475
+ type="source"
476
+ position={Position.Right}
477
+ id={`router-rule-${rule.id}`}
478
+ style={{
479
+ background: "#9c27b0",
480
+ top: "auto",
481
+ right: -8,
482
+ }}
483
+ />
484
+ </Box>
485
+ ))}
486
+ </Box>
487
+ )}
488
+
489
+ {/* Fallback */}
490
+ <Box
491
+ sx={{
492
+ mt: 1,
493
+ pt: 1,
494
+ borderTop: "1px dashed #ddd",
495
+ display: "flex",
496
+ alignItems: "center",
497
+ justifyContent: "space-between",
498
+ position: "relative",
499
+ }}
500
+ >
501
+ <Typography
502
+ variant="body2"
503
+ sx={{ fontSize: "12px", color: "text.secondary", pr: 2 }}
504
+ >
505
+ Default
506
+ </Typography>
507
+ <Handle
508
+ type="source"
509
+ position={Position.Right}
510
+ id="router-fallback"
511
+ style={{ background: "#9c27b0" }}
512
+ />
513
+ </Box>
514
+ </Box>
515
+ );
516
+ });
517
+
518
+ const EndNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
519
+ <Box sx={{ padding: "8px" }}>
520
+ <Handle
521
+ type="target"
522
+ position={Position.Left}
523
+ id="end-input"
524
+ style={{ background: "#f44336" }}
525
+ />
526
+
527
+ <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
528
+ Ending screen
529
+ </Typography>
530
+ <Typography
531
+ variant="body2"
532
+ sx={{ color: (theme) => theme.palette.text.secondary }}
533
+ >
534
+ {data.resumeFlow === "Yes" ? "Resume authentication flow" : "End flow"}
535
+ </Typography>
536
+ </Box>
537
+ ));
538
+
539
+ // Node types configuration
540
+ const nodeTypes: NodeTypes = {
541
+ start: StartNodeComponent,
542
+ step: StepNodeComponent,
543
+ flow: FlowNodeComponent,
544
+ router: RouterNodeComponent,
545
+ end: EndNodeComponent,
546
+ };
547
+
548
+ const FlowEditor: React.FC<FlowEditorProps> = ({
549
+ nodes = [],
550
+ start,
551
+ ending,
552
+ flows,
553
+ onNodeSelect,
554
+ onNodeUpdate,
555
+ onError,
556
+ }) => {
557
+ const theme = useTheme();
558
+
559
+ // Theme-aware node styles
560
+ const NODE_STYLES = React.useMemo(() => {
561
+ // Use original light mode colors, and only override for dark mode
562
+ const isDark = theme.palette.mode === "dark";
563
+ return {
564
+ start: {
565
+ background: isDark ? theme.palette.grey[800] : "#f5f5f5",
566
+ border: isDark
567
+ ? `1px solid ${theme.palette.divider}`
568
+ : "1px solid #e0e0e0",
569
+ borderRadius: "4px",
570
+ padding: "12px",
571
+ color: theme.palette.text.primary,
572
+ minWidth: "180px",
573
+ boxShadow: theme.shadows[1],
574
+ },
575
+ step: {
576
+ background: isDark ? theme.palette.grey[800] : "#f5f5f5",
577
+ border: isDark
578
+ ? `1px solid ${theme.palette.divider}`
579
+ : "1px solid #e0e0e0",
580
+ borderRadius: "4px",
581
+ padding: "12px",
582
+ color: theme.palette.text.primary,
583
+ minWidth: "280px",
584
+ maxWidth: "320px",
585
+ boxShadow: theme.shadows[1],
586
+ },
587
+ flow: {
588
+ background: isDark ? theme.palette.grey[800] : "#f5f5f5",
589
+ border: isDark
590
+ ? `1px solid ${theme.palette.divider}`
591
+ : "1px solid #e0e0e0",
592
+ borderRadius: "4px",
593
+ padding: "12px",
594
+ color: theme.palette.text.primary,
595
+ minWidth: "180px",
596
+ boxShadow: theme.shadows[1],
597
+ },
598
+ router: {
599
+ background: isDark ? theme.palette.grey[800] : "#f5f5f5",
600
+ border: isDark
601
+ ? `1px solid ${theme.palette.divider}`
602
+ : "1px solid #e0e0e0",
603
+ borderRadius: "4px",
604
+ padding: "12px",
605
+ color: theme.palette.text.primary,
606
+ minWidth: "200px",
607
+ maxWidth: "280px",
608
+ boxShadow: theme.shadows[1],
609
+ },
610
+ end: {
611
+ background: isDark ? theme.palette.grey[800] : "#f5f5f5",
612
+ border: isDark
613
+ ? `1px solid ${theme.palette.divider}`
614
+ : "1px solid #e0e0e0",
615
+ borderRadius: "4px",
616
+ padding: "12px",
617
+ color: theme.palette.text.primary,
618
+ minWidth: "180px",
619
+ boxShadow: theme.shadows[1],
620
+ },
621
+ };
622
+ }, [theme]);
623
+
624
+ // State for node selection and editor
625
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
626
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
627
+ // Memoized flow elements creation
628
+ const {
629
+ flowNodes: initialNodes,
630
+ edges: initialEdges,
631
+ warnings,
632
+ } = useMemo(() => {
633
+ const flowNodes: Node<CustomNodeData>[] = [];
634
+ const flowEdges: Edge[] = [];
635
+ const warnings: string[] = [];
636
+
637
+ try {
638
+ // Add ending node if present (move this up)
639
+ if (ending) {
640
+ const endNode: Node<CustomNodeData> = {
641
+ id: "end",
642
+ type: "end",
643
+ position: getNodePosition(
644
+ ending.coordinates,
645
+ flowNodes.length * 350 + 250,
646
+ 200,
647
+ ),
648
+ data: {
649
+ resumeFlow: ending.resume_flow ? "Yes" : "No",
650
+ },
651
+ style: NODE_STYLES.end,
652
+ };
653
+ flowNodes.push(endNode);
654
+ }
655
+
656
+ // Add start node if present
657
+ if (start) {
658
+ const startNode: Node<CustomNodeData> = {
659
+ id: "start",
660
+ type: "start",
661
+ position: getNodePosition(start.coordinates, 100, 100),
662
+ data: {
663
+ next: start.next_node,
664
+ },
665
+ style: NODE_STYLES.start,
666
+ };
667
+ flowNodes.push(startNode);
668
+
669
+ // Create edge from start to its next node
670
+ if (start.next_node) {
671
+ let target = start.next_node;
672
+ let targetHandle: string;
673
+ if (start.next_node === "$ending") {
674
+ target = "end";
675
+ targetHandle = "end-input";
676
+ } else {
677
+ targetHandle = getTargetHandle(target, nodes);
678
+ }
679
+
680
+ flowEdges.push({
681
+ id: `start-to-${target}`,
682
+ source: "start",
683
+ sourceHandle: "start-output",
684
+ target: target,
685
+ targetHandle: targetHandle,
686
+ type: "smoothstep",
687
+ animated: true,
688
+ markerEnd: {
689
+ type: MarkerType.ArrowClosed,
690
+ },
691
+ style: { stroke: "#1976d2", strokeWidth: 2 },
692
+ label: start.next_node === "$ending" ? "End" : undefined,
693
+ });
694
+ }
695
+ }
696
+
697
+ // Add form nodes
698
+ nodes.forEach((node, index) => {
699
+ if (!node.id) {
700
+ warnings.push(`Node at index ${index} missing required id`);
701
+ return;
702
+ }
703
+
704
+ // Determine node type for ReactFlow
705
+ let nodeType: string;
706
+ let nodeStyle: any;
707
+ if (node.type === "ROUTER") {
708
+ nodeType = "router";
709
+ nodeStyle = NODE_STYLES.router;
710
+ } else if (node.type === "FLOW") {
711
+ nodeType = "flow";
712
+ nodeStyle = NODE_STYLES.flow;
713
+ } else {
714
+ nodeType = "step";
715
+ nodeStyle = NODE_STYLES.step;
716
+ }
717
+
718
+ const defaultX = 250 + index * 350;
719
+
720
+ const flowNode: Node<CustomNodeData> = {
721
+ id: node.id,
722
+ type: nodeType,
723
+ position: getNodePosition(node.coordinates, defaultX, 200),
724
+ data: {
725
+ label: node.alias || node.id,
726
+ type: node.type,
727
+ next: node.config?.next_node,
728
+ components: node.config?.components || [],
729
+ flowId: node.config?.flow_id,
730
+ // Router-specific data
731
+ rules: node.config?.rules || [],
732
+ fallback: node.config?.fallback,
733
+ },
734
+ style: nodeStyle,
735
+ };
736
+ flowNodes.push(flowNode);
737
+
738
+ // Handle edges based on node type
739
+ if (node.type === "ROUTER") {
740
+ // Create edges for each rule
741
+ const rules = node.config?.rules || [];
742
+ rules.forEach((rule) => {
743
+ if (rule.next_node) {
744
+ const target =
745
+ rule.next_node === "$ending" ? "end" : rule.next_node;
746
+ const edgeId = `${node.id}-rule-${rule.id}-to-${target}`;
747
+ const targetHandle = getTargetHandle(target, nodes);
748
+
749
+ flowEdges.push({
750
+ id: edgeId,
751
+ source: node.id,
752
+ sourceHandle: `router-rule-${rule.id}`,
753
+ target: target,
754
+ targetHandle: targetHandle,
755
+ type: "smoothstep",
756
+ animated: true,
757
+ markerEnd: { type: MarkerType.ArrowClosed },
758
+ style: { stroke: "#9c27b0", strokeWidth: 2 },
759
+ label: rule.alias || undefined,
760
+ });
761
+ }
762
+ });
763
+
764
+ // Create edge for fallback
765
+ if (node.config?.fallback) {
766
+ const target =
767
+ node.config.fallback === "$ending" ? "end" : node.config.fallback;
768
+ const edgeId = `${node.id}-fallback-to-${target}`;
769
+ const targetHandle = getTargetHandle(target, nodes);
770
+
771
+ flowEdges.push({
772
+ id: edgeId,
773
+ source: node.id,
774
+ sourceHandle: "router-fallback",
775
+ target: target,
776
+ targetHandle: targetHandle,
777
+ type: "smoothstep",
778
+ animated: true,
779
+ markerEnd: { type: MarkerType.ArrowClosed },
780
+ style: {
781
+ stroke: "#9c27b0",
782
+ strokeWidth: 2,
783
+ strokeDasharray: "5,5",
784
+ },
785
+ label: "Default",
786
+ });
787
+ }
788
+ } else {
789
+ // Create edge to the next node for STEP and FLOW nodes
790
+ if (node.config?.next_node) {
791
+ const target =
792
+ node.config.next_node === "$ending"
793
+ ? "end"
794
+ : node.config.next_node;
795
+ const edgeId = `${node.id}-to-${target}`;
796
+
797
+ // Determine source and target handles
798
+ const sourceHandle =
799
+ node.type === "STEP" ? "step-output" : "flow-output";
800
+ const targetHandle = getTargetHandle(target, nodes);
801
+
802
+ // Validate that target exists or will exist
803
+ const targetExists =
804
+ target === "end" ||
805
+ nodes.some((n) => n.id === target) ||
806
+ (start && start.next_node === target);
807
+
808
+ if (targetExists || target === "end") {
809
+ flowEdges.push({
810
+ id: edgeId,
811
+ source: node.id,
812
+ sourceHandle: sourceHandle,
813
+ target: target,
814
+ targetHandle: targetHandle,
815
+ type: "smoothstep",
816
+ animated: true,
817
+ markerEnd: {
818
+ type: MarkerType.ArrowClosed,
819
+ },
820
+ style: { stroke: "#1976d2", strokeWidth: 2 },
821
+ label: node.config.next_node === "$ending" ? "End" : undefined,
822
+ });
823
+ } else {
824
+ warnings.push(
825
+ `Target node ${target} not found for edge from ${node.id}`,
826
+ );
827
+ }
828
+ }
829
+ }
830
+ });
831
+
832
+ // Validation logic
833
+ const connectedNodeIds = new Set<string>();
834
+ flowEdges.forEach((edge) => {
835
+ connectedNodeIds.add(edge.target);
836
+ });
837
+
838
+ const allNodeIds = new Set(flowNodes.map((node) => node.id));
839
+
840
+ // Mark orphaned and invalid nodes
841
+ flowNodes.forEach((node) => {
842
+ // Check for orphaned nodes
843
+ if (
844
+ node.id !== "start" &&
845
+ node.id !== "end" &&
846
+ !connectedNodeIds.has(node.id)
847
+ ) {
848
+ node.data.orphaned = true;
849
+ node.style = {
850
+ ...node.style,
851
+ border: "2px dashed #f44336",
852
+ boxShadow: "0 0 5px rgba(244, 67, 54, 0.5)",
853
+ };
854
+ warnings.push(`Orphaned node: ${node.id}`);
855
+ }
856
+
857
+ // Check for invalid connections
858
+ if (
859
+ node.data.next &&
860
+ node.data.next !== "$ending" &&
861
+ !allNodeIds.has(node.data.next)
862
+ ) {
863
+ node.data.invalidConnection = true;
864
+ node.style = {
865
+ ...node.style,
866
+ borderBottom: "3px solid #FF9800",
867
+ boxShadow: "0 2px 4px rgba(255, 152, 0, 0.5)",
868
+ };
869
+ warnings.push(
870
+ `Invalid connection in ${node.id}: ${node.data.next} not found`,
871
+ );
872
+ }
873
+ });
874
+ } catch (error) {
875
+ const errorMessage =
876
+ error instanceof Error ? error.message : "Unknown error occurred";
877
+ warnings.push(`Error creating flow: ${errorMessage}`);
878
+ onError?.(errorMessage);
879
+ }
880
+
881
+ return { flowNodes, edges: flowEdges, warnings };
882
+ }, [nodes, start, ending, onError]);
883
+
884
+ const [flowNodes, setNodes, onNodesChange] = useNodesState(initialNodes);
885
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
886
+
887
+ // Sync nodes and edges state with initial values when they change
888
+ React.useEffect(() => {
889
+ setNodes(initialNodes);
890
+ }, [initialNodes, setNodes]);
891
+
892
+ React.useEffect(() => {
893
+ setEdges(initialEdges);
894
+ }, [initialEdges, setEdges]);
895
+
896
+ // Handle node selection
897
+ const handleNodeClick = useCallback(
898
+ (_event: React.MouseEvent, node: Node) => {
899
+ setSelectedNodeId(node.id);
900
+ setIsEditorOpen(true);
901
+ onNodeSelect?.(node.id);
902
+ },
903
+ [onNodeSelect],
904
+ );
905
+
906
+ // Close the editor
907
+ const handleCloseEditor = useCallback(() => {
908
+ setIsEditorOpen(false);
909
+ }, []);
910
+
911
+ // Handle node updates from the editor
912
+ const handleNodeUpdate = useCallback(
913
+ (
914
+ nodeId: string,
915
+ updates: Partial<FlowNodeData> | Partial<StartNode> | Partial<EndingNode>,
916
+ ) => {
917
+ onNodeUpdate?.(nodeId, updates);
918
+ },
919
+ [onNodeUpdate],
920
+ );
921
+
922
+ // Find the selected node
923
+ const selectedNode = useMemo(() => {
924
+ if (!selectedNodeId) return null;
925
+ return flowNodes.find((node) => node.id === selectedNodeId) || null;
926
+ }, [selectedNodeId, flowNodes]);
927
+
928
+ // Statistics
929
+ const stats = useMemo(() => {
930
+ const orphanedCount = flowNodes.filter(
931
+ (node) => node.data?.orphaned,
932
+ ).length;
933
+ const invalidConnectionsCount = flowNodes.filter(
934
+ (node) => node.data?.invalidConnection,
935
+ ).length;
936
+
937
+ return { orphanedCount, invalidConnectionsCount };
938
+ }, [flowNodes]);
939
+
940
+ // Error handling for empty flow
941
+ if (flowNodes.length === 0) {
942
+ return (
943
+ <Box sx={{ padding: "20px" }}>
944
+ <Alert severity="info">
945
+ <Typography variant="h6">No Flow Elements</Typography>
946
+ <Typography>
947
+ No flow elements available to display. Please provide nodes, start,
948
+ or ending configuration.
949
+ </Typography>
950
+ </Alert>
951
+ </Box>
952
+ );
953
+ }
954
+
955
+ // Handle connecting nodes
956
+ const handleConnect = useCallback(
957
+ (connection: Connection) => {
958
+ if (!connection.source || !connection.target) return;
959
+
960
+ // Determine the target node id (map 'end' back to '$ending')
961
+ const targetNodeId =
962
+ connection.target === "end" ? "$ending" : connection.target;
963
+
964
+ // Update the source node's next_node
965
+ if (connection.source === "start") {
966
+ // Update start node
967
+ onNodeUpdate?.("start", { next_node: targetNodeId });
968
+ } else {
969
+ // Find the source node to get its current config
970
+ const sourceNode = nodes.find((n) => n.id === connection.source);
971
+ if (sourceNode) {
972
+ // Check if this is a router connection
973
+ if (sourceNode.type === "ROUTER" && connection.sourceHandle) {
974
+ if (connection.sourceHandle === "router-fallback") {
975
+ // Update the fallback
976
+ onNodeUpdate?.(connection.source, {
977
+ config: {
978
+ ...sourceNode.config,
979
+ fallback: targetNodeId,
980
+ },
981
+ });
982
+ } else if (connection.sourceHandle.startsWith("router-rule-")) {
983
+ // Update a specific rule's next_node
984
+ const ruleId = connection.sourceHandle.replace(
985
+ "router-rule-",
986
+ "",
987
+ );
988
+ const updatedRules = (sourceNode.config?.rules || []).map(
989
+ (rule: RouterRule) =>
990
+ rule.id === ruleId
991
+ ? { ...rule, next_node: targetNodeId }
992
+ : rule,
993
+ );
994
+ onNodeUpdate?.(connection.source, {
995
+ config: {
996
+ ...sourceNode.config,
997
+ rules: updatedRules,
998
+ },
999
+ });
1000
+ }
1001
+ } else {
1002
+ // Standard STEP/FLOW node
1003
+ onNodeUpdate?.(connection.source, {
1004
+ config: {
1005
+ ...sourceNode.config,
1006
+ next_node: targetNodeId,
1007
+ },
1008
+ });
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ // Add the edge visually
1014
+ const edgeStyle = connection.sourceHandle?.startsWith("router")
1015
+ ? { stroke: "#9c27b0", strokeWidth: 2 }
1016
+ : { stroke: "#1976d2", strokeWidth: 2 };
1017
+
1018
+ setEdges((eds) =>
1019
+ addEdge(
1020
+ {
1021
+ ...connection,
1022
+ type: "smoothstep",
1023
+ animated: true,
1024
+ markerEnd: { type: MarkerType.ArrowClosed },
1025
+ style: edgeStyle,
1026
+ },
1027
+ eds,
1028
+ ),
1029
+ );
1030
+ },
1031
+ [nodes, onNodeUpdate, setEdges],
1032
+ );
1033
+
1034
+ // Add Step handler
1035
+ const handleAddStep = useCallback(() => {
1036
+ // Generate a short random id (4 alphanumeric chars)
1037
+ const randomId = () => Math.random().toString(36).slice(2, 6);
1038
+ const stepId = `step_${randomId()}`;
1039
+ const nextButtonId = `next_button_${randomId()}`;
1040
+
1041
+ // Calculate position based on existing nodes
1042
+ const existingPositions = flowNodes.map((n) => n.position);
1043
+ const maxX = Math.max(...existingPositions.map((p) => p.x), 200);
1044
+ const avgY =
1045
+ existingPositions.length > 0
1046
+ ? existingPositions.reduce((sum, p) => sum + p.y, 0) /
1047
+ existingPositions.length
1048
+ : 200;
1049
+
1050
+ const newStep: FlowNodeData = {
1051
+ id: stepId,
1052
+ type: "STEP",
1053
+ coordinates: { x: maxX + 200, y: avgY },
1054
+ alias: "New step",
1055
+ config: {
1056
+ components: [
1057
+ {
1058
+ id: nextButtonId,
1059
+ type: "NEXT_BUTTON",
1060
+ config: { text: "Continue" },
1061
+ },
1062
+ ],
1063
+ },
1064
+ };
1065
+ onNodeUpdate?.(stepId, newStep);
1066
+ }, [onNodeUpdate, flowNodes]);
1067
+
1068
+ // Add Flow handler
1069
+ const handleAddFlow = useCallback(() => {
1070
+ // Generate a short random id (4 alphanumeric chars)
1071
+ const randomId = () => Math.random().toString(36).slice(2, 6);
1072
+ const flowId = `flow_${randomId()}`;
1073
+
1074
+ // Calculate position based on existing nodes
1075
+ const existingPositions = flowNodes.map((n) => n.position);
1076
+ const maxX = Math.max(...existingPositions.map((p) => p.x), 200);
1077
+ const avgY =
1078
+ existingPositions.length > 0
1079
+ ? existingPositions.reduce((sum, p) => sum + p.y, 0) /
1080
+ existingPositions.length
1081
+ : 200;
1082
+
1083
+ const newFlow: FlowNodeData = {
1084
+ id: flowId,
1085
+ type: "FLOW",
1086
+ coordinates: { x: maxX + 200, y: avgY },
1087
+ alias: "New flow",
1088
+ config: {
1089
+ flow_id: "",
1090
+ },
1091
+ };
1092
+ onNodeUpdate?.(flowId, newFlow);
1093
+ }, [onNodeUpdate, flowNodes]);
1094
+
1095
+ // Add Router handler
1096
+ const handleAddRouter = useCallback(() => {
1097
+ // Generate a short random id (4 alphanumeric chars)
1098
+ const randomId = () => Math.random().toString(36).slice(2, 6);
1099
+ const routerId = `router_${randomId()}`;
1100
+ const ruleId = `id_${Date.now()}`;
1101
+
1102
+ // Calculate position based on existing nodes
1103
+ const existingPositions = flowNodes.map((n) => n.position);
1104
+ const maxX = Math.max(...existingPositions.map((p) => p.x), 200);
1105
+ const avgY =
1106
+ existingPositions.length > 0
1107
+ ? existingPositions.reduce((sum, p) => sum + p.y, 0) /
1108
+ existingPositions.length
1109
+ : 200;
1110
+
1111
+ const newRouter: FlowNodeData = {
1112
+ id: routerId,
1113
+ type: "ROUTER",
1114
+ coordinates: { x: maxX + 200, y: avgY },
1115
+ alias: "New router",
1116
+ config: {
1117
+ rules: [
1118
+ {
1119
+ id: ruleId,
1120
+ alias: "Rule 1",
1121
+ condition: {
1122
+ operands: [],
1123
+ operator: "AND",
1124
+ },
1125
+ next_node: "$ending",
1126
+ },
1127
+ ],
1128
+ fallback: "$ending",
1129
+ },
1130
+ };
1131
+ onNodeUpdate?.(routerId, newRouter);
1132
+ }, [onNodeUpdate, flowNodes]);
1133
+
1134
+ return (
1135
+ <Box sx={{ width: "100%", height: "100%", position: "relative" }}>
1136
+ {/* Global styles for ReactFlow Controls to support dark mode */}
1137
+ <GlobalStyles
1138
+ styles={(theme) => ({
1139
+ ".react-flow__controls": {
1140
+ background: theme.palette.background.paper,
1141
+ border: `1px solid ${theme.palette.divider}`,
1142
+ borderRadius: 8,
1143
+ boxShadow:
1144
+ theme.palette.mode === "dark"
1145
+ ? "0 2px 8px rgba(0,0,0,0.32)"
1146
+ : "0 2px 8px rgba(0,0,0,0.08)",
1147
+ margin: 8,
1148
+ },
1149
+ ".react-flow__controls-button": {
1150
+ background: "none",
1151
+ color: theme.palette.text.primary,
1152
+ border: "none",
1153
+ transition: "background 0.2s",
1154
+ "&:hover": {
1155
+ background:
1156
+ theme.palette.mode === "dark"
1157
+ ? "rgba(255,255,255,0.08)"
1158
+ : "rgba(0,0,0,0.04)",
1159
+ },
1160
+ },
1161
+ ".react-flow__controls-button svg": {
1162
+ fill: theme.palette.text.primary,
1163
+ },
1164
+ })}
1165
+ />
1166
+
1167
+ {warnings.length > 0 && (
1168
+ <Alert severity="warning" sx={{ mb: 2 }}>
1169
+ <Typography variant="subtitle2">Flow Validation Warnings:</Typography>
1170
+ <ul>
1171
+ {warnings.slice(0, 5).map((warning, index) => (
1172
+ <li key={index}>{warning}</li>
1173
+ ))}
1174
+ </ul>
1175
+ {warnings.length > 5 && (
1176
+ <Typography variant="caption">
1177
+ ... and {warnings.length - 5} more warnings
1178
+ </Typography>
1179
+ )}
1180
+ </Alert>
1181
+ )}
1182
+
1183
+ <ReactFlow
1184
+ nodes={flowNodes}
1185
+ edges={edges}
1186
+ onNodesChange={onNodesChange}
1187
+ onEdgesChange={onEdgesChange}
1188
+ onConnect={handleConnect}
1189
+ onNodeClick={handleNodeClick}
1190
+ nodeTypes={nodeTypes}
1191
+ fitView
1192
+ fitViewOptions={FLOW_CONFIG.fitViewOptions}
1193
+ proOptions={{ hideAttribution: true }}
1194
+ defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
1195
+ elementsSelectable={true}
1196
+ nodesDraggable={true}
1197
+ nodesConnectable={true}
1198
+ minZoom={FLOW_CONFIG.minZoom}
1199
+ maxZoom={FLOW_CONFIG.maxZoom}
1200
+ attributionPosition="bottom-left"
1201
+ style={{ height: "100%", background: theme.palette.background.default }}
1202
+ >
1203
+ <Controls
1204
+ showInteractive={false}
1205
+ style={{
1206
+ background: theme.palette.background.paper,
1207
+ color: theme.palette.text.primary,
1208
+ borderRadius: 8,
1209
+ boxShadow:
1210
+ theme.palette.mode === "dark"
1211
+ ? "0 2px 8px rgba(0,0,0,0.32)"
1212
+ : "0 2px 8px rgba(0,0,0,0.08)",
1213
+ border: `1px solid ${theme.palette.divider}`,
1214
+ margin: 8,
1215
+ }}
1216
+ />
1217
+ <Background
1218
+ color={
1219
+ theme.palette.mode === "dark" ? theme.palette.divider : "#f0f0f0"
1220
+ }
1221
+ gap={12}
1222
+ size={1}
1223
+ />
1224
+ <Panel position="top-right">
1225
+ <Box
1226
+ sx={{
1227
+ p: 1,
1228
+ bgcolor: theme.palette.background.paper,
1229
+ borderRadius: "4px",
1230
+ boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
1231
+ minWidth: 160,
1232
+ }}
1233
+ >
1234
+ <Typography variant="caption" color="text.secondary">
1235
+ Form Flow Diagram
1236
+ </Typography>
1237
+ <Typography variant="caption" sx={{ display: "block", mt: 0.5 }}>
1238
+ Nodes: {flowNodes.length} | Edges: {edges.length}
1239
+ </Typography>
1240
+
1241
+ {stats.orphanedCount > 0 && (
1242
+ <Typography
1243
+ variant="caption"
1244
+ sx={{
1245
+ display: "block",
1246
+ color: "#f44336",
1247
+ mt: 0.5,
1248
+ }}
1249
+ >
1250
+ ⚠ {stats.orphanedCount} unconnected node
1251
+ {stats.orphanedCount > 1 ? "s" : ""}
1252
+ </Typography>
1253
+ )}
1254
+
1255
+ {stats.invalidConnectionsCount > 0 && (
1256
+ <Typography
1257
+ variant="caption"
1258
+ sx={{
1259
+ display: "block",
1260
+ color: "#FF9800",
1261
+ mt: 0.5,
1262
+ }}
1263
+ >
1264
+ ⚠ {stats.invalidConnectionsCount} invalid connection
1265
+ {stats.invalidConnectionsCount > 1 ? "s" : ""}
1266
+ </Typography>
1267
+ )}
1268
+ </Box>
1269
+ </Panel>
1270
+ </ReactFlow>
1271
+
1272
+ {/* Bottom center add buttons */}
1273
+ <Box
1274
+ sx={{
1275
+ position: "absolute",
1276
+ left: "50%",
1277
+ bottom: 12,
1278
+ transform: "translateX(-50%)",
1279
+ zIndex: 10,
1280
+ display: "flex",
1281
+ alignItems: "center",
1282
+ bgcolor: theme.palette.background.paper,
1283
+ borderRadius: 2,
1284
+ boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
1285
+ px: 1.5,
1286
+ py: 0.5,
1287
+ gap: 1,
1288
+ }}
1289
+ >
1290
+ <AddCircleOutlineIcon sx={{ color: "#1976d2", mr: 1 }} />
1291
+ <Box sx={{ display: "flex", gap: 1 }}>
1292
+ <button
1293
+ type="button"
1294
+ style={{
1295
+ border: `1px solid ${theme.palette.divider}`,
1296
+ background: theme.palette.background.paper,
1297
+ borderRadius: 6,
1298
+ padding: "4px 16px",
1299
+ fontWeight: 500,
1300
+ fontSize: 15,
1301
+ color: theme.palette.text.primary,
1302
+ cursor: "pointer",
1303
+ outline: "none",
1304
+ transition: "background 0.2s, border 0.2s",
1305
+ }}
1306
+ onClick={handleAddStep}
1307
+ >
1308
+ Step
1309
+ </button>
1310
+ <button
1311
+ type="button"
1312
+ style={{
1313
+ border: `1px solid ${theme.palette.divider}`,
1314
+ background: theme.palette.background.paper,
1315
+ borderRadius: 6,
1316
+ padding: "4px 16px",
1317
+ fontWeight: 500,
1318
+ fontSize: 15,
1319
+ color: theme.palette.text.primary,
1320
+ cursor: "pointer",
1321
+ outline: "none",
1322
+ transition: "background 0.2s, border 0.2s",
1323
+ }}
1324
+ onClick={handleAddRouter}
1325
+ >
1326
+ Router
1327
+ </button>
1328
+ <button
1329
+ type="button"
1330
+ style={{
1331
+ border: `1px solid ${theme.palette.divider}`,
1332
+ background: theme.palette.background.paper,
1333
+ borderRadius: 6,
1334
+ padding: "4px 16px",
1335
+ fontWeight: 500,
1336
+ fontSize: 15,
1337
+ color: theme.palette.text.primary,
1338
+ cursor: "pointer",
1339
+ outline: "none",
1340
+ transition: "background 0.2s, border 0.2s",
1341
+ }}
1342
+ onClick={handleAddFlow}
1343
+ >
1344
+ Flow
1345
+ </button>
1346
+ </Box>
1347
+ </Box>
1348
+ {/* Node Editor */}
1349
+ <NodeEditor
1350
+ open={isEditorOpen}
1351
+ selectedNode={selectedNode}
1352
+ nodes={nodes}
1353
+ start={start}
1354
+ ending={ending}
1355
+ flows={flows}
1356
+ onClose={handleCloseEditor}
1357
+ onNodeUpdate={handleNodeUpdate}
1358
+ />
1359
+ </Box>
1360
+ );
1361
+ };
1362
+
1363
+ export default FlowEditor;