Skip to main content

max / goingson

20.5 KB · 503 lines History Blame Raw
1 /**
2 * GoingsOn - Cloud Sync Module
3 * Cloud sync authentication, encryption setup, sync operations, and settings
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9
10 // ============ Cloud Sync ============
11
12 /**
13 * Render the cloud sync settings section into a container element.
14 * Shows the appropriate state (not configured / not authenticated / encryption setup / dashboard).
15 * @param {HTMLElement} container - The settings content container
16 */
17 async function renderSyncSection(container) {
18 let status;
19 try {
20 status = await GoingsOn.api.sync.status();
21 } catch (err) {
22 container.innerHTML = `
23 <div class="settings-section">
24 <h3 class="settings-heading">Cloud Sync</h3>
25 <p class="text-danger">Failed to load sync status: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>
26 </div>
27 `;
28 return;
29 }
30
31 const renderFormField = GoingsOn.ui.renderFormField;
32 let sectionContent;
33
34 if (!status.configured) {
35 sectionContent = `
36 <div class="text-center sync-empty">
37 <p class="text-secondary">Cloud sync is not available in this build.</p>
38 </div>
39 `;
40 } else if (!status.authenticated) {
41 sectionContent = `
42 <div class="text-center sync-empty">
43 <p class="text-secondary sync-empty-message">Connect your Makenot.work account to sync data across devices.</p>
44 <button class="btn btn-primary" data-act="settings.startSyncAuth">Connect to Makenot.work</button>
45 </div>
46 `;
47 } else if (!status.encryptionReady) {
48 const isNewDevice = status.hasServerKey === false;
49 const heading = isNewDevice ? 'Set Up Encryption' : 'Enter Encryption Password';
50 const hint = isNewDevice
51 ? 'Choose a password to encrypt your synced data. You will need this password on other devices.'
52 : 'Enter the encryption password you set up on your first device.';
53
54 sectionContent = `
55 <p class="text-secondary sync-hint">${esc(hint)}</p>
56 ${renderFormField({ kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true })}
57 ${isNewDevice ? renderFormField({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true }) : ''}
58 <div id="sync-encryption-error" class="sync-encryption-error"></div>
59 <div class="sync-section-actions">
60 <button class="btn btn-primary" data-act="settings.submitEncryption" data-args='[${isNewDevice}]'>${esc(heading)}</button>
61 </div>
62 `;
63 } else {
64 const lastSync = status.lastSyncAt
65 ? new Date(status.lastSyncAt).toLocaleString()
66 : 'Never';
67 const intervalOptions = [1, 2, 5, 10, 15, 30, 60];
68
69 // Phase 7 Tier 2 #8 — surface the connected MNW account so the user can
70 // tell which account this device is paired with (matters when juggling
71 // personal vs. work accounts). Failure is non-fatal: sync still works.
72 let accountRow = '';
73 try {
74 const account = await GoingsOn.api.sync.accountInfo();
75 accountRow = `
76 <div class="sync-account-row">
77 <span class="sync-account-label">Signed in as</span>
78 <span class="sync-account-value">${esc(account.email)} <span class="sync-account-username">(${esc(account.username)})</span></span>
79 </div>
80 `;
81 } catch (_) {}
82
83 sectionContent = `
84 <div class="sync-status-row">
85 <span class="sync-status-dot"></span>
86 <span class="sync-status-label">Connected to ${esc(status.serverUrl || 'server')}</span>
87 </div>
88
89 ${accountRow}
90
91 <div class="sync-stats-grid">
92 <div class="sync-stat">
93 <div class="sync-stat-label">Last Sync</div>
94 <div class="sync-stat-value">${esc(lastSync)}</div>
95 </div>
96 <div class="sync-stat">
97 <div class="sync-stat-label">Pending Changes</div>
98 <div class="sync-stat-value">${status.pendingChanges}</div>
99 </div>
100 </div>
101
102 <div class="sync-section-actions">
103 <button class="btn btn-primary" data-act="settings.doSyncNow" id="sync-now-btn">Sync Now</button>
104 </div>
105
106 <div class="section-divider">
107 <div class="form-group">
108 <label class="form-checkbox-label">
109 <input type="checkbox" id="sync-auto-enabled" ${status.autoSyncEnabled ? 'checked' : ''}
110 data-change="settings.updateSyncSettings">
111 <span>Enable automatic sync</span>
112 </label>
113 </div>
114 ${renderFormField({
115 kind: 'select',
116 name: 'sync-interval',
117 id: 'sync-interval',
118 label: 'Sync Interval',
119 value: status.syncIntervalMinutes,
120 attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' },
121 options: intervalOptions.map(m => ({
122 value: String(m),
123 label: m === 1 ? '1 minute' : m + ' minutes',
124 selected: status.syncIntervalMinutes === m,
125 })),
126 })}
127 </div>
128
129 <div class="section-divider">
130 <button class="btn btn-danger" data-act="settings.disconnectSync">Disconnect</button>
131 </div>
132 `;
133 }
134
135 container.innerHTML = `
136 <div class="settings-section">
137 <h3 class="settings-heading">Cloud Sync</h3>
138 <div id="sync-subscription-banner"></div>
139 ${sectionContent}
140 </div>
141 `;
142
143 // After render: check subscription status and show banner if needed
144 if (status.encryptionReady) {
145 checkSubscriptionBanner();
146 }
147 }
148
149 /**
150 * Check subscription status and show/hide the subscription banner.
151 */
152 async function checkSubscriptionBanner() {
153 const banner = document.getElementById('sync-subscription-banner');
154 if (!banner) return;
155
156 try {
157 const sub = await GoingsOn.api.sync.subscriptionStatus();
158 if (sub.active) {
159 const periodEnd = sub.currentPeriodEnd
160 ? new Date(sub.currentPeriodEnd).toLocaleDateString()
161 : '';
162 banner.innerHTML = `
163 <div class="sync-banner">
164 Sync subscription active${periodEnd ? ' (renews ' + esc(periodEnd) + ')' : ''}
165 </div>
166 `;
167 } else {
168 let tiersHtml = '<p class="sync-banner-tier-line">Loading pricing...</p>';
169 try {
170 // GoingsOn syncs metadata only — no blob storage — so the
171 // cap defaults to the formula's minimum (10 GiB), which
172 // pins the price at the floor (`min_charge_cents`).
173 const pricing = await GoingsOn.api.sync.getTiers();
174 if (pricing && pricing.min_charge_cents != null) {
175 const monthly = '$' + (pricing.min_charge_cents / 100);
176 const annual = '$' + ((pricing.min_charge_cents * pricing.annual_multiplier) / 100);
177 const monthlyCost = (pricing.min_charge_cents / 100) * 12;
178 const savings = monthlyCost - ((pricing.min_charge_cents * pricing.annual_multiplier) / 100);
179 tiersHtml = `
180 <p class="sync-banner-tier-line">
181 <strong>${esc(annual)}/year</strong>${savings > 0 ? ' (saves $' + savings + ' vs monthly)' : ''} or ${esc(monthly)}/month.
182 Annual saves you money because Stripe charges a fixed fee per transaction.
183 </p>
184 <div class="sync-banner-actions">
185 <button class="btn btn-primary" data-act="settings.subscribeSyncAnnual">Subscribe (${esc(annual)}/year)</button>
186 <button class="btn btn-secondary" data-act="settings.subscribeSyncMonthly">${esc(monthly)}/month</button>
187 </div>
188 `;
189 }
190 } catch (_) { /* fall through with loading text */ }
191 banner.innerHTML = `
192 <div class="sync-banner sync-banner--warning">
193 <p class="sync-banner-title">Subscription required</p>
194 <p class="sync-banner-body">
195 Cloud sync keeps your tasks, events, contacts, and email settings in sync across devices with end-to-end encryption.
196 </p>
197 ${tiersHtml}
198 </div>
199 `;
200 }
201 } catch (err) {
202 // Non-fatal — just hide the banner
203 banner.innerHTML = '';
204 }
205 }
206
207 /**
208 * Re-render the sync section into the current settings content container.
209 */
210 async function refreshSyncSection() {
211 const container = document.getElementById('settings-content');
212 if (container && GoingsOn.state.currentView === 'settings') {
213 await renderSyncSection(container);
214 }
215 }
216
217 async function startSyncAuth() {
218 try {
219 GoingsOn.ui.showToast('Starting authentication...', 'info');
220 const authData = await GoingsOn.api.sync.startAuth();
221
222 // Open the auth URL via the validated Rust opener (the JS shell.open
223 // permission is intentionally not granted). Fall back to the webview
224 // on mobile / if the command is unavailable.
225 try {
226 await GoingsOn.api.window.openExternal(authData.authUrl);
227 } catch (e) {
228 window.open(authData.authUrl, '_blank');
229 }
230
231 // Poll for callback result
232 pollSyncAuthResult(authData.port, authData.state);
233 } catch (err) {
234 GoingsOn.ui.showToast('Failed to start auth: ' + GoingsOn.utils.getErrorMessage(err), 'error');
235 }
236 }
237
238 function pollSyncAuthResult(port, expectedState) {
239 let attempts = 0;
240 const maxAttempts = 300; // 5 minutes at 1s intervals
241
242 const pollInterval = setInterval(async () => {
243 attempts++;
244 if (attempts > maxAttempts) {
245 clearInterval(pollInterval);
246 GoingsOn.ui.showToast('Authentication timed out', 'error');
247 return;
248 }
249
250 try {
251 // Poll the local callback server over IPC (no HTTP endpoint).
252 const data = await GoingsOn.api.oauth.pollResult(port);
253
254 // Still waiting for the browser redirect
255 if (data.status === 'pending') return;
256
257 clearInterval(pollInterval);
258
259 if (data.status === 'success' && data.code) {
260 // Complete auth
261 try {
262 await GoingsOn.api.sync.completeAuth({
263 code: data.code,
264 state: data.state,
265 });
266 GoingsOn.ui.showToast('Connected to Makenot.work!');
267 refreshSyncIndicator();
268 // Force re-render: open settings overlay and show sync section.
269 await GoingsOn.settings.open();
270 await GoingsOn.settings.showSection('sync');
271 } catch (err) {
272 GoingsOn.ui.showToast('Auth failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
273 }
274 } else if (data.status === 'error') {
275 GoingsOn.ui.showToast('Auth error: ' + (data.error || 'unknown'), 'error');
276 }
277 } catch (_) {
278 // Command briefly unavailable, keep polling
279 }
280 }, 1000);
281 }
282
283 /**
284 * Submit the encryption password form (new setup or existing unlock).
285 * @param {boolean} isNew - true if setting up encryption for the first time
286 */
287 async function submitEncryption(isNew) {
288 const password = document.getElementById('sync-encryption-password')?.value;
289 const confirm = document.getElementById('sync-encryption-confirm')?.value;
290 const errorDiv = document.getElementById('sync-encryption-error');
291
292 if (!password) {
293 if (errorDiv) {
294 errorDiv.textContent = 'Password is required';
295 errorDiv.classList.add('visible');
296 }
297 return;
298 }
299
300 if (isNew && password !== confirm) {
301 if (errorDiv) {
302 errorDiv.textContent = 'Passwords do not match';
303 errorDiv.classList.add('visible');
304 }
305 return;
306 }
307
308 try {
309 if (isNew) {
310 await GoingsOn.api.sync.setupEncryptionNew(password);
311 } else {
312 await GoingsOn.api.sync.setupEncryptionExisting(password);
313 }
314 GoingsOn.ui.showToast('Encryption configured!');
315 refreshSyncIndicator();
316 refreshSyncSection();
317 } catch (err) {
318 if (errorDiv) {
319 errorDiv.textContent = GoingsOn.utils.getErrorMessage(err);
320 errorDiv.classList.add('visible');
321 }
322 }
323 }
324
325 async function doSyncNow() {
326 const btn = document.getElementById('sync-now-btn');
327 if (btn) {
328 btn.disabled = true;
329 btn.textContent = 'Syncing...';
330 }
331
332 try {
333 const result = await GoingsOn.api.sync.syncNow();
334 GoingsOn.ui.showToast(`Synced: ${result.pushed} pushed, ${result.pulled} pulled`);
335 refreshSyncSection();
336 } catch (err) {
337 GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
338 action: { label: 'Retry', fn: doSyncNow },
339 duration: 8000,
340 });
341 if (btn) {
342 btn.disabled = false;
343 btn.textContent = 'Sync Now';
344 }
345 }
346 }
347
348 async function updateSyncSettings() {
349 const enabled = document.getElementById('sync-auto-enabled')?.checked;
350 const interval = parseInt(document.getElementById('sync-interval')?.value, 10);
351
352 try {
353 await GoingsOn.api.sync.updateSettings({
354 autoSyncEnabled: enabled,
355 syncIntervalMinutes: interval,
356 });
357 } catch (err) {
358 GoingsOn.ui.showToast('Failed to save sync settings: ' + GoingsOn.utils.getErrorMessage(err), 'error');
359 }
360 }
361
362 async function disconnectSync() {
363 const confirmed = await GoingsOn.ui.confirmDelete(
364 'Disconnect Sync',
365 'This will disconnect from the sync service. Your local data will not be deleted. You can reconnect later.'
366 );
367 if (!confirmed) return;
368
369 try {
370 await GoingsOn.api.sync.disconnect();
371 GoingsOn.ui.showToast('Disconnected from sync');
372 refreshSyncIndicator();
373 refreshSyncSection();
374 } catch (err) {
375 GoingsOn.ui.showToast('Failed to disconnect: ' + GoingsOn.utils.getErrorMessage(err), 'error');
376 }
377 }
378
379 // ============ Sync Status Indicator ============
380
381 /**
382 * Format an ISO timestamp as a short relative-time string.
383 * "just now" / "Nm ago" / "Nh ago" / "Mon Jan 14".
384 */
385 function _formatSyncAgo(iso) {
386 if (!iso) return null;
387 const t = new Date(iso).getTime();
388 if (!t) return null;
389 const deltaSec = Math.max(0, Math.floor((Date.now() - t) / 1000));
390 if (deltaSec < 45) return 'just now';
391 if (deltaSec < 60 * 60) return `${Math.floor(deltaSec / 60)}m ago`;
392 if (deltaSec < 60 * 60 * 24) return `${Math.floor(deltaSec / 3600)}h ago`;
393 return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
394 }
395
396 /**
397 * Update the sync status indicator (dot + label + title) in the header bar.
398 * Charter Phase 7 Tier 2 #6: every state must pair color with a non-color signal.
399 */
400 async function refreshSyncIndicator() {
401 const indicator = document.getElementById('sync-indicator');
402 const dot = document.getElementById('sync-dot');
403 const label = document.getElementById('sync-label');
404 if (!indicator || !dot) return;
405
406 try {
407 const status = await GoingsOn.api.sync.status();
408 if (!status.configured) {
409 indicator.classList.add('hidden');
410 return;
411 }
412 indicator.classList.remove('hidden');
413 dot.className = 'sync-dot';
414
415 let labelText;
416 let titleText;
417 if (!status.authenticated) {
418 dot.classList.add('warn');
419 labelText = 'Connect sync';
420 titleText = 'Cloud Sync — sign in to start syncing';
421 } else if (!status.encryptionReady) {
422 dot.classList.add('warn');
423 labelText = 'Set up encryption';
424 titleText = 'Cloud Sync — encryption setup needed';
425 } else {
426 dot.classList.add('connected');
427 const ago = _formatSyncAgo(status.lastSyncAt);
428 labelText = ago ? `Synced ${ago}` : 'Sync ready';
429 titleText = ago
430 ? `Cloud Sync — last sync ${ago}${status.pendingChanges ? ` · ${status.pendingChanges} pending` : ''}`
431 : 'Cloud Sync — ready';
432 }
433 if (label) label.textContent = labelText;
434 indicator.setAttribute('title', titleText);
435 indicator.setAttribute('aria-label', titleText);
436 } catch (_) {
437 indicator.classList.add('hidden');
438 }
439 }
440
441 /**
442 * Subscribe to cloud sync with the given interval.
443 * Opens the Stripe checkout page in the user's browser.
444 */
445 async function subscribeSync(interval) {
446 try {
447 await GoingsOn.api.sync.subscribe(interval);
448 GoingsOn.ui.showToast('Opening checkout in your browser. Complete payment, then return here.');
449 pollForSubscription();
450 } catch (err) {
451 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err), 'error');
452 }
453 }
454
455 /**
456 * Poll subscription status after checkout opens.
457 * Checks every 5s for up to 10 minutes.
458 */
459 function pollForSubscription() {
460 let attempts = 0;
461 const maxAttempts = 120; // 10 minutes at 5s intervals
462
463 const timer = setInterval(async () => {
464 attempts++;
465 if (attempts >= maxAttempts) {
466 clearInterval(timer);
467 return;
468 }
469
470 try {
471 const sub = await GoingsOn.api.sync.subscriptionStatus();
472 if (sub.active) {
473 clearInterval(timer);
474 GoingsOn.ui.showToast('Subscription activated! Sync is now enabled.', 'success');
475 refreshSyncSection();
476 }
477 } catch (_) {
478 // Ignore polling errors
479 }
480 }, 5000);
481 }
482
483 // ============ Populate GoingsOn Namespace ============
484
485 Object.assign(GoingsOn.settings, {
486 openCloudSync: function() {
487 GoingsOn.settings.open();
488 // Small delay to ensure settings view is active before switching section
489 setTimeout(() => GoingsOn.settings.showSection('sync'), 50);
490 },
491 renderSyncSection,
492 startSyncAuth,
493 submitEncryption,
494 doSyncNow,
495 updateSyncSettings,
496 disconnectSync,
497 refreshSyncIndicator,
498 subscribeSyncAnnual: () => subscribeSync('annual'),
499 subscribeSyncMonthly: () => subscribeSync('monthly'),
500 });
501
502 })();
503