SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose email was never verified is untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. Both the Google-adoption callback and admin activation (_activate_user_by_email) now drop that pre-set password and bump the session epoch when the account isn't email_verified, so a later Google login / admin approval can't hand the pre-set password a working login. No-op for a verified account (the legit no-SMTP flow). +2 tests. SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs resolved to the session DEFAULT account, so a tab acting as a secondary account would link Google / the YouTube grant to the wrong account. The frontend now threads the active account through the navigation URL (withActiveAccount -> ?account=<active>), and _complete_link accepts any wallet account, not only the default, so the identity lands on the account that started the flow.
51 lines
2.6 KiB
TypeScript
51 lines
2.6 KiB
TypeScript
import { useSyncExternalStore } from "react";
|
|
|
|
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
|
|
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
|
|
// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the
|
|
// account id is a lower-level primitive than the storage helpers that consume it.
|
|
//
|
|
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
|
|
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
|
|
|
export function activeAccountId(): 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;
|
|
}
|
|
}
|
|
|
|
// Append `?account=<active>` to a top-level navigation URL (SEC-11). XHRs carry the active account in
|
|
// the X-Siftlode-Account header, but a full-page navigation (window.location) can't, so the OAuth
|
|
// link/upgrade GETs would resolve to the SESSION DEFAULT account instead of the tab's active one — the
|
|
// identity/token would then land on the wrong account. This threads the active account through the URL
|
|
// so resolved_user_id picks it. No-op when there's no distinct active account pinned yet.
|
|
export function withActiveAccount(path: string): string {
|
|
const id = activeAccountId();
|
|
if (id == null) return path;
|
|
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
|
|
}
|
|
|
|
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
|
|
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
|
|
// account) would never re-read once the account is finally known. That bites exactly once, on first
|
|
// load: the provider tree above <App> mounts with NO account, then <App> pins it after /api/me resolves.
|
|
// The account setters call notifyActiveAccountChange() so useActiveAccountId subscribers can re-hydrate.
|
|
const accountListeners = new Set<() => void>();
|
|
function subscribeActiveAccount(cb: () => void): () => void {
|
|
accountListeners.add(cb);
|
|
return () => accountListeners.delete(cb);
|
|
}
|
|
export function notifyActiveAccountChange(): void {
|
|
for (const cb of [...accountListeners]) cb();
|
|
}
|
|
/** The current tab's active account id, as reactive state — re-renders when it is (re)pinned. The
|
|
* snapshot is a primitive (number | null), so useSyncExternalStore's Object.is check is stable. */
|
|
export function useActiveAccountId(): number | null {
|
|
return useSyncExternalStore(subscribeActiveAccount, activeAccountId, activeAccountId);
|
|
}
|