strapi-content-sync-pro 1.0.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/admin/src/components/ConfigTab.jsx +1038 -0
  4. package/admin/src/components/ContentTypesTab.jsx +160 -0
  5. package/admin/src/components/HelpTab.jsx +945 -0
  6. package/admin/src/components/LogsTab.jsx +136 -0
  7. package/admin/src/components/MediaTab.jsx +557 -0
  8. package/admin/src/components/SyncProfilesTab.jsx +715 -0
  9. package/admin/src/components/SyncTab.jsx +988 -0
  10. package/admin/src/index.js +31 -0
  11. package/admin/src/pages/App/index.jsx +129 -0
  12. package/admin/src/pluginId.js +3 -0
  13. package/package.json +84 -0
  14. package/server/src/bootstrap.js +151 -0
  15. package/server/src/config/index.js +5 -0
  16. package/server/src/content-types/index.js +7 -0
  17. package/server/src/content-types/sync-log/schema.json +24 -0
  18. package/server/src/controllers/alerts.js +59 -0
  19. package/server/src/controllers/config.js +292 -0
  20. package/server/src/controllers/content-type-discovery.js +9 -0
  21. package/server/src/controllers/dependencies.js +109 -0
  22. package/server/src/controllers/index.js +29 -0
  23. package/server/src/controllers/ping.js +7 -0
  24. package/server/src/controllers/sync-config.js +26 -0
  25. package/server/src/controllers/sync-enforcement.js +323 -0
  26. package/server/src/controllers/sync-execution.js +134 -0
  27. package/server/src/controllers/sync-log.js +18 -0
  28. package/server/src/controllers/sync-media.js +158 -0
  29. package/server/src/controllers/sync-profiles.js +182 -0
  30. package/server/src/controllers/sync.js +31 -0
  31. package/server/src/destroy.js +7 -0
  32. package/server/src/index.js +21 -0
  33. package/server/src/middlewares/verify-signature.js +32 -0
  34. package/server/src/register.js +7 -0
  35. package/server/src/routes/index.js +111 -0
  36. package/server/src/services/alerts.js +437 -0
  37. package/server/src/services/config.js +68 -0
  38. package/server/src/services/content-type-discovery.js +41 -0
  39. package/server/src/services/dependency-resolver.js +284 -0
  40. package/server/src/services/index.js +30 -0
  41. package/server/src/services/ping.js +7 -0
  42. package/server/src/services/sync-config.js +45 -0
  43. package/server/src/services/sync-enforcement.js +362 -0
  44. package/server/src/services/sync-execution.js +541 -0
  45. package/server/src/services/sync-log.js +56 -0
  46. package/server/src/services/sync-media.js +963 -0
  47. package/server/src/services/sync-profiles.js +380 -0
  48. package/server/src/services/sync.js +248 -0
  49. package/server/src/utils/applier.js +89 -0
  50. package/server/src/utils/comparator.js +83 -0
  51. package/server/src/utils/fetcher.js +142 -0
  52. package/server/src/utils/hmac.js +37 -0
  53. package/server/src/utils/pagination.js +51 -0
  54. package/server/src/utils/sync-guard.js +29 -0
  55. package/server/src/utils/sync-id.js +16 -0
