Sign-out never cleared local storage. It stopped push, flushed settings and removed the subscription -- that last one reasoned explicitly that a browser left holding someone's mail becomes somebody else's next -- and then left the settings cache and the recently-addressed list on disk. That list is other people's addresses, and nothing ever removed it. Clearing it on sign-out is now unconditional, because lending a laptop is the same exposure as a public machine, only quieter. The keep-list is short and deliberate: lastUser, which only a trusted device writes; the trust flag; and the random push device id. Everything else goes, so a key added later is forgotten by default rather than by nobody having thought about it. "Keep me signed in on this device" defaulted to true, which assumed the answer most costly to get wrong -- someone on a library machine got a thirty-day cookie unless they noticed a ticked box. It now asks whose computer this is, defaults to not yours, and says what each answer does. Untrusted means a session cookie, nothing written locally, no push subscription, and a five minute idle sign-out. The idle timer is there because the alternative does not work: custom beforeunload text was removed from browsers years ago, and no event fires at all for walking away from a signed-in screen, which is the case that matters. A timer needs nobody's cooperation. Reads are gated as well as writes, since a machine trusted once still has the residue; an untrusted sign-in purges it outright. The wire keeps calling this `remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate every session file on upgrade for a change of vocabulary. Verified in a browser against the mock, not only in tests: untrusted sign-in leaves localStorage empty through a full session including folder expansion; trusted writes settings, recent and lastUser as before; sign-out clears recent and settings while keeping lastUser; an untrusted sign-in afterwards clears even that.
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
import { create } from "zustand";
|
|
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
|
import type { Id, JmapSession } from "@/jmap/types";
|
|
import { push, type PushState } from "@/jmap/push";
|
|
import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting";
|
|
import { setServerLocale } from "@/lib/datetime";
|
|
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
|
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
|
|
import { unsubscribeThisDevice } from "@/lib/webpush";
|
|
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
|
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
|
|
|
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
|
|
|
interface SessionState {
|
|
status: AuthStatus;
|
|
session: JmapSession | null;
|
|
/** Selected mail account (defaults to primary). */
|
|
accountId: Id | null;
|
|
error: string | null;
|
|
pushConnected: boolean;
|
|
/** Finer than pushConnected: tells "reconnecting" from "not connected". */
|
|
pushState: PushState;
|
|
bootstrap(): Promise<void>;
|
|
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
|
|
logout(): Promise<void>;
|
|
refresh(): Promise<void>;
|
|
setAccount(id: Id): void;
|
|
/** The account to read and write for a capability, honouring the account switcher. */
|
|
accountFor(cap: string): Id | null;
|
|
/** The user's own account for a capability, whatever they are looking at. */
|
|
ownAccountFor(cap: string): Id | null;
|
|
}
|
|
|
|
export const useSession = create<SessionState>((set, get) => ({
|
|
status: "loading",
|
|
session: null,
|
|
accountId: null,
|
|
error: null,
|
|
pushConnected: false,
|
|
pushState: "disconnected",
|
|
|
|
async bootstrap() {
|
|
try {
|
|
const s = await apiFetch<JmapSession>("/api/auth/session");
|
|
applySession(s, set);
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null });
|
|
else set({ status: "anonymous", error: (err as Error).message });
|
|
}
|
|
},
|
|
|
|
async login(username, password, totp, remember) {
|
|
set({ error: null });
|
|
const s = await apiFetch<JmapSession>("/api/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ username, password, totp: totp || undefined, remember }),
|
|
});
|
|
applySession(s, set);
|
|
},
|
|
|
|
async logout() {
|
|
push.stop();
|
|
setServerLocale(null);
|
|
// Anything still sitting in the debounce is written while the session can
|
|
// still write it; a setting changed seconds before signing out is not lost.
|
|
try {
|
|
await flushSettingsPush();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
// A push subscription lives on the account, not the session, so signing out
|
|
// without removing it leaves this browser notifying for a mailbox nobody is
|
|
// signed into. On a shared machine that is somebody else's mail.
|
|
try {
|
|
await unsubscribeThisDevice();
|
|
} catch {
|
|
/* never block signing out over this */
|
|
}
|
|
stopSettingsSync();
|
|
try {
|
|
await apiFetch("/api/auth/logout", { method: "POST" });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
stopIdleLogout();
|
|
// Unconditional. The push subscription above is removed for exactly this
|
|
// reason -- that a browser left holding someone's mail is somebody else's
|
|
// problem next -- and the address book cached here is the same argument.
|
|
clearSignedInData();
|
|
client.session = null;
|
|
set({ status: "anonymous", session: null, accountId: null });
|
|
},
|
|
|
|
async refresh() {
|
|
try {
|
|
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
|
client.session = s;
|
|
setServerLocale(s.ihasmail?.userLocale);
|
|
set({ session: s });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
},
|
|
|
|
setAccount(id) {
|
|
set({ accountId: id });
|
|
},
|
|
|
|
accountFor(cap) {
|
|
return accountForCapability(get().session, get().accountId, cap);
|
|
},
|
|
|
|
ownAccountFor(cap) {
|
|
return ownAccountForCapability(get().session, cap);
|
|
},
|
|
}));
|
|
|
|
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
|
client.session = s;
|
|
setServerLocale(s.ihasmail?.userLocale);
|
|
// `remember` is the answer to "is this device yours", given at sign-in and
|
|
// carried on the session -- so a reload arrives at the same answer without
|
|
// the client storing it, which on an untrusted device it could not do anyway.
|
|
const trusted = Boolean(s.ihasmail?.remember);
|
|
setDeviceTrusted(trusted);
|
|
if (trusted) {
|
|
stopIdleLogout();
|
|
} else {
|
|
// Residue from an earlier trusted session on this machine is exactly what
|
|
// an untrusted sign-in is asking us not to keep.
|
|
clearAllData();
|
|
startIdleLogout(() => void useSession.getState().logout());
|
|
}
|
|
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
|
set({ status: "authenticated", session: s, accountId, error: null });
|
|
}
|
|
|
|
client.onUnauthenticated(() => {
|
|
push.stop();
|
|
stopSettingsSync();
|
|
stopIdleLogout();
|
|
clearSignedInData();
|
|
client.session = null;
|
|
// Ask before showing the sign-in form rather than after. A deploy is the
|
|
// usual reason to be signed out here, and reloading a form someone has
|
|
// already started typing into would throw the password away.
|
|
void reloadIfServerRebuilt().then((reloading) => {
|
|
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null });
|
|
});
|
|
});
|
|
|
|
push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));
|
|
|
|
export function hasCap(cap: string): boolean {
|
|
return client.hasCapability(cap);
|
|
}
|