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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue