@gadmin2n/schematics 0.0.150 → 0.0.151

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 (81) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.md +125 -36
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +42 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/gitignore +2 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +0 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -0
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/check-dev-env.js +218 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/page-manage.ts +78 -12
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +37 -67
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/index.ts +0 -17
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +46 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +60 -1
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-callback.controller.ts +3 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/workflow-callback-url.controller.ts +9 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +55 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.module.ts +2 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/page-transfer.service.ts +491 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.dto.ts +213 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.module.ts +28 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/resource-transfer.service.ts +137 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/role-transfer.service.ts +248 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-token.service.ts +65 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/user-transfer.service.ts +146 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +55 -0
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.module.ts +2 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +55 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.module.ts +2 -0
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.spec.ts +46 -0
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.ts +3 -2
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +55 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.module.ts +2 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +1 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +96 -14
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +41 -41
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.spec.ts +36 -0
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.ts +90 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +4 -0
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +1 -0
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +115 -63
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.spec.ts +166 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.ts +217 -0
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +33 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +57 -0
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +196 -0
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.ts +125 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-node-type.controller.ts +9 -3
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +148 -4
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +12 -1
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +14 -0
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +75 -45
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +25 -0
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferExportModal.tsx +115 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferImportModal.tsx +257 -0
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -2
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +40 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +15 -13
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +40 -0
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +15 -13
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +2 -1
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agendaJob/list.tsx +89 -65
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +51 -127
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +53 -109
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +45 -41
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +51 -72
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +49 -40
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +16 -5
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +28 -10
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +60 -1
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +94 -12
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +13 -0
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +3 -3
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +8 -1
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +167 -94
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +95 -95
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.test.tsx +81 -0
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.tsx +126 -0
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +48 -52
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +2 -2
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -3
  79. package/package.json +1 -1
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/ac.config.ts +0 -24
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/SqlModal.tsx +0 -495
@@ -34,6 +34,7 @@ import {
34
34
  NodeOutputsMap,
35
35
  validateWorkflowHandles,
36
36
  } from './workflow-handle-validator';
37
+ import { WorkflowLifecycleService } from './workflow-lifecycle.service';
37
38
 
