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:
+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)."""
|
||||
|
||||
Reference in New Issue
Block a user