// Credentials schema per provider name. Defines the fields to collect and how to label them. const PROVIDER_CREDENTIAL_SCHEMAS = { // todo Figure out SOTA authentication // see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx // https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js // Refresh token? Way to show user that they need to log in again because cached credentials aren't valid? // todo type: text/password distinction on text boxes so API keys can be obscured "SOTA": [ {key: "access_token", label: "SOTA Access Token", help: ""}, {key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."} ], "ParksNPeaks": [ {key: "user_id", label: "Parks N Peaks User ID", help: ""}, {key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."} ], "ZLOTA": [ {key: "user_id", label: "ZLOTA User ID", help: ""}, {key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."} ], "Tiles": [ { key: "offline_spot_gateway_pin", label: "Offline Spot Gateway PIN", help: "Get your PIN from your Tiles on the Air account profile." } ] }; // Load and inject the reCAPTCHA script function loadRecaptcha() { if (!document.getElementById('recaptcha-script')) { const script = document.createElement('script'); script.id = 'recaptcha-script'; script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha'; script.async = true; script.defer = true; document.head.appendChild(script); } $("#recaptcha-area").show(); } // Called by reCAPTCHA after its script loads function renderRecaptcha() { window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', { sitekey: RECAPTCHA_SITE_KEY, size: 'normal' }); } // Update the "Send spot to..." area based on the currently selected activity function updateUpstreamArea() { if (!ALLOW_UPSTREAM_SPOTTING) { $("#upstream-area").hide(); return; } const providers = SPOT_SUBMIT_PROVIDERS[$("#activity").val()] || []; if (providers.length === 0) { $("#upstream-area").hide(); return; } $("#upstream-area").show(); // Rebuild the provider checkboxes, keeping any that were already checked for the previous activity const previouslyChecked = getSelectedUpstreamProviders(); const container = $("#upstream-providers").empty(); $.each(providers, function (i, name) { const id = "submit-upstream-" + i; const row = $("
"); const check = $("
"); check.append($("") .attr("id", id).val(name).prop("checked", previouslyChecked.includes(name))); check.append($("").attr("for", id).text(name)); row.append($("
").append(check)); // Add a credentials button if this provider has an authentication mechanism and we need input from the user if (PROVIDER_CREDENTIAL_SCHEMAS[name]) { const button = $("") .attr("aria-label", "Credentials for " + name) .html(" Credentials") .click(() => showCredentialsModal(name)); row.append($("
").append(button)); } container.append(row); }); updateSpotholeOnlyCheck(); } // The "Spothole only" checkbox is checked when no upstream providers are selected function updateSpotholeOnlyCheck() { $("#spothole-only").prop("checked", getSelectedUpstreamProviders().length === 0); } // Get the names of the upstream providers the user has chosen to send the spot to function getSelectedUpstreamProviders() { return $(".upstream-provider-check:checked").map(function () { return $(this).val(); }).get(); } // Show the credentials modal for the given upstream provider function showCredentialsModal(providerName) { if (!PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return; const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName]; const stored = loadCredentials(providerName); $("#credentials-provider-name").text(providerName); $("#credentials-fields").empty(); $.each(schema, function (i, field) { const val = stored[field.key] || ""; let html = '
'; html += ''; html += ''; if (field.help) { html += '
' + field.help + '
'; } html += '
'; $("#credentials-fields").append(html); }); // Store provider name for saveCredentials() $("#credentials-modal").data("provider", providerName); new bootstrap.Modal(document.getElementById('credentials-modal')).show(); } // Save credentials from the modal to local storage function saveCredentials() { const providerName = $("#credentials-modal").data("provider"); if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return; const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName]; const creds = {}; $.each(schema, function (i, field) { creds[field.key] = $("#cred-" + field.key).val(); }); localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds)); bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide(); } // Load credentials for a provider from local storage function loadCredentials(providerName) { const stored = localStorage.getItem("upstream-credentials-" + providerName); return stored ? JSON.parse(stored) : {}; } // Method called to add a spot to the server function addSpot() { try { $("#result-bad").empty(); setSubmitting(true); // Save settings (this will save "your call" for future use) saveSettings(); // Unpack the user's entered values const dx = $("#dx-call").val().toUpperCase(); const freqStr = $("#freq").val(); const mode = $("#mode")[0].value; const activity = $("#activity")[0].value; const activityRef = $("#activity-ref").val(); const dxGrid = $("#dx-grid").val(); const comment = $("#comment").val(); const de = $("#de-call").val().toUpperCase(); // Prepare the spot object for the server const spot = {}; spot["dx_call"] = dx; spot["freq"] = parseFloat(freqStr) * 1000; if (mode !== "") spot["mode"] = mode; if (activity !== "") spot["activities"] = [activity]; if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}]; if (dxGrid !== "") spot["dx_grid"] = dxGrid; if (comment !== "") spot["comment"] = comment; spot["de_call"] = de; spot["time"] = moment.utc().valueOf() / 1000.0; // Prepare "handling" structure to tell the server what to do with this spot const handling = {}; // Add CAPTCHA token if reCAPTCHA is loaded if (window._recaptchaWidgetId !== undefined) { handling["captcha_token"] = grecaptcha.getResponse(window._recaptchaWidgetId); } // Upstream submission const upstreamProviders = getSelectedUpstreamProviders(); if (upstreamProviders.length > 0) { handling["upstream_providers"] = upstreamProviders; handling["upstream_credentials"] = {}; $.each(upstreamProviders, function (i, name) { handling["upstream_credentials"][name] = loadCredentials(name); }); } $.ajax("/api/v3/spot", { data: JSON.stringify({spot, handling}), contentType: 'application/json', type: 'POST', timeout: 60000, success: async function (result) { // Reset CAPTCHA for next use if (window._recaptchaWidgetId !== undefined) { grecaptcha.reset(window._recaptchaWidgetId); } if (result && result.startsWith && result.startsWith("Warning")) { $("#result-good").html(""); } else { $("#result-good").html(""); } $("#result-bad").html(""); setTimeout(() => { $("#result-good").hide(); window.location.replace("/"); }, 2000); }, error: function (result) { setSubmitting(false); if (window._recaptchaWidgetId !== undefined) { grecaptcha.reset(window._recaptchaWidgetId); } if (result.responseText) { showAddSpotError(result.responseText.slice(1, -1)); } else { showAddSpotError("The server did not return a response."); } } }); } catch (error) { setSubmitting(false); showAddSpotError(error); } return false; } // Show or hide the "in progress" state on the Spot button. function setSubmitting(submitting) { $("#add-spot-submit").prop("disabled", submitting); $("#add-spot-spinner").toggle(submitting); $("#add-spot-submit-text").text(submitting ? "Spotting..." : "Spot"); } // Show an "add spot" error. function showAddSpotError(text) { const div = $(""); div.append(" "); div.append(document.createTextNode(text)); div.append(""); $("#result-bad").empty().append(div); } // Force callsign and mode capitalisation $("#dx-call").change(function () { $(this).val($(this).val().trim().toUpperCase()); }); $("#de-call").change(function () { $(this).val($(this).val().trim().toUpperCase()); }); $("#mode").change(function () { $(this).val($(this).val().trim().toUpperCase()); }); // Update upstream area when activity changes $("#activity").change(function () { updateUpstreamArea(); }); // Keep the "Spothole only" checkbox in sync with the upstream provider checkboxes $("#upstream-providers").on("change", ".upstream-provider-check", function () { updateSpotholeOnlyCheck(); }); // Clicking "Spothole only" deselects all upstream providers. It can't be unchecked directly, as that would leave // nowhere to send the spot; the user unchecks it implicitly by choosing an upstream provider instead. $("#spothole-only").change(function () { $(".upstream-provider-check").prop("checked", false); updateSpotholeOnlyCheck(); }); // Startup $(document).ready(function () { // Load reCAPTCHA if a site key is configured if (RECAPTCHA_SITE_KEY) { loadRecaptcha(); } loadSettings(); // Update the upstream area for any pre-selected activity updateUpstreamArea(); });