@@ -0,0 +1,1038 @@
1
+ import { useState, useEffect } from 'react';
2
+ import {
3
+ Box,
4
+ Flex,
5
+ Typography,
6
+ TextInput,
7
+ Button,
8
+ Alert,
9
+ Field,
10
+ Tabs,
11
+ SingleSelect,
12
+ SingleSelectOption,
13
+ Switch,
14
+ NumberInput,
15
+ TextButton,
16
+ Badge,
17
+ Loader,
18
+ Modal,
19
+ } from '@strapi/design-system';
20
+ import { useFetchClient } from '@strapi/strapi/admin';
21
+
22
+ const PLUGIN_ID = 'strapi-content-sync-pro';
23
+
24
+ const ConfigTab = () => {
25
+ const { get, post, put } = useFetchClient();
26
+
27
+ // Connection config
28
+ const [config, setConfig] = useState({
29
+ baseUrl: '',
30
+ apiToken: '',
31
+ instanceId: '',
32
+ sharedSecret: '',
33
+ });
34
+
35
+ // Login modal state
36
+ const [showLoginModal, setShowLoginModal] = useState(false);
37
+ const [credentials, setCredentials] = useState({
38
+ email: '',
39
+ password: '',
40
+ });
41
+ const [loginState, setLoginState] = useState({
42
+ loading: false,
43
+ success: false,
44
+ error: null,
45
+ });
46
+
47
+ // Connection test state
48
+ const [connectionTest, setConnectionTest] = useState({
49
+ testing: false,
50
+ result: null,
51
+ });
52
+
53
+ // Enforcement settings
54
+ const [enforcement, setEnforcement] = useState({
55
+ enforceSchemaMatch: true,
56
+ schemaMatchMode: 'strict',
57
+ enforceVersionCheck: true,
58
+ allowedVersionDrift: 'minor',
59
+ enforceDateTimeSync: true,
60
+ maxTimeDriftMs: 60000,
61
+ validateBeforeSync: true,
62
+ blockOnFailure: true,
63
+ });
64
+
65
+ // Diagnostic state
66
+ const [diagnostics, setDiagnostics] = useState({
67
+ running: null, // 'schema' | 'version' | 'time' | 'all'
68
+ results: {},
69
+ });
70
+
71
+ // Alert settings
72
+ const [alerts, setAlerts] = useState({
73
+ enabled: true,
74
+ emailPluginConfigured: false,
75
+ channels: {
76
+ strapiNotification: { enabled: true, onSuccess: false, onFailure: true },
77
+ email: {
78
+ enabled: false,
79
+ onSuccess: false,
80
+ onFailure: true,
81
+ recipients: [],
82
+ from: '',
83
+ },
84
+ webhook: { enabled: false, onSuccess: true, onFailure: true, url: '' },
85
+ },
86
+ });
87
+
88
+ const [emailRecipients, setEmailRecipients] = useState('');
89
+
90
+ const [loading, setLoading] = useState(true);
91
+ const [saving, setSaving] = useState(false);
92
+ const [message, setMessage] = useState(null);
93
+
94
+ useEffect(() => {
95
+ fetchAllConfig();
96
+ }, []);
97
+
98
+ const fetchAllConfig = async () => {
99
+ try {
100
+ const [configRes, enforcementRes, alertsRes] = await Promise.all([
101
+ get(`/${PLUGIN_ID}/config`),
102
+ get(`/${PLUGIN_ID}/enforcement/settings`),
103
+ get(`/${PLUGIN_ID}/alerts/settings`),
104
+ ]);
105
+ if (configRes.data.data) {
106
+ setConfig((prev) => ({ ...prev, ...configRes.data.data }));
107
+ }
108
+ if (enforcementRes.data.data) {
109
+ setEnforcement((prev) => ({ ...prev, ...enforcementRes.data.data }));
110
+ }
111
+ if (alertsRes.data.data) {
112
+ setAlerts((prev) => ({ ...prev, ...alertsRes.data.data }));
113
+ if (alertsRes.data.data.channels?.email?.recipients) {
114
+ setEmailRecipients(alertsRes.data.data.channels.email.recipients.join(', '));
115
+ }
116
+ }
117
+ } catch (err) {
118
+ console.error('Failed to fetch config', err);
119
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to load configuration' });
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ };
124
+
125
+ const handleSaveConnection = async () => {
126
+ setSaving(true);
127
+ setMessage(null);
128
+ try {
129
+ const payload = {};
130
+ if (config.baseUrl) payload.baseUrl = config.baseUrl;
131
+ if (config.apiToken && config.apiToken !== '••••••••') payload.apiToken = config.apiToken;
132
+ if (config.instanceId) payload.instanceId = config.instanceId;
133
+ if (config.sharedSecret && config.sharedSecret !== '••••••••') payload.sharedSecret = config.sharedSecret;
134
+
135
+ await post(`/${PLUGIN_ID}/config`, payload);
136
+ setMessage({ type: 'success', text: 'Connection configuration saved' });
137
+ } catch (err) {
138
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to save configuration' });
139
+ } finally {
140
+ setSaving(false);
141
+ }
142
+ };
143
+
144
+ // Login with credentials to remote server and get/create API token
145
+ const handleLoginWithCredentials = async () => {
146
+ if (!config.baseUrl || !credentials.email || !credentials.password) {
147
+ setLoginState({ loading: false, success: false, error: 'Please fill in all fields' });
148
+ return;
149
+ }
150
+
151
+ setLoginState({ loading: true, success: false, error: null });
152
+
153
+ try {
154
+ // Call our backend to proxy the login request
155
+ const response = await post(`/${PLUGIN_ID}/config/remote-login`, {
156
+ baseUrl: config.baseUrl,
157
+ email: credentials.email,
158
+ password: credentials.password,
159
+ });
160
+
161
+ // The backend saves the token, so we need to refresh config
162
+ const configRes = await get(`/${PLUGIN_ID}/config`);
163
+ if (configRes.data.data) {
164
+ setConfig((prev) => ({ ...prev, ...configRes.data.data }));
165
+ }
166
+
167
+ // Clear credentials (they should not be stored)
168
+ setCredentials({ email: '', password: '' });
169
+
170
+ setLoginState({ loading: false, success: true, error: null });
171
+ setMessage({ type: 'success', text: 'API token created successfully!' });
172
+
173
+ // Close modal after short delay to show success
174
+ setTimeout(() => {
175
+ setShowLoginModal(false);
176
+ setLoginState({ loading: false, success: false, error: null });
177
+ }, 1500);
178
+ } catch (err) {
179
+ const errorMessage = err.response?.data?.error?.message || err.message || 'Authentication failed';
180
+ setLoginState({ loading: false, success: false, error: errorMessage });
181
+ }
182
+ };
183
+
184
+ // Test connection to remote server
185
+ const handleTestConnection = async () => {
186
+ setConnectionTest({ testing: true, result: null });
187
+ try {
188
+ const response = await get(`/${PLUGIN_ID}/config/test`);
189
+ const data = response.data.data;
190
+ setConnectionTest({
191
+ testing: false,
192
+ result: {
193
+ success: data.success,
194
+ latency: data.latency,
195
+ message: data.message,
196
+ stage: data.stage,
197
+ remoteInfo: data.remoteInfo,
198
+ timestamp: new Date().toISOString(),
199
+ },
200
+ });
201
+ } catch (err) {
202
+ setConnectionTest({
203
+ testing: false,
204
+ result: {
205
+ success: false,
206
+ message: err.response?.data?.error?.message || err.message || 'Connection failed',
207
+ error: err.response?.status || 'Network Error',
208
+ timestamp: new Date().toISOString(),
209
+ },
210
+ });
211
+ }
212
+ };
213
+
214
+ // Run individual diagnostic check
215
+ const handleRunDiagnostic = async (type) => {
216
+ setDiagnostics(prev => ({ ...prev, running: type }));
217
+ try {
218
+ const response = await get(`/${PLUGIN_ID}/enforcement/check/${type}`);
219
+ setDiagnostics(prev => ({
220
+ ...prev,
221
+ running: null,
222
+ results: {
223
+ ...prev.results,
224
+ [type]: {
225
+ ...response.data.data,
226
+ timestamp: new Date().toISOString(),
227
+ },
228
+ },
229
+ }));
230
+ } catch (err) {
231
+ setDiagnostics(prev => ({
232
+ ...prev,
233
+ running: null,
234
+ results: {
235
+ ...prev.results,
236
+ [type]: {
237
+ passed: false,
238
+ error: err.response?.data?.error?.message || err.message || 'Check failed',
239
+ timestamp: new Date().toISOString(),
240
+ },
241
+ },
242
+ }));
243
+ }
244
+ };
245
+
246
+ // Run all diagnostic checks
247
+ const handleRunAllDiagnostics = async () => {
248
+ setDiagnostics({ running: 'all', results: {} });
249
+
250
+ const checks = ['schema', 'version', 'time'];
251
+ const results = {};
252
+
253
+ for (const check of checks) {
254
+ try {
255
+ const response = await get(`/${PLUGIN_ID}/enforcement/check/${check}`);
256
+ results[check] = {
257
+ ...response.data.data,
258
+ timestamp: new Date().toISOString(),
259
+ };
260
+ } catch (err) {
261
+ results[check] = {
262
+ passed: false,
263
+ error: err.response?.data?.error?.message || err.message || 'Check failed',
264
+ timestamp: new Date().toISOString(),
265
+ };
266
+ }
267
+ }
268
+
269
+ setDiagnostics({ running: null, results });
270
+ };
271
+
272
+ // Clear diagnostic results
273
+ const handleClearDiagnostics = () => {
274
+ setDiagnostics({ running: null, results: {} });
275
+ };
276
+
277
+ const handleSaveEnforcement = async () => {
278
+ setSaving(true);
279
+ setMessage(null);
280
+ try {
281
+ await put(`/${PLUGIN_ID}/enforcement/settings`, enforcement);
282
+ setMessage({ type: 'success', text: 'Enforcement settings saved' });
283
+ } catch (err) {
284
+ setMessage({ type: 'danger', text: err.response?.data?.error?.message || 'Failed to save enforcement settings' });
285
+ } finally {
286
+ setSaving(false);
287
+ }
288
+ };
289
+
290
+ const handleSaveAlerts = async () => {
291
+ setSaving(true);
292
+ setMessage(null);
293
+ try {
294
+ const alertPayload = {
295
+ ...alerts,
296
+ channels: {
297
+ ...alerts.channels,
298
+ email: {
299
+ ...alerts.channels.email,
300
+ recipients: emailRecipients.split(',').map(e => e.trim()).filter(e => e),
301
+ },
302
+ },
303
+ };
304
+ await put(`/${PLUGIN_ID}/alerts/settings`, alertPayload);
305
+ setMessage({ type: 'success', text: 'Alert settings saved' });
306
+ } catch (err) {
307
+ setMessage({ type: 'danger', text: err.response?.data?.error?.message || 'Failed to save alert settings' });
308
+ } finally {
309
+ setSaving(false);
310
+ }
311
+ };
312
+
313
+ const handleTestAlert = async (channel) => {
314
+ try {
315
+ await post(`/${PLUGIN_ID}/alerts/test/${channel}`);
316
+ setMessage({ type: 'success', text: `Test alert sent to ${channel}` });
317
+ } catch (err) {
318
+ setMessage({ type: 'danger', text: err.response?.data?.error?.message || 'Failed to send test alert' });
319
+ }
320
+ };
321
+
322
+ if (loading) return <Typography>Loading…</Typography>;
323
+
324
+ return (
325
+ <Box>
326
+ <Typography variant="beta" tag="h2">Configuration</Typography>
327
+ <Box paddingTop={2} paddingBottom={4}>
328
+ <Typography variant="omega" textColor="neutral600">
329
+ Configure connection, enforcement policies, and alert notifications.
330
+ </Typography>
331
+ </Box>
332
+
333
+ {message && (
334
+ <Box paddingBottom={4}>
335
+ <Alert variant={message.type} closeLabel="Close" onClose={() => setMessage(null)}>
336
+ {message.text}
337
+ </Alert>
338
+ </Box>
339
+ )}
340
+
341
+ <Tabs.Root defaultValue="connection">
342
+ <Tabs.List>
343
+ <Tabs.Trigger value="connection">Connection</Tabs.Trigger>
344
+ <Tabs.Trigger value="enforcement">Enforcement</Tabs.Trigger>
345
+ <Tabs.Trigger value="alerts">Alerts</Tabs.Trigger>
346
+ </Tabs.List>
347
+
348
+ <Box paddingTop={4}>
349
+ {/* Connection Tab */}
350
+ <Tabs.Content value="connection">
351
+ <Box>
352
+ <Flex gap={6}>
353
+ {/* LEFT COLUMN: Remote Server */}
354
+ <Box flex="1">
355
+ <Typography variant="delta" paddingBottom={4}>Remote Server</Typography>
356
+
357
+ <Flex direction="column" gap={4}>
358
+ <Field.Root>
359
+ <Field.Label>Server URL</Field.Label>
360
+ <TextInput
361
+ placeholder="https://my-other-strapi.com"
362
+ value={config.baseUrl}
363
+ onChange={(e) => setConfig((p) => ({ ...p, baseUrl: e.target.value }))}
364
+ />
365
+ <Field.Hint>URL of the Strapi server to sync with</Field.Hint>
366
+ </Field.Root>
367
+
368
+ <Field.Root>
369
+ <Field.Label>API Token</Field.Label>
370
+ <Flex gap={2}>
371
+ <Box flex="1">
372
+ <TextInput
373
+ type="password"
374
+ placeholder="Paste API token or generate one"
375
+ value={config.apiToken}
376
+ onChange={(e) => setConfig((p) => ({ ...p, apiToken: e.target.value }))}
377
+ />
378
+ </Box>
379
+ <Button
380
+ variant="secondary"
381
+ onClick={() => setShowLoginModal(true)}
382
+ disabled={!config.baseUrl}
383
+ >
384
+ {config.apiToken ? 'Regenerate' : 'Generate'}
385
+ </Button>
386
+ </Flex>
387
+ <Field.Hint>Full Access token from the remote server</Field.Hint>
388
+ </Field.Root>
389
+ </Flex>
390
+ </Box>
391
+
392
+ {/* RIGHT COLUMN: Local Settings */}
393
+ <Box flex="1">
394
+ <Typography variant="delta" paddingBottom={4}>Local Settings</Typography>
395
+
396
+ <Flex direction="column" gap={4}>
397
+ <Field.Root>
398
+ <Field.Label>Instance Name</Field.Label>
399
+ <TextInput
400
+ placeholder="e.g., production, staging, local"
401
+ value={config.instanceId}
402
+ onChange={(e) => setConfig((p) => ({ ...p, instanceId: e.target.value }))}
403
+ />
404
+ <Field.Hint>Name to identify this server in logs</Field.Hint>
405
+ </Field.Root>
406
+
407
+ <Field.Root>
408
+ <Field.Label>Shared Secret</Field.Label>
409
+ <TextInput
410
+ type="password"
411
+ placeholder="Secret key for bi-directional sync"
412
+ value={config.sharedSecret}
413
+ onChange={(e) => setConfig((p) => ({ ...p, sharedSecret: e.target.value }))}
414
+ />
415
+ <Field.Hint>Must match on both servers</Field.Hint>
416
+ </Field.Root>
417
+ </Flex>
418
+ </Box>
419
+ </Flex>
420
+
421
+ {/* Connection Status */}
422
+ {connectionTest.result && (
423
+ <Box paddingTop={4}>
424
+ <Alert
425
+ variant={connectionTest.result.success ? 'success' : 'danger'}
426
+ closeLabel="Close"
427
+ onClose={() => setConnectionTest({ testing: false, result: null })}
428
+ title={connectionTest.result.success ? 'Connection OK' : `Failed at: ${connectionTest.result.stage || 'unknown'}`}
429
+ >
430
+ <Box>
431
+ <Typography variant="omega">
432
+ {connectionTest.result.message}
433
+ {connectionTest.result.latency != null && ` (${connectionTest.result.latency}ms)`}
434
+ </Typography>
435
+ {connectionTest.result.remoteInfo && (
436
+ <Box paddingTop={2}>
437
+ <Typography variant="pi" textColor="neutral600">
438
+ Remote Strapi: {connectionTest.result.remoteInfo.strapiVersion || 'unknown'} •
439
+ Server time: {connectionTest.result.remoteInfo.serverTime || 'unknown'}
440
+ </Typography>
441
+ </Box>
442
+ )}
443
+ </Box>
444
+ </Alert>
445
+ </Box>
446
+ )}
447
+
448
+ {/* Action Buttons */}
449
+ <Flex gap={2} paddingTop={6}>
450
+ <Button
451
+ onClick={handleSaveConnection}
452
+ loading={saving}
453
+ disabled={!config.baseUrl || !config.apiToken}
454
+ >
455
+ Save
456
+ </Button>
457
+ <Button
458
+ variant="secondary"
459
+ onClick={handleTestConnection}
460
+ loading={connectionTest.testing}
461
+ disabled={!config.baseUrl || !config.apiToken}
462
+ >
463
+ Test Connection
464
+ </Button>
465
+ </Flex>
466
+ </Box>
467
+
468
+ {/* Login Modal for Token Generation */}
469
+ {showLoginModal && (
470
+ <Modal.Root open={showLoginModal} onOpenChange={setShowLoginModal}>
471
+ <Modal.Content>
472
+ <Modal.Header>
473
+ <Modal.Title>Generate API Token</Modal.Title>
474
+ </Modal.Header>
475
+ <Modal.Body>
476
+ <Typography variant="omega" textColor="neutral600" paddingBottom={4}>
477
+ Log in to <strong>{config.baseUrl}</strong> to automatically create an API token.
478
+ Your credentials are not stored.
479
+ </Typography>
480
+
481
+ <Flex direction="column" gap={4}>
482
+ <Field.Root>
483
+ <Field.Label>Admin Email</Field.Label>
484
+ <TextInput
485
+ type="email"
486
+ placeholder="admin@example.com"
487
+ value={credentials.email}
488
+ onChange={(e) => setCredentials((p) => ({ ...p, email: e.target.value }))}
489
+ />
490
+ </Field.Root>
491
+
492
+ <Field.Root>
493
+ <Field.Label>Admin Password</Field.Label>
494
+ <TextInput
495
+ type="password"
496
+ placeholder="Enter password"
497
+ value={credentials.password}
498
+ onChange={(e) => setCredentials((p) => ({ ...p, password: e.target.value }))}
499
+ />
500
+ </Field.Root>
501
+
502
+ {loginState.error && (
503
+ <Alert variant="danger" closeLabel="Close" onClose={() => setLoginState((p) => ({ ...p, error: null }))}>
504
+ {loginState.error}
505
+ </Alert>
506
+ )}
507
+
508
+ {loginState.success && (
509
+ <Alert variant="success">
510
+ Token created successfully!
511
+ </Alert>
512
+ )}
513
+ </Flex>
514
+ </Modal.Body>
515
+ <Modal.Footer>
516
+ <Modal.Close>
517
+ <Button variant="tertiary">Cancel</Button>
518
+ </Modal.Close>
519
+ <Button
520
+ onClick={handleLoginWithCredentials}
521
+ loading={loginState.loading}
522
+ disabled={!credentials.email || !credentials.password || loginState.success}
523
+ >
524
+ {loginState.loading ? 'Creating...' : 'Create Token'}
525
+ </Button>
526
+ </Modal.Footer>
527
+ </Modal.Content>
528
+ </Modal.Root>
529
+ )}
530
+ </Tabs.Content>
531
+
532
+ {/* Enforcement Tab */}
533
+ <Tabs.Content value="enforcement">
534
+ <Box>
535
+ <Flex gap={6}>
536
+ {/* LEFT COLUMN: Settings */}
537
+ <Box flex="1">
538
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={4}>
539
+ <Typography variant="delta">Enforcement Settings</Typography>
540
+ <Button onClick={handleSaveEnforcement} loading={saving} size="S">
541
+ Save
542
+ </Button>
543
+ </Flex>
544
+
545
+ <Flex direction="column" gap={3}>
546
+ {/* Schema Match Row */}
547
+ <Box padding={3} background="neutral100" hasRadius>
548
+ <Flex justifyContent="space-between" alignItems="center">
549
+ <Box flex="1">
550
+ <Typography fontWeight="bold">Schema Match</Typography>
551
+ <Typography variant="pi" textColor="neutral500">Verify schemas are compatible</Typography>
552
+ </Box>
553
+ <Flex gap={2} alignItems="center">
554
+ {enforcement.enforceSchemaMatch && (
555
+ <SingleSelect
556
+ size="S"
557
+ value={enforcement.schemaMatchMode}
558
+ onChange={(value) => setEnforcement((p) => ({ ...p, schemaMatchMode: value }))}
559
+ style={{ width: '140px' }}
560
+ >
561
+ <SingleSelectOption value="strict">Strict</SingleSelectOption>
562
+ <SingleSelectOption value="compatible">Compatible</SingleSelectOption>
563
+ </SingleSelect>
564
+ )}
565
+ <Switch
566
+ checked={enforcement.enforceSchemaMatch}
567
+ onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, enforceSchemaMatch: checked }))}
568
+ />
569
+ <Button
570
+ variant="tertiary"
571
+ size="S"
572
+ onClick={() => handleRunDiagnostic('schema')}
573
+ loading={diagnostics.running === 'schema'}
574
+ >
575
+ Check
576
+ </Button>
577
+ </Flex>
578
+ </Flex>
579
+ </Box>
580
+
581
+ {/* Version Check Row */}
582
+ <Box padding={3} background="neutral100" hasRadius>
583
+ <Flex justifyContent="space-between" alignItems="center">
584
+ <Box flex="1">
585
+ <Typography fontWeight="bold">Version Check</Typography>
586
+ <Typography variant="pi" textColor="neutral500">Ensure Strapi versions match</Typography>
587
+ </Box>
588
+ <Flex gap={2} alignItems="center">
589
+ {enforcement.enforceVersionCheck && (
590
+ <SingleSelect
591
+ size="S"
592
+ value={enforcement.allowedVersionDrift}
593
+ onChange={(value) => setEnforcement((p) => ({ ...p, allowedVersionDrift: value }))}
594
+ style={{ width: '140px' }}
595
+ >
596
+ <SingleSelectOption value="exact">Exact</SingleSelectOption>
597
+ <SingleSelectOption value="minor">Minor</SingleSelectOption>
598
+ <SingleSelectOption value="major">Major</SingleSelectOption>
599
+ </SingleSelect>
600
+ )}
601
+ <Switch
602
+ checked={enforcement.enforceVersionCheck}
603
+ onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, enforceVersionCheck: checked }))}
604
+ />
605
+ <Button
606
+ variant="tertiary"
607
+ size="S"
608
+ onClick={() => handleRunDiagnostic('version')}
609
+ loading={diagnostics.running === 'version'}
610
+ >
611
+ Check
612
+ </Button>
613
+ </Flex>
614
+ </Flex>
615
+ </Box>
616
+
617
+ {/* Time Sync Row */}
618
+ <Box padding={3} background="neutral100" hasRadius>
619
+ <Flex justifyContent="space-between" alignItems="center">
620
+ <Box flex="1">
621
+ <Typography fontWeight="bold">Time Sync</Typography>
622
+ <Typography variant="pi" textColor="neutral500">Verify server clocks match</Typography>
623
+ </Box>
624
+ <Flex gap={2} alignItems="center">
625
+ {enforcement.enforceDateTimeSync && (
626
+ <Box style={{ width: '100px' }}>
627
+ <NumberInput
628
+ size="S"
629
+ value={enforcement.maxTimeDriftMs}
630
+ onValueChange={(value) => setEnforcement((p) => ({ ...p, maxTimeDriftMs: value }))}
631
+ min={1000}
632
+ max={86400000}
633
+ />
634
+ </Box>
635
+ )}
636
+ <Switch
637
+ checked={enforcement.enforceDateTimeSync}
638
+ onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, enforceDateTimeSync: checked }))}
639
+ />
640
+ <Button
641
+ variant="tertiary"
642
+ size="S"
643
+ onClick={() => handleRunDiagnostic('time')}
644
+ loading={diagnostics.running === 'time'}
645
+ >
646
+ Check
647
+ </Button>
648
+ </Flex>
649
+ </Flex>
650
+ </Box>
651
+
652
+ {/* Block on Failure Row */}
653
+ <Box padding={3} background="neutral100" hasRadius>
654
+ <Flex justifyContent="space-between" alignItems="center">
655
+ <Box flex="1">
656
+ <Typography fontWeight="bold">Block on Failure</Typography>
657
+ <Typography variant="pi" textColor="neutral500">Stop sync if checks fail</Typography>
658
+ </Box>
659
+ <Switch
660
+ checked={enforcement.blockOnFailure}
661
+ onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, blockOnFailure: checked }))}
662
+ />
663
+ </Flex>
664
+ </Box>
665
+
666
+ {/* Run All Button */}
667
+ <Box paddingTop={2}>
668
+ <Button
669
+ variant="secondary"
670
+ onClick={handleRunAllDiagnostics}
671
+ loading={diagnostics.running === 'all'}
672
+ fullWidth
673
+ >
674
+ Run All Checks
675
+ </Button>
676
+ </Box>
677
+ </Flex>
678
+ </Box>
679
+
680
+ {/* RIGHT COLUMN: Results */}
681
+ <Box flex="1">
682
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={4}>
683
+ <Typography variant="delta">Check Results</Typography>
684
+ {Object.keys(diagnostics.results).length > 0 && (
685
+ <Button variant="tertiary" size="S" onClick={handleClearDiagnostics}>
686
+ Clear
687
+ </Button>
688
+ )}
689
+ </Flex>
690
+
691
+ {Object.keys(diagnostics.results).length === 0 ? (
692
+ <Box padding={4} background="neutral100" hasRadius>
693
+ <Typography textColor="neutral500" textAlign="center">
694
+ No results yet. Click "Check" buttons to run diagnostics.
695
+ </Typography>
696
+ </Box>
697
+ ) : (
698
+ <Flex direction="column" gap={3}>
699
+ {/* Schema Result */}
700
+ {diagnostics.results.schema && (
701
+ <Box padding={3} background={diagnostics.results.schema.passed ? 'success100' : 'danger100'} hasRadius>
702
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={2}>
703
+ <Typography fontWeight="bold">Schema Match</Typography>
704
+ <Badge active={diagnostics.results.schema.passed}>
705
+ {diagnostics.results.schema.passed ? '✓ Pass' : '✗ Fail'}
706
+ </Badge>
707
+ </Flex>
708
+ <Typography variant="pi" textColor={diagnostics.results.schema.passed ? 'success700' : 'danger700'}>
709
+ {diagnostics.results.schema.error ||
710
+ (diagnostics.results.schema.details?.mismatches?.length > 0
711
+ ? `${diagnostics.results.schema.details.mismatches.length} mismatch(es) found`
712
+ : 'All schemas compatible')}
713
+ </Typography>
714
+ </Box>
715
+ )}
716
+
717
+ {/* Version Result */}
718
+ {diagnostics.results.version && (
719
+ <Box padding={3} background={diagnostics.results.version.passed ? 'success100' : 'danger100'} hasRadius>
720
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={2}>
721
+ <Typography fontWeight="bold">Version Check</Typography>
722
+ <Badge active={diagnostics.results.version.passed}>
723
+ {diagnostics.results.version.passed ? '✓ Pass' : '✗ Fail'}
724
+ </Badge>
725
+ </Flex>
726
+ <Typography variant="pi" textColor={diagnostics.results.version.passed ? 'success700' : 'danger700'}>
727
+ {diagnostics.results.version.error ||
728
+ `Local: ${diagnostics.results.version.details?.localVersion || 'N/A'} → Remote: ${diagnostics.results.version.details?.remoteVersion || 'N/A'}`}
729
+ </Typography>
730
+ </Box>
731
+ )}
732
+
733
+ {/* Time Result */}
734
+ {diagnostics.results.time && (
735
+ <Box padding={3} background={diagnostics.results.time.passed ? 'success100' : 'danger100'} hasRadius>
736
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={2}>
737
+ <Typography fontWeight="bold">Time Sync</Typography>
738
+ <Badge active={diagnostics.results.time.passed}>
739
+ {diagnostics.results.time.passed ? '✓ Pass' : '✗ Fail'}
740
+ </Badge>
741
+ </Flex>
742
+ <Typography variant="pi" textColor={diagnostics.results.time.passed ? 'success700' : 'danger700'}>
743
+ {diagnostics.results.time.error ||
744
+ `Drift: ${diagnostics.results.time.details?.driftMs || 0}ms (max: ${enforcement.maxTimeDriftMs}ms)`}
745
+ </Typography>
746
+ </Box>
747
+ )}
748
+ </Flex>
749
+ )}
750
+ </Box>
751
+ </Flex>
752
+ </Box>
753
+ </Tabs.Content>
754
+
755
+ {/* Alerts Tab */}
756
+ <Tabs.Content value="alerts">
757
+ <Box>
758
+ <Typography variant="delta" paddingBottom={2}>Alert Notifications</Typography>
759
+ <Typography variant="omega" textColor="neutral600" paddingBottom={4}>
760
+ Configure notifications for sync success and failure events.
761
+ </Typography>
762
+
763
+ <Flex direction="column" gap={4}>
764
+ <Flex justifyContent="space-between" alignItems="center">
765
+ <Typography fontWeight="bold">Enable Alerts</Typography>
766
+ <Switch
767
+ checked={alerts.enabled}
768
+ onCheckedChange={(checked) => setAlerts((p) => ({ ...p, enabled: checked }))}
769
+ />
770
+ </Flex>
771
+
772
+ {alerts.enabled && (
773
+ <>
774
+ {/* Strapi Notifications */}
775
+ <Box padding={4} background="neutral0" hasRadius>
776
+ <Flex justifyContent="space-between" alignItems="center">
777
+ <Box>
778
+ <Typography fontWeight="bold">Strapi Notifications</Typography>
779
+ <Typography variant="pi" textColor="neutral500">
780
+ Log events to sync log (visible in admin)
781
+ </Typography>
782
+ </Box>
783
+ <Switch
784
+ checked={alerts.channels.strapiNotification.enabled}
785
+ onCheckedChange={(checked) => setAlerts((p) => ({
786
+ ...p,
787
+ channels: {
788
+ ...p.channels,
789
+ strapiNotification: { ...p.channels.strapiNotification, enabled: checked },
790
+ },
791
+ }))}
792
+ />
793
+ </Flex>
794
+ {alerts.channels.strapiNotification.enabled && (
795
+ <Flex gap={4} paddingTop={3}>
796
+ <Flex alignItems="center" gap={2}>
797
+ <Switch
798
+ checked={alerts.channels.strapiNotification.onSuccess}
799
+ onCheckedChange={(checked) => setAlerts((p) => ({
800
+ ...p,
801
+ channels: {
802
+ ...p.channels,
803
+ strapiNotification: { ...p.channels.strapiNotification, onSuccess: checked },
804
+ },
805
+ }))}
806
+ />
807
+ <Typography variant="pi">On Success</Typography>
808
+ </Flex>
809
+ <Flex alignItems="center" gap={2}>
810
+ <Switch
811
+ checked={alerts.channels.strapiNotification.onFailure}
812
+ onCheckedChange={(checked) => setAlerts((p) => ({
813
+ ...p,
814
+ channels: {
815
+ ...p.channels,
816
+ strapiNotification: { ...p.channels.strapiNotification, onFailure: checked },
817
+ },
818
+ }))}
819
+ />
820
+ <Typography variant="pi">On Failure</Typography>
821
+ </Flex>
822
+ <TextButton onClick={() => handleTestAlert('strapiNotification')}>
823
+ Test
824
+ </TextButton>
825
+ </Flex>
826
+ )}
827
+ </Box>
828
+
829
+ {/* Email */}
830
+ <Box padding={4} background="neutral0" hasRadius>
831
+ <Flex justifyContent="space-between" alignItems="center">
832
+ <Box>
833
+ <Typography fontWeight="bold">Email Notifications</Typography>
834
+ <Typography variant="pi" textColor="neutral500">
835
+ Send email alerts using Strapi's email plugin
836
+ </Typography>
837
+ </Box>
838
+ <Switch
839
+ checked={alerts.channels.email.enabled}
840
+ onCheckedChange={(checked) => setAlerts((p) => ({
841
+ ...p,
842
+ channels: {
843
+ ...p.channels,
844
+ email: { ...p.channels.email, enabled: checked },
845
+ },
846
+ }))}
847
+ />
848
+ </Flex>
849
+ {alerts.channels.email.enabled && (
850
+ <Box paddingTop={3}>
851
+ {/* Email Plugin Status */}
852
+ {!alerts.emailPluginConfigured && (
853
+ <Alert
854
+ variant="warning"
855
+ title="Email Plugin Not Configured"
856
+ style={{ marginBottom: '16px' }}
857
+ >
858
+ <Typography variant="omega">
859
+ Strapi's email plugin is not configured. To enable email alerts, install and configure an email provider:
860
+ </Typography>
861
+ <ul style={{ paddingLeft: '20px', marginTop: '8px' }}>
862
+ <li><Typography variant="pi">@strapi/provider-email-sendgrid</Typography></li>
863
+ <li><Typography variant="pi">@strapi/provider-email-mailgun</Typography></li>
864
+ <li><Typography variant="pi">@strapi/provider-email-amazon-ses</Typography></li>
865
+ <li><Typography variant="pi">@strapi/provider-email-nodemailer</Typography></li>
866
+ </ul>
867
+ <Typography variant="pi" paddingTop={2}>
868
+ See: <a href="https://docs.strapi.io/dev-docs/providers" target="_blank" rel="noopener noreferrer">Strapi Email Providers Documentation</a>
869
+ </Typography>
870
+ </Alert>
871
+ )}
872
+ {alerts.emailPluginConfigured && (
873
+ <Alert variant="success" title="Email Plugin Configured" style={{ marginBottom: '16px' }}>
874
+ <Typography variant="omega">
875
+ Strapi's email plugin is configured and ready to send alerts.
876
+ </Typography>
877
+ </Alert>
878
+ )}
879
+
880
+ {/* Recipients */}
881
+ <Field.Root>
882
+ <Field.Label>Email Recipients (comma-separated)</Field.Label>
883
+ <TextInput
884
+ placeholder="admin@example.com, alerts@example.com"
885
+ value={emailRecipients}
886
+ onChange={(e) => setEmailRecipients(e.target.value)}
887
+ />
888
+ <Field.Hint>Enter email addresses to receive sync alerts</Field.Hint>
889
+ </Field.Root>
890
+
891
+ {/* Optional From Address */}
892
+ <Box paddingTop={3}>
893
+ <Field.Root>
894
+ <Field.Label>From Email Address (optional)</Field.Label>
895
+ <TextInput
896
+ placeholder="Leave empty to use default"
897
+ value={alerts.channels.email.from || ''}
898
+ onChange={(e) => setAlerts((p) => ({
899
+ ...p,
900
+ channels: {
901
+ ...p.channels,
902
+ email: { ...p.channels.email, from: e.target.value },
903
+ },
904
+ }))}
905
+ />
906
+ <Field.Hint>Override the default sender address from Strapi email plugin</Field.Hint>
907
+ </Field.Root>
908
+ </Box>
909
+
910
+ {/* Triggers */}
911
+ <Box paddingTop={4}>
912
+ <Typography variant="delta" paddingBottom={2}>Alert Triggers</Typography>
913
+ <Flex gap={4}>
914
+ <Flex alignItems="center" gap={2}>
915
+ <Switch
916
+ checked={alerts.channels.email.onSuccess}
917
+ onCheckedChange={(checked) => setAlerts((p) => ({
918
+ ...p,
919
+ channels: {
920
+ ...p.channels,
921
+ email: { ...p.channels.email, onSuccess: checked },
922
+ },
923
+ }))}
924
+ />
925
+ <Typography variant="pi">On Success</Typography>
926
+ </Flex>
927
+ <Flex alignItems="center" gap={2}>
928
+ <Switch
929
+ checked={alerts.channels.email.onFailure}
930
+ onCheckedChange={(checked) => setAlerts((p) => ({
931
+ ...p,
932
+ channels: {
933
+ ...p.channels,
934
+ email: { ...p.channels.email, onFailure: checked },
935
+ },
936
+ }))}
937
+ />
938
+ <Typography variant="pi">On Failure</Typography>
939
+ </Flex>
940
+ <TextButton
941
+ onClick={() => handleTestAlert('email')}
942
+ disabled={!alerts.emailPluginConfigured}
943
+ >
944
+ Send Test Email
945
+ </TextButton>
946
+ </Flex>
947
+ </Box>
948
+ </Box>
949
+ )}
950
+ </Box>
951
+
952
+ {/* Webhook */}
953
+ <Box padding={4} background="neutral0" hasRadius>
954
+ <Flex justifyContent="space-between" alignItems="center">
955
+ <Box>
956
+ <Typography fontWeight="bold">Webhook Notifications</Typography>
957
+ <Typography variant="pi" textColor="neutral500">
958
+ Send alerts to a custom webhook endpoint
959
+ </Typography>
960
+ </Box>
961
+ <Switch
962
+ checked={alerts.channels.webhook.enabled}
963
+ onCheckedChange={(checked) => setAlerts((p) => ({
964
+ ...p,
965
+ channels: {
966
+ ...p.channels,
967
+ webhook: { ...p.channels.webhook, enabled: checked },
968
+ },
969
+ }))}
970
+ />
971
+ </Flex>
972
+ {alerts.channels.webhook.enabled && (
973
+ <Box paddingTop={3}>
974
+ <Field.Root>
975
+ <Field.Label>Webhook URL</Field.Label>
976
+ <TextInput
977
+ placeholder="https://hooks.example.com/sync-alerts"
978
+ value={alerts.channels.webhook.url}
979
+ onChange={(e) => setAlerts((p) => ({
980
+ ...p,
981
+ channels: {
982
+ ...p.channels,
983
+ webhook: { ...p.channels.webhook, url: e.target.value },
984
+ },
985
+ }))}
986
+ />
987
+ </Field.Root>
988
+ <Flex gap={4} paddingTop={3}>
989
+ <Flex alignItems="center" gap={2}>
990
+ <Switch
991
+ checked={alerts.channels.webhook.onSuccess}
992
+ onCheckedChange={(checked) => setAlerts((p) => ({
993
+ ...p,
994
+ channels: {
995
+ ...p.channels,
996
+ webhook: { ...p.channels.webhook, onSuccess: checked },
997
+ },
998
+ }))}
999
+ />
1000
+ <Typography variant="pi">On Success</Typography>
1001
+ </Flex>
1002
+ <Flex alignItems="center" gap={2}>
1003
+ <Switch
1004
+ checked={alerts.channels.webhook.onFailure}
1005
+ onCheckedChange={(checked) => setAlerts((p) => ({
1006
+ ...p,
1007
+ channels: {
1008
+ ...p.channels,
1009
+ webhook: { ...p.channels.webhook, onFailure: checked },
1010
+ },
1011
+ }))}
1012
+ />
1013
+ <Typography variant="pi">On Failure</Typography>
1014
+ </Flex>
1015
+ <TextButton onClick={() => handleTestAlert('webhook')}>
1016
+ Test
1017
+ </TextButton>
1018
+ </Flex>
1019
+ </Box>
1020
+ )}
1021
+ </Box>
1022
+ </>
1023
+ )}
1024
+
1025
+ <Button onClick={handleSaveAlerts} loading={saving}>
1026
+ Save Alert Settings
1027
+ </Button>
1028
+ </Flex>
1029
+ </Box>
1030
+ </Tabs.Content>
1031
+ </Box>
1032
+ </Tabs.Root>
1033
+ </Box>
1034
+ );
1035
+ };
1036
+
1037
+ export { ConfigTab };
1038
+ export default ConfigTab;