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:
parent
89632e9937
commit
f326b41a08
23 changed files with 1637 additions and 95 deletions
|
|
@ -82,7 +82,9 @@ a:hover { text-decoration: underline; }
|
|||
.app-header .brand {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-header .brand:hover { color: var(--text); }
|
||||
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
|
||||
.app-header nav a {
|
||||
margin-left: 18px;
|
||||
|
|
@ -1034,19 +1036,55 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
|||
border-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* User chip in header */
|
||||
/* Import preview action row — two stacked buttons with an explainer. */
|
||||
.import-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.import-choice { flex: 1 1 240px; min-width: 220px; }
|
||||
.import-choice button { width: 100%; }
|
||||
.import-choice .settings-row__hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* User chip in header — now a button that toggles a dropdown menu. */
|
||||
.user-menu { position: relative; margin-left: 8px; }
|
||||
.user-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
margin-left: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-chip a {
|
||||
color: var(--muted);
|
||||
border-bottom: 1px dotted var(--muted);
|
||||
.user-chip:hover { color: var(--accent); }
|
||||
.user-menu__caret { margin-left: 4px; opacity: 0.6; }
|
||||
.user-menu__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--surface-1, var(--surface-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
z-index: 200;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.user-chip a:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.user-menu__item {
|
||||
display: block;
|
||||
padding: 8px 14px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.user-menu__item:hover { background: var(--surface-2); color: var(--accent); }
|
||||
|
||||
/* --- Upload page (drag-drop CSV) ------------------------------------- */
|
||||
|
||||
|
|
|
|||
280
app/static/js/portfolio-sync.js
Normal file
280
app/static/js/portfolio-sync.js
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/* Cassandra — client-side encrypted portfolio sync.
|
||||
*
|
||||
* The server only ever sees opaque ciphertext. The browser:
|
||||
* 1. Derives an AES-GCM key from the user's PIN with PBKDF2 (600k SHA-256).
|
||||
* 2. Encrypts the pie JSON, packs salt+nonce+ct into one blob.
|
||||
* 3. POSTs the blob to /api/portfolio/sync.
|
||||
* 4. On pull, fetches the blob, reverses the steps with the PIN.
|
||||
*
|
||||
* The derived key is cached in sessionStorage so the user enters the PIN
|
||||
* at most once per browser session (cleared on tab close / logout). The
|
||||
* server-side outer wrap (see app/services/portfolio_sync.py) hardens the
|
||||
* stored ciphertext against a DB-only leak.
|
||||
*
|
||||
* Packed inner-blob format (all bytes, then base64-url for transport):
|
||||
* byte 0: version (currently 1)
|
||||
* bytes 1..4: PBKDF2 iteration count, uint32 big-endian
|
||||
* bytes 5..20: salt (16 bytes)
|
||||
* bytes 21..32: nonce (12 bytes)
|
||||
* bytes 33..: AES-GCM ciphertext (includes 16-byte tag suffix)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const VERSION = 1;
|
||||
const ITERATIONS = 600_000;
|
||||
const SALT_LEN = 16;
|
||||
const NONCE_LEN = 12;
|
||||
const HEADER_LEN = 1 + 4 + SALT_LEN + NONCE_LEN; // = 33
|
||||
|
||||
const SESSION_KEY_STORAGE = 'cassandra.sync.key.v1';
|
||||
const SESSION_SALT_STORAGE = 'cassandra.sync.salt.v1';
|
||||
|
||||
// --- byte helpers ----------------------------------------------------
|
||||
|
||||
function u8concat(parts) {
|
||||
let n = 0;
|
||||
for (const p of parts) n += p.length;
|
||||
const out = new Uint8Array(n);
|
||||
let i = 0;
|
||||
for (const p of parts) { out.set(p, i); i += p.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function b64urlEncode(bytes) {
|
||||
let s = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
s += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function b64urlDecode(s) {
|
||||
const norm = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = norm + '='.repeat((4 - norm.length % 4) % 4);
|
||||
const bin = atob(padded);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- WebCrypto -------------------------------------------------------
|
||||
|
||||
async function pbkdf2Derive(pin, salt, iterations) {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(pin),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // extractable: we cache raw bytes in sessionStorage
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
async function exportKey(key) {
|
||||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
return new Uint8Array(raw);
|
||||
}
|
||||
|
||||
async function importKey(raw) {
|
||||
return crypto.subtle.importKey(
|
||||
'raw', raw, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// --- session cache ---------------------------------------------------
|
||||
|
||||
// We cache both the raw key AND the salt that produced it, so a push
|
||||
// after upload can rebuild the same packed-blob header without
|
||||
// re-prompting for a PIN. Lives in sessionStorage so it dies with the
|
||||
// tab.
|
||||
function cacheKey(rawKey, salt) {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY_STORAGE, b64urlEncode(rawKey));
|
||||
sessionStorage.setItem(SESSION_SALT_STORAGE, b64urlEncode(salt));
|
||||
} catch (e) {
|
||||
console.warn('cassandra.sync: sessionStorage write failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCachedKeyAndSalt() {
|
||||
const rk = sessionStorage.getItem(SESSION_KEY_STORAGE);
|
||||
const sk = sessionStorage.getItem(SESSION_SALT_STORAGE);
|
||||
if (!rk || !sk) return null;
|
||||
return {
|
||||
key: await importKey(b64urlDecode(rk)),
|
||||
salt: b64urlDecode(sk),
|
||||
};
|
||||
}
|
||||
|
||||
function clearCachedKey() {
|
||||
sessionStorage.removeItem(SESSION_KEY_STORAGE);
|
||||
sessionStorage.removeItem(SESSION_SALT_STORAGE);
|
||||
}
|
||||
|
||||
// --- pack / unpack ---------------------------------------------------
|
||||
|
||||
function packBlob(salt, nonce, ct, iterations) {
|
||||
const header = new Uint8Array(HEADER_LEN);
|
||||
header[0] = VERSION;
|
||||
new DataView(header.buffer).setUint32(1, iterations, false); // big-endian
|
||||
header.set(salt, 5);
|
||||
header.set(nonce, 5 + SALT_LEN);
|
||||
return u8concat([header, new Uint8Array(ct)]);
|
||||
}
|
||||
|
||||
function unpackBlob(bytes) {
|
||||
if (bytes.length < HEADER_LEN + 16) {
|
||||
throw new Error('blob too small');
|
||||
}
|
||||
const version = bytes[0];
|
||||
if (version !== VERSION) {
|
||||
throw new Error('unknown sync blob version: ' + version);
|
||||
}
|
||||
const iterations = new DataView(bytes.buffer, bytes.byteOffset, HEADER_LEN)
|
||||
.getUint32(1, false);
|
||||
const salt = bytes.slice(5, 5 + SALT_LEN);
|
||||
const nonce = bytes.slice(5 + SALT_LEN, HEADER_LEN);
|
||||
const ct = bytes.slice(HEADER_LEN);
|
||||
return { version, iterations, salt, nonce, ct };
|
||||
}
|
||||
|
||||
// --- encrypt / decrypt ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Encrypt a pie object with `pin`. Returns the packed blob as a
|
||||
* base64url string ready for POST /api/portfolio/sync. Also caches
|
||||
* the derived key in sessionStorage so subsequent pushes don't need
|
||||
* the PIN.
|
||||
*/
|
||||
async function encryptPie(pie, pin) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
||||
const key = await pbkdf2Derive(pin, salt, ITERATIONS);
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(pie));
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce }, key, plaintext,
|
||||
);
|
||||
cacheKey(await exportKey(key), salt);
|
||||
return b64urlEncode(packBlob(salt, nonce, ct, ITERATIONS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt with a cached key (no PIN needed). Re-uses the cached
|
||||
* salt so the blob remains decryptable with the same PIN later.
|
||||
* Returns null if no key is cached.
|
||||
*/
|
||||
async function encryptPieWithCachedKey(pie) {
|
||||
const cached = await getCachedKeyAndSalt();
|
||||
if (!cached) return null;
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(pie));
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce }, cached.key, plaintext,
|
||||
);
|
||||
return b64urlEncode(packBlob(cached.salt, nonce, ct, ITERATIONS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a server blob with `pin`. Throws BadPinError on auth failure.
|
||||
* Caches the derived key on success.
|
||||
*/
|
||||
class BadPinError extends Error {
|
||||
constructor() { super('Incorrect PIN'); this.name = 'BadPinError'; }
|
||||
}
|
||||
|
||||
async function decryptBlob(blobB64, pin) {
|
||||
const bytes = b64urlDecode(blobB64);
|
||||
const { iterations, salt, nonce, ct } = unpackBlob(bytes);
|
||||
const key = await pbkdf2Derive(pin, salt, iterations);
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: nonce }, key, ct,
|
||||
);
|
||||
} catch (_e) {
|
||||
throw new BadPinError();
|
||||
}
|
||||
cacheKey(await exportKey(key), salt);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||
}
|
||||
|
||||
// --- network ---------------------------------------------------------
|
||||
|
||||
async function getStatus() {
|
||||
const r = await fetch('/api/portfolio/sync/status', {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
if (r.status === 402) return { exists: false, paid: false };
|
||||
if (!r.ok) throw new Error('sync status: HTTP ' + r.status);
|
||||
const body = await r.json();
|
||||
return { exists: !!body.exists, updated_at: body.updated_at, paid: true };
|
||||
}
|
||||
|
||||
async function pushSync(pie, pin) {
|
||||
// If a cached key exists, re-use it; otherwise derive from the PIN.
|
||||
let blob = await encryptPieWithCachedKey(pie);
|
||||
if (!blob) {
|
||||
if (!pin) throw new Error('PIN required to enable sync');
|
||||
blob = await encryptPie(pie, pin);
|
||||
}
|
||||
const r = await fetch('/api/portfolio/sync', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blob }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
throw new Error(body.detail || ('sync push: HTTP ' + r.status));
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function pullSync(pin) {
|
||||
const r = await fetch('/api/portfolio/sync', {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
if (r.status === 404) return null;
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
// 429 → server already throttling; bubble the message up unchanged.
|
||||
throw new Error(body.detail || ('sync pull: HTTP ' + r.status));
|
||||
}
|
||||
const { blob } = await r.json();
|
||||
return decryptBlob(blob, pin);
|
||||
}
|
||||
|
||||
async function disableSync() {
|
||||
const r = await fetch('/api/portfolio/sync', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!r.ok) throw new Error('sync delete: HTTP ' + r.status);
|
||||
clearCachedKey();
|
||||
return r.json();
|
||||
}
|
||||
|
||||
window.CassandraSync = {
|
||||
getStatus,
|
||||
encryptPie,
|
||||
decryptBlob,
|
||||
pushSync,
|
||||
pullSync,
|
||||
disableSync,
|
||||
clearCachedKey,
|
||||
BadPinError,
|
||||
// Exposed for tests / debugging:
|
||||
_packBlob: packBlob,
|
||||
_unpackBlob: unpackBlob,
|
||||
};
|
||||
})();
|
||||
|
|
@ -168,10 +168,58 @@
|
|||
mount.innerHTML =
|
||||
'<div class="empty" style="padding:16px;">' +
|
||||
'No portfolio loaded in this browser. ' +
|
||||
'<a href="/upload">Import a T212 CSV →</a>' +
|
||||
'<a href="/settings#import">Import a T212 CSV →</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderRestoreFromCloud(mount, status) {
|
||||
const lastSynced = status.updated_at
|
||||
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
||||
: '—';
|
||||
mount.innerHTML =
|
||||
'<div class="pf-restore" style="padding:16px;">' +
|
||||
'<div class="result__head">▸ Restore from cloud</div>' +
|
||||
'<div class="result__row" style="margin-bottom:12px;">' +
|
||||
'A synced portfolio is available for this account (last synced ' +
|
||||
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
|
||||
'</div>' +
|
||||
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
|
||||
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
|
||||
'autocomplete="off" placeholder="PIN" ' +
|
||||
'style="flex:0 0 140px;">' +
|
||||
'<button type="submit">Restore</button>' +
|
||||
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
|
||||
'or import a new CSV →</a>' +
|
||||
'</form>' +
|
||||
'<div id="pf-restore-err" class="pf-warn" hidden style="margin-top:10px;"></div>' +
|
||||
'</div>';
|
||||
|
||||
const form = document.getElementById('pf-restore-form');
|
||||
const pin = document.getElementById('pf-restore-pin');
|
||||
const err = document.getElementById('pf-restore-err');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
const value = (pin.value || '').trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
const pie = await window.CassandraSync.pullSync(value);
|
||||
if (!pie) {
|
||||
err.textContent = 'No synced portfolio found.';
|
||||
err.hidden = false;
|
||||
return;
|
||||
}
|
||||
savePie(pie);
|
||||
mountAndRender();
|
||||
} catch (e2) {
|
||||
err.textContent = (e2 && e2.name === 'BadPinError')
|
||||
? 'Incorrect PIN.'
|
||||
: (e2.message || 'Could not restore.');
|
||||
err.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel(mount, pie, enriched, agg) {
|
||||
const ccyPills = Object.keys(agg.by_currency)
|
||||
.sort((a, b) => agg.by_currency[b] - agg.by_currency[a])
|
||||
|
|
@ -331,7 +379,15 @@
|
|||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
out.innerHTML = '<div class="pf-warn">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
|
||||
// FastAPI `detail` is usually a string, but some endpoints send
|
||||
// an object — e.g. the 402 paid-gate returns {code, message}.
|
||||
// Render the human-readable text either way; never the object
|
||||
// (which stringifies to the useless "[object Object]").
|
||||
const d = data && data.detail;
|
||||
const msg = (d && typeof d === 'object')
|
||||
? (d.message || JSON.stringify(d))
|
||||
: (d || ('HTTP ' + r.status));
|
||||
out.innerHTML = '<div class="pf-warn">' + esc(msg) + '</div>';
|
||||
return;
|
||||
}
|
||||
// Persist before rendering so auto-refresh can re-hydrate.
|
||||
|
|
@ -351,7 +407,20 @@
|
|||
if (!mount) return;
|
||||
const pie = loadPie();
|
||||
if (!pie || !pie.positions || !pie.positions.length) {
|
||||
renderEmpty(mount);
|
||||
// Before falling back to "no portfolio", check whether the account
|
||||
// has a synced blob this device could restore from. Status is
|
||||
// 402 for free-tier users — getStatus() returns paid:false there
|
||||
// and we fall through to the standard empty state.
|
||||
let status = null;
|
||||
if (window.CassandraSync) {
|
||||
try { status = await window.CassandraSync.getStatus(); }
|
||||
catch (e) { console.warn('sync status check failed', e); }
|
||||
}
|
||||
if (status && status.paid && status.exists) {
|
||||
renderRestoreFromCloud(mount, status);
|
||||
} else {
|
||||
renderEmpty(mount);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -369,72 +438,42 @@
|
|||
renderPanel(mount, pie, enriched, agg);
|
||||
}
|
||||
|
||||
// --- Upload page helper ------------------------------------------------
|
||||
|
||||
async function handleUpload(form, file, statusEl) {
|
||||
statusEl.className = 'result';
|
||||
statusEl.hidden = true;
|
||||
|
||||
// --- Parse primitive ---------------------------------------------------
|
||||
//
|
||||
// Hits /api/portfolio/parse and returns the parsed pie. The caller
|
||||
// decides whether to savePie() and whether to push to cloud sync — keeps
|
||||
// the post-parse decision in the inline UI script instead of buried in
|
||||
// this module.
|
||||
async function parseCsv(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/portfolio/parse', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
statusEl.className = 'result result--err';
|
||||
statusEl.innerHTML =
|
||||
'<div class="result__head">✕ Import failed</div>' +
|
||||
'<div class="result__row">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
|
||||
statusEl.hidden = false;
|
||||
return false;
|
||||
}
|
||||
savePie(data);
|
||||
|
||||
const warnings = (data.warnings || []).map(w =>
|
||||
'<div class="result__warn">' + esc(w) + '</div>').join('');
|
||||
|
||||
statusEl.className = 'result result--ok';
|
||||
statusEl.innerHTML =
|
||||
'<div class="result__head">' +
|
||||
'▸ Parsed <strong>' + esc(data.pie_name || 'pie') + '</strong> · ' +
|
||||
'<span class="result__tag">stored locally</span>' +
|
||||
'</div>' +
|
||||
'<div class="result__grid">' +
|
||||
'<div><div class="k">Positions</div><div class="v">' + data.positions.length + '</div></div>' +
|
||||
'<div><div class="k">Invested</div><div class="v">' + fmt(data.totals && data.totals.invested) + '</div></div>' +
|
||||
'<div><div class="k">Value</div><div class="v">' + fmt(data.totals && data.totals.value) + '</div></div>' +
|
||||
'<div><div class="k">Result</div><div class="v ' +
|
||||
((data.totals && data.totals.result >= 0) ? 'pos' : 'neg') + '">' +
|
||||
signed(data.totals && data.totals.result) + '</div></div>' +
|
||||
'</div>' +
|
||||
warnings +
|
||||
'<div class="result__row" style="margin-top:14px;">' +
|
||||
'<a href="/">Open dashboard →</a>' +
|
||||
'</div>';
|
||||
statusEl.hidden = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
statusEl.className = 'result result--err';
|
||||
statusEl.innerHTML =
|
||||
'<div class="result__head">✕ Import failed</div>' +
|
||||
'<div class="result__row">' + esc(err.message) + '</div>';
|
||||
statusEl.hidden = false;
|
||||
return false;
|
||||
const r = await fetch('/api/portfolio/parse', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.detail || ('HTTP ' + r.status));
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Public surface — usable from inline scripts on upload.html.
|
||||
// Formatting helpers exposed so inline UI scripts (like the import
|
||||
// preview in settings.html) don't have to re-implement them.
|
||||
window.CassandraPortfolio = {
|
||||
mountAndRender,
|
||||
handleUpload,
|
||||
parseCsv,
|
||||
loadPie,
|
||||
savePie,
|
||||
clearPie,
|
||||
fmt,
|
||||
signed,
|
||||
pct,
|
||||
cls,
|
||||
esc,
|
||||
};
|
||||
|
||||
// Auto-mount on dashboard load and refresh every minute.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue