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:
npeter83 2026-07-01 23:43:35 +02:00
parent c0487101fe
commit 5cd807ec51
6 changed files with 146 additions and 36 deletions

View file

@ -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

View file

@ -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<any> {
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<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),

View file

@ -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<typeof setTimeout> | 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() {