diff --git a/backend/app/auth.py b/backend/app/auth.py index 68b235f..c654dca 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -419,15 +419,22 @@ def _complete_link( @router.post("/logout") async def logout(request: Request): - """Sign out the active account. If other accounts have authenticated in this browser, - switch to the most recent one instead of fully logging out.""" - active = request.session.get("user_id") + """Sign the requesting tab's active account out of this browser (removing it from the wallet). + Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging + out in one tab doesn't disturb another tab running a different account. If the account being + removed was the session default, promote the most recent remaining account (or clear the + session when none remain).""" + active, is_default = resolved_user_id(request) remaining = [a for a in (request.session.get("account_ids") or []) if a != active] - if remaining: - request.session["account_ids"] = remaining - request.session["user_id"] = remaining[-1] - return JSONResponse({"ok": True, "switched": True}) - request.session.clear() + request.session["account_ids"] = remaining + if is_default: + if remaining: + request.session["user_id"] = remaining[-1] + return JSONResponse({"ok": True, "switched": True}) + request.session.clear() + return JSONResponse({"ok": True, "switched": False}) + # A non-default (per-tab) account signed out: it's gone from the wallet and the session default + # is untouched. The tab drops its own override and falls back to the default on reload. return JSONResponse({"ok": True, "switched": False}) @@ -659,20 +666,52 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict return {"ok": True} +# A tab can act as a different signed-in account than the session default by sending this header +# (per-tab identity: two tabs in one browser, two accounts). Lowercased — Starlette header lookup +# is case-insensitive, but we compare explicitly below. +ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" + + +def resolved_user_id(request: Request) -> tuple[int | None, bool]: + """Which account this request acts as, and whether that's the session's default account. + + The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser) + plus a default `user_id`. A request may override the default with the X-Siftlode-Account + header, but ONLY for an account already in the wallet — so a tab can't impersonate an account + that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the + cookie default. + """ + default_id = request.session.get("user_id") + wallet = request.session.get("account_ids") or [] + hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER) + if hdr: + try: + hid = int(hdr) + except ValueError: + hid = None + if hid is not None and hid in wallet: + return hid, hid == default_id + return default_id, True + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: - user_id = request.session.get("user_id") + user_id, is_default = resolved_user_id(request) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") user = db.get(User, user_id) if user is None or not user.is_active or user.is_suspended: # Suspended/deactivated mid-session → drop the session so the block takes effect at once. - request.session.clear() + # But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale + # per-tab header account just 401s that one tab (other tabs' default may still be valid). + if is_default: + request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") - # Always keep the active account in the switchable list — covers sessions created before + # Always keep the session default in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. + default_id = request.session.get("user_id") accounts = request.session.get("account_ids") or [] - if user_id not in accounts: - accounts.append(user_id) + if default_id and default_id not in accounts: + accounts.append(default_id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] return user diff --git a/backend/app/main.py b/backend/app/main.py index 944e294..7115870 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -131,6 +131,13 @@ app.include_router(setup_routes.router) # The built SPA (populated by the Docker frontend build stage). STATIC_DIR = Path(__file__).parent / "static_spa" +# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be +# heuristically cached — otherwise, after a deploy, a browser keeps serving the old index.html +# (pointing at the previous bundle) and runs stale code until a hard refresh. `no-cache` lets it +# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The +# hashed bundles under /assets can cache forever (a new build changes their filename). +INDEX_HTML = STATIC_DIR / "index.html" +INDEX_HEADERS = {"Cache-Control": "no-cache"} app.mount( "/assets", StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), @@ -140,7 +147,7 @@ app.mount( @app.get("/") async def index() -> FileResponse: - return FileResponse(STATIC_DIR / "index.html") + return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) @app.get("/{full_path:path}") @@ -155,4 +162,4 @@ async def spa_fallback(full_path: str) -> FileResponse: candidate = (STATIC_DIR / full_path).resolve() if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: return FileResponse(candidate) - return FileResponse(STATIC_DIR / "index.html") + return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 9718e1d..53a68e8 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -363,7 +363,19 @@ async def messages_ws(ws: WebSocket) -> None: """Live push channel: authenticated by the session cookie, registers the connection so sent messages reach this user's open tabs instantly. We don't consume client→server frames (sending goes through POST /api/messages); the receive loop only detects disconnect.""" - uid = ws.session.get("user_id") if "session" in ws.scope else None + # A browser WebSocket can't send custom headers, so the per-tab account (see + # X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for + # an account already in this browser's wallet. Falls back to the session default. + sess = ws.session if "session" in ws.scope else {} + uid = sess.get("user_id") + q = ws.query_params.get("account") + if q: + try: + qid = int(q) + except ValueError: + qid = None + if qid is not None and qid in (sess.get("account_ids") or []): + uid = qid if not uid: await ws.close(code=1008) return diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 0518be8..578a7ec 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -20,7 +20,7 @@ import { Tv, UserPlus, } from "lucide-react"; -import { api, type Me } from "../lib/api"; +import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; @@ -94,7 +94,14 @@ export default function NavSidebar({ }, [acctOpen]); async function logout() { - await fetch("/auth/logout", { method: "POST", credentials: "include" }); + // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then + // drops this tab's override and reloads onto whatever account remains the default. + try { + await api.logout(); + } catch { + /* clear locally and reload regardless */ + } + clearActiveAccount(); location.reload(); } @@ -106,21 +113,20 @@ export default function NavSidebar({ }); const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); - async function switchTo(id: number) { - try { - await api.switchAccount(id); - // Drop the previous account's in-module sub-view/overlay before the reload (which would - // otherwise preserve history.state across the swap). Otherwise the new account lands on a - // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity - // (often the new account's own id → an empty self-thread). Start at the module root instead. - const st = { ...(window.history.state || {}) }; - delete st._sub; - delete st._ov; - window.history.replaceState(st, ""); - location.reload(); // cleanest way to reload all per-user state for the new account - } catch { - /* a revoked/removed account — leave the popover open */ - } + function switchTo(id: number) { + // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own + // identity. No server round-trip — req() sends the account header (validated against the + // browser wallet server-side), so the cookie's default account is left untouched. + setActiveAccount(id); + // Drop the previous account's in-module sub-view/overlay before the reload (which would + // otherwise preserve history.state across the swap). Otherwise the new account lands on a + // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity + // (often the new account's own id → an empty self-thread). Start at the module root instead. + const st = { ...(window.history.state || {}) }; + delete st._sub; + delete st._ov; + window.history.replaceState(st, ""); + location.reload(); // cleanest way to reload all per-user state for the new account } // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cc2a873..9dee231 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -266,6 +266,38 @@ const setupHeaders = (token: string) => ({ "X-Setup-Token": token, }); +// --- Per-tab active account -------------------------------------------------------------- +// The signed session cookie is the browser's account "wallet"; which account a given TAB acts +// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in +// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. +const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; +const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount"; + +export function getActiveAccount(): number | null { + try { + const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; + } catch { + return null; + } +} +export function setActiveAccount(id: number): void { + try { + sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); + } catch { + /* ignore */ + } +} +export function clearActiveAccount(): void { + try { + sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); + } catch { + /* ignore */ + } +} + async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise { const method = opts.method ?? "GET"; const canRetry = cfg.idempotent ?? method === "GET"; @@ -274,9 +306,15 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr for (;;) { let r: Response; try { + const active = getActiveAccount(); r = await fetch(url, { credentials: "include", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + // Per-tab identity override (only setup calls pass their own headers, and those are + // pre-auth, so this is safe to put in the defaults that `...opts` may replace). + ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), + }, ...opts, }); } catch (e) { @@ -600,6 +638,10 @@ export const api = { req("/api/me/account", { method: "DELETE" }), switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), + // Signs the current tab's active account out of the browser wallet (per-tab aware via the + // X-Siftlode-Account header that req() attaches). + logout: (): Promise<{ ok: boolean; switched: boolean }> => + req("/auth/logout", { method: "POST" }), version: (): Promise => req("/api/version"), tags: (): Promise => req("/api/tags"), status: (): Promise => req("/api/sync/status"), diff --git a/frontend/src/lib/messagesSocket.ts b/frontend/src/lib/messagesSocket.ts index cafe909..73acd5e 100644 --- a/frontend/src/lib/messagesSocket.ts +++ b/frontend/src/lib/messagesSocket.ts @@ -2,7 +2,7 @@ // to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the // thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the // safety net if the socket is down. -import type { Message, MessageUser } from "./api"; +import { getActiveAccount, type Message, type MessageUser } from "./api"; // A pushed message plus both parties, so the dock can open/flash the right window. export interface IncomingMessage { @@ -20,7 +20,11 @@ let reconnectTimer: ReturnType | null = null; function url(): string { const proto = location.protocol === "https:" ? "wss" : "ws"; - return `${proto}://${location.host}/api/messages/ws`; + // A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the + // query string (validated against the browser wallet server-side). + const account = getActiveAccount(); + const qs = account != null ? `?account=${account}` : ""; + return `${proto}://${location.host}/api/messages/ws${qs}`; } function open() {