sync: encrypted cloud backup for portfolios + settings UX rework

Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-23 16:15:54 +02:00
parent 89632e9937
commit f326b41a08
23 changed files with 1637 additions and 95 deletions

View file

@ -137,13 +137,11 @@
<body>
<div class="app">
<header class="app-header">
<div class="brand">{{ BRAND_NAME }}</div>
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
</nav>
<div class="header-right">
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
@ -158,15 +156,45 @@
<span class="theme-toggle__label"></span>
</button>
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
{% if cu and cu.user %}
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
{% elif cu and cu.is_admin %}
<span class="user-chip">admin · <a href="/logout">logout</a></span>
{% if cu and (cu.user or cu.is_admin) %}
<div class="user-menu">
<button type="button" id="user-menu-toggle" class="user-chip"
aria-haspopup="true" aria-expanded="false">
{% if cu.user %}{{ cu.user.email }}{% else %}admin{% endif %}
<span class="user-menu__caret"></span>
</button>
<div id="user-menu" class="user-menu__panel" role="menu" hidden>
{% if cu.user %}
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
{% endif %}
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a>
</div>
</div>
{% endif %}
<span class="meta">v0.1 · UTC</span>
</div>
</header>
<script>
(function () {
var btn = document.getElementById('user-menu-toggle');
var menu = document.getElementById('user-menu');
if (!btn || !menu) return;
function close() { menu.hidden = true; btn.setAttribute('aria-expanded','false'); }
function open() { menu.hidden = false; btn.setAttribute('aria-expanded','true'); }
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) open(); else close();
});
document.addEventListener('click', function (e) {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
})();
</script>
<main class="app-main">
{% block main %}{% endblock %}
</main>

View file

@ -55,6 +55,7 @@
</div>
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<section id="log-panel" class="panel">

View file

