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>