Skip to main content

max / balanced_breakfast

20.8 KB · 528 lines History Blame Raw
1 /**
2 * @fileoverview Sync settings UI for MNW SyncKit cloud sync.
3 *
4 * 4-state flow:
5 * 1. Not configured / not authenticated — "Connect" button
6 * 2. Authenticating — spinner while waiting for OAuth callback
7 * 3. No encryption — password form to set up E2E encryption
8 * 4. Ready — status display, sync now, auto-sync toggle, disconnect
9 */
10 (function() {
11 'use strict';
12
13 /**
14 * Open the cloud sync settings modal. Loads current status and renders the
15 * appropriate UI state (connect, encryption setup, or ready).
16 * @returns {Promise<void>}
17 */
18 async function openSettings() {
19 const title = document.getElementById('modal-title');
20 const body = document.getElementById('modal-body');
21 title.textContent = 'Cloud Sync';
22 body.innerHTML = '<p>Loading...</p>';
23 BB.ui.openModal();
24
25 try {
26 const status = await BB.api.sync.status();
27 renderState(body, status);
28 } catch (err) {
29 body.innerHTML = '<p>Failed to load sync status.</p>';
30 BB.ui.showToast('Sync status error: ' + (err.message || err), 'error');
31 }
32 }
33
34 /**
35 * Render the sync UI for the current status.
36 * @param {HTMLElement} container - Modal body element.
37 * @param {Object} status - Sync status from the backend.
38 */
39 function renderState(container, status) {
40 container.innerHTML = '';
41
42 if (!status.configured || !status.authenticated) {
43 renderConnect(container);
44 } else if (!status.encryptionReady) {
45 renderEncryption(container, status);
46 } else {
47 renderReady(container, status);
48 }
49 }
50
51 // ── State 1: Connect ──
52
53 /**
54 * Render the connect button for OAuth authentication.
55 * @param {HTMLElement} container - Modal body element.
56 */
57 function renderConnect(container) {
58 // F4 (2026-06-02): inline styles moved into .sync-connect rules.
59 const div = document.createElement('div');
60 div.className = 'sync-connect';
61 div.innerHTML =
62 '<p>Sync your feeds and preferences across devices via Makenot.work.</p>' +
63 '<p>All data is encrypted on your device before it leaves.</p>';
64 const btn = document.createElement('button');
65 btn.className = 'btn btn-primary';
66 btn.textContent = 'Connect to Makenot.work';
67 btn.onclick = () => startAuth();
68 div.appendChild(btn);
69 container.appendChild(div);
70 }
71
72 /**
73 * Start the OAuth2 PKCE auth flow: open browser and show code entry UI.
74 * @returns {Promise<void>}
75 */
76 async function startAuth() {
77 const body = document.getElementById('modal-body');
78
79 try {
80 const result = await BB.api.sync.startAuth();
81
82 // Open browser for authentication
83 if (window.__TAURI__?.shell?.open) {
84 window.__TAURI__.shell.open(result.authUrl);
85 } else {
86 window.open(result.authUrl, '_blank');
87 }
88
89 // Show code entry / check status UI
90 showCodeEntry(result.state, result.codeVerifier, result.port);
91 } catch (err) {
92 BB.ui.showToast('Failed to start auth: ' + (err.message || err), 'error');
93 }
94 }
95
96 /**
97 * Show the authenticating UI and poll the callback server for the result.
98 * @param {string} expectedState - PKCE state parameter.
99 * @param {string} codeVerifier - PKCE code verifier.
100 * @param {number} port - Local callback server port.
101 */
102 function showCodeEntry(expectedState, codeVerifier, port) {
103 const body = document.getElementById('modal-body');
104 body.innerHTML = '';
105
106 const div = document.createElement('div');
107 div.innerHTML =
108 '<p>Waiting for authentication in your browser...</p>';
109
110 // F4 (2026-06-02): inline styles moved into .sync-auth-spinner rules.
111 const spinner = document.createElement('div');
112 spinner.className = 'sync-auth-spinner';
113 spinner.textContent = 'Polling for callback...';
114 div.appendChild(spinner);
115
116 // Manual code entry as fallback
117 const details = document.createElement('details');
118 details.className = 'sync-manual-entry';
119 details.innerHTML = '<summary>Enter code manually</summary>';
120
121 const form = document.createElement('form');
122 form.className = 'modal-form sync-manual-form';
123
124 const group = document.createElement('div');
125 group.className = 'form-group';
126 const label = document.createElement('label');
127 label.textContent = 'Authorization Code';
128 const input = document.createElement('input');
129 input.className = 'form-input';
130 input.type = 'text';
131 input.name = 'code';
132 input.required = true;
133 group.appendChild(label);
134 group.appendChild(input);
135 form.appendChild(group);
136
137 const submitBtn = document.createElement('button');
138 submitBtn.type = 'submit';
139 submitBtn.className = 'btn btn-primary';
140 submitBtn.textContent = 'Complete Auth';
141 form.appendChild(submitBtn);
142
143 form.onsubmit = async (e) => {
144 e.preventDefault();
145 const code = input.value.trim();
146 if (!code) return;
147 await completeAuth(code, expectedState, expectedState, codeVerifier, port);
148 };
149
150 details.appendChild(form);
151 div.appendChild(details);
152 body.appendChild(div);
153
154 // Auto-poll the callback server for the OAuth result
155 pollCallbackResult(port, expectedState, codeVerifier);
156 }
157
158 /**
159 * Poll the local callback server's /result endpoint until auth completes.
160 * @param {number} port - Callback server port.
161 * @param {string} expectedState - PKCE state parameter.
162 * @param {string} codeVerifier - PKCE code verifier.
163 */
164 function pollCallbackResult(port, expectedState, codeVerifier) {
165 let attempts = 0;
166 const maxAttempts = 300; // 5 minutes at 1s intervals
167
168 const pollInterval = setInterval(async () => {
169 attempts++;
170 if (attempts > maxAttempts) {
171 clearInterval(pollInterval);
172 BB.ui.showToast('Authentication timed out', 'error');
173 return;
174 }
175
176 try {
177 const resp = await fetch(`http://127.0.0.1:${port}/result`);
178 if (resp.status === 200) {
179 const data = await resp.json();
180
181 if (data.status === 'pending') return;
182
183 clearInterval(pollInterval);
184
185 if (data.status === 'success' && data.code) {
186 await completeAuth(data.code, data.state, expectedState, codeVerifier, port);
187 } else if (data.status === 'error') {
188 BB.ui.showToast('Auth error: ' + (data.error || 'Unknown error'), 'error');
189 }
190 }
191 } catch (_) {
192 // Server not ready yet, keep polling
193 }
194 }, 1000);
195 }
196
197 /**
198 * Complete OAuth auth with an authorization code.
199 * @param {string} code - Authorization code from the OAuth provider.
200 * @param {string} state - State parameter from the callback.
201 * @param {string} expectedState - Expected PKCE state parameter.
202 * @param {string} codeVerifier - PKCE code verifier.
203 * @param {number} port - Local callback server port.
204 * @returns {Promise<void>}
205 */
206 async function completeAuth(code, state, expectedState, codeVerifier, port) {
207 const body = document.getElementById('modal-body');
208 try {
209 await BB.api.sync.completeAuth({
210 code,
211 state,
212 expectedState,
213 codeVerifier,
214 port,
215 });
216 BB.ui.showToast('Connected!', 'success');
217 const status = await BB.api.sync.status();
218 renderState(body, status);
219 } catch (err) {
220 BB.ui.showToast('Auth failed: ' + (err.message || err), 'error');
221 }
222 }
223
224 // ── State 3: Encryption setup ──
225
226 /**
227 * Render the encryption password setup form.
228 * @param {HTMLElement} container - Modal body element.
229 * @param {Object} status - Sync status with `hasServerKey` flag.
230 */
231 function renderEncryption(container, status) {
232 container.innerHTML = '';
233 const div = document.createElement('div');
234
235 const hasKey = status.hasServerKey;
236
237 if (hasKey) {
238 div.innerHTML =
239 '<p><strong>Unlock this device.</strong> Enter the encryption password you set on your first device to decrypt your synced data.</p>';
240 } else {
241 div.innerHTML =
242 '<p><strong>First device setup.</strong> Choose an encryption password to protect your data with end-to-end encryption. ' +
243 'You\'ll need this same password when adding Balanced Breakfast on other devices.</p>';
244 }
245
246 const form = document.createElement('form');
247 form.className = 'modal-form';
248
249 const group = document.createElement('div');
250 group.className = 'form-group';
251 const label = document.createElement('label');
252 label.textContent = 'Encryption Password';
253 const input = document.createElement('input');
254 input.className = 'form-input';
255 input.type = 'password';
256 input.name = 'password';
257 input.required = true;
258 input.minLength = 8;
259 group.appendChild(label);
260 group.appendChild(input);
261 form.appendChild(group);
262
263 const actions = document.createElement('div');
264 actions.className = 'form-actions';
265 const submitBtn = document.createElement('button');
266 submitBtn.type = 'submit';
267 submitBtn.className = 'btn btn-primary';
268 submitBtn.textContent = hasKey ? 'Unlock' : 'Set Password';
269 actions.appendChild(submitBtn);
270 form.appendChild(actions);
271
272 form.onsubmit = async (e) => {
273 e.preventDefault();
274 submitBtn.disabled = true;
275 submitBtn.textContent = 'Setting up...';
276 try {
277 if (hasKey) {
278 await BB.api.sync.setupEncryptionExisting(input.value);
279 } else {
280 await BB.api.sync.setupEncryptionNew(input.value);
281 }
282 BB.ui.showToast('Encryption ready!', 'success');
283 const newStatus = await BB.api.sync.status();
284 renderState(container, newStatus);
285 } catch (err) {
286 submitBtn.disabled = false;
287 submitBtn.textContent = hasKey ? 'Unlock' : 'Set Password';
288 BB.ui.showToast('Encryption setup failed: ' + (err.message || err), 'error');
289 }
290 };
291
292 div.appendChild(form);
293 container.appendChild(div);
294 }
295
296 // ── State 4: Ready ──
297
298 /**
299 * Render the "ready" state: status info, sync now, auto-sync toggle, disconnect.
300 * @param {HTMLElement} container - Modal body element.
301 * @param {Object} status - Sync status with lastSyncAt, pendingChanges, etc.
302 */
303 function renderReady(container, status) {
304 container.innerHTML = '';
305 const div = document.createElement('div');
306 div.className = 'sync-ready';
307
308 // Subscription banner (populated async)
309 const subBanner = document.createElement('div');
310 subBanner.id = 'sync-subscription-banner';
311 div.appendChild(subBanner);
312 checkSubscriptionBanner(subBanner);
313
314 // Status info
315 const info = document.createElement('div');
316 info.className = 'sync-info';
317 const lastSync = status.lastSyncAt
318 ? new Date(status.lastSyncAt).toLocaleString()
319 : 'Never';
320 info.innerHTML =
321 '<p><strong>Last sync:</strong> ' + lastSync + '</p>' +
322 '<p><strong>Pending changes:</strong> ' + status.pendingChanges + '</p>';
323 div.appendChild(info);
324
325 // Sync now button
326 const syncBtn = document.createElement('button');
327 syncBtn.className = 'btn btn-primary sync-action-spaced';
328 syncBtn.textContent = 'Sync Now';
329 syncBtn.onclick = async () => {
330 syncBtn.disabled = true;
331 syncBtn.textContent = 'Syncing...';
332 try {
333 const result = await BB.api.sync.now();
334 BB.ui.showToast(
335 'Synced! Pushed: ' + result.pushed + ', Pulled: ' + result.pulled,
336 'success'
337 );
338 const newStatus = await BB.api.sync.status();
339 renderReady(container, newStatus);
340 } catch (err) {
341 syncBtn.disabled = false;
342 syncBtn.textContent = 'Sync Now';
343 BB.ui.showToast('Sync failed: ' + (err.message || err), 'error');
344 }
345 };
346 div.appendChild(syncBtn);
347
348 // Auto-sync settings
349 const settings = document.createElement('div');
350 settings.className = 'sync-settings';
351
352 // Toggle
353 const toggleGroup = document.createElement('div');
354 toggleGroup.className = 'form-group';
355 const toggleLabel = document.createElement('label');
356 toggleLabel.className = 'sync-toggle-label';
357 const toggle = document.createElement('input');
358 toggle.type = 'checkbox';
359 toggle.checked = status.autoSyncEnabled;
360 toggle.onchange = async () => {
361 try {
362 await BB.api.sync.updateSettings({ autoSyncEnabled: toggle.checked });
363 } catch (err) {
364 toggle.checked = !toggle.checked;
365 BB.ui.showToast('Failed to update setting', 'error');
366 }
367 };
368 toggleLabel.appendChild(toggle);
369 toggleLabel.appendChild(document.createTextNode('Auto-sync'));
370 toggleGroup.appendChild(toggleLabel);
371 settings.appendChild(toggleGroup);
372
373 // Interval selector
374 const intervalGroup = document.createElement('div');
375 intervalGroup.className = 'form-group';
376 const intervalLabel = document.createElement('label');
377 intervalLabel.textContent = 'Sync interval';
378 const intervalSelect = document.createElement('select');
379 intervalSelect.className = 'form-input';
380 [5, 15, 30, 60].forEach(min => {
381 const opt = document.createElement('option');
382 opt.value = min;
383 opt.textContent = min + ' minutes';
384 if (status.syncIntervalMinutes === min) opt.selected = true;
385 intervalSelect.appendChild(opt);
386 });
387 intervalSelect.onchange = async () => {
388 try {
389 await BB.api.sync.updateSettings({
390 syncIntervalMinutes: parseInt(intervalSelect.value, 10),
391 });
392 } catch (err) {
393 BB.ui.showToast('Failed to update interval', 'error');
394 }
395 };
396 intervalGroup.appendChild(intervalLabel);
397 intervalGroup.appendChild(intervalSelect);
398 settings.appendChild(intervalGroup);
399
400 div.appendChild(settings);
401
402 // Disconnect
403 const disconnectBtn = document.createElement('button');
404 disconnectBtn.className = 'btn sync-disconnect';
405 disconnectBtn.textContent = 'Disconnect';
406 disconnectBtn.onclick = async () => {
407 // F6 fix (2026-06-02): was native confirm() — replaced per
408 // charter "no-native-dialogs" rule.
409 const ok = await BB.ui.showConfirmDialog(
410 'Disconnect from sync',
411 'Disconnect from cloud sync? Local data will be preserved.',
412 { confirmLabel: 'Disconnect', danger: true },
413 );
414 if (!ok) return;
415 try {
416 await BB.api.sync.disconnect();
417 BB.ui.showToast('Disconnected', 'success');
418 const newStatus = await BB.api.sync.status();
419 renderState(container, newStatus);
420 } catch (err) {
421 BB.ui.showToast('Failed to disconnect: ' + (err.message || err), 'error');
422 }
423 };
424 div.appendChild(disconnectBtn);
425
426 container.appendChild(div);
427 }
428
429 /**
430 * Check subscription status and populate the banner element.
431 * @param {HTMLElement} banner - The banner container element.
432 */
433 async function checkSubscriptionBanner(banner) {
434 try {
435 const sub = await BB.api.sync.subscriptionStatus();
436 if (sub.active) {
437 const periodEnd = sub.currentPeriodEnd
438 ? new Date(sub.currentPeriodEnd).toLocaleDateString()
439 : '';
440 banner.innerHTML =
441 '<p class="sync-sub-active">Subscription active' +
442 (periodEnd ? ' (renews ' + periodEnd + ')' : '') + '</p>';
443 } else {
444 // Fetch dynamic pricing
445 let pricingHtml = '<p>Loading pricing...</p>';
446 try {
447 const tiers = await BB.api.sync.getTiers();
448 if (tiers.length > 0) {
449 const t = tiers[0];
450 const monthly = '$' + (t.monthly_price_cents / 100);
451 const annual = '$' + (t.annual_price_cents / 100);
452 const monthlyCost = (t.monthly_price_cents / 100) * 12;
453 const savings = monthlyCost - (t.annual_price_cents / 100);
454 // F4 (2026-06-02): inline styles moved into
455 // .sync-sub-fine-print and .sync-sub-actions rules.
456 pricingHtml =
457 '<p><strong>' + annual + '/year</strong>' + (savings > 0 ? ' (saves $' + savings + ' vs monthly)' : '') + ' or ' + monthly + '/month.</p>' +
458 '<p class="sync-sub-fine-print">' +
459 'Annual is cheaper because Stripe charges a fixed ~$0.30 + 2.9% fee per transaction. ' +
460 'On a monthly plan we pay that fee twelve times a year; annual pays it once. ' +
461 'We pass the difference back to you rather than pocket it.' +
462 '</p>' +
463 '<div class="sync-sub-actions">' +
464 '<button class="btn btn-primary" id="sync-sub-annual">Subscribe (' + annual + '/year)</button>' +
465 '<button class="btn" id="sync-sub-monthly">' + monthly + '/month</button>' +
466 '</div>';
467 }
468 } catch (_) { /* fall through */ }
469 banner.innerHTML =
470 '<div class="sync-sub-required">' +
471 '<p><strong>Subscription required</strong></p>' +
472 '<p>Cloud sync keeps your feeds, bookmarks, and settings in sync across devices with end-to-end encryption.</p>' +
473 pricingHtml + '</div>';
474 const annualBtn = banner.querySelector('#sync-sub-annual');
475 const monthlyBtn = banner.querySelector('#sync-sub-monthly');
476 if (annualBtn) annualBtn.onclick = () => doSubscribe('annual');
477 if (monthlyBtn) monthlyBtn.onclick = () => doSubscribe('monthly');
478 }
479 } catch (_) {
480 // Non-fatal
481 }
482 }
483
484 async function doSubscribe(interval) {
485 try {
486 await BB.api.sync.subscribe(interval);
487 BB.ui.showToast('Opening checkout in your browser. Complete payment, then return here.', 'success');
488 pollForSubscription();
489 } catch (err) {
490 BB.ui.showToast('Subscribe failed: ' + (err.message || err), 'error');
491 }
492 }
493
494 function pollForSubscription() {
495 let attempts = 0;
496 const maxAttempts = 120; // 10 minutes at 5s intervals
497 const timer = setInterval(async () => {
498 attempts++;
499 if (attempts >= maxAttempts) {
500 clearInterval(timer);
501 return;
502 }
503 try {
504 const sub = await BB.api.sync.subscriptionStatus();
505 if (sub.active) {
506 clearInterval(timer);
507 BB.ui.showToast('Subscription activated! Sync is now enabled.', 'success');
508 const banner = document.getElementById('sync-subscription-banner');
509 if (banner) checkSubscriptionBanner(banner);
510 }
511 } catch (_) {
512 // Ignore polling errors
513 }
514 }, 5000);
515 }
516
517 // Listen for sync events to refresh feed list
518 if (window.__TAURI__?.event?.listen) {
519 window.__TAURI__.event.listen('sync:changes-applied', () => {
520 BB.ui.showToast('Sync: changes applied', 'success');
521 if (BB.sources && BB.sources.load) BB.sources.load();
522 if (BB.items && BB.items.load) BB.items.load();
523 });
524 }
525
526 BB.sync = { openSettings };
527 })();
528