38
39
  export interface EventCatalogDeliveryRow {
39
40
  registrationCode: string;
@@ -407,6 +408,7 @@ export class WorkflowService {
407
408
  private temporalService: TemporalService,
408
409
  private humanApprovalService: HumanApprovalService,
409
410
  private workflowCompiler: WorkflowCompilerService,
411
+ private workflowLifecycle: WorkflowLifecycleService,
410
412
  ) {}
411
413
 
412
414
  /**
@@ -2012,15 +2014,13 @@ export class WorkflowService {
2012
2014
  retryCheckpoint: readRetryCheckpoint(instance.context),
2013
2015
  });
2014
2016
  } catch (error) {
2015
- await this.prisma.workflowInstance.updateMany({
2016
- where: { id: instance.id, status: 'PENDING', temporalRunId },
2017
- data: {
2018
- status: 'FAILED',
2019
- result: {
2020
- code: 'TEMPORAL_START_FAILED',
2021
- message: error instanceof Error ? error.message : String(error),
2022
- },
2023
- finishedAt: new Date(),
2017
+ await this.workflowLifecycle.markFailed({
2018
+ instanceId: instance.id,
2019
+ expectedStatuses: ['PENDING'],
2020
+ temporalRunId,
2021
+ result: {
2022
+ code: 'TEMPORAL_START_FAILED',
2023
+ message: error instanceof Error ? error.message : String(error),
2024
2024
  },
2025
2025
  });
2026
2026
  throw error;
@@ -2486,21 +2486,27 @@ export class WorkflowService {
2486
2486
  @Interval(60_000)
2487
2487
  async reconcileUnstartedInstances(): Promise<void> {
2488
2488
  const cutoff = new Date(Date.now() - 60_000);
2489
- await this.prisma.workflowInstance.updateMany({
2489
+ const unreservedInstances = await this.prisma.workflowInstance.findMany({
2490
2490
  where: {
2491
2491
  status: 'PENDING',
2492
2492
  temporalRunId: null,
2493
2493
  createdAt: { lte: cutoff },
2494
2494
  },
2495
- data: {
2496
- status: 'FAILED',
2495
+ orderBy: { id: 'asc' },
2496
+ take: 100,
2497
+ select: { id: true },
2498
+ });
2499
+ for (const instance of unreservedInstances) {
2500
+ await this.workflowLifecycle.markFailed({
2501
+ instanceId: instance.id,
2502
+ expectedStatuses: ['PENDING'],
2503
+ temporalRunId: null,
2497
2504
  result: {
2498
2505
  code: 'TEMPORAL_START_NOT_RESERVED',
2499
2506
  message: 'Workflow failed before Temporal execution was reserved',
2500
2507
  },
2501
- finishedAt: new Date(),
2502
- },
2503
- });
2508
+ });
2509
+ }
2504
2510
 
2505
2511
  const reservedInstances = await this.prisma.workflowInstance.findMany({
2506
2512
  where: {
@@ -2542,13 +2548,30 @@ export class WorkflowService {
2542
2548
  );
2543
2549
  continue;
2544
2550
  }
2545
- await this.temporalService.startWorkflow({
2546
- instanceId: Number(instance.id),
2547
- workflowId: Number(instance.workflowId),
2548
- versionId: Number(instance.versionId),
2549
- dsl: version.dsl,
2550
- context: (instance.context ?? {}) as Record<string, any>,
2551
- retryCheckpoint: readRetryCheckpoint(instance.context),
2551
+ try {
2552
+ await this.temporalService.startWorkflow({
2553
+ instanceId: Number(instance.id),
2554
+ workflowId: Number(instance.workflowId),
2555
+ versionId: Number(instance.versionId),
2556
+ dsl: version.dsl,
2557
+ context: (instance.context ?? {}) as Record<string, any>,
2558
+ retryCheckpoint: readRetryCheckpoint(instance.context),
2559
+ });
2560
+ } catch (error) {
2561
+ await this.failUnstartedInstance(
2562
+ instance.id,
2563
+ instance.temporalRunId!,
2564
+ error instanceof Error ? error.message : String(error),
2565
+ );
2566
+ continue;
2567
+ }
2568
+ await this.prisma.workflowEventDelivery.updateMany({
2569
+ where: { workflowInstanceId: instance.id, status: 'FAILED' },
2570
+ data: {
2571
+ status: 'DISPATCHED',
2572
+ dispatchedAt: new Date(),
2573
+ lastError: null,
2574
+ },
2552
2575
  });
2553
2576
  temporalState = await this.temporalService.inspectWorkflow(
2554
2577
  instance.temporalRunId!,
@@ -2574,15 +2597,13 @@ export class WorkflowService {
2574
2597
  instanceId: bigint,
2575
2598
  temporalRunId: string,
2576
2599
  ): Promise<void> {
2577
- await this.prisma.workflowInstance.updateMany({
2578
- where: { id: instanceId, status: 'RUNNING', temporalRunId },
2579
- data: {
2580
- status: 'FAILED',
2581
- result: {
2582
- code: 'TEMPORAL_EXECUTION_MISSING',
2583
- message: 'Temporal execution disappeared while Workflow was running',
2584
- },
2585
- finishedAt: new Date(),
2600
+ await this.workflowLifecycle.markFailed({
2601
+ instanceId,
2602
+ expectedStatuses: ['RUNNING'],
2603
+ temporalRunId,
2604
+ result: {
2605
+ code: 'TEMPORAL_EXECUTION_MISSING',
2606
+ message: 'Temporal execution disappeared while Workflow was running',
2586
2607
  },
2587
2608
  });
2588
2609
  }
@@ -2592,13 +2613,11 @@ export class WorkflowService {
2592
2613
  temporalRunId: string,
2593
2614
  message: string,
2594
2615
  ): Promise<void> {
2595
- await this.prisma.workflowInstance.updateMany({
2596
- where: { id: instanceId, status: 'PENDING', temporalRunId },
2597
- data: {
2598
- status: 'FAILED',
2599
- result: { code: 'TEMPORAL_START_RECONCILIATION_FAILED', message },
2600
- finishedAt: new Date(),
2601
- },
2616
+ await this.workflowLifecycle.markFailed({
2617
+ instanceId,
2618
+ expectedStatuses: ['PENDING'],
2619
+ temporalRunId,
2620
+ result: { code: 'TEMPORAL_START_RECONCILIATION_FAILED', message },
2602
2621
  });
2603
2622
  }
2604
2623
 
@@ -2663,12 +2682,23 @@ export class WorkflowService {
2663
2682
  }
2664
2683
  return;
2665
2684
  }
2666
- const status =
2667
- state.status === 'COMPLETED'
2668
- ? 'COMPLETED'
2669
- : state.status === 'CANCELLED'
2670
- ? 'CANCELLED'
2671
- : 'FAILED';
2685
+ if (state.status !== 'COMPLETED' && state.status !== 'CANCELLED') {
2686
+ await this.workflowLifecycle.markFailed({
2687
+ instanceId,
2688
+ expectedStatuses: ['PENDING', 'RUNNING'],
2689
+ temporalRunId,
2690
+ result:
2691
+ state.result === undefined
2692
+ ? {
2693
+ code: 'TEMPORAL_TERMINAL_STATUS_RECONCILED',
2694
+ temporalStatus: state.status,
2695
+ }
2696
+ : normalizeTemporalCompletedResult(state.result),
2697
+ startedAt: state.startedAt,
2698
+ finishedAt: state.finishedAt,
2699
+ });
2700
+ return;
2701
+ }
2672
2702
  await this.prisma.workflowInstance.updateMany({
2673
2703
  where: {
2674
2704
  id: instanceId,
@@ -2676,7 +2706,7 @@ export class WorkflowService {
2676
2706
  temporalRunId,
2677
2707
  },
2678
2708
  data: {
2679
- status,
2709
+ status: state.status,
2680
2710
  result:
2681
2711
  state.result === undefined
2682
2712
  ? {
@@ -409,4 +409,29 @@ describe('event trigger operation rules schema', () => {
409
409
  ]),
410
410
  ).toThrow(BadRequestException);
411
411
  });
412
+ it('accepts a configured failure callback and rejects a non-relative path', () => {
413
+ const base = {
414
+ registrationCode: 'request-changes',
415
+ processingMode: 'PARALLEL',
416
+ operationRules: [{ operation: 'INSERT' }],
417
+ };
418
+ expect(() =>
419
+ service.validateOrThrow('event:6', schema, {
420
+ ...base,
421
+ failureCallback: {
422
+ connectionRef: 'hardware-service',
423
+ path: '/api/hardware-disposal/workflow-failed',
424
+ },
425
+ }),
426
+ ).not.toThrow();
427
+ expect(() =>
428
+ service.validateOrThrow('event:6', schema, {
429
+ ...base,
430
+ failureCallback: {
431
+ connectionRef: 'hardware-service',
432
+ path: 'https://unexpected.example.com/callback',
433
+ },
434
+ }),
435
+ ).toThrow(BadRequestException);
436
+ });
412
437
  });
@@ -0,0 +1,115 @@
1
+ import { CopyOutlined, LoadingOutlined } from '@ant-design/icons';
2
+ import { Alert, Button, Input, Modal, Space, message } from 'antd';
3
+ import { customRequest } from 'helpers/http';
4
+ import React, { useCallback, useEffect, useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ export type TransferEntity = 'user' | 'role' | 'page' | 'resource';
8
+
9
+ interface TransferExportModalProps {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ /** e.g. `role/SYSTEM_ADMIN/export-token`; null disables fetching */
13
+ exportTokenEndpoint: string | null;
14
+ /** drives the "what this export contains" hint */
15
+ entity: TransferEntity;
16
+ title?: string;
17
+ }
18
+
19
+ /**
20
+ * Generic "export via signed URL" modal (workflow ExportModal pattern).
21
+ * Asks the backend for a 30-minute export URL and lets the user copy it
22
+ * into the target environment's TransferImportModal.
23
+ */
24
+ export function TransferExportModal({
25
+ open,
26
+ onClose,
27
+ exportTokenEndpoint,
28
+ entity,
29
+ title,
30
+ }: TransferExportModalProps) {
31
+ const { t } = useTranslation();
32
+ const [url, setUrl] = useState<string>('');
33
+ const [loading, setLoading] = useState(false);
34
+
35
+ useEffect(() => {
36
+ if (open && exportTokenEndpoint) {
37
+ setUrl('');
38
+ setLoading(true);
39
+ customRequest<{ url: string }>(exportTokenEndpoint, 'POST')
40
+ .then((res) => setUrl(res.url))
41
+ .catch((e: any) =>
42
+ message.error(e?.message ?? t('transfer.export.failed')),
43
+ )
44
+ .finally(() => setLoading(false));
45
+ }
46
+ }, [open, exportTokenEndpoint]);
47
+
48
+ const handleCopy = useCallback(async () => {
49
+ try {
50
+ await navigator.clipboard.writeText(url);
51
+ message.success(t('transfer.export.copied'));
52
+ } catch {
53
+ // Fallback for older browsers
54
+ const textarea = document.getElementById(
55
+ 'transfer-export-url-input',
56
+ ) as HTMLTextAreaElement;
57
+ textarea?.select();
58
+ document.execCommand('copy');
59
+ message.success(t('transfer.export.copied'));
60
+ }
61
+ }, [url]);
62
+
63
+ return (
64
+ <Modal
65
+ title={title ?? t('transfer.export.title')}
66
+ open={open}
67
+ onCancel={onClose}
68
+ footer={
69
+ <Space>
70
+ <Button onClick={onClose}>{t('buttons.cancel')}</Button>
71
+ <Button
72
+ type="primary"
73
+ icon={<CopyOutlined />}
74
+ onClick={handleCopy}
75
+ disabled={!url}
76
+ >
77
+ {t('transfer.export.copyUrl')}
78
+ </Button>
79
+ </Space>
80
+ }
81
+ >
82
+ <Alert
83
+ type="info"
84
+ showIcon
85
+ style={{ marginBottom: 16 }}
86
+ message={t('transfer.export.scopeTitle')}
87
+ description={t(`transfer.export.scope.${entity}`)}
88
+ />
89
+ {loading ? (
90
+ <div style={{ textAlign: 'center', padding: 24 }}>
91
+ <LoadingOutlined style={{ fontSize: 24 }} />
92
+ <p>{t('transfer.export.generating')}</p>
93
+ </div>
94
+ ) : (
95
+ <>
96
+ <p
97
+ style={{
98
+ marginBottom: 8,
99
+ color: 'var(--ant-color-text-secondary)',
100
+ }}
101
+ >
102
+ {t('transfer.export.urlHint')}
103
+ </p>
104
+ <Input.TextArea
105
+ id="transfer-export-url-input"
106
+ value={url}
107
+ readOnly
108
+ autoSize={{ minRows: 3, maxRows: 5 }}
109
+ onFocus={(e) => e.target.select()}
110
+ />
111
+ </>
112
+ )}
113
+ </Modal>
114
+ );
115
+ }
@@ -0,0 +1,257 @@
1
+ import { CheckOutlined, CloudDownloadOutlined } from '@ant-design/icons';
2
+ import { Alert, Button, Input, Modal, Space, Spin, message } from 'antd';
3
+ import { customRequest } from 'helpers/http';
4
+ import React, { useCallback, useState } from 'react';
5
+ import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
6
+ import { useTranslation } from 'react-i18next';
7
+
8
+ interface MissingRef {
9
+ type: 'page' | 'resource' | 'parentPage';
10
+ code: string;
11
+ }
12
+
13
+ interface PreviewResponse {
14
+ current: any;
15
+ incoming: any;
16
+ missingRefs: MissingRef[];
17
+ conflicts: string[];
18
+ }
19
+
20
+ interface TransferImportModalProps {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ onSuccess: () => void;
24
+ /** e.g. 'role/import-preview' */
25
+ previewEndpoint: string;
26
+ /** e.g. 'role/import-confirm' */
27
+ confirmEndpoint: string;
28
+ /** extra follow-up note shown after a successful import (e.g. "grant the
29
+ * page to a role, otherwise the menu stays hidden") */
30
+ successHint?: string;
31
+ title?: string;
32
+ }
33
+
34
+ type Phase = 'input' | 'preview' | 'confirming';
35
+
36
+ /**
37
+ * Generic "import from a source-environment export URL" modal
38
+ * (workflow ImportModal pattern): paste URL → diff preview → confirm.
39
+ * Import is blocked when the preview reports missing referenced
40
+ * pages/resources or conflicts.
41
+ */
42
+ export function TransferImportModal({
43
+ open,
44
+ onClose,
45
+ onSuccess,
46
+ previewEndpoint,
47
+ confirmEndpoint,
48
+ successHint,
49
+ title,
50
+ }: TransferImportModalProps) {
51
+ const { t } = useTranslation();
52
+ const [sourceUrl, setSourceUrl] = useState('');
53
+ const [phase, setPhase] = useState<Phase>('input');
54
+ const [loading, setLoading] = useState(false);
55
+ const [currentJson, setCurrentJson] = useState('');
56
+ const [incomingJson, setIncomingJson] = useState('');
57
+ const [missingRefs, setMissingRefs] = useState<MissingRef[]>([]);
58
+ const [conflicts, setConflicts] = useState<string[]>([]);
59
+ const [payload, setPayload] = useState<any>(null);
60
+
61
+ const reset = useCallback(() => {
62
+ setSourceUrl('');
63
+ setPhase('input');
64
+ setLoading(false);
65
+ setCurrentJson('');
66
+ setIncomingJson('');
67
+ setMissingRefs([]);
68
+ setConflicts([]);
69
+ setPayload(null);
70
+ }, []);
71
+
72
+ const handleClose = useCallback(() => {
73
+ reset();
74
+ onClose();
75
+ }, [reset, onClose]);
76
+
77
+ const blocked = missingRefs.length > 0 || conflicts.length > 0;
78
+
79
+ const handleFetchPreview = useCallback(async () => {
80
+ if (!sourceUrl.trim()) {
81
+ message.warning(t('transfer.import.pasteUrl'));
82
+ return;
83
+ }
84
+
85
+ setLoading(true);
86
+ try {
87
+ // Step 1: Fetch from source URL (cross-origin)
88
+ const response = await fetch(sourceUrl.trim());
89
+ if (!response.ok) {
90
+ const errorData = await response.json().catch(() => null);
91
+ throw new Error(
92
+ errorData?.message ||
93
+ t('transfer.import.fetchFailed', { status: response.status }),
94
+ );
95
+ }
96
+ const exportData = await response.json();
97
+
98
+ // Step 2: Send to our import-preview endpoint
99
+ const preview = await customRequest<PreviewResponse>(
100
+ previewEndpoint,
101
+ 'POST',
102
+ { payload: exportData },
103
+ );
104
+
105
+ // Step 3: Format for diff viewer
106
+ setCurrentJson(
107
+ preview.current ? JSON.stringify(preview.current, null, 2) : '',
108
+ );
109
+ setIncomingJson(JSON.stringify(preview.incoming, null, 2));
110
+ setMissingRefs(preview.missingRefs ?? []);
111
+ setConflicts(preview.conflicts ?? []);
112
+ setPayload(exportData);
113
+ setPhase('preview');
114
+ } catch (e: any) {
115
+ if (e.message?.includes('expired') || e.message?.includes('invalid')) {
116
+ message.error(t('transfer.import.expired'));
117
+ } else {
118
+ message.error(e?.message || t('transfer.import.unreachable'));
119
+ }
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ }, [sourceUrl, previewEndpoint]);
124
+
125
+ const handleConfirm = useCallback(async () => {
126
+ if (!payload || blocked) return;
127
+ setPhase('confirming');
128
+ try {
129
+ const result = await customRequest<{ success: boolean; key: string }>(
130
+ confirmEndpoint,
131
+ 'POST',
132
+ { payload },
133
+ );
134
+
135
+ message.success(t('transfer.import.success', { name: result.key }));
136
+ if (successHint) {
137
+ // longer duration: this is an actionable follow-up, not a status blip
138
+ message.info(successHint, 10);
139
+ }
140
+ reset();
141
+ onClose();
142
+ onSuccess();
143
+ } catch (e: any) {
144
+ message.error(e?.message ?? t('transfer.import.failed'));
145
+ setPhase('preview');
146
+ }
147
+ }, [
148
+ payload,
149
+ blocked,
150
+ confirmEndpoint,
151
+ successHint,
152
+ reset,
153
+ onClose,
154
+ onSuccess,
155
+ ]);
156
+
157
+ return (
158
+ <Modal
159
+ title={title ?? t('transfer.import.title')}
160
+ open={open}
161
+ onCancel={handleClose}
162
+ width={phase === 'preview' ? '90vw' : 520}
163
+ footer={
164
+ phase === 'input' ? (
165
+ <Space>
166
+ <Button onClick={handleClose}>{t('buttons.cancel')}</Button>
167
+ <Button
168
+ type="primary"
169
+ icon={<CloudDownloadOutlined />}
170
+ onClick={handleFetchPreview}
171
+ loading={loading}
172
+ >
173
+ {t('transfer.import.fetchPreview')}
174
+ </Button>
175
+ </Space>
176
+ ) : (
177
+ <Space>
178
+ <Button onClick={handleClose}>{t('buttons.cancel')}</Button>
179
+ <Button
180
+ type="primary"
181
+ icon={<CheckOutlined />}
182
+ onClick={handleConfirm}
183
+ loading={phase === 'confirming'}
184
+ disabled={blocked}
185
+ >
186
+ {t('transfer.import.confirm')}
187
+ </Button>
188
+ </Space>
189
+ )
190
+ }
191
+ >
192
+ {phase === 'input' && (
193
+ <>
194
+ <p style={{ marginBottom: 8 }}>{t('transfer.import.urlHint')}</p>
195
+ <Input.TextArea
196
+ value={sourceUrl}
197
+ onChange={(e) => setSourceUrl(e.target.value)}
198
+ placeholder="https://dev.example.com/api/role/export/SYSTEM_ADMIN?token=..."
199
+ autoSize={{ minRows: 3, maxRows: 5 }}
200
+ />
201
+ </>
202
+ )}
203
+
204
+ {phase === 'preview' && (
205
+ <div style={{ maxHeight: '70vh', overflow: 'auto' }}>
206
+ {missingRefs.length > 0 && (
207
+ <Alert
208
+ type="warning"
209
+ showIcon
210
+ style={{ marginBottom: 12 }}
211
+ message={t('transfer.import.missingRefsTitle')}
212
+ description={
213
+ <ul style={{ margin: 0, paddingInlineStart: 20 }}>
214
+ {missingRefs.map((m) => (
215
+ <li key={`${m.type}:${m.code}`}>
216
+ {t(`transfer.import.missingRefType.${m.type}`)}: {m.code}
217
+ </li>
218
+ ))}
219
+ </ul>
220
+ }
221
+ />
222
+ )}
223
+ {conflicts.length > 0 && (
224
+ <Alert
225
+ type="error"
226
+ showIcon
227
+ style={{ marginBottom: 12 }}
228
+ message={t('transfer.import.conflictsTitle')}
229
+ description={
230
+ <ul style={{ margin: 0, paddingInlineStart: 20 }}>
231
+ {conflicts.map((c) => (
232
+ <li key={c}>{c}</li>
233
+ ))}
234
+ </ul>
235
+ }
236
+ />
237
+ )}
238
+ <ReactDiffViewer
239
+ oldValue={currentJson}
240
+ newValue={incomingJson}
241
+ splitView
242
+ compareMethod={DiffMethod.LINES}
243
+ leftTitle={t('transfer.import.current')}
244
+ rightTitle={t('transfer.import.incoming')}
245
+ />
246
+ </div>
247
+ )}
248
+
249
+ {phase === 'confirming' && (
250
+ <div style={{ textAlign: 'center', padding: 40 }}>
251
+ <Spin size="large" />
252
+ <p style={{ marginTop: 16 }}>{t('transfer.import.importing')}</p>
253
+ </div>
254
+ )}
255
+ </Modal>
256
+ );
257
+ }
@@ -6,7 +6,6 @@ import {
6
6
  BgColorsOutlined,
7
7
  ScheduleOutlined,
8
8
  ApartmentOutlined,
9
- SafetyCertificateOutlined,
10
9
  TeamOutlined,
11
10
  UserOutlined,
12
11
  } from '@ant-design/icons';
@@ -50,7 +49,6 @@ export const iconMapping: Record<string, React.ReactNode> = {
50
49
  agenda: <ScheduleOutlined />,
51
50
  workflow: <ApartmentOutlined />,
52
51
  person_directory: <UserOutlined />,
53
- user: <SafetyCertificateOutlined />,
54
52
  people_group: <TeamOutlined />,
55
53
  };
56
54
 
@@ -371,5 +371,45 @@
371
371
  "renameDuplicate": "Name already exists",
372
372
  "renameFailed": "Rename failed"
373
373
  }
