PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/js/metasync-admin.js +585 -170 2.6.6 → 2.7.2 View file →
@@ -49,8 +49,44 @@
49 49 return window.MetasyncConfig && window.MetasyncConfig.ottoName ? window.MetasyncConfig.ottoName : 'OTTO';
50 50 }
51 51
52 52 /**
53 + * Whether a Search Atlas API key is currently configured.
54 + *
55 + * The key is no longer rendered into an editable input — it lives encrypted
56 + * server-side and is shown only as a masked, read-only display. Presence is
57 + * signalled by the data-has-key attribute on that display element, so the
58 + * cleartext key never needs to exist in the DOM for the UI to know it is set.
59 + *
60 + * @returns {boolean}
61 + */
62 + function searchAtlasKeyConfigured() {
63 + var $display = $('#searchatlas-api-key-display');
64 + if ($display.length === 0) {
65 + return false;
66 + }
67 + return $display.attr('data-has-key') === '1';
68 + }
69 +
70 + /**
71 + * Update the read-only masked API key display.
72 + *
73 + * @param {string} maskedKey Server-provided masked value (e.g. "••••••••ab12"),
74 + * or falsy to render the "Not configured" state.
75 + */
76 + function setSearchAtlasMaskedDisplay(maskedKey) {
77 + var $display = $('#searchatlas-api-key-display');
78 + if ($display.length === 0) {
79 + return;
80 + }
81 + if (maskedKey) {
82 + $display.text(maskedKey).attr('data-has-key', '1');
83 + } else {
84 + $display.html('<em style="color:#646970;">Not configured</em>').attr('data-has-key', '0');
85 + }
86 + }
87 +
88 + /**
53 89 * Update integration status indicator in header
54 90 * @param {boolean} isIntegrated - Whether the integration is active
55 91 * @param {string} statusText - Status text to display
56 92 * @param {string} titleText - Tooltip text
@@ -166,8 +202,134 @@
166 202 return whitelabelFields.join('&');
167 203 }
168 204
169 205 /**
206 + * White-label branding fields that are mirrored into the metasync.php
207 + * plugin header. Keyed by the form input name.
208 + */
209 + var brandingFieldConfigs = {
210 + 'metasync_options[general][white_label_plugin_name]': { label: 'Plugin Name', url: false },
211 + 'metasync_options[general][white_label_plugin_description]': { label: 'Plugin Description', url: false },
212 + 'metasync_options[general][white_label_plugin_author]': { label: 'Author', url: false },
213 + 'metasync_options[general][white_label_plugin_author_uri]': { label: 'Author URL', url: true },
214 + 'metasync_options[general][white_label_plugin_uri]': { label: 'Plugin URL', url: true }
215 + };
216 +
217 + /**
218 + * Client-side mirror of the server-side plugin-header contract for the
219 + * white-label branding fields, for immediate feedback while typing or
220 + * submitting. The server-side validation stays authoritative.
221 + *
222 + * @param {string} value Trimmed field value.
223 + * @param {boolean} isUrl Whether the value must be an http(s) URL.
224 + * @returns {string} Empty when valid, otherwise the problem description.
225 + */
226 + function validateBrandingValue(value, isUrl) {
227 + if (value === '') {
228 + return '';
229 + }
230 + if (/[\r\n]/.test(value)) {
231 + return 'must be a single line';
232 + }
233 + if (value.indexOf('*/') !== -1) {
234 + return 'must not contain "*/"';
235 + }
236 + if (/<\?php|\?>/i.test(value)) {
237 + return 'must not contain PHP tags';
238 + }
239 + // Control characters, line/paragraph separators, and invisible format
240 + // characters (including the bidirectional marks used for brand spoofing).
241 + // Unicode property escapes cover exactly the ranges the server checks
242 + // (Cc, Cf, Zl, Zp) without literal control-character sequences.
243 + try {
244 + if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) {
245 + return 'must not contain control or invisible characters';
246 + }
247 + } catch (e) {
248 + // Very old engines without Unicode property escapes: fall through,
249 + // the server-side check remains authoritative.
250 + }
251 + // Mirror of the server allow-list: letters (incl. Unicode), marks,
252 + // numbers, punctuation, symbols and space separators (incl. NBSP and
253 + // the ideographic space, which the server-side contract accepts).
254 + try {
255 + if (!/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u.test(value)) {
256 + return 'contains characters that are not allowed';
257 + }
258 + } catch (e) {
259 + // Very old engines without Unicode property escapes: fall through,
260 + // the server-side check remains authoritative.
261 + }
262 + if (isUrl) {
263 + var parsed;
264 + try {
265 + parsed = new URL(value);
266 + } catch (e) {
267 + return 'must be a valid http:// or https:// address';
268 + }
269 + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
270 + return 'must be a valid http:// or https:// address';
271 + }
272 + }
273 + return '';
274 + }
275 +
276 + /**
277 + * Remove all inline white-label branding field errors.
278 + */
279 + function clearBrandingFieldErrors() {
280 + $('.metasync-branding-field-error').remove();
281 + }
282 +
283 + /**
284 + * Show an inline error beside a branding input. Accepts either the full
285 + * input name or the bare setting key (as returned by the server).
286 + *
287 + * @param {string} fieldKey Input name or white-label setting key.
288 + * @param {string} message Error message to display.
289 + */
290 + function showBrandingFieldError(fieldKey, message) {
291 + var inputName = brandingFieldConfigs[fieldKey]
292 + ? fieldKey
293 + : 'metasync_options[general][' + fieldKey + ']';
294 + var input = $('input[name="' + inputName + '"]');
295 + if (input.length === 0) {
296 + return;
297 + }
298 + input.last().after(
299 + $('<p>').addClass('description metasync-branding-field-error')
300 + .css({ color: 'var(--dashboard-error, #b32d2e)', fontWeight: 600, marginTop: '4px' })
301 + .text(message)
302 + );
303 + }
304 +
305 + /**
306 + * Validate every branding input currently in the DOM (both save paths —
307 + * the form submit handler and the floating "Save Now" button — call this).
308 + * Renders the inline per-field errors and returns the message list.
309 + *
310 + * @returns {string[]} Error messages; empty when everything is valid.
311 + */
312 + function validateBrandingFieldsInDom() {
313 + clearBrandingFieldErrors();
314 + var problems = [];
315 + Object.keys(brandingFieldConfigs).forEach(function (inputName) {
316 + var config = brandingFieldConfigs[inputName];
317 + var input = $('input[name="' + inputName + '"]');
318 + if (input.length === 0) {
319 + return;
320 + }
321 + var problem = validateBrandingValue(String(input.val() || '').trim(), config.url);
322 + if (problem) {
323 + var fullMessage = config.label + ' ' + problem + '.';
324 + showBrandingFieldError(inputName, fullMessage);
325 + problems.push(fullMessage);
326 + }
327 + });
328 + return problems;
329 + }
330 +
331 + /**
170 332 * Display notice message in plugin area
171 333 * @param {string} type - Notice type: 'success' or 'error'
172 334 * @param {string} title - Notice title
173 335 * @param {string} message - Notice message
@@ -235,9 +397,10 @@
235 397
236 398 function metasyncLGLogin(user, pass) {
237 399 jQuery.post(ajaxurl, {
238 400 action: 'metasync_lglogin',
239 - username: user, password: pass
401 + username: user, password: pass,
402 + nonce: metaSync.lglogin_nonce || ''
240 403 }, function (response) {
241 404 if (typeof response.token !== 'undefined') {
242 405 $('#linkgraph_token').val(response.token);
243 406 $('#linkgraph_customer_id').val(response.customer_id);
@@ -243,9 +406,9 @@
243 406 $('#linkgraph_customer_id').val(response.customer_id);
244 407 $('.input.lguser,#lgerror').addClass('hidden');
245 408 localStorage.setItem('token', response.token);
246 409 } else {
247 - $('#lgerror').html(`${response.detail} (${response.kind})`).removeClass('hidden');
410 + $('#lgerror').text(response.detail + ' (' + response.kind + ')').removeClass('hidden');
248 411 }
249 412 }
250 413 );
251 414 }
@@ -470,9 +633,9 @@
470 633
471 634 function resetConnectButton() {
472 635 var $button = $('#connect-searchatlas-btn');
473 636 var $progressContainer = $('.metasync-sa-connect-progress');
474 - var hasApiKey = $('#searchatlas-api-key').val().trim() !== '';
637 + var hasApiKey = searchAtlasKeyConfigured();
475 638
476 639 $button.prop('disabled', false)
477 640 .removeClass('connecting authenticating success dashboard-loading') // Remove all loading classes
478 641 .html(hasApiKey ? '🔄 Re-authenticate with ' + getPluginName() : '🔗 Connect to ' + getPluginName());
@@ -480,9 +643,14 @@
480 643 }
481 644
482 645 function startSearchAtlasPolling(nonceToken) {
483 646 var pollCount = 0;
484 - var maxPolls = 12; // Poll for 60 seconds (12 * 5 seconds)
647 + // WP-558 (Option A): poll for 2 minutes (24 * 5s). Each poll also drives
648 + // the outbound pull-based connect fallback server-side, which needs a
649 + // slightly longer window than the legacy push-only 60s to ride out the
650 + // human SSO-authorization delay. ~24 requests stays inside the CA
651 + // endpoint's 120/hour per-domain budget.
652 + var maxPolls = 24; // Poll for 120 seconds (24 * 5 seconds)
485 653 var $progressContainer = $('.metasync-sa-connect-progress');
486 654 var $progressFill = $('.metasync-sa-connect-progress-fill');
487 655 var $progressText = $('.metasync-sa-connect-progress-text');
488 656 var $button = $('#connect-searchatlas-btn');
@@ -521,20 +689,20 @@
521 689
522 690 // ✅ Reset the authentication flow while keeping the timeout component
523 691 resetConnectButton();
524 692
525 - showConnectError('⏰ Authentication Timeout',
526 - 'The authentication process timed out after 60 seconds. Please complete the authentication more quickly or check for network issues.',
693 + showConnectError('⏰ Authentication Timeout',
694 + 'The authentication process timed out after 2 minutes. Please complete the authentication more quickly or check for network issues.',
527 695 [{
528 696 text: '🔄 Try Again',
529 697 action: function () {
530 - handleSearchAtlasConnect();
698 + handleSearchAtlasConnect();
531 699 }
532 700 }, {
533 701 text: '💬 Contact Support',
534 - action: function () {
702 + action: function () {
535 703 var supportEmail = metaSync.support_email || '[email protected]';
536 - window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (30s)', '_blank');
704 + window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (120s)', '_blank');
537 705 }
538 706 }]
539 707 );
540 708 return;
@@ -569,9 +737,9 @@
569 737
570 738 // Handle different status codes with enhanced UX
571 739 if (statusCode === 200) {
572 740 // Success: Update all UI elements to reflect connected state
573 - updateUIForConnectedState(response.data.api_key, response.data.otto_pixel_uuid);
741 + updateUIForConnectedState(response.data.masked_key, response.data.otto_pixel_uuid);
574 742
575 743 // Refresh Plugin Auth Token field to show the auto-generated token
576 744 // This will either show the existing token or the newly auto-generated one
577 745 $.ajax({
@@ -602,14 +770,15 @@
602 770 $button.closest('.metasync-sa-connect-container').removeClass('metasync-sa-connect-success-animation');
603 771 }, 600);
604 772
605 773 // Track 1-click activation in GA4
606 - var hasExistingApiKey = $('#searchatlas-api-key').val() && $('#searchatlas-api-key').val().trim() !== '';
774 + var hasExistingApiKey = searchAtlasKeyConfigured();
607 775 $.ajax({
608 776 url: metaSync.ajax_url,
609 777 type: 'POST',
610 778 data: {
611 779 action: 'metasync_track_one_click_activation',
780 + nonce: metaSync.nonce,
612 781 auth_method: 'searchatlas_connect',
613 782 is_reconnection: hasExistingApiKey
614 783 }
615 784 });
@@ -736,9 +905,9 @@
736 905 var timeElapsed = currentPoll * 5;
737 906 var timeRemaining = (maxPolls - currentPoll) * 5;
738 907 $progressTime.text(timeElapsed + 's elapsed, ' + timeRemaining + 's remaining');
739 908
740 - // Update progress text based on time elapsed (optimized for 60-second timeout)
909 + // Update progress text based on time elapsed (optimized for 120-second timeout)
741 910 var progressMessages = [
742 911 'Establishing connection and opening authentication window...',
743 912 'Please complete authentication in the popup window...',
744 913 'Almost done! Finalizing your authentication...'
@@ -861,10 +1030,19 @@
861 1030 '⚠️ Website Not Registered',
862 1031 'Your website hasn\'t been registered with ' + getPluginName() + ' yet. Registration is required to enable 1-click connect to retrieve your Search Atlas API key and Otto UUID.',
863 1032 [{
864 1033 text: '🌐 Register Website',
865 - action: function () {
866 - window.open(registerUrl, '_blank');
1034 + action: function () {
1035 + try {
1036 + var parsedRegister = new URL(registerUrl);
1037 + if (parsedRegister.protocol === 'https:' || parsedRegister.protocol === 'http:') {
1038 + var a = document.createElement('a');
1039 + a.href = parsedRegister.href;
1040 + a.target = '_blank';
1041 + a.rel = 'noopener';
1042 + a.click();
1043 + }
1044 + } catch (e) { /* invalid URL */ }
867 1045 },
868 1046 primary: true
869 1047 }, {
870 1048 text: '📚 Learn More About Registration',
@@ -869,9 +1047,18 @@
869 1047 }, {
870 1048 text: '📚 Learn More About Registration',
871 1049 action: function () {
872 1050 var docDomain = metaSync.documentation_domain || 'https://searchatlas.com';
873 - window.open(docDomain, '_blank');
1051 + try {
1052 + var parsedDoc = new URL(docDomain);
1053 + if (parsedDoc.protocol === 'https:' || parsedDoc.protocol === 'http:') {
1054 + var a = document.createElement('a');
1055 + a.href = parsedDoc.href;
1056 + a.target = '_blank';
1057 + a.rel = 'noopener';
1058 + a.click();
1059 + }
1060 + } catch (e) { /* invalid URL */ }
874 1061 }
875 1062 }, {
876 1063 text: '🔄 Try Authentication Again',
877 1064 action: function () {
@@ -953,9 +1140,9 @@
953 1140 'Check if firewall is blocking the request'
954 1141 ];
955 1142 } else if (context === 'timeout') {
956 1143 recoverySuggestions = [
957 - 'Complete authentication within 60 seconds',
1144 + 'Complete authentication within 2 minutes',
958 1145 'Check if the popup window needs attention',
959 1146 'Ensure you have your ' + getPluginName() + ' login ready',
960 1147 'Try the authentication process again',
961 1148 'Contact support if timeouts persist'
@@ -1085,17 +1272,17 @@
1085 1272
1086 1273 // Add connection status indicator
1087 1274 function updateConnectionStatus() {
1088 1275 var $button = $('#connect-searchatlas-btn');
1089 - var $apiKeyField = $('#searchatlas-api-key');
1090 -
1091 - // Only update status if we're on a page with the API key field (General Settings)
1276 + var $apiKeyField = $('#searchatlas-api-key-display');
1277 +
1278 + // Only update status if we're on a page with the API key display (General Settings)
1092 1279 // On other pages, preserve the PHP-determined status in the header
1093 1280 if ($apiKeyField.length === 0) {
1094 - return; // Don't update status on pages without the API key field
1281 + return; // Don't update status on pages without the API key display
1095 1282 }
1096 -
1097 - var hasApiKey = $apiKeyField.val() && $apiKeyField.val().trim() !== '';
1283 +
1284 + var hasApiKey = searchAtlasKeyConfigured();
1098 1285 var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== '';
1099 1286 var isFullyConnected = hasApiKey && hasOttoUuid;
1100 1287
1101 1288 // Update button text based on connection state
@@ -1108,11 +1295,24 @@
1108 1295 $button.html('🔗 Connect to ' + getPluginName());
1109 1296 }
1110 1297 }
1111 1298
1112 - // Update header status indicator (only on General Settings page)
1113 - if (isFullyConnected) {
1299 + // Update header status indicator (only on General Settings page).
1300 + // the header badge must reflect the confirmed heartbeat
1301 + // round-trip (metaSync.is_connected), NOT mere API-key/UUID presence —
1302 + // otherwise a revoked/invalid key still shows "Connected" because the
1303 + // key string and UUID remain stored locally.
1304 + // NOTE: wp_localize_script stringifies PHP booleans (true -> "1",
1305 + // false -> ""), so normalise to a real boolean rather than ===.
1306 + var heartbeatConnected = metaSync.is_connected === true
1307 + || metaSync.is_connected === '1'
1308 + || metaSync.is_connected === 1;
1309 + if (isFullyConnected && heartbeatConnected) {
1114 1310 updateHeaderStatus(true, 'Synced', getPluginName() + ' API key and ' + getOttoName() + ' UUID are configured');
1311 + } else if (isFullyConnected) {
1312 + // Key + UUID are configured but the heartbeat is not succeeding
1313 + // (e.g. the key was revoked on the Search Atlas side).
1314 + updateHeaderStatus(false, 'Not Connected', getPluginName() + ' API key is set but the heartbeat is not responding — the connection may be revoked or unreachable.');
1115 1315 } else if (hasApiKey && !hasOttoUuid) {
1116 1316 updateHeaderStatus(false, 'Warning', 'Connected but ' + getOttoName() + ' UUID is missing — deploys will not work. Please reconnect.');
1117 1317 } else {
1118 1318 updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID');
@@ -1118,11 +1318,11 @@
1118 1318 updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID');
1119 1319 }
1120 1320 }
1121 1321
1122 - // Monitor API key field changes
1123 - $('#searchatlas-api-key').on('input', updateConnectionStatus);
1124 -
1322 + // The API key is no longer manually editable (one-click auth only), so there
1323 + // is no input event to monitor; status changes are driven by connect/disconnect.
1324 +
1125 1325 // Initial status update
1126 1326 updateConnectionStatus();
1127 1327
1128 1328 // Initialize dashboard iframe functionality
@@ -1131,13 +1331,12 @@
1131 1331 // Settings dropdown now handled by inline script in HTML
1132 1332
1133 1333 // Add debug function for connection status (accessible in console)
1134 1334 window.debugConnectionStatus = function () {
1135 - var apiKey = $('#searchatlas-api-key').val();
1136 - var hasApiKey = apiKey.trim() !== '';
1137 -
1335 + var hasApiKey = searchAtlasKeyConfigured();
1336 +
1138 1337 console.log('🔍 Connection Status Debug:', {
1139 - searchatlas_api_key: hasApiKey ? (apiKey.substring(0, 8) + '...') : 'EMPTY',
1338 + searchatlas_credential_state: hasApiKey ? 'CONFIGURED (masked)' : 'EMPTY',
1140 1339 otto_pixel_uuid: metaSync.otto_pixel_uuid || 'NOT SET',
1141 1340 connection_state: hasApiKey && metaSync.otto_pixel_uuid ? 'CONNECTED' :
1142 1341 hasApiKey ? 'PARTIAL (Missing ' + getOttoName() + ' UUID)' : 'NOT CONNECTED',
1143 1342 dashboard_tab_visible: hasApiKey && metaSync.otto_pixel_uuid ? 'YES' : 'NO',
@@ -1259,9 +1458,8 @@
1259 1458 }
1260 1459
1261 1460 var $resetButton = $('#reset-searchatlas-auth');
1262 1461 var $connectButton = $('#connect-searchatlas-btn');
1263 - var $apiKeyField = $('#searchatlas-api-key');
1264 1462
1265 1463 // Show loading state (prevent dashboard.js conflicts)
1266 1464 $resetButton.prop('disabled', true)
1267 1465 .addClass('no-loading') // Prevent dashboard.js interference
@@ -1280,11 +1478,11 @@
1280 1478 nonce: metaSync.reset_auth_nonce
1281 1479 },
1282 1480 success: function (response) {
1283 1481 if (response.success) {
1284 - // Clear the API key field
1285 - $apiKeyField.val('');
1286 -
1482 + // Reset the masked display to the "Not configured" state
1483 + setSearchAtlasMaskedDisplay('');
1484 +
1287 1485 // Update button states
1288 1486 $connectButton.html('🔗 Connect to ' + getPluginName());
1289 1487 $resetButton.remove(); // Remove reset button since no longer connected
1290 1488
@@ -1379,10 +1577,18 @@
1379 1577 /**
1380 1578 * Update UI elements when account is disconnected
1381 1579 */
1382 1580 function updateUIForDisconnectedState() {
1581 + // a cached manual-sync cooldown must not survive disconnect —
1582 + // it keeps the Sync Now button locked after the user reconnects even
1583 + // though the server-side cooldown stamp was cleared with the auth data.
1584 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
1585 + refreshManualSyncCooldownUI();
1586 + $('#sendAuthToken').prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now');
1587 +
1383 1588 // Update API key field placeholder
1384 - $('#searchatlas-api-key').attr('placeholder', 'Your API key will appear here after authentication');
1589 + // Reset the masked API key display to the "Not configured" state
1590 + setSearchAtlasMaskedDisplay('');
1385 1591
1386 1592 // ✅ Clear OTTO Pixel UUID field
1387 1593 $('input[name="metasync_options[general][otto_pixel_uuid]"]').val('');
1388 1594
@@ -1432,14 +1638,13 @@
1432 1638 /**
1433 1639 * Update UI elements when account is connected/authenticated
1434 1640 * Complementary function to updateUIForDisconnectedState()
1435 1641 */
1436 - function updateUIForConnectedState(apiKey, ottoPixelUuid) {
1437 - // Update API key field
1438 - if (apiKey) {
1439 - $('#searchatlas-api-key').val(apiKey);
1440 - }
1441 -
1642 + function updateUIForConnectedState(maskedKey, ottoPixelUuid) {
1643 + // Update the read-only masked API key display. Only the masked value is
1644 + // ever shown — the cleartext key is never rendered into the DOM.
1645 + setSearchAtlasMaskedDisplay(maskedKey || '••••••••');
1646 +
1442 1647 // ✅ Update OTTO Pixel UUID field
1443 1648 if (ottoPixelUuid) {
1444 1649 $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(ottoPixelUuid);
1445 1650 }
@@ -1570,9 +1775,23 @@
1570 1775 $('#' + id).val('');
1571 1776 }
1572 1777 }
1573 1778
1779 + function updateAnalyticsConsentStatus() {
1780 + var $toggle = $('#metasync_analytics_opt_in');
1781 + var $status = $('.metasync-consent-status');
1782 + if (!$toggle.length || !$status.length) {
1783 + return;
1784 + }
1785 +
1786 + var label = $toggle.is(':checked') ? $status.data('enabled-label') : $status.data('disabled-label');
1787 + $status.text(label);
1788 + }
1789 +
1790 + $(document).on('change', '#metasync_analytics_opt_in', updateAnalyticsConsentStatus);
1791 +
1574 1792 $(function () {
1793 + updateAnalyticsConsentStatus();
1575 1794 $('#addNewTime').on('click', function () {
1576 1795 $('#daysTime').append(
1577 1796 '<li>' +
1578 1797 '<select name="metasync_options[localseo][days][]">' +
@@ -1620,31 +1839,10 @@
1620 1839 });
1621 1840 $(document).on('click', '#number-delete', deleteNumber);
1622 1841 });
1623 1842
1624 - function deleteSourceUrl() {
1625 - $(this).parent().remove();
1626 - }
1627 1843 $(function () {
1628 - $('#addNewSourceUrl').on('click', function () {
1629 - $('#source_urls').append(
1630 - '<li>' +
1631 - '<input type="text" class="regular-text" name="source_url[]">' +
1632 - '<select name="search_type[]">' +
1633 - '<option value="exact">Exact</option>' +
1634 - '<option value="contain">Contain</option>' +
1635 - '<option value="start">Start With</option>' +
1636 - '<option value="end">End With</option>' +
1637 - '</select>' +
1638 - '<button id="source_url_delete">Remove</button>' +
1639 - '</li>');
1640 - return;
1641 - });
1642 - $(document).on('click', '#source_url_delete', deleteSourceUrl);
1643 - });
1644 1844
1645 - $(function () {
1646 -
1647 1845 setToken();
1648 1846
1649 1847 $('body').on('click', '#wp_metasync_sync', function (e) {
1650 1848 e.preventDefault();
@@ -1843,8 +2041,9 @@
1843 2041 method: 'POST',
1844 2042 url: 'admin-ajax.php',
1845 2043 data: {
1846 2044 action: 'metasync_send_giapi',
2045 + nonce: metaSync.nonce,
1847 2046 metasync_giapi_url: url.val(),
1848 2047 metasync_giapi_action: action.val()
1849 2048 }
1850 2049 })
@@ -1965,8 +2164,9 @@
1965 2164
1966 2165 // DEBUG: Log request parameters
1967 2166 const requestData = {
1968 2167 action: 'metasync_send_customer_params',
2168 + nonce: metaSync.nonce,
1969 2169 is_heart_beat : is_hb
1970 2170 };
1971 2171
1972 2172 jQuery.ajax({
@@ -1984,17 +2184,18 @@
1984 2184 if (showAlerts) {
1985 2185 $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
1986 2186 }
1987 2187
1988 - if ($('#searchatlas-api-key') && $('#searchatlas-api-key').val() === '') {
1989 - $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key');
1990 - $('#sendAuthTokenTimestamp').css({ color: 'red' });
2188 + if ($('#searchatlas-api-key-display').length > 0 && !searchAtlasKeyConfigured()) {
2189 + if (!is_hb) {
2190 + $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key');
2191 + $('#sendAuthTokenTimestamp').css({ color: 'red' });
1991 2192
1992 - // Remove stale PHP-rendered "✓ Synced" label.
1993 - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2193 + // Remove stale PHP-rendered "✓ Synced" label.
2194 + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
1994 2195
1995 - // Update header status to "Not Synced" for missing API key
1996 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required');
2196 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required');
2197 + }
1997 2198
1998 2199 if (showAlerts) {
1999 2200 showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.');
2000 2201 }
@@ -1999,34 +2200,23 @@
1999 2200 showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.');
2000 2201 }
2001 2202
2002 2203 } else if (response && response.throttled) {
2003 - // Handle throttling response
2004 - var remainingMinutes = response.remaining_minutes || 5;
2005 - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again');
2006 - //$('#sendAuthTokenTimestamp').css({ color: 'orange' });
2007 -
2008 - // Disable sync button and show countdown
2204 + // Compute expiry locally from a duration (remaining_seconds) so server/client
2205 + // clock drift cannot lock the user out or let them bypass the throttle.
2206 + var remainingSeconds = (typeof response.remaining_seconds === 'number')
2207 + ? response.remaining_seconds
2208 + : ((response.remaining_minutes || 5) * 60);
2209 + var expiryMs = Date.now() + (remainingSeconds * 1000);
2210 + var remainingMinutes = Math.max(1, Math.ceil(remainingSeconds / 60));
2211 +
2009 2212 if (showAlerts) {
2010 - $('#sendAuthToken').prop('disabled', true).html('⏰ Throttled (' + remainingMinutes + 'm)');
2011 -
2012 - // Start countdown timer
2013 - var countdownInterval = setInterval(function () {
2014 - remainingMinutes--;
2015 - if (remainingMinutes <= 0) {
2016 - clearInterval(countdownInterval);
2017 - $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
2018 - $('#sendAuthTokenTimestamp').html('Ready to sync');
2019 - $('#sendAuthTokenTimestamp').css({ color: 'green' });
2020 - } else {
2021 - $('#sendAuthToken').html('⏰ Throttled (' + remainingMinutes + 'm)');
2022 - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again');
2023 - }
2024 - }, 60000); // Update every minute
2213 + localStorage.setItem('metasync_manual_sync_throttle_expires', String(expiryMs));
2214 + refreshManualSyncCooldownUI();
2025 2215 }
2026 2216
2027 - // Update header status to show throttling
2028 - updateHeaderStatus(false, 'Throttled', 'Throttled - Please wait ' + remainingMinutes + ' minutes');
2217 + // Throttle is transient — don't change the connection badge.
2218 + // The toast notification already informs the user.
2029 2219
2030 2220 if (showAlerts) {
2031 2221 showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.');
2032 2222 }
@@ -2031,17 +2221,19 @@
2031 2221 showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.');
2032 2222 }
2033 2223
2034 2224 } else if (response && response.detail) {
2035 - $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key');
2036 - $('#sendAuthTokenTimestamp').css({ color: 'red' });
2225 + if (!is_hb) {
2226 + $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key');
2227 + $('#sendAuthTokenTimestamp').css({ color: 'red' });
2037 2228
2038 - // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server.
2039 - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2229 + // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server.
2230 + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2231 + }
2040 2232
2041 - // Update header status to "Not Synced" for invalid API key
2233 + // No is_hb guard here — genuinely invalid keys should always flip the badge
2042 2234 updateHeaderStatus(false, 'Not Synced', 'Not Synced - Invalid API key');
2043 -
2235 +
2044 2236 if (showAlerts) {
2045 2237 showSyncError('❌ Invalid API Key', 'Please provide a valid ' + getPluginName() + ' API key.');
2046 2238 }
2047 2239
@@ -2046,9 +2238,11 @@
2046 2238 }
2047 2239
2048 2240 } else if (response === null || !response.id) {
2049 2241 // Update header status to "Not Synced" after failed sync
2050 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed');
2242 + if (!is_hb) {
2243 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed');
2244 + }
2051 2245
2052 2246 if (showAlerts) {
2053 2247 showSyncError('❌ Sync Failed', 'Something went wrong during synchronization. Please check your connection and try again.');
2054 2248 }
@@ -2057,8 +2251,16 @@
2057 2251 } else {
2058 2252 var dateString = dateFormat();
2059 2253 $('#sendAuthTokenTimestamp').html(dateString);
2060 2254 $('#sendAuthTokenTimestamp').css({ color: 'green' });
2255 + // Server starts a 5-minute manual-sync cooldown after a successful sync.
2256 + // Reflect that on the client so the button shows the cooldown across refreshes
2257 + // instead of misleadingly saying "Sync Now" only to be rejected on next click.
2258 + if (!is_hb) {
2259 + var successCooldownMs = 5 * 60 * 1000;
2260 + localStorage.setItem('metasync_manual_sync_throttle_expires', String(Date.now() + successCooldownMs));
2261 + refreshManualSyncCooldownUI();
2262 + }
2061 2263
2062 2264 // Update header status — check if UUID is present before declaring fully synced
2063 2265 var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== '';
2064 2266 if (hasOttoUuid) {
@@ -2073,9 +2275,11 @@
2073 2275 }
2074 2276 },
2075 2277 error: function (xhr, status, error) {
2076 2278 // Update header status to "Not Synced" for network errors
2077 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync');
2279 + if (!is_hb) {
2280 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync');
2281 + }
2078 2282
2079 2283 // Reset button state
2080 2284 if (showAlerts) {
2081 2285 $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
@@ -2104,9 +2308,10 @@
2104 2308 url: ajaxurl,
2105 2309 type: 'GET',
2106 2310 data: {
2107 2311 action: 'metasync_clear_otto_cache',
2108 - clear_otto_cache: 1
2312 + clear_otto_cache: 1,
2313 + nonce: metaSync.clear_otto_cache_nonce || ''
2109 2314 },
2110 2315 success: function (response) {
2111 2316 const now = new Date();
2112 2317 $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString());
@@ -2114,8 +2319,65 @@
2114 2319 }
2115 2320 });
2116 2321 }
2117 2322
2323 + // Holds the active manual-sync countdown interval so we can replace it
2324 + // instead of stacking multiple timers if refreshManualSyncCooldownUI()
2325 + // is called more than once (e.g. throttled response then refresh).
2326 + var _manualSyncCountdownInterval = null;
2327 +
2328 + /**
2329 + * Read the manual-sync throttle expiry (client-time ms) from localStorage
2330 + * and apply UI state: disable the button, show "Throttled (Nm)", and tick
2331 + * every 15s. Idempotent — safe to call repeatedly.
2332 + */
2333 + function refreshManualSyncCooldownUI() {
2334 + var $btn = $('#sendAuthToken');
2335 + if ($btn.length === 0) {
2336 + return;
2337 + }
2338 + if (_manualSyncCountdownInterval) {
2339 + clearInterval(_manualSyncCountdownInterval);
2340 + _manualSyncCountdownInterval = null;
2341 + }
2342 + var stored = localStorage.getItem('metasync_manual_sync_throttle_expires');
2343 + if (!stored) {
2344 + return;
2345 + }
2346 + var storedExpiry = parseInt(stored, 10);
2347 + if (!storedExpiry || isNaN(storedExpiry)) {
2348 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
2349 + return;
2350 + }
2351 + var nowMs = Date.now();
2352 + if (storedExpiry <= nowMs) {
2353 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
2354 + return;
2355 + }
2356 + var remainingMinutes = Math.max(1, Math.ceil((storedExpiry - nowMs) / 60000));
2357 + // addClass('is-throttled') drives the visual state via CSS so it
2358 + // doesn't depend on the disabled attribute being preserved by other
2359 + // code paths in the AJAX callback chain (which strip it on success).
2360 + $btn.prop('disabled', true).addClass('is-throttled').text('⏰ Throttled (' + remainingMinutes + 'm)');
2361 + _manualSyncCountdownInterval = setInterval(function () {
2362 + var currentNowMs = Date.now();
2363 + if (storedExpiry <= currentNowMs) {
2364 + clearInterval(_manualSyncCountdownInterval);
2365 + _manualSyncCountdownInterval = null;
2366 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
2367 + $btn.prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now');
2368 + $('#sendAuthTokenTimestamp').text('Ready to sync').css({ color: 'green' });
2369 + // Cooldown is over — clear the lingering "Request Throttled" admin
2370 + // notice at the top of the page. Without this the button resets to
2371 + // "Sync Now" but the red throttled bar stays visible (QA).
2372 + $('.metasync-sync-notice, .metasync-sync-error').remove();
2373 + return;
2374 + }
2375 + remainingMinutes = Math.max(1, Math.ceil((storedExpiry - currentNowMs) / 60000));
2376 + $btn.text('⏰ Throttled (' + remainingMinutes + 'm)');
2377 + }, 15000);
2378 + }
2379 +
2118 2380 jQuery(document).ready(function () {
2119 2381
2120 2382 // sendCustomerParams();
2121 2383 $('#sendAuthToken').on('click', function (e) {
@@ -2120,11 +2382,14 @@
2120 2382 // sendCustomerParams();
2121 2383 $('#sendAuthToken').on('click', function (e) {
2122 2384 e.preventDefault();
2123 2385 sendCustomerParams();
2124 -
2386 +
2125 2387 });
2126 2388
2389 + // Restore manual-sync throttle countdown state across page refreshes.
2390 + refreshManualSyncCooldownUI();
2391 +
2127 2392 // handle otto clear cache button
2128 2393 $('#clear_otto_caches').on('click', function (e){
2129 2394 e.preventDefault();
2130 2395 clear_otto_caches();
@@ -2146,8 +2411,27 @@
2146 2411 return true; // Let the form submit normally
2147 2412 }
2148 2413
2149 2414 e.preventDefault(); // Prevent the default form submission for regular settings
2415 +
2416 + // Immediate client-side validation of the white-label branding
2417 + // fields (the server-side contract stays authoritative): block the
2418 + // submit and show an error beside each invalid field instead of
2419 + // letting an invalid value reach the server and get rejected there.
2420 + var brandingValidationErrors = validateBrandingFieldsInDom();
2421 + if (brandingValidationErrors.length > 0) {
2422 + var $brandingErrorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap');
2423 + var $brandingErrorList = $('<ul>');
2424 + brandingValidationErrors.forEach(function (errorMessage) {
2425 + $brandingErrorList.append($('<li>').text(errorMessage));
2426 + });
2427 + $brandingErrorNotice.append($brandingErrorList);
2428 + $('.metasync-error-wrap').remove();
2429 + $('#metaSyncGeneralSetting').before($brandingErrorNotice);
2430 + $('html, body').animate({ scrollTop: 0 }, 'slow');
2431 + return;
2432 + }
2433 +
2150 2434 var actionField = $(this).find('input[name="action"]');
2151 2435 var optionPage= $(this).find('input[name="option_page"]');
2152 2436 var wpHttpReferer= $(this).find('input[name="_wp_http_referer"]');
2153 2437 var wpnonce= $(this).find('input[name="_wpnonce"]');
@@ -2179,40 +2463,77 @@
2179 2463 data: formData + '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab), // Add the action and current tab
2180 2464 success: function (response) {
2181 2465 // Handle success response
2182 2466 if(response.success){
2467 + // Per-field whitelabel validation issues: the save succeeded for
2468 + // other fields, so show a warning notice and stay on the page so the
2469 + // user can see which values were rejected.
2470 + const wlErrors = response.data && response.data.whitelabel_errors;
2471 + if (wlErrors && Object.keys(wlErrors).length > 0) {
2472 + var $warnNotice = $('<div>').addClass('notice notice-warning metasync-error-wrap');
2473 + var $warnList = $('<ul>');
2474 + Object.keys(wlErrors).forEach(function (fieldKey) {
2475 + $warnList.append($('<li>').text(wlErrors[fieldKey]));
2476 + });
2477 + $warnNotice.append($('<p>').text('Settings saved, but some values were not applied:'));
2478 + $warnNotice.append($warnList);
2479 + $('.metasync-error-wrap').remove();
2480 + $('#metaSyncGeneralSetting').before($warnNotice);
2481 + $('html, body').animate({ scrollTop: 0 }, 'slow');
2482 + return;
2483 + }
2484 +
2183 2485 // get value of input field white_label_plugin_menu_slug
2184 2486 const whiteLableUrl = $('#metaSyncGeneralSetting input[name="metasync_options[general][white_label_plugin_menu_slug]"]').val();
2185 2487 // check condition if it is empty or not and redirect it
2186 -
2488 +
2187 2489 // add the tag query to the window location
2188 2490 let tabParam = new URLSearchParams(window.location.search).get('tab');
2189 2491 let tabQuery = tabParam ? '&tab=' + encodeURIComponent(tabParam) : '';
2190 -
2191 - // Handle undefined or empty white label URL
2192 - const pageSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas';
2193 - window.location = metaSync.admin_url + '?page=' + pageSlug + tabQuery;
2492 +
2493 + // Prefer the server-sanitized slug so the redirect matches the page
2494 + // WordPress actually registered; fall back to the raw input field value.
2495 + const savedSlug = response.data && typeof response.data.saved_menu_slug === 'string' ? response.data.saved_menu_slug : null;
2496 + const inputSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas';
2497 + const rawSlug = (savedSlug !== null) ? (savedSlug || 'searchatlas') : inputSlug;
2498 + const pageSlug = encodeURIComponent(rawSlug.replace(/[^a-zA-Z0-9_\-]/g, '')) || 'searchatlas';
2499 + var redirectUrl = metaSync.admin_url + '?page=' + pageSlug + tabQuery;
2500 + try {
2501 + var parsedRedirect = new URL(redirectUrl, window.location.origin);
2502 + if (parsedRedirect.origin === window.location.origin) {
2503 + var navLink = document.createElement('a');
2504 + navLink.href = parsedRedirect.href;
2505 + navLink.click();
2506 + }
2507 + } catch (e) { /* invalid URL */ }
2194 2508 }else {
2195 2509 // Handle error response
2196 2510 const errors = response.data?.errors || [];
2511 + const fieldErrors = response.data?.field_errors || {};
2197 2512
2198 - // Create a notice element to display the errors
2199 - let html = '<div class="notice notice-error metasync-error-wrap">';
2200 - if (Array.isArray(errors)) {
2201 - html += '<ul>';
2513 + // Build error notice using DOM methods to avoid XSS
2514 + var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap');
2515 + if (Array.isArray(errors) && errors.length > 0) {
2516 + var $ul = $('<ul>');
2202 2517 errors.forEach(function (err) {
2203 - html += '<li>' + err + '</li>';
2518 + $ul.append($('<li>').text(err));
2204 2519 });
2205 - html += '</ul>';
2520 + $errorNotice.append($ul);
2206 2521 }
2207 - html += '</div>';
2208 2522
2209 2523 // Remove previous error notices
2210 2524 $('.metasync-error-wrap').remove();
2211 -
2525 +
2212 2526 // Insert the error message before the form
2213 - $('#metaSyncGeneralSetting').before(html);
2527 + $('#metaSyncGeneralSetting').before($errorNotice);
2214 2528
2529 + // Field-specific feedback: show the server's per-field
2530 + // error beside each rejected white-label branding input.
2531 + clearBrandingFieldErrors();
2532 + Object.keys(fieldErrors).forEach(function (settingKey) {
2533 + showBrandingFieldError(settingKey, fieldErrors[settingKey]);
2534 + });
2535 +
2215 2536 // Scroll to the top to ensure visibility
2216 2537 $('html, body').animate({ scrollTop: 0 }, 'slow');
2217 2538 }
2218 2539
@@ -2425,11 +2746,11 @@
2425 2746 // Form submission handler already exists above, so we just need to listen for successful response
2426 2747 var originalAjaxHandler = $(this).data('events') && $(this).data('events').submit;
2427 2748 });
2428 2749
2429 - // Listen for successful form submission to clear the unsaved changes flag
2430 - $(document).ajaxSuccess(function (event, xhr, settings) {
2431 - if (settings.data && typeof settings.data === 'string' && settings.data.indexOf('action=meta_sync_save_settings') > -1) {
2750 + // Listen for successful form submission to clear the unsaved changes flag
2751 + $(document).ajaxSuccess(function (event, xhr, settings) {
2752 + if (settings.data && typeof settings.data === 'string' && settings.data.indexOf('action=meta_sync_save_settings') > -1) {
2432 2753 try {
2433 2754 var response = JSON.parse(xhr.responseText);
2434 2755 if (response.success) {
2435 2756 hasUnsavedChanges = false;
@@ -2449,17 +2770,47 @@
2449 2770
2450 2771 // Save Changes function - use AJAX instead of form submission
2451 2772 window.saveChanges = function () {
2452 2773 isSaving = true;
2453 -
2454 - // Completely remove the floating notification immediately when clicked
2774 +
2775 + var $form = $('#metaSyncGeneralSetting');
2776 + if ($form.length > 0) {
2777 + // Immediate client-side validation of the white-label branding
2778 + // fields — the floating save bypasses the form submit handler,
2779 + // so the same contract is enforced here too. The server-side
2780 + // validation stays authoritative. Runs BEFORE the notification
2781 + // is removed so a blocked save keeps the persistent unsaved-
2782 + // changes cue visible.
2783 + var brandingProblems = validateBrandingFieldsInDom();
2784 + if (brandingProblems.length > 0) {
2785 + var $brandingNotice = $('<div>').addClass('notice notice-error metasync-save-notice').css({ margin: '20px 0', padding: '12px' });
2786 + var $brandingList = $('<ul>');
2787 + brandingProblems.forEach(function (errorMessage) {
2788 + $brandingList.append($('<li>').text(errorMessage));
2789 + });
2790 + $brandingNotice.append($brandingList);
2791 + $('.metasync-save-notice').remove();
2792 + var $navWrapperForBranding = $('.metasync-nav-wrapper');
2793 + if ($navWrapperForBranding.length > 0) {
2794 + $navWrapperForBranding.after($brandingNotice);
2795 + } else {
2796 + $('.metasync-dashboard-wrap').prepend($brandingNotice);
2797 + }
2798 + $('html, body').animate({ scrollTop: 0 }, 'slow');
2799 + isSaving = false;
2800 + return;
2801 + }
2802 + }
2803 +
2804 + // Completely remove the floating notification immediately when the
2805 + // save is actually proceeding (a blocked save left it in place).
2455 2806 var $notification = $('.metasync-unsaved-notification');
2456 2807 if ($notification.length > 0) {
2457 2808 $notification.remove(); // Completely remove from DOM, no animations
2458 2809 }
2459 -
2460 - var $form = $('#metaSyncGeneralSetting');
2810 +
2461 2811 if ($form.length > 0) {
2812 +
2462 2813 // Get form data and submit via AJAX
2463 2814 var formData = $form.serialize();
2464 2815
2465 2816 // Ensure ALL whitelabel data is included using helper function
@@ -2475,11 +2826,8 @@
2475 2826 var currentTab = urlParams.get('tab') || 'general';
2476 2827
2477 2828 formData += '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab);
2478 2829
2479 - // Capture the current API key value so we can restore it client-side if validation fails
2480 - var savedApiKeyValue = $('#searchatlas-api-key').val();
2481 -
2482 2830 $.ajax({
2483 2831 url: metaSync.ajax_url,
2484 2832 type: 'POST',
2485 2833 data: formData,
@@ -2501,10 +2849,17 @@
2501 2849 } else if (response.data && response.data.api_key_validated === false) {
2502 2850 noticeType = 'notice-error';
2503 2851 noticeMessage = $('<span/>').text(response.data.warning || 'The API key could not be verified. Your other settings were saved.').html();
2504 2852 noAutoDismiss = true;
2505 - // Revert the input field to the value before save attempt
2506 - $('#searchatlas-api-key').val(savedApiKeyValue);
2853 + // The server reverts the stored key to the last valid one on
2854 + // verification failure, and the masked display is read-only, so
2855 + // nothing in the DOM needs reverting here. The cleartext key is
2856 + // never sent to the browser.
2857 + } else if (response.data && response.data.api_key_removed === true) {
2858 + // API key was cleared — show warning, update status, page will reload
2859 + noticeType = 'notice-warning';
2860 + noticeMessage = $('<span/>').text((response.data.warning || 'The API key has been removed.') + ' Reloading\u2026').html();
2861 + updateHeaderStatus(false, 'Disconnected', getPluginName() + ' disconnected');
2507 2862 } else if (response.data && response.data.warning) {
2508 2863 // Network failure — key saved but unverified
2509 2864 noticeType = 'notice-warning';
2510 2865 noticeMessage = $('<span/>').text(response.data.warning).html();
@@ -2526,11 +2881,11 @@
2526 2881
2527 2882 // Scroll to the notice for better visibility
2528 2883 $('html, body').animate({ scrollTop: 0 }, 'slow');
2529 2884
2530 - // Reload page when API key was validated so server-rendered
2885 + // Reload page when API key status changed so server-rendered
2531 2886 // sections (One-Click Authentication, promo sidebar) update
2532 - if (response.data && response.data.api_key_validated === true) {
2887 + if (response.data && (response.data.api_key_validated === true || response.data.api_key_removed === true)) {
2533 2888 setTimeout(function () {
2534 2889 location.reload();
2535 2890 }, 1500);
2536 2891 return;
@@ -2546,33 +2901,42 @@
2546 2901 }
2547 2902 } else {
2548 2903 // Handle validation errors
2549 2904 var errors = response.data && response.data.errors ? response.data.errors : [];
2550 - var errorHtml = '<div class="notice notice-error metasync-error-wrap" style="margin: 20px; padding: 12px;">';
2905 + var fieldErrors = (response.data && response.data.field_errors) ? response.data.field_errors : {};
2906 +
2907 + // Build error notice using DOM methods to avoid XSS
2908 + var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap').css({ margin: '20px', padding: '12px' });
2551 2909 if (Array.isArray(errors)) {
2552 - errorHtml += '<ul>';
2910 + var $ul = $('<ul>');
2553 2911 for (var i = 0; i < errors.length; i++) {
2554 - errorHtml += '<li>' + errors[i] + '</li>';
2912 + $ul.append($('<li>').text(errors[i]));
2555 2913 }
2556 - errorHtml += '</ul>';
2914 + $errorNotice.append($ul);
2557 2915 } else {
2558 2916 var message = 'An error occurred while saving settings.';
2559 2917 if (response.data && response.data.message) {
2560 2918 message = response.data.message;
2561 2919 }
2562 - errorHtml += '<p>' + message + '</p>';
2920 + $errorNotice.append($('<p>').text(message));
2563 2921 }
2564 - errorHtml += '</div>';
2565 -
2922 +
2566 2923 // Insert error notice in plugin area
2567 2924 var $navWrapper = $('.metasync-nav-wrapper');
2568 2925 if ($navWrapper.length > 0) {
2569 - $navWrapper.after(errorHtml);
2926 + $navWrapper.after($errorNotice);
2570 2927 } else {
2571 2928 // Fallback: insert at top of plugin content area
2572 - $('.metasync-dashboard-wrap').prepend(errorHtml);
2929 + $('.metasync-dashboard-wrap').prepend($errorNotice);
2573 2930 }
2574 -
2931 +
2932 + // Field-specific feedback: show the server's per-field
2933 + // error beside each rejected white-label branding input.
2934 + clearBrandingFieldErrors();
2935 + Object.keys(fieldErrors).forEach(function (settingKey) {
2936 + showBrandingFieldError(settingKey, fieldErrors[settingKey]);
2937 + });
2938 +
2575 2939 // Scroll to the error message for better visibility
2576 2940 $('html, body').animate({ scrollTop: 0 }, 'slow');
2577 2941
2578 2942 setTimeout(function () {
@@ -2658,13 +3022,14 @@
2658 3022 dataType: 'json',
2659 3023 success: function (response) {
2660 3024 if (response.success) {
2661 3025 // Show success message above Indexation Control section
2662 - $('#seo-controls-messages').html(
2663 - '<div class="notice notice-success is-dismissible" style="margin-bottom: 20px;">' +
2664 - '<p><strong>✅ Success!</strong> ' + response.data.message + '</p>' +
2665 - '</div>'
2666 - );
3026 + var $successNotice = $('<div>').addClass('notice notice-success is-dismissible').css('margin-bottom', '20px');
3027 + var $successP = $('<p>');
3028 + $successP.append($('<strong>').text('✅ Success! '));
3029 + $successP[0].appendChild(document.createTextNode(response.data.message));
3030 + $successNotice.append($successP);
3031 + $('#seo-controls-messages').empty().append($successNotice);
2667 3032
2668 3033 // Clear unsaved changes state
2669 3034 hasUnsavedChanges = false;
2670 3035 if (typeof updateUnsavedChangesIndicator === 'function') {
@@ -2680,13 +3045,14 @@
2680 3045 $('#seo-controls-messages .notice-success').fadeOut();
2681 3046 }, 5000);
2682 3047 } else {
2683 3048 // Show error message above Indexation Control section
2684 - $('#seo-controls-messages').html(
2685 - '<div class="notice notice-error is-dismissible" style="margin-bottom: 20px;">' +
2686 - '<p><strong>❌ Error!</strong> ' + (response.data.message || 'Failed to save settings') + '</p>' +
2687 - '</div>'
2688 - );
3049 + var $errorNoticeCtrl = $('<div>').addClass('notice notice-error is-dismissible').css('margin-bottom', '20px');
3050 + var $errorPCtrl = $('<p>');
3051 + $errorPCtrl.append($('<strong>').text('❌ Error! '));
3052 + $errorPCtrl[0].appendChild(document.createTextNode(response.data.message || 'Failed to save settings'));
3053 + $errorNoticeCtrl.append($errorPCtrl);
3054 + $('#seo-controls-messages').empty().append($errorNoticeCtrl);
2689 3055 }
2690 3056
2691 3057 // Make dismiss buttons work
2692 3058 $('#seo-controls-messages .notice-dismissible').each(function () {
@@ -2712,9 +3078,9 @@
2712 3078
2713 3079 // Don't clear unsaved changes on network error
2714 3080 // User may want to retry or fix the issue
2715 3081 },
2716 - complete: function() {
3082 + complete: function () {
2717 3083 // Re-enable save button if it was disabled
2718 3084 $('.metasync-save-button').prop('disabled', false);
2719 3085 }
2720 3086 });
@@ -3098,26 +3464,65 @@
3098 3464 * @param {jQuery} $trigger - The trigger button
3099 3465 * @param {jQuery} $tooltip - The tooltip element
3100 3466 */
3101 3467 function positionTooltip($trigger, $tooltip) {
3102 - // Skip positioning on mobile (uses fixed positioning)
3468 + // Mobile uses the CSS fixed bottom-sheet layout; clear any inline desktop
3469 + // styles so the stylesheet takes over.
3103 3470 if (window.innerWidth <= 768) {
3471 + $tooltip.css({ position: '', left: '', top: '', right: '', bottom: '', transform: '', margin: '' });
3104 3472 return;
3105 3473 }
3106 3474
3107 - var triggerRect = $trigger[0].getBoundingClientRect();
3108 - var tooltipWidth = $tooltip.outerWidth();
3109 - var viewportWidth = $(window).width();
3110 - var spaceRight = viewportWidth - triggerRect.right;
3475 + // Move the tooltip up to the plugin wrapper so it escapes the
3476 + // overflow:hidden on accordion sections / cards that was clipping it.
3477 + // The wrapper carries data-theme (so the --dashboard-* CSS vars still
3478 + // resolve) and is position:relative (not transformed), so the
3479 + // position:fixed below stays relative to the viewport. Verified in a
3480 + // headless-Chrome harness before shipping.
3481 + var portal = document.querySelector('.metasync-dashboard-wrap') || document.body;
3482 + if ($tooltip[0].parentNode !== portal) {
3483 + portal.appendChild($tooltip[0]);
3484 + }
3111 3485
3112 - // Check if tooltip would overflow on the right
3113 - if (spaceRight < tooltipWidth + 20) {
3114 - // Position on the left side
3115 - $tooltip.attr('data-position', 'left');
3116 - } else {
3117 - // Position on the right side (default)
3118 - $tooltip.attr('data-position', 'right');
3119 - }
3486 + var margin = 12;
3487 + var rect = $trigger[0].getBoundingClientRect();
3488 + var vw = window.innerWidth;
3489 + var vh = window.innerHeight;
3490 + var tw = $tooltip.outerWidth();
3491 + var th = $tooltip.outerHeight();
3492 +
3493 + // Horizontal: prefer to the right of the icon, else flip left; clamp to viewport.
3494 + var left = rect.right + margin;
3495 + if (left + tw + margin > vw) { left = rect.left - margin - tw; }
3496 + if (left < margin) { left = margin; }
3497 + if (left + tw + margin > vw) { left = Math.max(margin, vw - tw - margin); }
3498 +
3499 + // Vertical: center on the icon, then clamp so the top/bottom is never cut off.
3500 + var top = rect.top + (rect.height / 2) - (th / 2);
3501 + if (top < margin) { top = margin; }
3502 + if (top + th + margin > vh) { top = Math.max(margin, vh - th - margin); }
3503 +
3504 + $tooltip.find('.metasync-tooltip-arrow').css('display', 'none');
3505 + // Place it, then play a small left-to-right slide-in. Position is
3506 + // handled by left/top (fixed), so transform is free for the entrance
3507 + // animation: start ~12px to the left and slide right into place.
3508 + $tooltip.css({
3509 + position: 'fixed',
3510 + left: Math.round(left) + 'px',
3511 + top: Math.round(top) + 'px',
3512 + right: 'auto',
3513 + bottom: 'auto',
3514 + margin: '0',
3515 + transition: 'opacity 0.2s ease',
3516 + transform: 'translateX(-12px)'
3517 + });
3518 + // Force a reflow so the start position is committed before animating.
3519 + void $tooltip[0].offsetWidth;
3520 + $tooltip.css({
3521 + transition: 'opacity 0.2s ease, transform 0.2s ease',
3522 + transform: 'translateX(0)'
3523 + });
3524 + $tooltip.removeAttr('data-position');
3120 3525 }
3121 3526
3122 3527 // Initialize tooltip system
3123 3528 initTooltipSystem();
@@ -3123,11 +3528,15 @@
3123 3528 initTooltipSystem();
3124 3529
3125 3530 // PR3: Burst ping — 10 min polling when UNREGISTERED or KEY_PENDING. Stop after 5 failed attempts.
3126 3531 (function () {
3127 - if (typeof metaSync === 'undefined' || !metaSync.heartbeat_state) return;
3532 + if (typeof metaSync === 'undefined' || !metaSync.heartbeat_state) {
3533 + return;
3534 + }
3128 3535 var state = metaSync.heartbeat_state;
3129 - if (state !== 'UNREGISTERED' && state !== 'KEY_PENDING') return;
3536 + if (state !== 'UNREGISTERED' && state !== 'KEY_PENDING') {
3537 + return;
3538 + }
3130 3539
3131 3540 var INTERVAL_MS = 10 * 60 * 1000;
3132 3541 var MAX_ATTEMPTS = 5;
3133 3542 var attempts = 0;
@@ -3148,9 +3557,11 @@
3148 3557 })
3149 3558 .done(function (res) {
3150 3559 if (!res || !res.data) {
3151 3560 attempts++;
3152 - if (attempts >= MAX_ATTEMPTS) stop();
3561 + if (attempts >= MAX_ATTEMPTS) {
3562 + stop();
3563 + }
3153 3564 return;
3154 3565 }
3155 3566 var data = res.data;
3156 3567 var newState = data.state || lastState;
@@ -3170,13 +3581,17 @@
3170 3581 }
3171 3582 return;
3172 3583 }
3173 3584 attempts++;
3174 - if (attempts >= MAX_ATTEMPTS) stop();
3585 + if (attempts >= MAX_ATTEMPTS) {
3586 + stop();
3587 + }
3175 3588 })
3176 3589 .fail(function () {
3177 3590 attempts++;
3178 - if (attempts >= MAX_ATTEMPTS) stop();
3591 + if (attempts >= MAX_ATTEMPTS) {
3592 + stop();
3593 + }
3179 3594 });
3180 3595 }, INTERVAL_MS);
3181 3596 })();
3182 3597