strapi-content-sync-pro 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/admin/src/components/ConfigTab.jsx +1010 -1007
- package/admin/src/components/HelpTab.jsx +36 -0
- package/package.json +1 -1
|
@@ -1,1037 +1,1040 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
19
|
} from '@strapi/design-system';
|
|
20
20
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
21
21
|
|
|
22
22
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
23
23
|
|
|
24
24
|
const ConfigTab = () => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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({
|
|
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({
|
|
203
49
|
testing: false,
|
|
204
|
-
result:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
running: null,
|
|
222
|
-
results: {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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,
|
|
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,
|
|
296
75
|
channels: {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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: '' },
|
|
302
85
|
},
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
{message.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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>
|
|
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
|
+
};
|
|
391
143
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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>
|
|
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
|
+
}
|
|
420
150
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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>
|
|
466
331
|
</Box>
|
|
467
332
|
|
|
468
|
-
{
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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>
|
|
333
|
+
{message && (
|
|
334
|
+
<Box paddingBottom={4}>
|
|
335
|
+
<Alert variant={message.type} closeLabel="Close" onClose={() => setMessage(null)}>
|
|
336
|
+
{message.text}
|
|
337
|
+
</Alert>
|
|
678
338
|
</Box>
|
|
339
|
+
)}
|
|
679
340
|
|
|
680
|
-
|
|
681
|
-
<
|
|
682
|
-
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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">
|
|
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">
|
|
777
351
|
<Box>
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
|
|
380
|
+
</Flex>
|
|
381
|
+
<Field.Hint>Full Access token from the remote server</Field.Hint>
|
|
382
|
+
</Field.Root>
|
|
383
|
+
<Field.Root>
|
|
384
|
+
<Button
|
|
385
|
+
variant="secondary"
|
|
386
|
+
onClick={() => setShowLoginModal(true)}
|
|
387
|
+
disabled={!config.baseUrl}
|
|
388
|
+
>
|
|
389
|
+
{config.apiToken ? 'Regenerate' : 'Generate'}
|
|
390
|
+
</Button>
|
|
391
|
+
</Field.Root>
|
|
392
|
+
</Flex>
|
|
393
|
+
</Box>
|
|
394
|
+
|
|
395
|
+
{/* RIGHT COLUMN: Local Settings */}
|
|
396
|
+
<Box flex="1">
|
|
397
|
+
<Typography variant="delta" paddingBottom={4}>Local Settings</Typography>
|
|
398
|
+
|
|
399
|
+
<Flex direction="column" gap={4}>
|
|
400
|
+
<Field.Root>
|
|
401
|
+
<Field.Label>Instance Name</Field.Label>
|
|
402
|
+
<TextInput
|
|
403
|
+
placeholder="e.g., production, staging, local"
|
|
404
|
+
value={config.instanceId}
|
|
405
|
+
onChange={(e) => setConfig((p) => ({ ...p, instanceId: e.target.value }))}
|
|
406
|
+
/>
|
|
407
|
+
<Field.Hint>Name to identify this server in logs</Field.Hint>
|
|
408
|
+
</Field.Root>
|
|
409
|
+
|
|
410
|
+
<Field.Root>
|
|
411
|
+
<Field.Label>Shared Secret</Field.Label>
|
|
412
|
+
<TextInput
|
|
413
|
+
type="password"
|
|
414
|
+
placeholder="Secret key for bi-directional sync"
|
|
415
|
+
value={config.sharedSecret}
|
|
416
|
+
onChange={(e) => setConfig((p) => ({ ...p, sharedSecret: e.target.value }))}
|
|
417
|
+
/>
|
|
418
|
+
<Field.Hint>Must match on both servers</Field.Hint>
|
|
419
|
+
</Field.Root>
|
|
420
|
+
</Flex>
|
|
421
|
+
</Box>
|
|
422
|
+
</Flex>
|
|
423
|
+
|
|
424
|
+
{/* Connection Status */}
|
|
425
|
+
{connectionTest.result && (
|
|
426
|
+
<Box paddingTop={4}>
|
|
427
|
+
<Alert
|
|
428
|
+
variant={connectionTest.result.success ? 'success' : 'danger'}
|
|
429
|
+
closeLabel="Close"
|
|
430
|
+
onClose={() => setConnectionTest({ testing: false, result: null })}
|
|
431
|
+
title={connectionTest.result.success ? 'Connection OK' : `Failed at: ${connectionTest.result.stage || 'unknown'}`}
|
|
432
|
+
>
|
|
433
|
+
<Box>
|
|
434
|
+
<Typography variant="omega">
|
|
435
|
+
{connectionTest.result.message}
|
|
436
|
+
{connectionTest.result.latency != null && ` (${connectionTest.result.latency}ms)`}
|
|
437
|
+
</Typography>
|
|
438
|
+
{connectionTest.result.remoteInfo && (
|
|
439
|
+
<Box paddingTop={2}>
|
|
440
|
+
<Typography variant="pi" textColor="neutral600">
|
|
441
|
+
Remote Strapi: {connectionTest.result.remoteInfo.strapiVersion || 'unknown'} •
|
|
442
|
+
Server time: {connectionTest.result.remoteInfo.serverTime || 'unknown'}
|
|
443
|
+
</Typography>
|
|
444
|
+
</Box>
|
|
445
|
+
)}
|
|
446
|
+
</Box>
|
|
447
|
+
</Alert>
|
|
448
|
+
</Box>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{/* Action Buttons */}
|
|
452
|
+
<Flex gap={2} paddingTop={6}>
|
|
453
|
+
<Button
|
|
454
|
+
onClick={handleSaveConnection}
|
|
455
|
+
loading={saving}
|
|
456
|
+
disabled={!config.baseUrl || !config.apiToken}
|
|
457
|
+
>
|
|
458
|
+
Save
|
|
459
|
+
</Button>
|
|
460
|
+
<Button
|
|
461
|
+
variant="secondary"
|
|
462
|
+
onClick={handleTestConnection}
|
|
463
|
+
loading={connectionTest.testing}
|
|
464
|
+
disabled={!config.baseUrl || !config.apiToken}
|
|
465
|
+
>
|
|
466
|
+
Test Connection
|
|
467
|
+
</Button>
|
|
468
|
+
</Flex>
|
|
782
469
|
</Box>
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
470
|
+
|
|
471
|
+
{/* Login Modal for Token Generation */}
|
|
472
|
+
{showLoginModal && (
|
|
473
|
+
<Modal.Root open={showLoginModal} onOpenChange={setShowLoginModal}>
|
|
474
|
+
<Modal.Content>
|
|
475
|
+
<Modal.Header>
|
|
476
|
+
<Modal.Title>Generate API Token</Modal.Title>
|
|
477
|
+
</Modal.Header>
|
|
478
|
+
<Modal.Body>
|
|
479
|
+
<Typography variant="omega" textColor="neutral600" paddingBottom={4}>
|
|
480
|
+
Log in to <strong>{config.baseUrl}</strong> to automatically create an API token.
|
|
481
|
+
Your credentials are not stored.
|
|
482
|
+
</Typography>
|
|
483
|
+
|
|
484
|
+
<Flex direction="column" gap={4}>
|
|
485
|
+
<Field.Root>
|
|
486
|
+
<Field.Label>Admin Email</Field.Label>
|
|
487
|
+
<TextInput
|
|
488
|
+
type="email"
|
|
489
|
+
placeholder="admin@example.com"
|
|
490
|
+
value={credentials.email}
|
|
491
|
+
onChange={(e) => setCredentials((p) => ({ ...p, email: e.target.value }))}
|
|
492
|
+
/>
|
|
493
|
+
</Field.Root>
|
|
494
|
+
|
|
495
|
+
<Field.Root>
|
|
496
|
+
<Field.Label>Admin Password</Field.Label>
|
|
497
|
+
<TextInput
|
|
498
|
+
type="password"
|
|
499
|
+
placeholder="Enter password"
|
|
500
|
+
value={credentials.password}
|
|
501
|
+
onChange={(e) => setCredentials((p) => ({ ...p, password: e.target.value }))}
|
|
502
|
+
/>
|
|
503
|
+
</Field.Root>
|
|
504
|
+
|
|
505
|
+
{loginState.error && (
|
|
506
|
+
<Alert variant="danger" closeLabel="Close" onClose={() => setLoginState((p) => ({ ...p, error: null }))}>
|
|
507
|
+
{loginState.error}
|
|
508
|
+
</Alert>
|
|
509
|
+
)}
|
|
510
|
+
|
|
511
|
+
{loginState.success && (
|
|
512
|
+
<Alert variant="success">
|
|
513
|
+
Token created successfully!
|
|
514
|
+
</Alert>
|
|
515
|
+
)}
|
|
516
|
+
</Flex>
|
|
517
|
+
</Modal.Body>
|
|
518
|
+
<Modal.Footer>
|
|
519
|
+
<Modal.Close>
|
|
520
|
+
<Button variant="tertiary">Cancel</Button>
|
|
521
|
+
</Modal.Close>
|
|
522
|
+
<Button
|
|
523
|
+
onClick={handleLoginWithCredentials}
|
|
524
|
+
loading={loginState.loading}
|
|
525
|
+
disabled={!credentials.email || !credentials.password || loginState.success}
|
|
526
|
+
>
|
|
527
|
+
{loginState.loading ? 'Creating...' : 'Create Token'}
|
|
528
|
+
</Button>
|
|
529
|
+
</Modal.Footer>
|
|
530
|
+
</Modal.Content>
|
|
531
|
+
</Modal.Root>
|
|
532
|
+
)}
|
|
533
|
+
</Tabs.Content>
|
|
534
|
+
|
|
535
|
+
{/* Enforcement Tab */}
|
|
536
|
+
<Tabs.Content value="enforcement">
|
|
832
537
|
<Box>
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
538
|
+
<Flex gap={6}>
|
|
539
|
+
{/* LEFT COLUMN: Settings */}
|
|
540
|
+
<Box flex="1">
|
|
541
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={4}>
|
|
542
|
+
<Typography variant="delta">Enforcement Settings</Typography>
|
|
543
|
+
<Button onClick={handleSaveEnforcement} loading={saving} size="S">
|
|
544
|
+
Save
|
|
545
|
+
</Button>
|
|
546
|
+
</Flex>
|
|
547
|
+
|
|
548
|
+
<Flex direction="column" gap={3}>
|
|
549
|
+
{/* Schema Match Row */}
|
|
550
|
+
<Box padding={3} background="neutral100" hasRadius>
|
|
551
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
552
|
+
<Box flex="1">
|
|
553
|
+
<Typography fontWeight="bold">Schema Match</Typography>
|
|
554
|
+
<Typography variant="pi" textColor="neutral500">Verify schemas are compatible</Typography>
|
|
555
|
+
</Box>
|
|
556
|
+
<Flex gap={2} alignItems="center">
|
|
557
|
+
{enforcement.enforceSchemaMatch && (
|
|
558
|
+
<SingleSelect
|
|
559
|
+
size="S"
|
|
560
|
+
value={enforcement.schemaMatchMode}
|
|
561
|
+
onChange={(value) => setEnforcement((p) => ({ ...p, schemaMatchMode: value }))}
|
|
562
|
+
style={{ width: '140px' }}
|
|
563
|
+
>
|
|
564
|
+
<SingleSelectOption value="strict">Strict</SingleSelectOption>
|
|
565
|
+
<SingleSelectOption value="compatible">Compatible</SingleSelectOption>
|
|
566
|
+
</SingleSelect>
|
|
567
|
+
)}
|
|
568
|
+
<Switch
|
|
569
|
+
checked={enforcement.enforceSchemaMatch}
|
|
570
|
+
onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, enforceSchemaMatch: checked }))}
|
|
571
|
+
/>
|
|
572
|
+
<Button
|
|
573
|
+
variant="tertiary"
|
|
574
|
+
size="S"
|
|
575
|
+
onClick={() => handleRunDiagnostic('schema')}
|
|
576
|
+
loading={diagnostics.running === 'schema'}
|
|
577
|
+
>
|
|
578
|
+
Check
|
|
579
|
+
</Button>
|
|
580
|
+
</Flex>
|
|
581
|
+
</Flex>
|
|
582
|
+
</Box>
|
|
583
|
+
|
|
584
|
+
{/* Version Check Row */}
|
|
585
|
+
<Box padding={3} background="neutral100" hasRadius>
|
|
586
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
587
|
+
<Box flex="1">
|
|
588
|
+
<Typography fontWeight="bold">Version Check</Typography>
|
|
589
|
+
<Typography variant="pi" textColor="neutral500">Ensure Strapi versions match</Typography>
|
|
590
|
+
</Box>
|
|
591
|
+
<Flex gap={2} alignItems="center">
|
|
592
|
+
{enforcement.enforceVersionCheck && (
|
|
593
|
+
<SingleSelect
|
|
594
|
+
size="S"
|
|
595
|
+
value={enforcement.allowedVersionDrift}
|
|
596
|
+
onChange={(value) => setEnforcement((p) => ({ ...p, allowedVersionDrift: value }))}
|
|
597
|
+
style={{ width: '140px' }}
|
|
598
|
+
>
|
|
599
|
+
<SingleSelectOption value="exact">Exact</SingleSelectOption>
|
|
600
|
+
<SingleSelectOption value="minor">Minor</SingleSelectOption>
|
|
601
|
+
<SingleSelectOption value="major">Major</SingleSelectOption>
|
|
602
|
+
</SingleSelect>
|
|
603
|
+
)}
|
|
604
|
+
<Switch
|
|
605
|
+
checked={enforcement.enforceVersionCheck}
|
|
606
|
+
onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, enforceVersionCheck: checked }))}
|
|
607
|
+
/>
|
|
608
|
+
<Button
|
|
609
|
+
variant="tertiary"
|
|
610
|
+
size="S"
|
|
611
|
+
onClick={() => handleRunDiagnostic('version')}
|
|
612
|
+
loading={diagnostics.running === 'version'}
|
|
613
|
+
>
|
|
614
|
+
Check
|
|
615
|
+
</Button>
|
|
616
|
+
</Flex>
|
|
617
|
+
</Flex>
|
|
618
|
+
</Box>
|
|
619
|
+
|
|
620
|
+
{/* Time Sync Row */}
|
|
621
|
+
<Box padding={3} background="neutral100" hasRadius>
|
|
622
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
623
|
+
<Box flex="1">
|
|
624
|
+
<Typography fontWeight="bold">Time Sync</Typography>
|
|
625
|
+
<Typography variant="pi" textColor="neutral500">Verify server clocks match</Typography>
|
|
626
|
+
</Box>
|
|
627
|
+
<Flex gap={2} alignItems="center">
|
|
628
|
+
{enforcement.enforceDateTimeSync && (
|
|
629
|
+
<Box style={{ width: '100px' }}>
|
|
630
|
+
<NumberInput
|
|
631
|
+
size="S"
|
|
632
|
+
value={enforcement.maxTimeDriftMs}
|
|
633
|
+
onValueChange={(value) => setEnforcement((p) => ({ ...p, maxTimeDriftMs: value }))}
|
|
634
|
+
min={1000}
|
|
635
|
+
max={86400000}
|
|
636
|
+
/>
|
|
637
|
+
</Box>
|
|
638
|
+
)}
|
|
639
|
+
<Switch
|
|
640
|
+
checked={enforcement.enforceDateTimeSync}
|
|
641
|
+
onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, enforceDateTimeSync: checked }))}
|
|
642
|
+
/>
|
|
643
|
+
<Button
|
|
644
|
+
variant="tertiary"
|
|
645
|
+
size="S"
|
|
646
|
+
onClick={() => handleRunDiagnostic('time')}
|
|
647
|
+
loading={diagnostics.running === 'time'}
|
|
648
|
+
>
|
|
649
|
+
Check
|
|
650
|
+
</Button>
|
|
651
|
+
</Flex>
|
|
652
|
+
</Flex>
|
|
653
|
+
</Box>
|
|
654
|
+
|
|
655
|
+
{/* Block on Failure Row */}
|
|
656
|
+
<Box padding={3} background="neutral100" hasRadius>
|
|
657
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
658
|
+
<Box flex="1">
|
|
659
|
+
<Typography fontWeight="bold">Block on Failure</Typography>
|
|
660
|
+
<Typography variant="pi" textColor="neutral500">Stop sync if checks fail</Typography>
|
|
661
|
+
</Box>
|
|
662
|
+
<Switch
|
|
663
|
+
checked={enforcement.blockOnFailure}
|
|
664
|
+
onCheckedChange={(checked) => setEnforcement((p) => ({ ...p, blockOnFailure: checked }))}
|
|
665
|
+
/>
|
|
666
|
+
</Flex>
|
|
667
|
+
</Box>
|
|
668
|
+
|
|
669
|
+
{/* Run All Button */}
|
|
670
|
+
<Box paddingTop={2}>
|
|
671
|
+
<Button
|
|
672
|
+
variant="secondary"
|
|
673
|
+
onClick={handleRunAllDiagnostics}
|
|
674
|
+
loading={diagnostics.running === 'all'}
|
|
675
|
+
fullWidth
|
|
676
|
+
>
|
|
677
|
+
Run All Checks
|
|
678
|
+
</Button>
|
|
679
|
+
</Box>
|
|
680
|
+
</Flex>
|
|
681
|
+
</Box>
|
|
682
|
+
|
|
683
|
+
{/* RIGHT COLUMN: Results */}
|
|
684
|
+
<Box flex="1">
|
|
685
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={4}>
|
|
686
|
+
<Typography variant="delta">Check Results</Typography>
|
|
687
|
+
{Object.keys(diagnostics.results).length > 0 && (
|
|
688
|
+
<Button variant="tertiary" size="S" onClick={handleClearDiagnostics}>
|
|
689
|
+
Clear
|
|
690
|
+
</Button>
|
|
691
|
+
)}
|
|
692
|
+
</Flex>
|
|
693
|
+
|
|
694
|
+
{Object.keys(diagnostics.results).length === 0 ? (
|
|
695
|
+
<Box padding={4} background="neutral100" hasRadius>
|
|
696
|
+
<Typography textColor="neutral500" textAlign="center">
|
|
697
|
+
No results yet. Click "Check" buttons to run diagnostics.
|
|
698
|
+
</Typography>
|
|
699
|
+
</Box>
|
|
700
|
+
) : (
|
|
701
|
+
<Flex direction="column" gap={3}>
|
|
702
|
+
{/* Schema Result */}
|
|
703
|
+
{diagnostics.results.schema && (
|
|
704
|
+
<Box padding={3} background={diagnostics.results.schema.passed ? 'success100' : 'danger100'} hasRadius>
|
|
705
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={2}>
|
|
706
|
+
<Typography fontWeight="bold">Schema Match</Typography>
|
|
707
|
+
<Badge active={diagnostics.results.schema.passed}>
|
|
708
|
+
{diagnostics.results.schema.passed ? '✓ Pass' : '✗ Fail'}
|
|
709
|
+
</Badge>
|
|
710
|
+
</Flex>
|
|
711
|
+
<Typography variant="pi" textColor={diagnostics.results.schema.passed ? 'success700' : 'danger700'}>
|
|
712
|
+
{diagnostics.results.schema.error ||
|
|
713
|
+
(diagnostics.results.schema.details?.mismatches?.length > 0
|
|
714
|
+
? `${diagnostics.results.schema.details.mismatches.length} mismatch(es) found`
|
|
715
|
+
: 'All schemas compatible')}
|
|
716
|
+
</Typography>
|
|
717
|
+
</Box>
|
|
718
|
+
)}
|
|
719
|
+
|
|
720
|
+
{/* Version Result */}
|
|
721
|
+
{diagnostics.results.version && (
|
|
722
|
+
<Box padding={3} background={diagnostics.results.version.passed ? 'success100' : 'danger100'} hasRadius>
|
|
723
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={2}>
|
|
724
|
+
<Typography fontWeight="bold">Version Check</Typography>
|
|
725
|
+
<Badge active={diagnostics.results.version.passed}>
|
|
726
|
+
{diagnostics.results.version.passed ? '✓ Pass' : '✗ Fail'}
|
|
727
|
+
</Badge>
|
|
728
|
+
</Flex>
|
|
729
|
+
<Typography variant="pi" textColor={diagnostics.results.version.passed ? 'success700' : 'danger700'}>
|
|
730
|
+
{diagnostics.results.version.error ||
|
|
731
|
+
`Local: ${diagnostics.results.version.details?.localVersion || 'N/A'} → Remote: ${diagnostics.results.version.details?.remoteVersion || 'N/A'}`}
|
|
732
|
+
</Typography>
|
|
733
|
+
</Box>
|
|
734
|
+
)}
|
|
735
|
+
|
|
736
|
+
{/* Time Result */}
|
|
737
|
+
{diagnostics.results.time && (
|
|
738
|
+
<Box padding={3} background={diagnostics.results.time.passed ? 'success100' : 'danger100'} hasRadius>
|
|
739
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={2}>
|
|
740
|
+
<Typography fontWeight="bold">Time Sync</Typography>
|
|
741
|
+
<Badge active={diagnostics.results.time.passed}>
|
|
742
|
+
{diagnostics.results.time.passed ? '✓ Pass' : '✗ Fail'}
|
|
743
|
+
</Badge>
|
|
744
|
+
</Flex>
|
|
745
|
+
<Typography variant="pi" textColor={diagnostics.results.time.passed ? 'success700' : 'danger700'}>
|
|
746
|
+
{diagnostics.results.time.error ||
|
|
747
|
+
`Drift: ${diagnostics.results.time.details?.driftMs || 0}ms (max: ${enforcement.maxTimeDriftMs}ms)`}
|
|
748
|
+
</Typography>
|
|
749
|
+
</Box>
|
|
750
|
+
)}
|
|
751
|
+
</Flex>
|
|
752
|
+
)}
|
|
753
|
+
</Box>
|
|
946
754
|
</Flex>
|
|
947
|
-
</Box>
|
|
948
755
|
</Box>
|
|
949
|
-
|
|
950
|
-
</Box>
|
|
756
|
+
</Tabs.Content>
|
|
951
757
|
|
|
952
|
-
{/*
|
|
953
|
-
<
|
|
954
|
-
<Flex justifyContent="space-between" alignItems="center">
|
|
758
|
+
{/* Alerts Tab */}
|
|
759
|
+
<Tabs.Content value="alerts">
|
|
955
760
|
<Box>
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
761
|
+
<Typography variant="delta" paddingBottom={2}>Alert Notifications</Typography>
|
|
762
|
+
<Typography variant="omega" textColor="neutral600" paddingBottom={4}>
|
|
763
|
+
Configure notifications for sync success and failure events.
|
|
764
|
+
</Typography>
|
|
765
|
+
|
|
766
|
+
<Flex direction="column" gap={4}>
|
|
767
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
768
|
+
<Typography fontWeight="bold">Enable Alerts</Typography>
|
|
769
|
+
<Switch
|
|
770
|
+
checked={alerts.enabled}
|
|
771
|
+
onCheckedChange={(checked) => setAlerts((p) => ({ ...p, enabled: checked }))}
|
|
772
|
+
/>
|
|
773
|
+
</Flex>
|
|
774
|
+
|
|
775
|
+
{alerts.enabled && (
|
|
776
|
+
<>
|
|
777
|
+
{/* Strapi Notifications */}
|
|
778
|
+
<Box padding={4} background="neutral0" hasRadius>
|
|
779
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
780
|
+
<Box>
|
|
781
|
+
<Typography fontWeight="bold">Strapi Notifications</Typography>
|
|
782
|
+
<Typography variant="pi" textColor="neutral500">
|
|
783
|
+
Log events to sync log (visible in admin)
|
|
784
|
+
</Typography>
|
|
785
|
+
</Box>
|
|
786
|
+
<Switch
|
|
787
|
+
checked={alerts.channels.strapiNotification.enabled}
|
|
788
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
789
|
+
...p,
|
|
790
|
+
channels: {
|
|
791
|
+
...p.channels,
|
|
792
|
+
strapiNotification: { ...p.channels.strapiNotification, enabled: checked },
|
|
793
|
+
},
|
|
794
|
+
}))}
|
|
795
|
+
/>
|
|
796
|
+
</Flex>
|
|
797
|
+
{alerts.channels.strapiNotification.enabled && (
|
|
798
|
+
<Flex gap={4} paddingTop={3}>
|
|
799
|
+
<Flex alignItems="center" gap={2}>
|
|
800
|
+
<Switch
|
|
801
|
+
checked={alerts.channels.strapiNotification.onSuccess}
|
|
802
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
803
|
+
...p,
|
|
804
|
+
channels: {
|
|
805
|
+
...p.channels,
|
|
806
|
+
strapiNotification: { ...p.channels.strapiNotification, onSuccess: checked },
|
|
807
|
+
},
|
|
808
|
+
}))}
|
|
809
|
+
/>
|
|
810
|
+
<Typography variant="pi">On Success</Typography>
|
|
811
|
+
</Flex>
|
|
812
|
+
<Flex alignItems="center" gap={2}>
|
|
813
|
+
<Switch
|
|
814
|
+
checked={alerts.channels.strapiNotification.onFailure}
|
|
815
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
816
|
+
...p,
|
|
817
|
+
channels: {
|
|
818
|
+
...p.channels,
|
|
819
|
+
strapiNotification: { ...p.channels.strapiNotification, onFailure: checked },
|
|
820
|
+
},
|
|
821
|
+
}))}
|
|
822
|
+
/>
|
|
823
|
+
<Typography variant="pi">On Failure</Typography>
|
|
824
|
+
</Flex>
|
|
825
|
+
<TextButton onClick={() => handleTestAlert('strapiNotification')}>
|
|
826
|
+
Test
|
|
827
|
+
</TextButton>
|
|
828
|
+
</Flex>
|
|
829
|
+
)}
|
|
830
|
+
</Box>
|
|
831
|
+
|
|
832
|
+
{/* Email */}
|
|
833
|
+
<Box padding={4} background="neutral0" hasRadius>
|
|
834
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
835
|
+
<Box>
|
|
836
|
+
<Typography fontWeight="bold">Email Notifications</Typography>
|
|
837
|
+
<Typography variant="pi" textColor="neutral500">
|
|
838
|
+
Send email alerts using Strapi's email plugin
|
|
839
|
+
</Typography>
|
|
840
|
+
</Box>
|
|
841
|
+
<Switch
|
|
842
|
+
checked={alerts.channels.email.enabled}
|
|
843
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
844
|
+
...p,
|
|
845
|
+
channels: {
|
|
846
|
+
...p.channels,
|
|
847
|
+
email: { ...p.channels.email, enabled: checked },
|
|
848
|
+
},
|
|
849
|
+
}))}
|
|
850
|
+
/>
|
|
851
|
+
</Flex>
|
|
852
|
+
{alerts.channels.email.enabled && (
|
|
853
|
+
<Box paddingTop={3}>
|
|
854
|
+
{/* Email Plugin Status */}
|
|
855
|
+
{!alerts.emailPluginConfigured && (
|
|
856
|
+
<Alert
|
|
857
|
+
variant="warning"
|
|
858
|
+
title="Email Plugin Not Configured"
|
|
859
|
+
style={{ marginBottom: '16px' }}
|
|
860
|
+
>
|
|
861
|
+
<Typography variant="omega">
|
|
862
|
+
Strapi's email plugin is not configured. To enable email alerts, install and configure an email provider:
|
|
863
|
+
</Typography>
|
|
864
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px' }}>
|
|
865
|
+
<li><Typography variant="pi">@strapi/provider-email-sendgrid</Typography></li>
|
|
866
|
+
<li><Typography variant="pi">@strapi/provider-email-mailgun</Typography></li>
|
|
867
|
+
<li><Typography variant="pi">@strapi/provider-email-amazon-ses</Typography></li>
|
|
868
|
+
<li><Typography variant="pi">@strapi/provider-email-nodemailer</Typography></li>
|
|
869
|
+
</ul>
|
|
870
|
+
<Typography variant="pi" paddingTop={2}>
|
|
871
|
+
See: <a href="https://docs.strapi.io/dev-docs/providers" target="_blank" rel="noopener noreferrer">Strapi Email Providers Documentation</a>
|
|
872
|
+
</Typography>
|
|
873
|
+
</Alert>
|
|
874
|
+
)}
|
|
875
|
+
{alerts.emailPluginConfigured && (
|
|
876
|
+
<Alert variant="success" title="Email Plugin Configured" style={{ marginBottom: '16px' }}>
|
|
877
|
+
<Typography variant="omega">
|
|
878
|
+
Strapi's email plugin is configured and ready to send alerts.
|
|
879
|
+
</Typography>
|
|
880
|
+
</Alert>
|
|
881
|
+
)}
|
|
882
|
+
|
|
883
|
+
{/* Recipients */}
|
|
884
|
+
<Field.Root>
|
|
885
|
+
<Field.Label>Email Recipients (comma-separated)</Field.Label>
|
|
886
|
+
<TextInput
|
|
887
|
+
placeholder="admin@example.com, alerts@example.com"
|
|
888
|
+
value={emailRecipients}
|
|
889
|
+
onChange={(e) => setEmailRecipients(e.target.value)}
|
|
890
|
+
/>
|
|
891
|
+
<Field.Hint>Enter email addresses to receive sync alerts</Field.Hint>
|
|
892
|
+
</Field.Root>
|
|
893
|
+
|
|
894
|
+
{/* Optional From Address */}
|
|
895
|
+
<Box paddingTop={3}>
|
|
896
|
+
<Field.Root>
|
|
897
|
+
<Field.Label>From Email Address (optional)</Field.Label>
|
|
898
|
+
<TextInput
|
|
899
|
+
placeholder="Leave empty to use default"
|
|
900
|
+
value={alerts.channels.email.from || ''}
|
|
901
|
+
onChange={(e) => setAlerts((p) => ({
|
|
902
|
+
...p,
|
|
903
|
+
channels: {
|
|
904
|
+
...p.channels,
|
|
905
|
+
email: { ...p.channels.email, from: e.target.value },
|
|
906
|
+
},
|
|
907
|
+
}))}
|
|
908
|
+
/>
|
|
909
|
+
<Field.Hint>Override the default sender address from Strapi email plugin</Field.Hint>
|
|
910
|
+
</Field.Root>
|
|
911
|
+
</Box>
|
|
912
|
+
|
|
913
|
+
{/* Triggers */}
|
|
914
|
+
<Box paddingTop={4}>
|
|
915
|
+
<Typography variant="delta" paddingBottom={2}>Alert Triggers</Typography>
|
|
916
|
+
<Flex gap={4}>
|
|
917
|
+
<Flex alignItems="center" gap={2}>
|
|
918
|
+
<Switch
|
|
919
|
+
checked={alerts.channels.email.onSuccess}
|
|
920
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
921
|
+
...p,
|
|
922
|
+
channels: {
|
|
923
|
+
...p.channels,
|
|
924
|
+
email: { ...p.channels.email, onSuccess: checked },
|
|
925
|
+
},
|
|
926
|
+
}))}
|
|
927
|
+
/>
|
|
928
|
+
<Typography variant="pi">On Success</Typography>
|
|
929
|
+
</Flex>
|
|
930
|
+
<Flex alignItems="center" gap={2}>
|
|
931
|
+
<Switch
|
|
932
|
+
checked={alerts.channels.email.onFailure}
|
|
933
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
934
|
+
...p,
|
|
935
|
+
channels: {
|
|
936
|
+
...p.channels,
|
|
937
|
+
email: { ...p.channels.email, onFailure: checked },
|
|
938
|
+
},
|
|
939
|
+
}))}
|
|
940
|
+
/>
|
|
941
|
+
<Typography variant="pi">On Failure</Typography>
|
|
942
|
+
</Flex>
|
|
943
|
+
<TextButton
|
|
944
|
+
onClick={() => handleTestAlert('email')}
|
|
945
|
+
disabled={!alerts.emailPluginConfigured}
|
|
946
|
+
>
|
|
947
|
+
Send Test Email
|
|
948
|
+
</TextButton>
|
|
949
|
+
</Flex>
|
|
950
|
+
</Box>
|
|
951
|
+
</Box>
|
|
952
|
+
)}
|
|
953
|
+
</Box>
|
|
954
|
+
|
|
955
|
+
{/* Webhook */}
|
|
956
|
+
<Box padding={4} background="neutral0" hasRadius>
|
|
957
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
958
|
+
<Box>
|
|
959
|
+
<Typography fontWeight="bold">Webhook Notifications</Typography>
|
|
960
|
+
<Typography variant="pi" textColor="neutral500">
|
|
961
|
+
Send alerts to a custom webhook endpoint
|
|
962
|
+
</Typography>
|
|
963
|
+
</Box>
|
|
964
|
+
<Switch
|
|
965
|
+
checked={alerts.channels.webhook.enabled}
|
|
966
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
967
|
+
...p,
|
|
968
|
+
channels: {
|
|
969
|
+
...p.channels,
|
|
970
|
+
webhook: { ...p.channels.webhook, enabled: checked },
|
|
971
|
+
},
|
|
972
|
+
}))}
|
|
973
|
+
/>
|
|
974
|
+
</Flex>
|
|
975
|
+
{alerts.channels.webhook.enabled && (
|
|
976
|
+
<Box paddingTop={3}>
|
|
977
|
+
<Field.Root>
|
|
978
|
+
<Field.Label>Webhook URL</Field.Label>
|
|
979
|
+
<TextInput
|
|
980
|
+
placeholder="https://hooks.example.com/sync-alerts"
|
|
981
|
+
value={alerts.channels.webhook.url}
|
|
982
|
+
onChange={(e) => setAlerts((p) => ({
|
|
983
|
+
...p,
|
|
984
|
+
channels: {
|
|
985
|
+
...p.channels,
|
|
986
|
+
webhook: { ...p.channels.webhook, url: e.target.value },
|
|
987
|
+
},
|
|
988
|
+
}))}
|
|
989
|
+
/>
|
|
990
|
+
</Field.Root>
|
|
991
|
+
<Flex gap={4} paddingTop={3}>
|
|
992
|
+
<Flex alignItems="center" gap={2}>
|
|
993
|
+
<Switch
|
|
994
|
+
checked={alerts.channels.webhook.onSuccess}
|
|
995
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
996
|
+
...p,
|
|
997
|
+
channels: {
|
|
998
|
+
...p.channels,
|
|
999
|
+
webhook: { ...p.channels.webhook, onSuccess: checked },
|
|
1000
|
+
},
|
|
1001
|
+
}))}
|
|
1002
|
+
/>
|
|
1003
|
+
<Typography variant="pi">On Success</Typography>
|
|
1004
|
+
</Flex>
|
|
1005
|
+
<Flex alignItems="center" gap={2}>
|
|
1006
|
+
<Switch
|
|
1007
|
+
checked={alerts.channels.webhook.onFailure}
|
|
1008
|
+
onCheckedChange={(checked) => setAlerts((p) => ({
|
|
1009
|
+
...p,
|
|
1010
|
+
channels: {
|
|
1011
|
+
...p.channels,
|
|
1012
|
+
webhook: { ...p.channels.webhook, onFailure: checked },
|
|
1013
|
+
},
|
|
1014
|
+
}))}
|
|
1015
|
+
/>
|
|
1016
|
+
<Typography variant="pi">On Failure</Typography>
|
|
1017
|
+
</Flex>
|
|
1018
|
+
<TextButton onClick={() => handleTestAlert('webhook')}>
|
|
1019
|
+
Test
|
|
1020
|
+
</TextButton>
|
|
1021
|
+
</Flex>
|
|
1022
|
+
</Box>
|
|
1023
|
+
)}
|
|
1024
|
+
</Box>
|
|
1025
|
+
</>
|
|
1026
|
+
)}
|
|
1027
|
+
|
|
1028
|
+
<Button onClick={handleSaveAlerts} loading={saving}>
|
|
1029
|
+
Save Alert Settings
|
|
1030
|
+
</Button>
|
|
1001
1031
|
</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
1032
|
</Box>
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
)}
|
|
1024
|
-
|
|
1025
|
-
<Button onClick={handleSaveAlerts} loading={saving}>
|
|
1026
|
-
Save Alert Settings
|
|
1027
|
-
</Button>
|
|
1028
|
-
</Flex>
|
|
1029
|
-
</Box>
|
|
1030
|
-
</Tabs.Content>
|
|
1033
|
+
</Tabs.Content>
|
|
1034
|
+
</Box>
|
|
1035
|
+
</Tabs.Root>
|
|
1031
1036
|
</Box>
|
|
1032
|
-
|
|
1033
|
-
</Box>
|
|
1034
|
-
);
|
|
1037
|
+
);
|
|
1035
1038
|
};
|
|
1036
1039
|
|
|
1037
1040
|
export { ConfigTab };
|