feat(auth): per-tab account — different account per browser tab
Two tabs in one browser can now run two different signed-in accounts at once. - The signed session cookie stays the browser's account WALLET (account_ids). Which account a given tab acts as is a per-tab choice held in sessionStorage and sent per-request via the X-Siftlode-Account header; current_user honours it only for an account already in the wallet, without mutating the cookie's default account. Switching accounts sets the header + reloads THIS tab only, instead of the old cookie-wide switch that changed every tab. - WebSocket can't send headers, so the per-tab account rides in the ?account= query param (validated against the wallet). - Logout is per-tab aware: it signs the requesting tab's active account out of the wallet (promoting a new default only if the removed one was the default), and the tab drops its override. A stale per-tab header account 401s just that tab instead of clearing the session. - Serve index.html with Cache-Control: no-cache so a deploy's new hashed bundle is picked up immediately instead of the browser running a heuristically-cached stale index.html.
This commit is contained in:
parent
c0487101fe
commit
5cd807ec51
6 changed files with 146 additions and 36 deletions
|
|
@ -419,16 +419,23 @@ def _complete_link(
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(request: Request):
|
async def logout(request: Request):
|
||||||
"""Sign out the active account. If other accounts have authenticated in this browser,
|
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
|
||||||
switch to the most recent one instead of fully logging out."""
|
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
|
||||||
active = request.session.get("user_id")
|
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]
|
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
||||||
if remaining:
|
|
||||||
request.session["account_ids"] = remaining
|
request.session["account_ids"] = remaining
|
||||||
|
if is_default:
|
||||||
|
if remaining:
|
||||||
request.session["user_id"] = remaining[-1]
|
request.session["user_id"] = remaining[-1]
|
||||||
return JSONResponse({"ok": True, "switched": True})
|
return JSONResponse({"ok": True, "switched": True})
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
return JSONResponse({"ok": True, "switched": False})
|
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})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-access")
|
@router.post("/request-access")
|
||||||
|
|
@ -659,20 +666,52 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
|
||||||
return {"ok": True}
|
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:
|
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:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None or not user.is_active or user.is_suspended:
|
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.
|
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
|
||||||
|
# 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()
|
request.session.clear()
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
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.
|
# 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 []
|
accounts = request.session.get("account_ids") or []
|
||||||
if user_id not in accounts:
|
if default_id and default_id not in accounts:
|
||||||
accounts.append(user_id)
|
accounts.append(default_id)
|
||||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,13 @@ app.include_router(setup_routes.router)
|
||||||
|
|
||||||
# The built SPA (populated by the Docker frontend build stage).
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
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(
|
app.mount(
|
||||||
"/assets",
|
"/assets",
|
||||||
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
|
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
|
||||||
|
|
@ -140,7 +147,7 @@ app.mount(
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index() -> FileResponse:
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
|
|
@ -155,4 +162,4 @@ async def spa_fallback(full_path: str) -> FileResponse:
|
||||||
candidate = (STATIC_DIR / full_path).resolve()
|
candidate = (STATIC_DIR / full_path).resolve()
|
||||||
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
|
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
|
||||||
return FileResponse(candidate)
|
return FileResponse(candidate)
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,19 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||||
"""Live push channel: authenticated by the session cookie, registers the connection so
|
"""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
|
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."""
|
(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:
|
if not uid:
|
||||||
await ws.close(code=1008)
|
await ws.close(code=1008)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
Tv,
|
Tv,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} 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 { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
|
|
@ -94,7 +94,14 @@ export default function NavSidebar({
|
||||||
}, [acctOpen]);
|
}, [acctOpen]);
|
||||||
|
|
||||||
async function logout() {
|
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();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,9 +113,11 @@ export default function NavSidebar({
|
||||||
});
|
});
|
||||||
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
|
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
|
||||||
|
|
||||||
async function switchTo(id: number) {
|
function switchTo(id: number) {
|
||||||
try {
|
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
|
||||||
await api.switchAccount(id);
|
// 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
|
// 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
|
// 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
|
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
||||||
|
|
@ -118,9 +127,6 @@ export default function NavSidebar({
|
||||||
delete st._ov;
|
delete st._ov;
|
||||||
window.history.replaceState(st, "");
|
window.history.replaceState(st, "");
|
||||||
location.reload(); // cleanest way to reload all per-user state for the new account
|
location.reload(); // cleanest way to reload all per-user state for the new account
|
||||||
} catch {
|
|
||||||
/* a revoked/removed account — leave the popover open */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,38 @@ const setupHeaders = (token: string) => ({
|
||||||
"X-Setup-Token": token,
|
"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<any> {
|
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
|
||||||
const method = opts.method ?? "GET";
|
const method = opts.method ?? "GET";
|
||||||
const canRetry = cfg.idempotent ?? method === "GET";
|
const canRetry = cfg.idempotent ?? method === "GET";
|
||||||
|
|
@ -274,9 +306,15 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||||
for (;;) {
|
for (;;) {
|
||||||
let r: Response;
|
let r: Response;
|
||||||
try {
|
try {
|
||||||
|
const active = getActiveAccount();
|
||||||
r = await fetch(url, {
|
r = await fetch(url, {
|
||||||
credentials: "include",
|
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,
|
...opts,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -600,6 +638,10 @@ export const api = {
|
||||||
req("/api/me/account", { method: "DELETE" }),
|
req("/api/me/account", { method: "DELETE" }),
|
||||||
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
||||||
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
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<VersionInfo> => req("/api/version"),
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||||
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
|
// 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
|
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
|
||||||
// safety net if the socket is down.
|
// 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.
|
// A pushed message plus both parties, so the dock can open/flash the right window.
|
||||||
export interface IncomingMessage {
|
export interface IncomingMessage {
|
||||||
|
|
@ -20,7 +20,11 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function url(): string {
|
function url(): string {
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
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() {
|
function open() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue