| @@ -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 |
| @@ -235,9 +271,10 @@ | ||
| 235 | 271 | |
| 236 | 272 | function metasyncLGLogin(user, pass) { |
| 237 | 273 | jQuery.post(ajaxurl, { |
| 238 | 274 | action: 'metasync_lglogin', |
| 239 | - username: user, password: pass | |
| 275 | + username: user, password: pass, | |
| 276 | + nonce: metaSync.lglogin_nonce || '' | |
| 240 | 277 | }, function (response) { |
| 241 | 278 | if (typeof response.token !== 'undefined') { |
| 242 | 279 | $('#linkgraph_token').val(response.token); |
| 243 | 280 | $('#linkgraph_customer_id').val(response.customer_id); |
| @@ -243,9 +280,9 @@ | ||
| 243 | 280 | $('#linkgraph_customer_id').val(response.customer_id); |
| 244 | 281 | $('.input.lguser,#lgerror').addClass('hidden'); |
| 245 | 282 | localStorage.setItem('token', response.token); |
| 246 | 283 | } else { |
| 247 | - $('#lgerror').html(`${response.detail} (${response.kind})`).removeClass('hidden'); | |
| 284 | + $('#lgerror').text(response.detail + ' (' + response.kind + ')').removeClass('hidden'); | |
| 248 | 285 | } |
| 249 | 286 | } |
| 250 | 287 | ); |
| 251 | 288 | } |
| @@ -470,9 +507,9 @@ | ||
| 470 | 507 | |
| 471 | 508 | function resetConnectButton() { |
| 472 | 509 | var $button = $('#connect-searchatlas-btn'); |
| 473 | 510 | var $progressContainer = $('.metasync-sa-connect-progress'); |
| 474 | - var hasApiKey = $('#searchatlas-api-key').val().trim() !== ''; | |
| 511 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 475 | 512 | |
| 476 | 513 | $button.prop('disabled', false) |
| 477 | 514 | .removeClass('connecting authenticating success dashboard-loading') // Remove all loading classes |
| 478 | 515 | .html(hasApiKey ? '🔄 Re-authenticate with ' + getPluginName() : '🔗 Connect to ' + getPluginName()); |
| @@ -480,9 +517,14 @@ | ||
| 480 | 517 | } |
| 481 | 518 | |
| 482 | 519 | function startSearchAtlasPolling(nonceToken) { |
| 483 | 520 | var pollCount = 0; |
| 484 | - var maxPolls = 12; // Poll for 60 seconds (12 * 5 seconds) | |
| 521 | + // WP-558 (Option A): poll for 2 minutes (24 * 5s). Each poll also drives | |
| 522 | + // the outbound pull-based connect fallback server-side, which needs a | |
| 523 | + // slightly longer window than the legacy push-only 60s to ride out the | |
| 524 | + // human SSO-authorization delay. ~24 requests stays inside the CA | |
| 525 | + // endpoint's 120/hour per-domain budget. | |
| 526 | + var maxPolls = 24; // Poll for 120 seconds (24 * 5 seconds) | |
| 485 | 527 | var $progressContainer = $('.metasync-sa-connect-progress'); |
| 486 | 528 | var $progressFill = $('.metasync-sa-connect-progress-fill'); |
| 487 | 529 | var $progressText = $('.metasync-sa-connect-progress-text'); |
| 488 | 530 | var $button = $('#connect-searchatlas-btn'); |
| @@ -521,20 +563,20 @@ | ||
| 521 | 563 | |
| 522 | 564 | // ✅ Reset the authentication flow while keeping the timeout component |
| 523 | 565 | resetConnectButton(); |
| 524 | 566 | |
| 525 | - showConnectError('⏰ Authentication Timeout', | |
| 526 | - 'The authentication process timed out after 60 seconds. Please complete the authentication more quickly or check for network issues.', | |
| 567 | + showConnectError('⏰ Authentication Timeout', | |
| 568 | + 'The authentication process timed out after 2 minutes. Please complete the authentication more quickly or check for network issues.', | |
| 527 | 569 | [{ |
| 528 | 570 | text: '🔄 Try Again', |
| 529 | 571 | action: function () { |
| 530 | - handleSearchAtlasConnect(); | |
| 572 | + handleSearchAtlasConnect(); | |
| 531 | 573 | } |
| 532 | 574 | }, { |
| 533 | 575 | text: '💬 Contact Support', |
| 534 | - action: function () { | |
| 576 | + action: function () { | |
| 535 | 577 | var supportEmail = metaSync.support_email || '[email protected]'; |
| 536 | - window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (30s)', '_blank'); | |
| 578 | + window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (120s)', '_blank'); | |
| 537 | 579 | } |
| 538 | 580 | }] |
| 539 | 581 | ); |
| 540 | 582 | return; |
| @@ -569,9 +611,9 @@ | ||
| 569 | 611 | |
| 570 | 612 | // Handle different status codes with enhanced UX |
| 571 | 613 | if (statusCode === 200) { |
| 572 | 614 | // Success: Update all UI elements to reflect connected state |
| 573 | - updateUIForConnectedState(response.data.api_key, response.data.otto_pixel_uuid); | |
| 615 | + updateUIForConnectedState(response.data.masked_key, response.data.otto_pixel_uuid); | |
| 574 | 616 | |
| 575 | 617 | // Refresh Plugin Auth Token field to show the auto-generated token |
| 576 | 618 | // This will either show the existing token or the newly auto-generated one |
| 577 | 619 | $.ajax({ |
| @@ -602,14 +644,15 @@ | ||
| 602 | 644 | $button.closest('.metasync-sa-connect-container').removeClass('metasync-sa-connect-success-animation'); |
| 603 | 645 | }, 600); |
| 604 | 646 | |
| 605 | 647 | // Track 1-click activation in GA4 |
| 606 | - var hasExistingApiKey = $('#searchatlas-api-key').val() && $('#searchatlas-api-key').val().trim() !== ''; | |
| 648 | + var hasExistingApiKey = searchAtlasKeyConfigured(); | |
| 607 | 649 | $.ajax({ |
| 608 | 650 | url: metaSync.ajax_url, |
| 609 | 651 | type: 'POST', |
| 610 | 652 | data: { |
| 611 | 653 | action: 'metasync_track_one_click_activation', |
| 654 | + nonce: metaSync.nonce, | |
| 612 | 655 | auth_method: 'searchatlas_connect', |
| 613 | 656 | is_reconnection: hasExistingApiKey |
| 614 | 657 | } |
| 615 | 658 | }); |
| @@ -736,9 +779,9 @@ | ||
| 736 | 779 | var timeElapsed = currentPoll * 5; |
| 737 | 780 | var timeRemaining = (maxPolls - currentPoll) * 5; |
| 738 | 781 | $progressTime.text(timeElapsed + 's elapsed, ' + timeRemaining + 's remaining'); |
| 739 | 782 | |
| 740 | - // Update progress text based on time elapsed (optimized for 60-second timeout) | |
| 783 | + // Update progress text based on time elapsed (optimized for 120-second timeout) | |
| 741 | 784 | var progressMessages = [ |
| 742 | 785 | 'Establishing connection and opening authentication window...', |
| 743 | 786 | 'Please complete authentication in the popup window...', |
| 744 | 787 | 'Almost done! Finalizing your authentication...' |
| @@ -861,10 +904,19 @@ | ||
| 861 | 904 | '⚠️ Website Not Registered', |
| 862 | 905 | '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 | 906 | [{ |
| 864 | 907 | text: '🌐 Register Website', |
| 865 | - action: function () { | |
| 866 | - window.open(registerUrl, '_blank'); | |
| 908 | + action: function () { | |
| 909 | + try { | |
| 910 | + var parsedRegister = new URL(registerUrl); | |
| 911 | + if (parsedRegister.protocol === 'https:' || parsedRegister.protocol === 'http:') { | |
| 912 | + var a = document.createElement('a'); | |
| 913 | + a.href = parsedRegister.href; | |
| 914 | + a.target = '_blank'; | |
| 915 | + a.rel = 'noopener'; | |
| 916 | + a.click(); | |
| 917 | + } | |
| 918 | + } catch (e) { /* invalid URL */ } | |
| 867 | 919 | }, |
| 868 | 920 | primary: true |
| 869 | 921 | }, { |
| 870 | 922 | text: '📚 Learn More About Registration', |
| @@ -869,9 +921,18 @@ | ||
| 869 | 921 | }, { |
| 870 | 922 | text: '📚 Learn More About Registration', |
| 871 | 923 | action: function () { |
| 872 | 924 | var docDomain = metaSync.documentation_domain || 'https://searchatlas.com'; |
| 873 | - window.open(docDomain, '_blank'); | |
| 925 | + try { | |
| 926 | + var parsedDoc = new URL(docDomain); | |
| 927 | + if (parsedDoc.protocol === 'https:' || parsedDoc.protocol === 'http:') { | |
| 928 | + var a = document.createElement('a'); | |
| 929 | + a.href = parsedDoc.href; | |
| 930 | + a.target = '_blank'; | |
| 931 | + a.rel = 'noopener'; | |
| 932 | + a.click(); | |
| 933 | + } | |
| 934 | + } catch (e) { /* invalid URL */ } | |
| 874 | 935 | } |
| 875 | 936 | }, { |
| 876 | 937 | text: '🔄 Try Authentication Again', |
| 877 | 938 | action: function () { |
| @@ -953,9 +1014,9 @@ | ||
| 953 | 1014 | 'Check if firewall is blocking the request' |
| 954 | 1015 | ]; |
| 955 | 1016 | } else if (context === 'timeout') { |
| 956 | 1017 | recoverySuggestions = [ |
| 957 | - 'Complete authentication within 60 seconds', | |
| 1018 | + 'Complete authentication within 2 minutes', | |
| 958 | 1019 | 'Check if the popup window needs attention', |
| 959 | 1020 | 'Ensure you have your ' + getPluginName() + ' login ready', |
| 960 | 1021 | 'Try the authentication process again', |
| 961 | 1022 | 'Contact support if timeouts persist' |
| @@ -1085,17 +1146,17 @@ | ||
| 1085 | 1146 | |
| 1086 | 1147 | // Add connection status indicator |
| 1087 | 1148 | function updateConnectionStatus() { |
| 1088 | 1149 | 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) | |
| 1150 | + var $apiKeyField = $('#searchatlas-api-key-display'); | |
| 1151 | + | |
| 1152 | + // Only update status if we're on a page with the API key display (General Settings) | |
| 1092 | 1153 | // On other pages, preserve the PHP-determined status in the header |
| 1093 | 1154 | if ($apiKeyField.length === 0) { |
| 1094 | - return; // Don't update status on pages without the API key field | |
| 1155 | + return; // Don't update status on pages without the API key display | |
| 1095 | 1156 | } |
| 1096 | - | |
| 1097 | - var hasApiKey = $apiKeyField.val() && $apiKeyField.val().trim() !== ''; | |
| 1157 | + | |
| 1158 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 1098 | 1159 | var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== ''; |
| 1099 | 1160 | var isFullyConnected = hasApiKey && hasOttoUuid; |
| 1100 | 1161 | |
| 1101 | 1162 | // Update button text based on connection state |
| @@ -1108,11 +1169,24 @@ | ||
| 1108 | 1169 | $button.html('🔗 Connect to ' + getPluginName()); |
| 1109 | 1170 | } |
| 1110 | 1171 | } |
| 1111 | 1172 | |
| 1112 | - // Update header status indicator (only on General Settings page) | |
| 1113 | - if (isFullyConnected) { | |
| 1173 | + // Update header status indicator (only on General Settings page). | |
| 1174 | + // the header badge must reflect the confirmed heartbeat | |
| 1175 | + // round-trip (metaSync.is_connected), NOT mere API-key/UUID presence — | |
| 1176 | + // otherwise a revoked/invalid key still shows "Connected" because the | |
| 1177 | + // key string and UUID remain stored locally. | |
| 1178 | + // NOTE: wp_localize_script stringifies PHP booleans (true -> "1", | |
| 1179 | + // false -> ""), so normalise to a real boolean rather than ===. | |
| 1180 | + var heartbeatConnected = metaSync.is_connected === true | |
| 1181 | + || metaSync.is_connected === '1' | |
| 1182 | + || metaSync.is_connected === 1; | |
| 1183 | + if (isFullyConnected && heartbeatConnected) { | |
| 1114 | 1184 | updateHeaderStatus(true, 'Synced', getPluginName() + ' API key and ' + getOttoName() + ' UUID are configured'); |
| 1185 | + } else if (isFullyConnected) { | |
| 1186 | + // Key + UUID are configured but the heartbeat is not succeeding | |
| 1187 | + // (e.g. the key was revoked on the Search Atlas side). | |
| 1188 | + updateHeaderStatus(false, 'Not Connected', getPluginName() + ' API key is set but the heartbeat is not responding — the connection may be revoked or unreachable.'); | |
| 1115 | 1189 | } else if (hasApiKey && !hasOttoUuid) { |
| 1116 | 1190 | updateHeaderStatus(false, 'Warning', 'Connected but ' + getOttoName() + ' UUID is missing — deploys will not work. Please reconnect.'); |
| 1117 | 1191 | } else { |
| 1118 | 1192 | updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID'); |
| @@ -1118,11 +1192,11 @@ | ||
| 1118 | 1192 | updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID'); |
| 1119 | 1193 | } |
| 1120 | 1194 | } |
| 1121 | 1195 | |
| 1122 | - // Monitor API key field changes | |
| 1123 | - $('#searchatlas-api-key').on('input', updateConnectionStatus); | |
| 1124 | - | |
| 1196 | + // The API key is no longer manually editable (one-click auth only), so there | |
| 1197 | + // is no input event to monitor; status changes are driven by connect/disconnect. | |
| 1198 | + | |
| 1125 | 1199 | // Initial status update |
| 1126 | 1200 | updateConnectionStatus(); |
| 1127 | 1201 | |
| 1128 | 1202 | // Initialize dashboard iframe functionality |
| @@ -1131,13 +1205,12 @@ | ||
| 1131 | 1205 | // Settings dropdown now handled by inline script in HTML |
| 1132 | 1206 | |
| 1133 | 1207 | // Add debug function for connection status (accessible in console) |
| 1134 | 1208 | window.debugConnectionStatus = function () { |
| 1135 | - var apiKey = $('#searchatlas-api-key').val(); | |
| 1136 | - var hasApiKey = apiKey.trim() !== ''; | |
| 1137 | - | |
| 1209 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 1210 | + | |
| 1138 | 1211 | console.log('🔍 Connection Status Debug:', { |
| 1139 | - searchatlas_api_key: hasApiKey ? (apiKey.substring(0, 8) + '...') : 'EMPTY', | |
| 1212 | + searchatlas_credential_state: hasApiKey ? 'CONFIGURED (masked)' : 'EMPTY', | |
| 1140 | 1213 | otto_pixel_uuid: metaSync.otto_pixel_uuid || 'NOT SET', |
| 1141 | 1214 | connection_state: hasApiKey && metaSync.otto_pixel_uuid ? 'CONNECTED' : |
| 1142 | 1215 | hasApiKey ? 'PARTIAL (Missing ' + getOttoName() + ' UUID)' : 'NOT CONNECTED', |
| 1143 | 1216 | dashboard_tab_visible: hasApiKey && metaSync.otto_pixel_uuid ? 'YES' : 'NO', |
| @@ -1259,9 +1332,8 @@ | ||
| 1259 | 1332 | } |
| 1260 | 1333 | |
| 1261 | 1334 | var $resetButton = $('#reset-searchatlas-auth'); |
| 1262 | 1335 | var $connectButton = $('#connect-searchatlas-btn'); |
| 1263 | - var $apiKeyField = $('#searchatlas-api-key'); | |
| 1264 | 1336 | |
| 1265 | 1337 | // Show loading state (prevent dashboard.js conflicts) |
| 1266 | 1338 | $resetButton.prop('disabled', true) |
| 1267 | 1339 | .addClass('no-loading') // Prevent dashboard.js interference |
| @@ -1280,11 +1352,11 @@ | ||
| 1280 | 1352 | nonce: metaSync.reset_auth_nonce |
| 1281 | 1353 | }, |
| 1282 | 1354 | success: function (response) { |
| 1283 | 1355 | if (response.success) { |
| 1284 | - // Clear the API key field | |
| 1285 | - $apiKeyField.val(''); | |
| 1286 | - | |
| 1356 | + // Reset the masked display to the "Not configured" state | |
| 1357 | + setSearchAtlasMaskedDisplay(''); | |
| 1358 | + | |
| 1287 | 1359 | // Update button states |
| 1288 | 1360 | $connectButton.html('🔗 Connect to ' + getPluginName()); |
| 1289 | 1361 | $resetButton.remove(); // Remove reset button since no longer connected |
| 1290 | 1362 | |
| @@ -1379,10 +1451,18 @@ | ||
| 1379 | 1451 | /** |
| 1380 | 1452 | * Update UI elements when account is disconnected |
| 1381 | 1453 | */ |
| 1382 | 1454 | function updateUIForDisconnectedState() { |
| 1455 | + // a cached manual-sync cooldown must not survive disconnect — | |
| 1456 | + // it keeps the Sync Now button locked after the user reconnects even | |
| 1457 | + // though the server-side cooldown stamp was cleared with the auth data. | |
| 1458 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 1459 | + refreshManualSyncCooldownUI(); | |
| 1460 | + $('#sendAuthToken').prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now'); | |
| 1461 | + | |
| 1383 | 1462 | // Update API key field placeholder |
| 1384 | - $('#searchatlas-api-key').attr('placeholder', 'Your API key will appear here after authentication'); | |
| 1463 | + // Reset the masked API key display to the "Not configured" state | |
| 1464 | + setSearchAtlasMaskedDisplay(''); | |
| 1385 | 1465 | |
| 1386 | 1466 | // ✅ Clear OTTO Pixel UUID field |
| 1387 | 1467 | $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(''); |
| 1388 | 1468 | |
| @@ -1432,14 +1512,13 @@ | ||
| 1432 | 1512 | /** |
| 1433 | 1513 | * Update UI elements when account is connected/authenticated |
| 1434 | 1514 | * Complementary function to updateUIForDisconnectedState() |
| 1435 | 1515 | */ |
| 1436 | - function updateUIForConnectedState(apiKey, ottoPixelUuid) { | |
| 1437 | - // Update API key field | |
| 1438 | - if (apiKey) { | |
| 1439 | - $('#searchatlas-api-key').val(apiKey); | |
| 1440 | - } | |
| 1441 | - | |
| 1516 | + function updateUIForConnectedState(maskedKey, ottoPixelUuid) { | |
| 1517 | + // Update the read-only masked API key display. Only the masked value is | |
| 1518 | + // ever shown — the cleartext key is never rendered into the DOM. | |
| 1519 | + setSearchAtlasMaskedDisplay(maskedKey || '••••••••'); | |
| 1520 | + | |
| 1442 | 1521 | // ✅ Update OTTO Pixel UUID field |
| 1443 | 1522 | if (ottoPixelUuid) { |
| 1444 | 1523 | $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(ottoPixelUuid); |
| 1445 | 1524 | } |
| @@ -1843,8 +1922,9 @@ | ||
| 1843 | 1922 | method: 'POST', |
| 1844 | 1923 | url: 'admin-ajax.php', |
| 1845 | 1924 | data: { |
| 1846 | 1925 | action: 'metasync_send_giapi', |
| 1926 | + nonce: metaSync.nonce, | |
| 1847 | 1927 | metasync_giapi_url: url.val(), |
| 1848 | 1928 | metasync_giapi_action: action.val() |
| 1849 | 1929 | } |
| 1850 | 1930 | }) |
| @@ -1965,8 +2045,9 @@ | ||
| 1965 | 2045 | |
| 1966 | 2046 | // DEBUG: Log request parameters |
| 1967 | 2047 | const requestData = { |
| 1968 | 2048 | action: 'metasync_send_customer_params', |
| 2049 | + nonce: metaSync.nonce, | |
| 1969 | 2050 | is_heart_beat : is_hb |
| 1970 | 2051 | }; |
| 1971 | 2052 | |
| 1972 | 2053 | jQuery.ajax({ |
| @@ -1984,17 +2065,18 @@ | ||
| 1984 | 2065 | if (showAlerts) { |
| 1985 | 2066 | $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now'); |
| 1986 | 2067 | } |
| 1987 | 2068 | |
| 1988 | - if ($('#searchatlas-api-key') && $('#searchatlas-api-key').val() === '') { | |
| 1989 | - $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key'); | |
| 1990 | - $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2069 | + if ($('#searchatlas-api-key-display').length > 0 && !searchAtlasKeyConfigured()) { | |
| 2070 | + if (!is_hb) { | |
| 2071 | + $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key'); | |
| 2072 | + $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 1991 | 2073 | |
| 1992 | - // Remove stale PHP-rendered "✓ Synced" label. | |
| 1993 | - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2074 | + // Remove stale PHP-rendered "✓ Synced" label. | |
| 2075 | + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 1994 | 2076 | |
| 1995 | - // Update header status to "Not Synced" for missing API key | |
| 1996 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required'); | |
| 2077 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required'); | |
| 2078 | + } | |
| 1997 | 2079 | |
| 1998 | 2080 | if (showAlerts) { |
| 1999 | 2081 | showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.'); |
| 2000 | 2082 | } |
| @@ -1999,34 +2081,23 @@ | ||
| 1999 | 2081 | showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.'); |
| 2000 | 2082 | } |
| 2001 | 2083 | |
| 2002 | 2084 | } 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 | |
| 2085 | + // Compute expiry locally from a duration (remaining_seconds) so server/client | |
| 2086 | + // clock drift cannot lock the user out or let them bypass the throttle. | |
| 2087 | + var remainingSeconds = (typeof response.remaining_seconds === 'number') | |
| 2088 | + ? response.remaining_seconds | |
| 2089 | + : ((response.remaining_minutes || 5) * 60); | |
| 2090 | + var expiryMs = Date.now() + (remainingSeconds * 1000); | |
| 2091 | + var remainingMinutes = Math.max(1, Math.ceil(remainingSeconds / 60)); | |
| 2092 | + | |
| 2009 | 2093 | 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 | |
| 2094 | + localStorage.setItem('metasync_manual_sync_throttle_expires', String(expiryMs)); | |
| 2095 | + refreshManualSyncCooldownUI(); | |
| 2025 | 2096 | } |
| 2026 | 2097 | |
| 2027 | - // Update header status to show throttling | |
| 2028 | - updateHeaderStatus(false, 'Throttled', 'Throttled - Please wait ' + remainingMinutes + ' minutes'); | |
| 2098 | + // Throttle is transient — don't change the connection badge. | |
| 2099 | + // The toast notification already informs the user. | |
| 2029 | 2100 | |
| 2030 | 2101 | if (showAlerts) { |
| 2031 | 2102 | showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.'); |
| 2032 | 2103 | } |
| @@ -2031,17 +2102,19 @@ | ||
| 2031 | 2102 | showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.'); |
| 2032 | 2103 | } |
| 2033 | 2104 | |
| 2034 | 2105 | } else if (response && response.detail) { |
| 2035 | - $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key'); | |
| 2036 | - $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2106 | + if (!is_hb) { | |
| 2107 | + $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key'); | |
| 2108 | + $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2037 | 2109 | |
| 2038 | - // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server. | |
| 2039 | - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2110 | + // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server. | |
| 2111 | + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2112 | + } | |
| 2040 | 2113 | |
| 2041 | - // Update header status to "Not Synced" for invalid API key | |
| 2114 | + // No is_hb guard here — genuinely invalid keys should always flip the badge | |
| 2042 | 2115 | updateHeaderStatus(false, 'Not Synced', 'Not Synced - Invalid API key'); |
| 2043 | - | |
| 2116 | + | |
| 2044 | 2117 | if (showAlerts) { |
| 2045 | 2118 | showSyncError('❌ Invalid API Key', 'Please provide a valid ' + getPluginName() + ' API key.'); |
| 2046 | 2119 | } |
| 2047 | 2120 | |
| @@ -2046,9 +2119,11 @@ | ||
| 2046 | 2119 | } |
| 2047 | 2120 | |
| 2048 | 2121 | } else if (response === null || !response.id) { |
| 2049 | 2122 | // Update header status to "Not Synced" after failed sync |
| 2050 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed'); | |
| 2123 | + if (!is_hb) { | |
| 2124 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed'); | |
| 2125 | + } | |
| 2051 | 2126 | |
| 2052 | 2127 | if (showAlerts) { |
| 2053 | 2128 | showSyncError('❌ Sync Failed', 'Something went wrong during synchronization. Please check your connection and try again.'); |
| 2054 | 2129 | } |
| @@ -2057,8 +2132,16 @@ | ||
| 2057 | 2132 | } else { |
| 2058 | 2133 | var dateString = dateFormat(); |
| 2059 | 2134 | $('#sendAuthTokenTimestamp').html(dateString); |
| 2060 | 2135 | $('#sendAuthTokenTimestamp').css({ color: 'green' }); |
| 2136 | + // Server starts a 5-minute manual-sync cooldown after a successful sync. | |
| 2137 | + // Reflect that on the client so the button shows the cooldown across refreshes | |
| 2138 | + // instead of misleadingly saying "Sync Now" only to be rejected on next click. | |
| 2139 | + if (!is_hb) { | |
| 2140 | + var successCooldownMs = 5 * 60 * 1000; | |
| 2141 | + localStorage.setItem('metasync_manual_sync_throttle_expires', String(Date.now() + successCooldownMs)); | |
| 2142 | + refreshManualSyncCooldownUI(); | |
| 2143 | + } | |
| 2061 | 2144 | |
| 2062 | 2145 | // Update header status — check if UUID is present before declaring fully synced |
| 2063 | 2146 | var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== ''; |
| 2064 | 2147 | if (hasOttoUuid) { |
| @@ -2073,9 +2156,11 @@ | ||
| 2073 | 2156 | } |
| 2074 | 2157 | }, |
| 2075 | 2158 | error: function (xhr, status, error) { |
| 2076 | 2159 | // Update header status to "Not Synced" for network errors |
| 2077 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync'); | |
| 2160 | + if (!is_hb) { | |
| 2161 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync'); | |
| 2162 | + } | |
| 2078 | 2163 | |
| 2079 | 2164 | // Reset button state |
| 2080 | 2165 | if (showAlerts) { |
| 2081 | 2166 | $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now'); |
| @@ -2104,9 +2189,10 @@ | ||
| 2104 | 2189 | url: ajaxurl, |
| 2105 | 2190 | type: 'GET', |
| 2106 | 2191 | data: { |
| 2107 | 2192 | action: 'metasync_clear_otto_cache', |
| 2108 | - clear_otto_cache: 1 | |
| 2193 | + clear_otto_cache: 1, | |
| 2194 | + nonce: metaSync.clear_otto_cache_nonce || '' | |
| 2109 | 2195 | }, |
| 2110 | 2196 | success: function (response) { |
| 2111 | 2197 | const now = new Date(); |
| 2112 | 2198 | $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString()); |
| @@ -2114,8 +2200,65 @@ | ||
| 2114 | 2200 | } |
| 2115 | 2201 | }); |
| 2116 | 2202 | } |
| 2117 | 2203 | |
| 2204 | + // Holds the active manual-sync countdown interval so we can replace it | |
| 2205 | + // instead of stacking multiple timers if refreshManualSyncCooldownUI() | |
| 2206 | + // is called more than once (e.g. throttled response then refresh). | |
| 2207 | + var _manualSyncCountdownInterval = null; | |
| 2208 | + | |
| 2209 | + /** | |
| 2210 | + * Read the manual-sync throttle expiry (client-time ms) from localStorage | |
| 2211 | + * and apply UI state: disable the button, show "Throttled (Nm)", and tick | |
| 2212 | + * every 15s. Idempotent — safe to call repeatedly. | |
| 2213 | + */ | |
| 2214 | + function refreshManualSyncCooldownUI() { | |
| 2215 | + var $btn = $('#sendAuthToken'); | |
| 2216 | + if ($btn.length === 0) { | |
| 2217 | + return; | |
| 2218 | + } | |
| 2219 | + if (_manualSyncCountdownInterval) { | |
| 2220 | + clearInterval(_manualSyncCountdownInterval); | |
| 2221 | + _manualSyncCountdownInterval = null; | |
| 2222 | + } | |
| 2223 | + var stored = localStorage.getItem('metasync_manual_sync_throttle_expires'); | |
| 2224 | + if (!stored) { | |
| 2225 | + return; | |
| 2226 | + } | |
| 2227 | + var storedExpiry = parseInt(stored, 10); | |
| 2228 | + if (!storedExpiry || isNaN(storedExpiry)) { | |
| 2229 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2230 | + return; | |
| 2231 | + } | |
| 2232 | + var nowMs = Date.now(); | |
| 2233 | + if (storedExpiry <= nowMs) { | |
| 2234 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2235 | + return; | |
| 2236 | + } | |
| 2237 | + var remainingMinutes = Math.max(1, Math.ceil((storedExpiry - nowMs) / 60000)); | |
| 2238 | + // addClass('is-throttled') drives the visual state via CSS so it | |
| 2239 | + // doesn't depend on the disabled attribute being preserved by other | |
| 2240 | + // code paths in the AJAX callback chain (which strip it on success). | |
| 2241 | + $btn.prop('disabled', true).addClass('is-throttled').text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2242 | + _manualSyncCountdownInterval = setInterval(function () { | |
| 2243 | + var currentNowMs = Date.now(); | |
| 2244 | + if (storedExpiry <= currentNowMs) { | |
| 2245 | + clearInterval(_manualSyncCountdownInterval); | |
| 2246 | + _manualSyncCountdownInterval = null; | |
| 2247 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2248 | + $btn.prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now'); | |
| 2249 | + $('#sendAuthTokenTimestamp').text('Ready to sync').css({ color: 'green' }); | |
| 2250 | + // Cooldown is over — clear the lingering "Request Throttled" admin | |
| 2251 | + // notice at the top of the page. Without this the button resets to | |
| 2252 | + // "Sync Now" but the red throttled bar stays visible (QA). | |
| 2253 | + $('.metasync-sync-notice, .metasync-sync-error').remove(); | |
| 2254 | + return; | |
| 2255 | + } | |
| 2256 | + remainingMinutes = Math.max(1, Math.ceil((storedExpiry - currentNowMs) / 60000)); | |
| 2257 | + $btn.text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2258 | + }, 15000); | |
| 2259 | + } | |
| 2260 | + | |
| 2118 | 2261 | jQuery(document).ready(function () { |
| 2119 | 2262 | |
| 2120 | 2263 | // sendCustomerParams(); |
| 2121 | 2264 | $('#sendAuthToken').on('click', function (e) { |
| @@ -2120,11 +2263,14 @@ | ||
| 2120 | 2263 | // sendCustomerParams(); |
| 2121 | 2264 | $('#sendAuthToken').on('click', function (e) { |
| 2122 | 2265 | e.preventDefault(); |
| 2123 | 2266 | sendCustomerParams(); |
| 2124 | - | |
| 2267 | + | |
| 2125 | 2268 | }); |
| 2126 | 2269 | |
| 2270 | + // Restore manual-sync throttle countdown state across page refreshes. | |
| 2271 | + refreshManualSyncCooldownUI(); | |
| 2272 | + | |
| 2127 | 2273 | // handle otto clear cache button |
| 2128 | 2274 | $('#clear_otto_caches').on('click', function (e){ |
| 2129 | 2275 | e.preventDefault(); |
| 2130 | 2276 | clear_otto_caches(); |
| @@ -2179,39 +2325,68 @@ | ||
| 2179 | 2325 | data: formData + '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab), // Add the action and current tab |
| 2180 | 2326 | success: function (response) { |
| 2181 | 2327 | // Handle success response |
| 2182 | 2328 | if(response.success){ |
| 2329 | + // Per-field whitelabel validation issues: the save succeeded for | |
| 2330 | + // other fields, so show a warning notice and stay on the page so the | |
| 2331 | + // user can see which values were rejected. | |
| 2332 | + const wlErrors = response.data && response.data.whitelabel_errors; | |
| 2333 | + if (wlErrors && Object.keys(wlErrors).length > 0) { | |
| 2334 | + var $warnNotice = $('<div>').addClass('notice notice-warning metasync-error-wrap'); | |
| 2335 | + var $warnList = $('<ul>'); | |
| 2336 | + Object.keys(wlErrors).forEach(function (fieldKey) { | |
| 2337 | + $warnList.append($('<li>').text(wlErrors[fieldKey])); | |
| 2338 | + }); | |
| 2339 | + $warnNotice.append($('<p>').text('Settings saved, but some values were not applied:')); | |
| 2340 | + $warnNotice.append($warnList); | |
| 2341 | + $('.metasync-error-wrap').remove(); | |
| 2342 | + $('#metaSyncGeneralSetting').before($warnNotice); | |
| 2343 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2344 | + return; | |
| 2345 | + } | |
| 2346 | + | |
| 2183 | 2347 | // get value of input field white_label_plugin_menu_slug |
| 2184 | 2348 | const whiteLableUrl = $('#metaSyncGeneralSetting input[name="metasync_options[general][white_label_plugin_menu_slug]"]').val(); |
| 2185 | 2349 | // check condition if it is empty or not and redirect it |
| 2186 | - | |
| 2350 | + | |
| 2187 | 2351 | // add the tag query to the window location |
| 2188 | 2352 | let tabParam = new URLSearchParams(window.location.search).get('tab'); |
| 2189 | 2353 | 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; | |
| 2354 | + | |
| 2355 | + // Prefer the server-sanitized slug so the redirect matches the page | |
| 2356 | + // WordPress actually registered; fall back to the raw input field value. | |
| 2357 | + const savedSlug = response.data && typeof response.data.saved_menu_slug === 'string' ? response.data.saved_menu_slug : null; | |
| 2358 | + const inputSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas'; | |
| 2359 | + const rawSlug = (savedSlug !== null) ? (savedSlug || 'searchatlas') : inputSlug; | |
| 2360 | + const pageSlug = encodeURIComponent(rawSlug.replace(/[^a-zA-Z0-9_\-]/g, '')) || 'searchatlas'; | |
| 2361 | + var redirectUrl = metaSync.admin_url + '?page=' + pageSlug + tabQuery; | |
| 2362 | + try { | |
| 2363 | + var parsedRedirect = new URL(redirectUrl, window.location.origin); | |
| 2364 | + if (parsedRedirect.origin === window.location.origin) { | |
| 2365 | + var navLink = document.createElement('a'); | |
| 2366 | + navLink.href = parsedRedirect.href; | |
| 2367 | + navLink.click(); | |
| 2368 | + } | |
| 2369 | + } catch (e) { /* invalid URL */ } | |
| 2194 | 2370 | }else { |
| 2195 | 2371 | // Handle error response |
| 2196 | 2372 | const errors = response.data?.errors || []; |
| 2197 | 2373 | |
| 2198 | - // Create a notice element to display the errors | |
| 2199 | - let html = '<div class="notice notice-error metasync-error-wrap">'; | |
| 2374 | + // Build error notice using DOM methods to avoid XSS | |
| 2375 | + var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap'); | |
| 2200 | 2376 | if (Array.isArray(errors)) { |
| 2201 | - html += '<ul>'; | |
| 2377 | + var $ul = $('<ul>'); | |
| 2202 | 2378 | errors.forEach(function (err) { |
| 2203 | - html += '<li>' + err + '</li>'; | |
| 2379 | + $ul.append($('<li>').text(err)); | |
| 2204 | 2380 | }); |
| 2205 | - html += '</ul>'; | |
| 2381 | + $errorNotice.append($ul); | |
| 2206 | 2382 | } |
| 2207 | - html += '</div>'; | |
| 2208 | 2383 | |
| 2209 | 2384 | // Remove previous error notices |
| 2210 | 2385 | $('.metasync-error-wrap').remove(); |
| 2211 | - | |
| 2386 | + | |
| 2212 | 2387 | // Insert the error message before the form |
| 2213 | - $('#metaSyncGeneralSetting').before(html); | |
| 2388 | + $('#metaSyncGeneralSetting').before($errorNotice); | |
| 2214 | 2389 | |
| 2215 | 2390 | // Scroll to the top to ensure visibility |
| 2216 | 2391 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2217 | 2392 | } |
| @@ -2425,11 +2600,11 @@ | ||
| 2425 | 2600 | // Form submission handler already exists above, so we just need to listen for successful response |
| 2426 | 2601 | var originalAjaxHandler = $(this).data('events') && $(this).data('events').submit; |
| 2427 | 2602 | }); |
| 2428 | 2603 | |
| 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) { | |
| 2604 | + // Listen for successful form submission to clear the unsaved changes flag | |
| 2605 | + $(document).ajaxSuccess(function (event, xhr, settings) { | |
| 2606 | + if (settings.data && typeof settings.data === 'string' && settings.data.indexOf('action=meta_sync_save_settings') > -1) { | |
| 2432 | 2607 | try { |
| 2433 | 2608 | var response = JSON.parse(xhr.responseText); |
| 2434 | 2609 | if (response.success) { |
| 2435 | 2610 | hasUnsavedChanges = false; |
| @@ -2484,12 +2659,41 @@ | ||
| 2484 | 2659 | if (response.success) { |
| 2485 | 2660 | // Clear unsaved changes flag |
| 2486 | 2661 | hasUnsavedChanges = false; |
| 2487 | 2662 | updateUnsavedChangesIndicator(); |
| 2488 | - | |
| 2663 | + | |
| 2664 | + // Clear any previous save notices (success, error, or warning) | |
| 2665 | + $('.metasync-save-notice').remove(); | |
| 2666 | + | |
| 2489 | 2667 | // Show temporary success indication in plugin area |
| 2490 | - var successNotice = '<div class="notice notice-success is-dismissible metasync-save-notice" style="margin: 20px 0; padding: 12px;"><p><strong>✅ Settings saved successfully!</strong></p></div>'; | |
| 2491 | - | |
| 2668 | + var noticeType, noticeMessage, noAutoDismiss = false; | |
| 2669 | + if (response.data && response.data.api_key_validated === true) { | |
| 2670 | + noticeType = 'notice-success'; | |
| 2671 | + noticeMessage = $('<span/>').text('Settings saved successfully! API key verified & connected. Reloading\u2026').html(); | |
| 2672 | + updateHeaderStatus(true, 'Synced', getPluginName() + ' connected'); | |
| 2673 | + } else if (response.data && response.data.api_key_validated === false) { | |
| 2674 | + noticeType = 'notice-error'; | |
| 2675 | + noticeMessage = $('<span/>').text(response.data.warning || 'The API key could not be verified. Your other settings were saved.').html(); | |
| 2676 | + noAutoDismiss = true; | |
| 2677 | + // The server reverts the stored key to the last valid one on | |
| 2678 | + // verification failure, and the masked display is read-only, so | |
| 2679 | + // nothing in the DOM needs reverting here. The cleartext key is | |
| 2680 | + // never sent to the browser. | |
| 2681 | + } else if (response.data && response.data.api_key_removed === true) { | |
| 2682 | + // API key was cleared — show warning, update status, page will reload | |
| 2683 | + noticeType = 'notice-warning'; | |
| 2684 | + noticeMessage = $('<span/>').text((response.data.warning || 'The API key has been removed.') + ' Reloading\u2026').html(); | |
| 2685 | + updateHeaderStatus(false, 'Disconnected', getPluginName() + ' disconnected'); | |
| 2686 | + } else if (response.data && response.data.warning) { | |
| 2687 | + // Network failure — key saved but unverified | |
| 2688 | + noticeType = 'notice-warning'; | |
| 2689 | + noticeMessage = $('<span/>').text(response.data.warning).html(); | |
| 2690 | + } else { | |
| 2691 | + noticeType = 'notice-success'; | |
| 2692 | + noticeMessage = $('<span/>').text('Settings saved successfully!').html(); | |
| 2693 | + } | |
| 2694 | + var successNotice = '<div class="notice ' + noticeType + ' is-dismissible metasync-save-notice" style="margin: 20px 0; padding: 12px;"><p><strong>' + noticeMessage + '</strong></p></div>'; | |
| 2695 | + | |
| 2492 | 2696 | // Insert between navigation menu and page content |
| 2493 | 2697 | var $navWrapper = $('.metasync-nav-wrapper'); |
| 2494 | 2698 | if ($navWrapper.length > 0) { |
| 2495 | 2699 | // Position after navigation menu but before first dashboard card or form |
| @@ -2497,43 +2701,56 @@ | ||
| 2497 | 2701 | } else { |
| 2498 | 2702 | // Fallback: insert at top of settings page |
| 2499 | 2703 | $('.metasync-dashboard-wrap').prepend(successNotice); |
| 2500 | 2704 | } |
| 2501 | - | |
| 2502 | - // Scroll to the success message for better visibility | |
| 2705 | + | |
| 2706 | + // Scroll to the notice for better visibility | |
| 2503 | 2707 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2504 | - | |
| 2505 | - setTimeout(function () { | |
| 2506 | - $('.metasync-save-notice').fadeOut(300, function () { | |
| 2507 | - $(this).remove(); | |
| 2508 | - }); | |
| 2509 | - }, 3000); | |
| 2708 | + | |
| 2709 | + // Reload page when API key status changed so server-rendered | |
| 2710 | + // sections (One-Click Authentication, promo sidebar) update | |
| 2711 | + if (response.data && (response.data.api_key_validated === true || response.data.api_key_removed === true)) { | |
| 2712 | + setTimeout(function () { | |
| 2713 | + location.reload(); | |
| 2714 | + }, 1500); | |
| 2715 | + return; | |
| 2716 | + } | |
| 2717 | + | |
| 2718 | + // Auto-dismiss success/warning notices; keep errors visible | |
| 2719 | + if (!noAutoDismiss) { | |
| 2720 | + setTimeout(function () { | |
| 2721 | + $('.metasync-save-notice').fadeOut(300, function () { | |
| 2722 | + $(this).remove(); | |
| 2723 | + }); | |
| 2724 | + }, 3000); | |
| 2725 | + } | |
| 2510 | 2726 | } else { |
| 2511 | 2727 | // Handle validation errors |
| 2512 | 2728 | var errors = response.data && response.data.errors ? response.data.errors : []; |
| 2513 | - var errorHtml = '<div class="notice notice-error metasync-error-wrap" style="margin: 20px; padding: 12px;">'; | |
| 2729 | + | |
| 2730 | + // Build error notice using DOM methods to avoid XSS | |
| 2731 | + var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap').css({ margin: '20px', padding: '12px' }); | |
| 2514 | 2732 | if (Array.isArray(errors)) { |
| 2515 | - errorHtml += '<ul>'; | |
| 2733 | + var $ul = $('<ul>'); | |
| 2516 | 2734 | for (var i = 0; i < errors.length; i++) { |
| 2517 | - errorHtml += '<li>' + errors[i] + '</li>'; | |
| 2735 | + $ul.append($('<li>').text(errors[i])); | |
| 2518 | 2736 | } |
| 2519 | - errorHtml += '</ul>'; | |
| 2737 | + $errorNotice.append($ul); | |
| 2520 | 2738 | } else { |
| 2521 | 2739 | var message = 'An error occurred while saving settings.'; |
| 2522 | 2740 | if (response.data && response.data.message) { |
| 2523 | 2741 | message = response.data.message; |
| 2524 | 2742 | } |
| 2525 | - errorHtml += '<p>' + message + '</p>'; | |
| 2743 | + $errorNotice.append($('<p>').text(message)); | |
| 2526 | 2744 | } |
| 2527 | - errorHtml += '</div>'; | |
| 2528 | - | |
| 2745 | + | |
| 2529 | 2746 | // Insert error notice in plugin area |
| 2530 | 2747 | var $navWrapper = $('.metasync-nav-wrapper'); |
| 2531 | 2748 | if ($navWrapper.length > 0) { |
| 2532 | - $navWrapper.after(errorHtml); | |
| 2749 | + $navWrapper.after($errorNotice); | |
| 2533 | 2750 | } else { |
| 2534 | 2751 | // Fallback: insert at top of plugin content area |
| 2535 | - $('.metasync-dashboard-wrap').prepend(errorHtml); | |
| 2752 | + $('.metasync-dashboard-wrap').prepend($errorNotice); | |
| 2536 | 2753 | } |
| 2537 | 2754 | |
| 2538 | 2755 | // Scroll to the error message for better visibility |
| 2539 | 2756 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| @@ -2621,13 +2838,14 @@ | ||
| 2621 | 2838 | dataType: 'json', |
| 2622 | 2839 | success: function (response) { |
| 2623 | 2840 | if (response.success) { |
| 2624 | 2841 | // Show success message above Indexation Control section |
| 2625 | - $('#seo-controls-messages').html( | |
| 2626 | - '<div class="notice notice-success is-dismissible" style="margin-bottom: 20px;">' + | |
| 2627 | - '<p><strong>✅ Success!</strong> ' + response.data.message + '</p>' + | |
| 2628 | - '</div>' | |
| 2629 | - ); | |
| 2842 | + var $successNotice = $('<div>').addClass('notice notice-success is-dismissible').css('margin-bottom', '20px'); | |
| 2843 | + var $successP = $('<p>'); | |
| 2844 | + $successP.append($('<strong>').text('✅ Success! ')); | |
| 2845 | + $successP[0].appendChild(document.createTextNode(response.data.message)); | |
| 2846 | + $successNotice.append($successP); | |
| 2847 | + $('#seo-controls-messages').empty().append($successNotice); | |
| 2630 | 2848 | |
| 2631 | 2849 | // Clear unsaved changes state |
| 2632 | 2850 | hasUnsavedChanges = false; |
| 2633 | 2851 | if (typeof updateUnsavedChangesIndicator === 'function') { |
| @@ -2643,13 +2861,14 @@ | ||
| 2643 | 2861 | $('#seo-controls-messages .notice-success').fadeOut(); |
| 2644 | 2862 | }, 5000); |
| 2645 | 2863 | } else { |
| 2646 | 2864 | // Show error message above Indexation Control section |
| 2647 | - $('#seo-controls-messages').html( | |
| 2648 | - '<div class="notice notice-error is-dismissible" style="margin-bottom: 20px;">' + | |
| 2649 | - '<p><strong>❌ Error!</strong> ' + (response.data.message || 'Failed to save settings') + '</p>' + | |
| 2650 | - '</div>' | |
| 2651 | - ); | |
| 2865 | + var $errorNoticeCtrl = $('<div>').addClass('notice notice-error is-dismissible').css('margin-bottom', '20px'); | |
| 2866 | + var $errorPCtrl = $('<p>'); | |
| 2867 | + $errorPCtrl.append($('<strong>').text('❌ Error! ')); | |
| 2868 | + $errorPCtrl[0].appendChild(document.createTextNode(response.data.message || 'Failed to save settings')); | |
| 2869 | + $errorNoticeCtrl.append($errorPCtrl); | |
| 2870 | + $('#seo-controls-messages').empty().append($errorNoticeCtrl); | |
| 2652 | 2871 | } |
| 2653 | 2872 | |
| 2654 | 2873 | // Make dismiss buttons work |
| 2655 | 2874 | $('#seo-controls-messages .notice-dismissible').each(function () { |
| @@ -2675,9 +2894,9 @@ | ||
| 2675 | 2894 | |
| 2676 | 2895 | // Don't clear unsaved changes on network error |
| 2677 | 2896 | // User may want to retry or fix the issue |
| 2678 | 2897 | }, |
| 2679 | - complete: function() { | |
| 2898 | + complete: function () { | |
| 2680 | 2899 | // Re-enable save button if it was disabled |
| 2681 | 2900 | $('.metasync-save-button').prop('disabled', false); |
| 2682 | 2901 | } |
| 2683 | 2902 | }); |
| @@ -3061,26 +3280,65 @@ | ||
| 3061 | 3280 | * @param {jQuery} $trigger - The trigger button |
| 3062 | 3281 | * @param {jQuery} $tooltip - The tooltip element |
| 3063 | 3282 | */ |
| 3064 | 3283 | function positionTooltip($trigger, $tooltip) { |
| 3065 | - // Skip positioning on mobile (uses fixed positioning) | |
| 3284 | + // Mobile uses the CSS fixed bottom-sheet layout; clear any inline desktop | |
| 3285 | + // styles so the stylesheet takes over. | |
| 3066 | 3286 | if (window.innerWidth <= 768) { |
| 3287 | + $tooltip.css({ position: '', left: '', top: '', right: '', bottom: '', transform: '', margin: '' }); | |
| 3067 | 3288 | return; |
| 3068 | 3289 | } |
| 3069 | 3290 | |
| 3070 | - var triggerRect = $trigger[0].getBoundingClientRect(); | |
| 3071 | - var tooltipWidth = $tooltip.outerWidth(); | |
| 3072 | - var viewportWidth = $(window).width(); | |
| 3073 | - var spaceRight = viewportWidth - triggerRect.right; | |
| 3291 | + // Move the tooltip up to the plugin wrapper so it escapes the | |
| 3292 | + // overflow:hidden on accordion sections / cards that was clipping it. | |
| 3293 | + // The wrapper carries data-theme (so the --dashboard-* CSS vars still | |
| 3294 | + // resolve) and is position:relative (not transformed), so the | |
| 3295 | + // position:fixed below stays relative to the viewport. Verified in a | |
| 3296 | + // headless-Chrome harness before shipping. | |
| 3297 | + var portal = document.querySelector('.metasync-dashboard-wrap') || document.body; | |
| 3298 | + if ($tooltip[0].parentNode !== portal) { | |
| 3299 | + portal.appendChild($tooltip[0]); | |
| 3300 | + } | |
| 3074 | 3301 | |
| 3075 | - // Check if tooltip would overflow on the right | |
| 3076 | - if (spaceRight < tooltipWidth + 20) { | |
| 3077 | - // Position on the left side | |
| 3078 | - $tooltip.attr('data-position', 'left'); | |
| 3079 | - } else { | |
| 3080 | - // Position on the right side (default) | |
| 3081 | - $tooltip.attr('data-position', 'right'); | |
| 3082 | - } | |
| 3302 | + var margin = 12; | |
| 3303 | + var rect = $trigger[0].getBoundingClientRect(); | |
| 3304 | + var vw = window.innerWidth; | |
| 3305 | + var vh = window.innerHeight; | |
| 3306 | + var tw = $tooltip.outerWidth(); | |
| 3307 | + var th = $tooltip.outerHeight(); | |
| 3308 | + | |
| 3309 | + // Horizontal: prefer to the right of the icon, else flip left; clamp to viewport. | |
| 3310 | + var left = rect.right + margin; | |
| 3311 | + if (left + tw + margin > vw) { left = rect.left - margin - tw; } | |
| 3312 | + if (left < margin) { left = margin; } | |
| 3313 | + if (left + tw + margin > vw) { left = Math.max(margin, vw - tw - margin); } | |
| 3314 | + | |
| 3315 | + // Vertical: center on the icon, then clamp so the top/bottom is never cut off. | |
| 3316 | + var top = rect.top + (rect.height / 2) - (th / 2); | |
| 3317 | + if (top < margin) { top = margin; } | |
| 3318 | + if (top + th + margin > vh) { top = Math.max(margin, vh - th - margin); } | |
| 3319 | + | |
| 3320 | + $tooltip.find('.metasync-tooltip-arrow').css('display', 'none'); | |
| 3321 | + // Place it, then play a small left-to-right slide-in. Position is | |
| 3322 | + // handled by left/top (fixed), so transform is free for the entrance | |
| 3323 | + // animation: start ~12px to the left and slide right into place. | |
| 3324 | + $tooltip.css({ | |
| 3325 | + position: 'fixed', | |
| 3326 | + left: Math.round(left) + 'px', | |
| 3327 | + top: Math.round(top) + 'px', | |
| 3328 | + right: 'auto', | |
| 3329 | + bottom: 'auto', | |
| 3330 | + margin: '0', | |
| 3331 | + transition: 'opacity 0.2s ease', | |
| 3332 | + transform: 'translateX(-12px)' | |
| 3333 | + }); | |
| 3334 | + // Force a reflow so the start position is committed before animating. | |
| 3335 | + void $tooltip[0].offsetWidth; | |
| 3336 | + $tooltip.css({ | |
| 3337 | + transition: 'opacity 0.2s ease, transform 0.2s ease', | |
| 3338 | + transform: 'translateX(0)' | |
| 3339 | + }); | |
| 3340 | + $tooltip.removeAttr('data-position'); | |
| 3083 | 3341 | } |
| 3084 | 3342 | |
| 3085 | 3343 | // Initialize tooltip system |
| 3086 | 3344 | initTooltipSystem(); |
| @@ -3086,11 +3344,15 @@ | ||
| 3086 | 3344 | initTooltipSystem(); |
| 3087 | 3345 | |
| 3088 | 3346 | // PR3: Burst ping — 10 min polling when UNREGISTERED or KEY_PENDING. Stop after 5 failed attempts. |
| 3089 | 3347 | (function () { |
| 3090 | - if (typeof metaSync === 'undefined' || !metaSync.heartbeat_state) return; | |
| 3348 | + if (typeof metaSync === 'undefined' || !metaSync.heartbeat_state) { | |
| 3349 | + return; | |
| 3350 | + } | |
| 3091 | 3351 | var state = metaSync.heartbeat_state; |
| 3092 | - if (state !== 'UNREGISTERED' && state !== 'KEY_PENDING') return; | |
| 3352 | + if (state !== 'UNREGISTERED' && state !== 'KEY_PENDING') { | |
| 3353 | + return; | |
| 3354 | + } | |
| 3093 | 3355 | |
| 3094 | 3356 | var INTERVAL_MS = 10 * 60 * 1000; |
| 3095 | 3357 | var MAX_ATTEMPTS = 5; |
| 3096 | 3358 | var attempts = 0; |
| @@ -3111,9 +3373,11 @@ | ||
| 3111 | 3373 | }) |
| 3112 | 3374 | .done(function (res) { |
| 3113 | 3375 | if (!res || !res.data) { |
| 3114 | 3376 | attempts++; |
| 3115 | - if (attempts >= MAX_ATTEMPTS) stop(); | |
| 3377 | + if (attempts >= MAX_ATTEMPTS) { | |
| 3378 | + stop(); | |
| 3379 | + } | |
| 3116 | 3380 | return; |
| 3117 | 3381 | } |
| 3118 | 3382 | var data = res.data; |
| 3119 | 3383 | var newState = data.state || lastState; |
| @@ -3133,13 +3397,17 @@ | ||
| 3133 | 3397 | } |
| 3134 | 3398 | return; |
| 3135 | 3399 | } |
| 3136 | 3400 | attempts++; |
| 3137 | - if (attempts >= MAX_ATTEMPTS) stop(); | |
| 3401 | + if (attempts >= MAX_ATTEMPTS) { | |
| 3402 | + stop(); | |
| 3403 | + } | |
| 3138 | 3404 | }) |
| 3139 | 3405 | .fail(function () { |
| 3140 | 3406 | attempts++; |
| 3141 | - if (attempts >= MAX_ATTEMPTS) stop(); | |
| 3407 | + if (attempts >= MAX_ATTEMPTS) { | |
| 3408 | + stop(); | |
| 3409 | + } | |
| 3142 | 3410 | }); |
| 3143 | 3411 | }, INTERVAL_MS); |
| 3144 | 3412 | })(); |
| 3145 | 3413 | |