@ -39,6 +39,28 @@
</div>
</div>
{# --- Import portfolio --------------------------------------------- #}
<div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
<p class="settings-section__lede">
Export your pie from T212
(<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>)
and drop the CSV here. We&rsquo;ll parse it and show a preview before
importing anywhere.
</p>
<div id="drop-zone" class="dz">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop a T212 pie CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> &middot; max 1 MB</div>
<div class="dz__filename" id="dz-filename"></div>
</div>
<div id="import-preview" hidden style="margin-top:14px;"></div>
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
</div>
{# --- Referral block ---------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Invite a friend</div>
@ -74,6 +96,31 @@
</div>
</div>
{# --- Cloud sync block --------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div>
<p class="settings-section__lede">
Manage the encrypted server-side copy of your portfolio. Sync is
opted-in per import (see the Import section above).
</p>
{% if paid and paid.active %}
<div id="sync-status" class="settings-row">
<div class="settings-row__label">Status</div>
<div class="settings-row__value">
<span class="settings-row__hint">checking&hellip;</span>
</div>
</div>
<div id="sync-actions" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;"></div>
<div id="sync-feedback" class="settings-row__hint" style="margin-top:10px;"></div>
{% else %}
<p class="settings-row__hint">
Available on the paid tier. Upgrade or apply an invite credit
above to enable cloud sync.
</p>
{% endif %}
</div>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
{% endif %}
@ -81,6 +128,187 @@
</div>
</section>
{% if user and paid and paid.active %}
<div id="sync-modal" class="modal"
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
display:none;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--panel-bg,#fff);color:var(--text,#000);
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
Enable cloud sync
</div>
<div id="sync-modal-body" style="font-size:13px;line-height:1.55;
color:var(--muted,#666);margin-bottom:14px;">
Choose a PIN (4&ndash;12 characters). The same PIN unlocks the
portfolio on any device. There is no recovery if you forget it.
</div>
<form id="sync-modal-form" autocomplete="off">
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
<input id="sync-pin1" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
<input id="sync-pin2" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
<label style="display:flex;align-items:flex-start;gap:8px;
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
<input id="sync-ack" type="checkbox" required>
I understand that losing this PIN means I'll have to re-import my CSV.
</label>
<div id="sync-modal-err" class="pf-warn" hidden style="margin-bottom:10px;"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" id="sync-modal-cancel" class="pf-secondary">Cancel</button>
<button type="submit" id="sync-modal-submit">Enable</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script>
(function () {
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
document.addEventListener('DOMContentLoaded', function () {
if (!window.CassandraSync) return;
const statusEl = $('sync-status');
const actionsEl = $('sync-actions');
const feedbackEl = $('sync-feedback');
const modal = $('sync-modal');
const pin1 = $('sync-pin1');
const pin2 = $('sync-pin2');
const ack = $('sync-ack');
const errEl = $('sync-modal-err');
function setFeedback(msg, ok) {
feedbackEl.style.color = ok ? 'var(--ok,#2a9d57)' : '';
feedbackEl.textContent = msg || '';
}
// External callers (the Import section above) can pass a callback
// that fires after a successful enable-and-push.
let pendingOnSuccess = null;
function openModal(opts) {
pendingOnSuccess = (opts && opts.onSuccess) || null;
modal.style.display = 'flex';
// Focus PIN field after the layout flush so the caret lands.
setTimeout(() => pin1.focus(), 0);
}
function closeModal() {
modal.style.display = 'none';
pin1.value = ''; pin2.value = '';
ack.checked = false; errEl.hidden = true;
pendingOnSuccess = null;
}
$('sync-modal-cancel').addEventListener('click', closeModal);
// Backdrop click + Esc key dismiss the modal.
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
});
$('sync-modal-form').addEventListener('submit', async function (e) {
e.preventDefault();
errEl.hidden = true;
if (pin1.value !== pin2.value) {
errEl.textContent = 'PINs do not match.';
errEl.hidden = false; return;
}
if (pin1.value.length < 4) {
errEl.textContent = 'PIN must be at least 4 characters.';
errEl.hidden = false; return;
}
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
if (!pie) {
errEl.textContent =
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
errEl.hidden = false; return;
}
try {
await window.CassandraSync.pushSync(pie, pin1.value);
const cb = pendingOnSuccess;
closeModal(); // clears pendingOnSuccess
await refresh();
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
if (typeof cb === 'function') {
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
}
} catch (e2) {
errEl.textContent = e2.message || 'Failed to enable sync.';
errEl.hidden = false;
}
});
async function refresh() {
let status;
try { status = await window.CassandraSync.getStatus(); }
catch (e) {
statusEl.querySelector('.settings-row__value').innerHTML =
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
return;
}
const valueEl = statusEl.querySelector('.settings-row__value');
actionsEl.innerHTML = '';
if (status.exists) {
const when = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
valueEl.innerHTML =
'<span class="badge badge--ok">On</span> ' +
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
const disable = document.createElement('button');
disable.type = 'button';
disable.className = 'pf-secondary';
disable.textContent = 'Disable sync';
disable.addEventListener('click', async function () {
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
try {
await window.CassandraSync.disableSync();
await refresh();
setFeedback('Cloud sync disabled. Server copy removed.', true);
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
});
actionsEl.appendChild(disable);
} else {
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
// Only offer 'Enable' when there's actually a pie to encrypt;
// otherwise the user would hit a dead-end at the modal.
const hasPie = !!localStorage.getItem('cassandra.pie');
if (!hasPie) {
const hint = document.createElement('span');
hint.className = 'settings-row__hint';
hint.innerHTML =
'Nothing to sync yet &mdash; ' +
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
actionsEl.appendChild(hint);
return;
}
const enable = document.createElement('button');
enable.type = 'button';
enable.textContent = 'Enable cloud sync';
enable.addEventListener('click', openModal);
actionsEl.appendChild(enable);
}
}
// Hooks for the Import section to drive this modal + status row.
window.cassandraOpenSyncModal = openModal;
window.cassandraRefreshSyncStatus = refresh;
refresh();
});
})();
</script>
{% endif %}
<script>
(function () {
var btn = document.getElementById('invite-copy');
@ -99,4 +327,252 @@
});
})();
</script>
{% if user %}
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script>
(function () {
// Server-side hint: did the user have paid privileges when the page
// rendered? Used to decide whether to offer the 'Import & sync' button.
// We still call CassandraSync.getStatus() at click time as the source
// of truth, but this lets us skip rendering a button we know is dead.
var IS_PAID = {{ 'true' if paid and paid.active else 'false' }};
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
ready(function () {
var P = window.CassandraPortfolio;
if (!P) return;
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var previewEl = document.getElementById('import-preview');
var resultEl = document.getElementById('import-result');
if (!dropZone) return;
var currentPie = null; // most recently parsed pie, awaiting commit
function showError(msg) {
previewEl.hidden = true;
resultEl.className = 'result result--err';
resultEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(msg) + '</div>';
resultEl.hidden = false;
}
function showSuccess(headline, sub) {
previewEl.hidden = true;
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">' + esc(headline) + '</div>' +
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
'<div class="result__row" style="margin-top:14px;">' +
'<a href="/">Open dashboard →</a>' +
'</div>';
resultEl.hidden = false;
}
function renderPreview(pie) {
currentPie = pie;
resultEl.hidden = true;
var t = pie.totals || {};
var rows = (pie.positions || []).map(function (p) {
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + fmt(invested) + '</td>' +
'</tr>';
}).join('');
var warnings = (pie.warnings || []).map(function (w) {
return '<div class="result__warn">' + esc(w) + '</div>';
}).join('');
var syncBtn = IS_PAID
? ('<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>')
: ('<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>');
previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' +
'<div class="result__head">' +
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
'</div>' +
'<div class="result__grid">' +
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
'</div>' +
warnings +
(rows
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num">Qty</th>' +
'<th class="num">Avg</th>' +
'<th class="num">Invested</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>'
: ''
) +
'<div class="import-actions">' +
'<div class="import-choice">' +
'<button type="button" id="commit-local">Import to this browser</button>' +
'<div class="settings-row__hint">' +
'Saved to this browser only. No server-side copy of your holdings.' +
'</div>' +
'</div>' +
syncBtn +
'<div style="flex-basis:100%;">' +
'<button type="button" id="commit-cancel" class="pf-secondary">' +
'Cancel</button>' +
'</div>' +
'</div>' +
'</div>';
previewEl.hidden = false;
document.getElementById('commit-local').addEventListener('click', commitLocal);
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
var syncEl = document.getElementById('commit-sync');
if (syncEl) syncEl.addEventListener('click', commitSync);
}
function commitLocal() {
if (!currentPie) return;
P.savePie(currentPie);
showSuccess('▸ Imported to this browser.',
'Pie kept locally; no server-side copy.');
currentPie = null;
}
async function commitSync() {
if (!currentPie) return;
// Save locally first so the cloud-sync flow uses the freshly-imported
// pie (the enable-PIN modal in this same page reads from localStorage).
P.savePie(currentPie);
var S = window.CassandraSync;
if (!S) { showError('Cloud sync module not loaded.'); return; }
var status;
try { status = await S.getStatus(); }
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
if (!status.paid) {
showError('Cloud sync requires the paid tier.');
return;
}
if (status.exists) {
// Already enabled — try a direct push using the cached session
// key. If no key is cached (fresh browser session), this throws,
// and we fall back to the enable-PIN modal so the user can
// re-enter their PIN.
try {
await S.pushSync(currentPie, null);
showSuccess('▸ Imported and synced.',
'Encrypted copy updated on the server.');
currentPie = null;
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
return;
} catch (e) {
// Fall through to modal so the user can re-auth with their PIN.
console.warn('direct push failed, falling back to PIN modal', e);
}
}
// !status.exists OR cached-key push failed → use the modal.
if (window.cassandraOpenSyncModal) {
window.cassandraOpenSyncModal({
onSuccess: function () {
showSuccess('▸ Imported and synced.',
'Cloud sync is now enabled and the pie is stored encrypted.');
currentPie = null;
},
});
} else {
showError('Cloud sync UI unavailable on this page. ' +
'Use the Cloud sync section below to enable.');
}
}
function resetUploader() {
currentPie = null;
previewEl.hidden = true;
previewEl.innerHTML = '';
resultEl.hidden = true;
filenameEl.textContent = '';
fileInput.value = '';
}
async function parseFile(file) {
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
previewEl.hidden = true;
resultEl.hidden = true;
try {
var pie = await P.parseCsv(file);
renderPreview(pie);
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
} catch (e) {
filenameEl.textContent = file.name + ' (failed)';
showError(e.message || 'Unknown error');
}
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) parseFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
var f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) parseFile(f);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
});
})();
</script>
{% endif %}
{% endblock %}

View file

@ -5,7 +5,7 @@
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Import portfolio (Trading 212 CSV)</span>
<span class="meta">stays in your browser · never persists server-side</span>
<span class="meta">held locally · optional encrypted cloud sync (paid)</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
@ -13,9 +13,11 @@
Export your pie from the T212 web app
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
and drop the CSV here. Each Slice is resolved to its Yahoo ticker;
the parsed pie is kept in <em>this browser's localStorage</em> only.
The server learns just which tickers exist (anonymously) so it can
fetch their prices.
the parsed pie is kept in <em>this browser's localStorage</em>.
The server learns only which tickers exist (anonymously) so it can
fetch their prices. If you have <a href="/settings">cloud sync</a>
enabled, an <strong>encrypted</strong> copy is also pushed to the
server &mdash; only your PIN can decrypt it.
</p>
<form id="upload-form" autocomplete="off">
@ -34,6 +36,7 @@
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script>
(function () {