/** * @fileoverview Sync settings UI for MNW SyncKit cloud sync. * * 4-state flow: * 1. Not configured / not authenticated — "Connect" button * 2. Authenticating — spinner while waiting for OAuth callback * 3. No encryption — password form to set up E2E encryption * 4. Ready — status display, sync now, auto-sync toggle, disconnect */ (function() { 'use strict'; /** * Open the cloud sync settings modal. Loads current status and renders the * appropriate UI state (connect, encryption setup, or ready). * @returns {Promise} */ async function openSettings() { const title = document.getElementById('modal-title'); const body = document.getElementById('modal-body'); title.textContent = 'Cloud Sync'; body.innerHTML = '

Loading...

'; BB.ui.openModal(); try { const status = await BB.api.sync.status(); renderState(body, status); } catch (err) { body.innerHTML = '

Failed to load sync status.

'; BB.ui.showToast('Sync status error: ' + (err.message || err), 'error'); } } /** * Render the sync UI for the current status. * @param {HTMLElement} container - Modal body element. * @param {Object} status - Sync status from the backend. */ function renderState(container, status) { container.innerHTML = ''; if (!status.configured || !status.authenticated) { renderConnect(container); } else if (!status.encryptionReady) { renderEncryption(container, status); } else { renderReady(container, status); } } // ── State 1: Connect ── /** * Render the connect button for OAuth authentication. * @param {HTMLElement} container - Modal body element. */ function renderConnect(container) { // F4 (2026-06-02): inline styles moved into .sync-connect rules. const div = document.createElement('div'); div.className = 'sync-connect'; div.innerHTML = '

Sync your feeds and preferences across devices via Makenot.work.

' + '

All data is encrypted on your device before it leaves.

'; const btn = document.createElement('button'); btn.className = 'btn btn-primary'; btn.textContent = 'Connect to Makenot.work'; btn.onclick = () => startAuth(); div.appendChild(btn); container.appendChild(div); } /** * Start the OAuth2 PKCE auth flow: open browser and show code entry UI. * @returns {Promise} */ async function startAuth() { const body = document.getElementById('modal-body'); try { const result = await BB.api.sync.startAuth(); // Open browser for authentication if (window.__TAURI__?.shell?.open) { window.__TAURI__.shell.open(result.authUrl); } else { window.open(result.authUrl, '_blank'); } // Show code entry / check status UI showCodeEntry(result.state, result.codeVerifier, result.port); } catch (err) { BB.ui.showToast('Failed to start auth: ' + (err.message || err), 'error'); } } /** * Show the authenticating UI and poll the callback server for the result. * @param {string} expectedState - PKCE state parameter. * @param {string} codeVerifier - PKCE code verifier. * @param {number} port - Local callback server port. */ function showCodeEntry(expectedState, codeVerifier, port) { const body = document.getElementById('modal-body'); body.innerHTML = ''; const div = document.createElement('div'); div.innerHTML = '

Waiting for authentication in your browser...

'; // F4 (2026-06-02): inline styles moved into .sync-auth-spinner rules. const spinner = document.createElement('div'); spinner.className = 'sync-auth-spinner'; spinner.textContent = 'Polling for callback...'; div.appendChild(spinner); // Manual code entry as fallback const details = document.createElement('details'); details.className = 'sync-manual-entry'; details.innerHTML = 'Enter code manually'; const form = document.createElement('form'); form.className = 'modal-form sync-manual-form'; const group = document.createElement('div'); group.className = 'form-group'; const label = document.createElement('label'); label.textContent = 'Authorization Code'; const input = document.createElement('input'); input.className = 'form-input'; input.type = 'text'; input.name = 'code'; input.required = true; group.appendChild(label); group.appendChild(input); form.appendChild(group); const submitBtn = document.createElement('button'); submitBtn.type = 'submit'; submitBtn.className = 'btn btn-primary'; submitBtn.textContent = 'Complete Auth'; form.appendChild(submitBtn); form.onsubmit = async (e) => { e.preventDefault(); const code = input.value.trim(); if (!code) return; await completeAuth(code, expectedState, expectedState, codeVerifier, port); }; details.appendChild(form); div.appendChild(details); body.appendChild(div); // Auto-poll the callback server for the OAuth result pollCallbackResult(port, expectedState, codeVerifier); } /** * Poll the local callback server's /result endpoint until auth completes. * @param {number} port - Callback server port. * @param {string} expectedState - PKCE state parameter. * @param {string} codeVerifier - PKCE code verifier. */ function pollCallbackResult(port, expectedState, codeVerifier) { let attempts = 0; const maxAttempts = 300; // 5 minutes at 1s intervals const pollInterval = setInterval(async () => { attempts++; if (attempts > maxAttempts) { clearInterval(pollInterval); BB.ui.showToast('Authentication timed out', 'error'); return; } try { const resp = await fetch(`http://127.0.0.1:${port}/result`); if (resp.status === 200) { const data = await resp.json(); if (data.status === 'pending') return; clearInterval(pollInterval); if (data.status === 'success' && data.code) { await completeAuth(data.code, data.state, expectedState, codeVerifier, port); } else if (data.status === 'error') { BB.ui.showToast('Auth error: ' + (data.error || 'Unknown error'), 'error'); } } } catch (_) { // Server not ready yet, keep polling } }, 1000); } /** * Complete OAuth auth with an authorization code. * @param {string} code - Authorization code from the OAuth provider. * @param {string} state - State parameter from the callback. * @param {string} expectedState - Expected PKCE state parameter. * @param {string} codeVerifier - PKCE code verifier. * @param {number} port - Local callback server port. * @returns {Promise} */ async function completeAuth(code, state, expectedState, codeVerifier, port) { const body = document.getElementById('modal-body'); try { await BB.api.sync.completeAuth({ code, state, expectedState, codeVerifier, port, }); BB.ui.showToast('Connected!', 'success'); const status = await BB.api.sync.status(); renderState(body, status); } catch (err) { BB.ui.showToast('Auth failed: ' + (err.message || err), 'error'); } } // ── State 3: Encryption setup ── /** * Render the encryption password setup form. * @param {HTMLElement} container - Modal body element. * @param {Object} status - Sync status with `hasServerKey` flag. */ function renderEncryption(container, status) { container.innerHTML = ''; const div = document.createElement('div'); const hasKey = status.hasServerKey; if (hasKey) { div.innerHTML = '

Unlock this device. Enter the encryption password you set on your first device to decrypt your synced data.

'; } else { div.innerHTML = '

First device setup. Choose an encryption password to protect your data with end-to-end encryption. ' + 'You\'ll need this same password when adding Balanced Breakfast on other devices.

'; } const form = document.createElement('form'); form.className = 'modal-form'; const group = document.createElement('div'); group.className = 'form-group'; const label = document.createElement('label'); label.textContent = 'Encryption Password'; const input = document.createElement('input'); input.className = 'form-input'; input.type = 'password'; input.name = 'password'; input.required = true; input.minLength = 8; group.appendChild(label); group.appendChild(input); form.appendChild(group); const actions = document.createElement('div'); actions.className = 'form-actions'; const submitBtn = document.createElement('button'); submitBtn.type = 'submit'; submitBtn.className = 'btn btn-primary'; submitBtn.textContent = hasKey ? 'Unlock' : 'Set Password'; actions.appendChild(submitBtn); form.appendChild(actions); form.onsubmit = async (e) => { e.preventDefault(); submitBtn.disabled = true; submitBtn.textContent = 'Setting up...'; try { if (hasKey) { await BB.api.sync.setupEncryptionExisting(input.value); } else { await BB.api.sync.setupEncryptionNew(input.value); } BB.ui.showToast('Encryption ready!', 'success'); const newStatus = await BB.api.sync.status(); renderState(container, newStatus); } catch (err) { submitBtn.disabled = false; submitBtn.textContent = hasKey ? 'Unlock' : 'Set Password'; BB.ui.showToast('Encryption setup failed: ' + (err.message || err), 'error'); } }; div.appendChild(form); container.appendChild(div); } // ── State 4: Ready ── /** * Render the "ready" state: status info, sync now, auto-sync toggle, disconnect. * @param {HTMLElement} container - Modal body element. * @param {Object} status - Sync status with lastSyncAt, pendingChanges, etc. */ function renderReady(container, status) { container.innerHTML = ''; const div = document.createElement('div'); div.className = 'sync-ready'; // Subscription banner (populated async) const subBanner = document.createElement('div'); subBanner.id = 'sync-subscription-banner'; div.appendChild(subBanner); checkSubscriptionBanner(subBanner); // Status info const info = document.createElement('div'); info.className = 'sync-info'; const lastSync = status.lastSyncAt ? new Date(status.lastSyncAt).toLocaleString() : 'Never'; info.innerHTML = '

Last sync: ' + lastSync + '

' + '

Pending changes: ' + status.pendingChanges + '

'; div.appendChild(info); // Sync now button const syncBtn = document.createElement('button'); syncBtn.className = 'btn btn-primary sync-action-spaced'; syncBtn.textContent = 'Sync Now'; syncBtn.onclick = async () => { syncBtn.disabled = true; syncBtn.textContent = 'Syncing...'; try { const result = await BB.api.sync.now(); BB.ui.showToast( 'Synced! Pushed: ' + result.pushed + ', Pulled: ' + result.pulled, 'success' ); const newStatus = await BB.api.sync.status(); renderReady(container, newStatus); } catch (err) { syncBtn.disabled = false; syncBtn.textContent = 'Sync Now'; BB.ui.showToast('Sync failed: ' + (err.message || err), 'error'); } }; div.appendChild(syncBtn); // Auto-sync settings const settings = document.createElement('div'); settings.className = 'sync-settings'; // Toggle const toggleGroup = document.createElement('div'); toggleGroup.className = 'form-group'; const toggleLabel = document.createElement('label'); toggleLabel.className = 'sync-toggle-label'; const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = status.autoSyncEnabled; toggle.onchange = async () => { try { await BB.api.sync.updateSettings({ autoSyncEnabled: toggle.checked }); } catch (err) { toggle.checked = !toggle.checked; BB.ui.showToast('Failed to update setting', 'error'); } }; toggleLabel.appendChild(toggle); toggleLabel.appendChild(document.createTextNode('Auto-sync')); toggleGroup.appendChild(toggleLabel); settings.appendChild(toggleGroup); // Interval selector const intervalGroup = document.createElement('div'); intervalGroup.className = 'form-group'; const intervalLabel = document.createElement('label'); intervalLabel.textContent = 'Sync interval'; const intervalSelect = document.createElement('select'); intervalSelect.className = 'form-input'; [5, 15, 30, 60].forEach(min => { const opt = document.createElement('option'); opt.value = min; opt.textContent = min + ' minutes'; if (status.syncIntervalMinutes === min) opt.selected = true; intervalSelect.appendChild(opt); }); intervalSelect.onchange = async () => { try { await BB.api.sync.updateSettings({ syncIntervalMinutes: parseInt(intervalSelect.value, 10), }); } catch (err) { BB.ui.showToast('Failed to update interval', 'error'); } }; intervalGroup.appendChild(intervalLabel); intervalGroup.appendChild(intervalSelect); settings.appendChild(intervalGroup); div.appendChild(settings); // Disconnect const disconnectBtn = document.createElement('button'); disconnectBtn.className = 'btn sync-disconnect'; disconnectBtn.textContent = 'Disconnect'; disconnectBtn.onclick = async () => { // F6 fix (2026-06-02): was native confirm() — replaced per // charter "no-native-dialogs" rule. const ok = await BB.ui.showConfirmDialog( 'Disconnect from sync', 'Disconnect from cloud sync? Local data will be preserved.', { confirmLabel: 'Disconnect', danger: true }, ); if (!ok) return; try { await BB.api.sync.disconnect(); BB.ui.showToast('Disconnected', 'success'); const newStatus = await BB.api.sync.status(); renderState(container, newStatus); } catch (err) { BB.ui.showToast('Failed to disconnect: ' + (err.message || err), 'error'); } }; div.appendChild(disconnectBtn); container.appendChild(div); } /** * Check subscription status and populate the banner element. * @param {HTMLElement} banner - The banner container element. */ async function checkSubscriptionBanner(banner) { try { const sub = await BB.api.sync.subscriptionStatus(); if (sub.active) { const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd).toLocaleDateString() : ''; banner.innerHTML = '

Subscription active' + (periodEnd ? ' (renews ' + periodEnd + ')' : '') + '

'; } else { // Fetch dynamic pricing let pricingHtml = '

Loading pricing...

'; try { const tiers = await BB.api.sync.getTiers(); if (tiers.length > 0) { const t = tiers[0]; const monthly = '$' + (t.monthly_price_cents / 100); const annual = '$' + (t.annual_price_cents / 100); const monthlyCost = (t.monthly_price_cents / 100) * 12; const savings = monthlyCost - (t.annual_price_cents / 100); // F4 (2026-06-02): inline styles moved into // .sync-sub-fine-print and .sync-sub-actions rules. pricingHtml = '

' + annual + '/year' + (savings > 0 ? ' (saves $' + savings + ' vs monthly)' : '') + ' or ' + monthly + '/month.

' + '

' + 'Annual is cheaper because Stripe charges a fixed ~$0.30 + 2.9% fee per transaction. ' + 'On a monthly plan we pay that fee twelve times a year; annual pays it once. ' + 'We pass the difference back to you rather than pocket it.' + '

' + '
' + '' + '' + '
'; } } catch (_) { /* fall through */ } banner.innerHTML = '
' + '

Subscription required

' + '

Cloud sync keeps your feeds, bookmarks, and settings in sync across devices with end-to-end encryption.

' + pricingHtml + '
'; const annualBtn = banner.querySelector('#sync-sub-annual'); const monthlyBtn = banner.querySelector('#sync-sub-monthly'); if (annualBtn) annualBtn.onclick = () => doSubscribe('annual'); if (monthlyBtn) monthlyBtn.onclick = () => doSubscribe('monthly'); } } catch (_) { // Non-fatal } } async function doSubscribe(interval) { try { await BB.api.sync.subscribe(interval); BB.ui.showToast('Opening checkout in your browser. Complete payment, then return here.', 'success'); pollForSubscription(); } catch (err) { BB.ui.showToast('Subscribe failed: ' + (err.message || err), 'error'); } } function pollForSubscription() { let attempts = 0; const maxAttempts = 120; // 10 minutes at 5s intervals const timer = setInterval(async () => { attempts++; if (attempts >= maxAttempts) { clearInterval(timer); return; } try { const sub = await BB.api.sync.subscriptionStatus(); if (sub.active) { clearInterval(timer); BB.ui.showToast('Subscription activated! Sync is now enabled.', 'success'); const banner = document.getElementById('sync-subscription-banner'); if (banner) checkSubscriptionBanner(banner); } } catch (_) { // Ignore polling errors } }, 5000); } // Listen for sync events to refresh feed list if (window.__TAURI__?.event?.listen) { window.__TAURI__.event.listen('sync:changes-applied', () => { BB.ui.showToast('Sync: changes applied', 'success'); if (BB.sources && BB.sources.load) BB.sources.load(); if (BB.items && BB.items.load) BB.items.load(); }); } BB.sync = { openSettings }; })();