Accounts Phase 4: prefs sync + account/settings panel
- Prefs sync: GET/PUT /api/prefs store Calm Filters/Boundaries on the account. On sign-in the client adopts the account's prefs if present, else seeds them from the device; every change PUTs to the account so tuning follows you across devices. (Login side-effects run under untrack so browsing doesn't re-trigger.) - Account panel: GET /api/account (email, connected sign-in methods, saved count, active sessions); Export my data (GET /api/account/export → JSON download); Sign out everywhere (revoke all sessions); Delete account (cascades to all account data) with an inline confirm. Reachable from You → Account. Deferred to a follow-up: link/unlink a provider (OAuth link-mode) and per-session revoke. 118 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,10 @@ export async function postJSON(url, body) {
|
||||
return sendJSON('POST', url, body);
|
||||
}
|
||||
|
||||
export async function putJSON(url, body) {
|
||||
return sendJSON('PUT', url, body);
|
||||
}
|
||||
|
||||
export async function delJSON(url) {
|
||||
return sendJSON('DELETE', url);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,13 @@ export async function logout() {
|
||||
try {
|
||||
await postJSON('/api/auth/logout', {});
|
||||
} finally {
|
||||
auth.user = null;
|
||||
savedIds.clear();
|
||||
clearLocal();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear client auth state without a logout call (cookie already cleared server-side,
|
||||
// e.g. after delete-account or sign-out-everywhere).
|
||||
export function clearLocal() {
|
||||
auth.user = null;
|
||||
savedIds.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||
import { clearLocal } from '$lib/auth.svelte.js';
|
||||
|
||||
let { onclose } = $props();
|
||||
let info = $state(null);
|
||||
let error = $state('');
|
||||
let confirmingDelete = $state(false);
|
||||
let busy = $state('');
|
||||
|
||||
const PROVIDER_LABEL = { email: 'Email link', google: 'Google', apple: 'Apple' };
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
info = await getJSON('/api/account');
|
||||
} catch {
|
||||
error = "Couldn't load your account.";
|
||||
}
|
||||
});
|
||||
|
||||
async function logoutEverywhere() {
|
||||
busy = 'logout';
|
||||
try {
|
||||
await postJSON('/api/account/logout-all', {});
|
||||
clearLocal();
|
||||
onclose?.();
|
||||
} catch {
|
||||
error = 'Could not sign out everywhere — try again.';
|
||||
} finally {
|
||||
busy = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
busy = 'delete';
|
||||
try {
|
||||
await delJSON('/api/account');
|
||||
clearLocal();
|
||||
onclose?.();
|
||||
} catch {
|
||||
error = 'Could not delete the account — try again.';
|
||||
} finally {
|
||||
busy = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel rise">
|
||||
<div class="phead">
|
||||
<h2>Account</h2>
|
||||
<button class="close" onclick={onclose}>done</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
|
||||
{#if info}
|
||||
<div class="row"><span class="k">Email</span><span class="v">{info.user.email}</span></div>
|
||||
<div class="row">
|
||||
<span class="k">Sign-in methods</span>
|
||||
<span class="v">{info.providers.map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}</span>
|
||||
</div>
|
||||
<div class="row"><span class="k">Saved articles</span><span class="v">{info.saved_count}</span></div>
|
||||
<div class="row"><span class="k">Active sessions</span><span class="v">{info.sessions}</span></div>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn" href="/api/account/export">Export my data</a>
|
||||
<button class="btn" onclick={logoutEverywhere} disabled={busy === 'logout'}>
|
||||
{busy === 'logout' ? 'Signing out…' : 'Sign out everywhere'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="danger">
|
||||
{#if !confirmingDelete}
|
||||
<button class="link-danger" onclick={() => (confirmingDelete = true)}>Delete my account</button>
|
||||
{:else}
|
||||
<p class="warn">This permanently removes your account, saved articles, and history. This can't be undone.</p>
|
||||
<div class="actions">
|
||||
<button class="btn danger-btn" onclick={deleteAccount} disabled={busy === 'delete'}>
|
||||
{busy === 'delete' ? 'Deleting…' : 'Yes, delete everything'}
|
||||
</button>
|
||||
<button class="btn" onclick={() => (confirmingDelete = false)}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="fine">Browsing without an account still works — signing out keeps your saved items safe on the server for next time.</p>
|
||||
{:else if !error}
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex; justify-content: space-between; gap: 16px;
|
||||
padding: 10px 2px; border-bottom: 1px solid var(--line); font-size: 0.92rem;
|
||||
}
|
||||
.row .k { color: var(--muted); }
|
||||
.row .v { color: var(--ink); text-align: right; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 6px; }
|
||||
.btn {
|
||||
font: inherit; font-size: 0.86rem; font-weight: 600;
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: 8px 14px;
|
||||
background: var(--surface); color: var(--accent-deep); cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: var(--accent-soft); }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.danger { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
.link-danger {
|
||||
background: none; border: none; color: #9a3b3b; font-size: 0.86rem;
|
||||
text-decoration: underline; cursor: pointer; padding: 0;
|
||||
}
|
||||
.warn { color: #9a3b3b; font-size: 0.88rem; margin: 0 0 10px; }
|
||||
.danger-btn { color: #fff; background: #9a3b3b; border-color: #9a3b3b; }
|
||||
.danger-btn:hover { background: #843232; }
|
||||
.err { color: #9a3b3b; font-size: 0.86rem; }
|
||||
.fine { margin-top: 16px; color: var(--muted); font-size: 0.8rem; }
|
||||
.muted { color: var(--muted); }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
|
||||
import * as P from '$lib/prefs.js';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||
@@ -8,6 +8,7 @@
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||||
import { auth, savedIds, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
|
||||
|
||||
let moods = $state([]);
|
||||
@@ -22,6 +23,7 @@
|
||||
let showHistory = $state(false);
|
||||
let showYou = $state(false); // mobile "You" sheet
|
||||
let showSignIn = $state(false);
|
||||
let showAccount = $state(false);
|
||||
|
||||
function openAccount() {
|
||||
showYou = false;
|
||||
@@ -38,19 +40,39 @@
|
||||
loadServerHistory();
|
||||
}
|
||||
|
||||
// On first sign-in (per account, per device), fold this device's anonymous
|
||||
// history + saved into the account so nothing's lost.
|
||||
// React only to sign-in (auth.user); untrack so reading history/prefs inside
|
||||
// doesn't make this re-run on every browse.
|
||||
$effect(() => {
|
||||
const u = auth.user;
|
||||
if (!u || typeof window === 'undefined') return;
|
||||
const key = 'goodnews:imported:' + u.id;
|
||||
if (localStorage.getItem(key)) return;
|
||||
// Fold in this device's MEANINGFUL history (opened/replaced), not everything shown.
|
||||
const seen = history.map((a) => a.id);
|
||||
postJSON('/api/import', { seen, saved: [] })
|
||||
.then(() => { localStorage.setItem(key, '1'); loadServerHistory(); })
|
||||
.catch(() => {});
|
||||
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
|
||||
});
|
||||
|
||||
async function onLogin(u) {
|
||||
// One-time per account/device: fold this device's MEANINGFUL history
|
||||
// (opened/replaced — not everything shown) into the account.
|
||||
const key = 'goodnews:imported:' + u.id;
|
||||
if (!localStorage.getItem(key)) {
|
||||
try {
|
||||
await postJSON('/api/import', { seen: history.map((a) => a.id), saved: [] });
|
||||
localStorage.setItem(key, '1');
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
loadServerHistory();
|
||||
// Prefs: adopt the account's saved prefs if any; otherwise seed from this device.
|
||||
try {
|
||||
const res = await getJSON('/api/prefs');
|
||||
if (res && res.prefs) {
|
||||
const incoming = Object.assign(P.blank(), res.prefs);
|
||||
if (JSON.stringify(incoming) !== JSON.stringify(userPrefs)) {
|
||||
userPrefs = incoming;
|
||||
P.save(userPrefs);
|
||||
select(selected, true);
|
||||
}
|
||||
} else {
|
||||
await putJSON('/api/prefs', { prefs: userPrefs });
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
@@ -223,6 +245,7 @@
|
||||
function refreshPrefs() {
|
||||
userPrefs = { ...userPrefs };
|
||||
P.save(userPrefs);
|
||||
if (auth.user) putJSON('/api/prefs', { prefs: userPrefs }).catch(() => {}); // sync to the account
|
||||
select(selected, true);
|
||||
}
|
||||
function applyAction(kind, value) {
|
||||
@@ -369,6 +392,7 @@
|
||||
<span>History</span>{#if historyItems.length}<span class="dot">{historyItems.length}</span>{/if}
|
||||
</button>
|
||||
{#if auth.user}
|
||||
<button class="yourow" onclick={() => { showYou = false; showAccount = true; }}><span>Account</span></button>
|
||||
<button class="yourow" onclick={signOut}><span>Sign out</span></button>
|
||||
{:else}
|
||||
<button class="yourow" onclick={() => { showYou = false; showSignIn = true; }}>
|
||||
@@ -378,6 +402,10 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showAccount}
|
||||
<AccountPanel onclose={() => (showAccount = false)} />
|
||||
{/if}
|
||||
|
||||
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
|
||||
+103
@@ -300,6 +300,10 @@ class ImportBody(BaseModel):
|
||||
saved: list[int] = []
|
||||
|
||||
|
||||
class PrefsBody(BaseModel):
|
||||
prefs: dict = {}
|
||||
|
||||
|
||||
# --- App --------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -508,6 +512,105 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# --- Prefs sync (Calm Filters / Boundaries follow the account) --------
|
||||
|
||||
@app.get("/api/prefs")
|
||||
def get_prefs(request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
row = conn.execute(
|
||||
"SELECT prefs_json FROM user_prefs WHERE user_id = ?", (user["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"prefs": None} # no row yet → caller seeds from the device
|
||||
try:
|
||||
return {"prefs": json.loads(row["prefs_json"])}
|
||||
except (ValueError, TypeError):
|
||||
return {"prefs": None}
|
||||
|
||||
@app.put("/api/prefs")
|
||||
def put_prefs(body: PrefsBody, request: Request) -> dict:
|
||||
blob = json.dumps(body.prefs)[:20000]
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
conn.execute(
|
||||
"INSERT INTO user_prefs (user_id, prefs_json, updated_at) "
|
||||
"VALUES (?, ?, CURRENT_TIMESTAMP) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET prefs_json = excluded.prefs_json, "
|
||||
"updated_at = CURRENT_TIMESTAMP",
|
||||
(user["id"], blob),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# --- Account: profile, sessions, export, delete -----------------------
|
||||
|
||||
@app.get("/api/account")
|
||||
def account_info(request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
providers = [r["provider"] for r in conn.execute(
|
||||
"SELECT provider FROM identities WHERE user_id = ?", (user["id"],)
|
||||
)]
|
||||
sessions = conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions WHERE user_id = ?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
saved = conn.execute(
|
||||
"SELECT COUNT(*) FROM saved_articles WHERE user_id = ?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"user": {"id": user["id"], "email": user["email"], "display_name": user["display_name"]},
|
||||
"providers": providers,
|
||||
"sessions": sessions,
|
||||
"saved_count": saved,
|
||||
}
|
||||
|
||||
@app.post("/api/account/logout-all")
|
||||
def logout_all(request: Request, response: Response) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user["id"],))
|
||||
conn.commit()
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/account/export")
|
||||
def export_account(request: Request) -> Response:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
uid = user["id"]
|
||||
providers = [r["provider"] for r in conn.execute(
|
||||
"SELECT provider FROM identities WHERE user_id = ?", (uid,)
|
||||
)]
|
||||
saved = queries.saved(conn, uid, limit=10000)
|
||||
hist = queries.history(conn, uid, limit=10000)
|
||||
prow = conn.execute(
|
||||
"SELECT prefs_json FROM user_prefs WHERE user_id = ?", (uid,)
|
||||
).fetchone()
|
||||
slim = lambda a: {"id": a["id"], "title": a["title"], "url": a["canonical_url"]}
|
||||
data = {
|
||||
"account": {"id": uid, "email": user["email"],
|
||||
"display_name": user["display_name"], "created_at": user["created_at"]},
|
||||
"sign_in_methods": providers,
|
||||
"saved": [slim(a) for a in saved],
|
||||
"history": [slim(a) for a in hist],
|
||||
"preferences": json.loads(prow["prefs_json"]) if prow else None,
|
||||
}
|
||||
return Response(
|
||||
content=json.dumps(data, indent=2),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": "attachment; filename=upbeatbytes-data.json"},
|
||||
)
|
||||
|
||||
@app.delete("/api/account")
|
||||
def delete_account(request: Request, response: Response) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user["id"],)) # cascades to all account data
|
||||
conn.commit()
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/import")
|
||||
def import_local(body: ImportBody, request: Request) -> dict:
|
||||
"""Fold this device's anonymous history/saved into the account (one-time)."""
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
db = tmp_path / "t.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
from goodnews.db import connect, init_db
|
||||
c = connect(str(db)); init_db(c)
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (1,1,'http://s/1','t1','h1')")
|
||||
c.commit(); c.close()
|
||||
return api.create_app(), api
|
||||
|
||||
|
||||
def _signed_in(app, api):
|
||||
tc = TestClient(app)
|
||||
sent = {}
|
||||
import goodnews.email_send as es
|
||||
orig = es.send_magic_link
|
||||
es.send_magic_link = lambda to, link: sent.update(link=link)
|
||||
try:
|
||||
tc.post("/api/auth/email/start", json={"email": "a@b.com"})
|
||||
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
|
||||
finally:
|
||||
es.send_magic_link = orig
|
||||
return tc
|
||||
|
||||
|
||||
def test_prefs_roundtrip(client):
|
||||
app, api = client
|
||||
tc = _signed_in(app, api)
|
||||
assert tc.get("/api/prefs").json() == {"prefs": None} # nothing yet → seed from device
|
||||
tc.put("/api/prefs", json={"prefs": {"mute_topics": ["health"], "avoid_terms": ["war"]}})
|
||||
assert tc.get("/api/prefs").json()["prefs"]["mute_topics"] == ["health"]
|
||||
|
||||
|
||||
def test_account_info_and_export(client):
|
||||
app, api = client
|
||||
tc = _signed_in(app, api)
|
||||
tc.post("/api/saved/1")
|
||||
info = tc.get("/api/account").json()
|
||||
assert info["user"]["email"] == "a@b.com"
|
||||
assert info["providers"] == ["email"] and info["sessions"] >= 1 and info["saved_count"] == 1
|
||||
exp = tc.get("/api/account/export")
|
||||
assert exp.headers["content-disposition"].endswith("upbeatbytes-data.json")
|
||||
data = json.loads(exp.content)
|
||||
assert data["account"]["email"] == "a@b.com" and {a["id"] for a in data["saved"]} == {1}
|
||||
|
||||
|
||||
def test_logout_all_and_delete(client):
|
||||
app, api = client
|
||||
tc = _signed_in(app, api)
|
||||
tc.post("/api/saved/1")
|
||||
assert tc.post("/api/account/logout-all").json() == {"ok": True}
|
||||
assert tc.get("/api/auth/me").json() is None # session revoked
|
||||
|
||||
tc2 = _signed_in(app, api) # same email → same account, has the save
|
||||
assert tc2.get("/api/saved/ids").json() == [1]
|
||||
assert tc2.delete("/api/account").json() == {"ok": True}
|
||||
assert tc2.get("/api/auth/me").json() is None
|
||||
# a fresh sign-in is a brand-new account (old data gone)
|
||||
tc3 = _signed_in(app, api)
|
||||
assert tc3.get("/api/saved/ids").json() == []
|
||||
Reference in New Issue
Block a user