374
+ },
375
+ "transfer": {
376
+ "import": {
377
+ "title": "Import",
378
+ "fetchPreview": "Fetch & Preview",
379
+ "confirm": "Confirm Import",
380
+ "urlHint": "Paste the export URL generated by the source environment:",
381
+ "current": "Current env",
382
+ "incoming": "Source env",
383
+ "importing": "Importing…",
384
+ "pasteUrl": "Please paste an export URL",
385
+ "fetchFailed": "Failed to fetch data: {{status}}",
386
+ "expired": "Export link expired or invalid. Please regenerate it in the source environment.",
387
+ "unreachable": "Cannot reach the source environment. Check the URL and retry.",
388
+ "success": "\"{{name}}\" imported successfully!",
389
+ "failed": "Import failed",
390
+ "missingRefsTitle": "The target environment is missing these references. Import them first:",
391
+ "missingRefType": {
392
+ "page": "Page",
393
+ "resource": "Resource",
394
+ "parentPage": "Parent page"
395
+ },
396
+ "conflictsTitle": "Conflicts detected, import blocked:",
397
+ "pageRoleHint": "The page was imported but is not granted to any role yet, so it will not appear in the menu. Import the matching role, or grant the page to a role under Role Management."
398
+ },
399
+ "export": {
400
+ "title": "Export",
401
+ "copyUrl": "Copy URL",
402
+ "generating": "Generating export URL…",
403
+ "urlHint": "Export URL (valid for 30 minutes). Paste it into the target environment's import dialog:",
404
+ "failed": "Failed to generate export URL",
405
+ "copied": "URL copied to clipboard",
406
+ "scopeTitle": "What this export contains",
407
+ "scope": {
408
+ "user": "Includes: userid, name, roles, active status. Excludes: API keys and other credentials (never transferred across environments).",
409
+ "role": "Includes: role details, accessible pages (rolePages) and resource permissions (roleResource). Note: referenced pages and resources must already exist in the target environment.",
410
+ "page": "Includes: this page with all its descendants and their resource bindings (pageResource). Excludes: role grants (rolePages) — after importing, also import the matching role or grant it under Role Management, otherwise the menu stays hidden.",
411
+ "resource": "Includes: resource code, name, description and the common-resource flag."
412
+ }
413
+ }
374
414
  }
375
415
  }