From 34baca43658c8b2ad97dc0cf6f26ab972f85c1de Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 18:44:45 -0700 Subject: [PATCH] Account switcher: more than one account signed in at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone who looks after several mailboxes of their own can now keep them all signed in in one browser and move between them from the account menu, without signing out (multi-account spec, MA-B; forum topic 75). How it holds them. The session cookie is unchanged: it is the account in front, and every request is answered with it, so nothing else in the server changes. The others ride in a second cookie, _more, as a list of their own session cookies. Each session stays its own -- sealed credential, expiry and "this is my device" -- and nothing about one is read through another. At most 5 in all, all on this mail server. - Add account (account menu): with sign-in on the mail server's page it goes there with prompt=login, so the server asks again rather than reuse the first sign-in; with the password form, a small dialog asks. Refused, and the front stays, when either account's organization has addAccounts off (inbuxa:SharingPolicy), the account is on another server, or 5 are open. The same account again just comes to the front. - Switching (POST /api/auth/accounts//front) swaps it into front; the web app clears what it cached for the previous account and reloads. A message being written blocks the switch. - Sign out ends only the account in front, and the next one comes forward; Sign out of all accounts ends every one. - GET /api/auth/accounts lists them, the front first, and says whether one more may be added. Not in this change: unread counts and notifications for the accounts not in front (MA-8), which the spec puts last. The mock can sign in a second user (MOCK_SECOND_USER/PASS) and answer addAccounts false (MOCK_NO_ADD_ACCOUNTS), for the new server tests: two accounts joining, switching, the same account twice, a switch to a session it doesn't hold, signing out of one and of all, and an organization that forbids it; and the OAuth start asking prompt=login. The client tests cover listing, switching (cache cleared, reload) and both sign-outs. Checked in Chrome against the mock: add, switch, sign out of one. New strings (9, English only in the other ten catalogs): "Add account", "Sign out of all accounts", "Add an account", "Both accounts stay signed in here; switch between them from this menu.", "Working…", "That account couldn't be added.", "You can't add more accounts here.", and the other-server and organization refusals. typecheck, tests (web 1538, server 276) and build pass. --- server/src/accounts.test.ts | 141 +++++++++++++++ server/src/accounts.ts | 58 +++++++ server/src/app.ts | 161 +++++++++++++++++- server/src/mock/index.ts | 25 ++- server/src/oauth.test.ts | 20 +++ server/src/oauth.ts | 13 +- .../store/__tests__/account-switcher.test.ts | 77 +++++++++ web/src/store/session.ts | 58 ++++++- web/src/views/AddAccountDialog.tsx | 111 ++++++++++++ web/src/views/AppShell.tsx | 61 +++++++ 10 files changed, 716 insertions(+), 9 deletions(-) create mode 100644 server/src/accounts.test.ts create mode 100644 server/src/accounts.ts create mode 100644 web/src/store/__tests__/account-switcher.test.ts create mode 100644 web/src/views/AddAccountDialog.tsx diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts new file mode 100644 index 0000000..431dd5e --- /dev/null +++ b/server/src/accounts.test.ts @@ -0,0 +1,141 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +/** + * inbuxa MA-B: more than one account signed in in one browser. The session + * cookie is the account in front; the others ride in `_more`. Adding + * signs a second account in beside the first, switching swaps them, signing + * out ends only the one in front, and an organization that doesn't allow it + * keeps adding off. + */ + +const PORT = 18801; +process.env.MOCK_PORT = String(PORT); +process.env.MOCK_USER = "first@example.com"; +process.env.MOCK_PASS = "first-password"; +process.env.MOCK_SECOND_USER = "second@example.com"; +process.env.MOCK_SECOND_PASS = "second-password"; +process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`; +process.env.APP_SECRET = "test-secret-for-accounts"; + +const mock = await import("./mock/index.js"); +const { createApp } = await import("./app.js"); +const { config } = await import("./config.js"); +const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js"); + +const app = createApp(); +const FRONT = config.cookieName; +const MORE = `${config.cookieName}_more`; + +after(() => { + (mock as { server?: { close(): void } }).server?.close(); +}); + +/** A browser's cookie jar, as far as these two cookies go. */ +class Browser { + jar = new Map(); + + private take(res: Response) { + for (const line of res.headers.getSetCookie()) { + const [pair, ...attrs] = line.split(";"); + const at = pair!.indexOf("="); + const name = pair!.slice(0, at).trim(); + const value = pair!.slice(at + 1).trim(); + const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a)); + if (expired || !value) this.jar.delete(name); + else this.jar.set(name, value); + } + } + + async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> { + const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; "); + const res = await app.request(path, { + method: init.method ?? "GET", + headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }); + this.take(res); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; + } + + signIn(username: string, password: string, add = false) { + return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } }); + } + + async accounts(): Promise<{ username: string; front: boolean; id: string }[]> { + const res = await this.call("/api/auth/accounts"); + assert.equal(res.status, 200, JSON.stringify(res.body)); + return res.body.accounts; + } +} + +test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => { + const good = "abcdefghij.ABCDEFGHIJKLMN"; + assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]); + const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`); + assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1); + assert.equal(serializeOthers(["bad", good]), good); +}); + +test("a second account joins the first, and switching swaps them", async () => { + const b = new Browser(); + assert.equal((await b.signIn("first@example.com", "first-password")).status, 200); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + const first = b.jar.get(FRONT); + + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + assert.equal(added.body.added, true); + assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one"); + let accounts = await b.accounts(); + assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["second@example.com", true], ["first@example.com", false]]); + + // The same account again is not a second copy + await b.signIn("first@example.com", "first-password", true); + accounts = await b.accounts(); + assert.equal(accounts.length, 2); + assert.equal(accounts[0]!.username, "first@example.com", "it came to the front instead"); + + // Switch back + const second = accounts.find((a) => !a.front)!; + assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200); + assert.equal((await b.accounts())[0]!.username, "second@example.com"); + + // Signing out ends only the one in front; the other comes forward + const out = await b.call("/api/auth/logout", { method: "POST" }); + assert.equal(out.body.next, true); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + + // Sign out of all + await b.signIn("second@example.com", "second-password", true); + assert.equal((await b.accounts()).length, 2); + await b.call("/api/auth/logout-all", { method: "POST" }); + assert.equal(b.jar.has(FRONT), false); + assert.equal(b.jar.has(MORE), false); + assert.equal((await b.call("/api/auth/accounts")).status, 401); +}); + +test("an account in front can't switch to one it doesn't hold", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404); +}); + +test("an organization that doesn't allow it keeps adding off", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + process.env.MOCK_NO_ADD_ACCOUNTS = "1"; + try { + // The cached upstream session is a minute old at most; ask afresh + await b.call("/api/auth/session?refresh=1"); + const res = await b.call("/api/auth/accounts"); + assert.equal(res.body.canAdd, false); + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 403); + assert.equal(added.body.error, "add_not_allowed"); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"], "the front stayed"); + } finally { + delete process.env.MOCK_NO_ADD_ACCOUNTS; + } +}); diff --git a/server/src/accounts.ts b/server/src/accounts.ts new file mode 100644 index 0000000..820a8ae --- /dev/null +++ b/server/src/accounts.ts @@ -0,0 +1,58 @@ +/** + * More than one signed-in account in a browser (multi-account spec, MA-B). + * + * The session cookie is unchanged: it is the account in front, and every + * request is answered with it, so nothing else in the server has to know + * there may be others. The others ride in a second cookie, `_more`: a + * list of their own session cookies, each `id.secret` exactly as the front one + * is. Switching swaps one of them into front; adding moves the front one into + * the list. Each session stays its own -- its own sealed credential, its own + * expiry, its own "this is my device" -- and nothing about one can be read + * through another. + * + * At most `MAX_ACCOUNTS` in all, all on the same mail server (MA-9), and only + * while both accounts' organizations allow it (`addAccounts`, MA-C). + */ +import type { UpstreamSession } from "./upstream.js"; + +export const MAX_ACCOUNTS = 5; + +/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */ +const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/; +const SEP = "~"; + +export function parseOthers(value: string | undefined): string[] { + if (!value) return []; + const seen = new Set(); + const out: string[] = []; + for (const part of value.split(SEP)) { + if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue; + seen.add(part); + out.push(part); + if (out.length >= MAX_ACCOUNTS - 1) break; + } + return out; +} + +export function serializeOthers(cookies: string[]): string { + return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP); +} + +/** + * Whether the account behind `upstream` may have other accounts beside it: + * `addAccounts` on its own account's `urn:inbuxa:jmap` capability. A server + * that doesn't say (an older one, or not inbuxa) allows it, as before. + */ +export function mayAddAccounts(upstream: UpstreamSession): boolean { + const primary = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(upstream.accounts ?? {})[0]; + const account = primary ? (upstream.accounts?.[primary] as { accountCapabilities?: Record } | undefined) : undefined; + const inbuxa = account?.accountCapabilities?.["urn:inbuxa:jmap"] as { addAccounts?: unknown } | undefined; + return inbuxa?.addAccounts !== false; +} + +/** Why an account can't be added, in words for the person. */ +export const ADD_REFUSED: Record = { + add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`, + add_not_allowed: "Your organization doesn't allow adding other accounts here.", + add_other_server: "That account is on another mail server. Only accounts on this server can be added.", +}; diff --git a/server/src/app.ts b/server/src/app.ts index c350bab..86fec4b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -45,6 +45,7 @@ import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwo import { icsProxyHandler } from "./icsproxy.js"; import { staticHandler } from "./static.js"; import { mailNode, webmailNode } from "./nodes.js"; +import { ADD_REFUSED, MAX_ACCOUNTS, mayAddAccounts, parseOthers, serializeOthers } from "./accounts.js"; type Env = { Variables: { session: LiveSession } }; @@ -330,6 +331,93 @@ function setSessionCookie(c: Context, value: string, remember: boolean) { }); } +/* + * inbuxa MA-B: the other signed-in accounts, beside the one in front. See + * accounts.ts. Kept across a browser restart only when every account in it + * would be (MA-7). + */ +const OTHERS_COOKIE = `${config.cookieName}_more`; + +function setOthersCookie(c: Context, cookies: string[]) { + if (!cookies.length) { + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return; + } + const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true); + setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), { + httpOnly: true, + sameSite: "Lax", + secure: isSecureRequest(c), + path: cookiePath, + ...(remember ? { maxAge: config.sessionRememberTtl } : {}), + }); +} + +/** The other accounts still signed in, in their order; ended ones are left out. */ +function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { + const out: { cookie: string; session: LiveSession }[] = []; + for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) { + const session = sessions.resolve(cookie); + if (session) out.push({ cookie, session }); + } + return out; +} + +/** Whether the account in front may have more beside it, or why not. */ +async function addRefusal(c: Context, front: LiveSession): Promise { + if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full"; + try { + const upstream = await getUpstreamSession(front.id, front.authorization, upstreamFor(front.username)); + if (!mayAddAccounts(upstream)) return "add_not_allowed"; + } catch { + return "add_not_allowed"; + } + return null; +} + +/** + * A session just signed in to be added beside the one in front (MA-B). It + * comes to the front and the old front joins the others; or, refused, it is + * ended and the front stays. Returns the refusal, or null. + */ +async function joinAccount( + c: Context, + created: { cookie: string; session: LiveSession }, + upstream: Awaited>, +): Promise { + const frontCookie = getCookie(c, config.cookieName); + const front = sessions.resolve(frontCookie); + if (!front || !frontCookie) { + // Nobody in front any more: an ordinary sign-in + setSessionCookie(c, created.cookie, created.session.remember); + return null; + } + const others = liveOthers(c); + const end = (code: string | null) => { + sessions.destroy(created.session.id); + forgetUpstreamSession(created.session.id); + return code; + }; + if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server"); + // Both organizations must allow it + if (!mayAddAccounts(upstream)) return end("add_not_allowed"); + const frontRefusal = await addRefusal(c, front); + if (frontRefusal === "add_not_allowed") return end(frontRefusal); + // Already open: that one comes to the front instead of a second copy + if (created.session.account === front.account) return end(null); + const existing = others.find((o) => o.session.account === created.session.account); + if (existing) { + end(null); + setSessionCookie(c, existing.cookie, existing.session.remember); + setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]); + return null; + } + if (1 + others.length >= MAX_ACCOUNTS) return end("add_full"); + setSessionCookie(c, created.cookie, created.session.remember); + setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]); + return null; +} + function upstreamFailure(c: Context, err: unknown) { if (err instanceof UpstreamError) { return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502); @@ -406,8 +494,19 @@ export function createApp(basePath = config.basePath): Hono { return c.redirect(`${basePath}/?signin_error=rate_limited`, 302); } const username = (c.req.query("username") ?? "").trim().slice(0, 320); + // inbuxa MA-B: another account beside the one in front, if it may have one + const front = c.req.query("add") === "1" ? sessions.resolve(getCookie(c, config.cookieName)) : null; + if (front) { + const refused = await addRefusal(c, front); + if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302); + } try { - const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" }); + const { location, state } = await startSignIn({ + username, + base: upstreamFor(username), + remember: c.req.query("remember") === "1", + adding: front !== null, + }); setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 }); return c.redirect(location, 302); } catch (err) { @@ -452,6 +551,11 @@ export function createApp(basePath = config.basePath): Hono { userAgent: c.req.header("user-agent") ?? "", ip: clientIp(c), }); + if (result.adding) { + const refused = await joinAccount(c, { cookie, session }, upstream); + if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302); + return c.redirect(`${basePath}/`, 302); + } setSessionCookie(c, cookie, session.remember); const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"]; if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session)); @@ -473,7 +577,7 @@ export function createApp(basePath = config.basePath): Hono { c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp))); return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429); } - let body: { username?: string; password?: string; totp?: string; remember?: boolean }; + let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean }; try { body = await c.req.json(); } catch { @@ -536,6 +640,12 @@ export function createApp(basePath = config.basePath): Hono { userAgent: c.req.header("user-agent") ?? "", ip, }); + // inbuxa MA-B: beside the account in front, when that's what was asked + if (body.add && sessions.resolve(getCookie(c, config.cookieName))) { + const refused = await joinAccount(c, { cookie, session }, upstream); + if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403); + return c.json({ ok: true, added: true }); + } setSessionCookie(c, cookie, session.remember); // Start the account's push subscription now, so it is usually verified // by the time the browser opens its stream. See push.ts. @@ -606,7 +716,54 @@ export function createApp(basePath = config.basePath): Hono { sessions.destroy(session.id); forgetUpstreamSession(session.id); } + // inbuxa MA-B: only this account ends; the next one comes to the front + const [next, ...rest] = liveOthers(c); + if (next) { + setSessionCookie(c, next.cookie, next.session.remember); + setOthersCookie(c, rest.map((o) => o.cookie)); + return c.json({ ok: true, next: true }); + } deleteCookie(c, config.cookieName, { path: cookiePath }); + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return c.json({ ok: true }); + }); + + /* inbuxa MA-B: every account signed in here ends. */ + api.post("/auth/logout-all", async (c) => { + const front = sessions.resolve(getCookie(c, config.cookieName)); + for (const session of [front, ...liveOthers(c).map((o) => o.session)]) { + if (!session) continue; + sessions.destroy(session.id); + forgetUpstreamSession(session.id); + } + deleteCookie(c, config.cookieName, { path: cookiePath }); + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return c.json({ ok: true }); + }); + + /* inbuxa MA-B: the accounts signed in here, the one in front first, and whether one more may be added. */ + api.get("/auth/accounts", requireSession, async (c) => { + const front = c.get("session"); + const others = liveOthers(c); + if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) { + setOthersCookie(c, others.map((o) => o.cookie)); + } + const canAdd = (await addRefusal(c, front)) === null; + return c.json({ + accounts: [front, ...others.map((o) => o.session)].map((s, i) => ({ id: s.id, username: s.username, front: i === 0 })), + canAdd, + max: MAX_ACCOUNTS, + }); + }); + + /* inbuxa MA-B: bring another signed-in account to the front. */ + api.post("/auth/accounts/:id/front", requireSession, async (c) => { + const frontCookie = getCookie(c, config.cookieName)!; + const others = liveOthers(c); + const chosen = others.find((o) => o.session.id === c.req.param("id")); + if (!chosen) return c.json({ error: "not_found" }, 404); + setSessionCookie(c, chosen.cookie, chosen.session.remember); + setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]); return c.json({ ok: true }); }); diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 491e2de..748ae31 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -23,9 +23,25 @@ function unauthorized(res: ServerResponse) { res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); } +/* + * inbuxa MA-B: an optional second user, so the account switcher has two real + * accounts to move between. It signs in with a password only, and reads the + * same mailbox: what the tests look at is who the session says it is. + */ +const SECOND_USER = process.env.MOCK_SECOND_USER; +const SECOND_PASS = process.env.MOCK_SECOND_PASS; + +/** Who a Basic header signs in as, when it is the second user. */ +function secondUser(req: IncomingMessage): boolean { + const h = req.headers.authorization ?? ""; + if (!SECOND_USER || !h.startsWith("Basic ")) return false; + return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`; +} + function checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; if (checkBearer(h)) return true; + if (secondUser(req)) return true; if (!h.startsWith("Basic ") || basicRefused) return false; const raw = Buffer.from(h.slice(6), "base64").toString(); const sep = raw.indexOf(":"); @@ -83,7 +99,14 @@ export const server = createServer(async (req, res) => { if (!checkAuth(req)) return unauthorized(res); if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { res.writeHead(200, { "content-type": "application/json" }); - return res.end(JSON.stringify(session())); + const answer = session() as ReturnType & { username: string }; + if (secondUser(req)) answer.username = SECOND_USER!; + // MA-C: an organization that doesn't allow adding accounts + if (process.env.MOCK_NO_ADD_ACCOUNTS === "1") { + const own = answer.accounts[ACCOUNT] as { accountCapabilities: Record }; + own.accountCapabilities = { ...own.accountCapabilities, "urn:inbuxa:jmap": { addAccounts: false } }; + } + return res.end(JSON.stringify(answer)); } // The account info endpoint; the only place a server reports its edition. if (url.pathname === "/api/account" && req.method === "GET") { diff --git a/server/src/oauth.test.ts b/server/src/oauth.test.ts index 236111c..e15528b 100644 --- a/server/src/oauth.test.ts +++ b/server/src/oauth.test.ts @@ -217,3 +217,23 @@ test("push keeps a credential that renews itself", async () => { assert.notEqual(second, first, "a fresh access token"); assert.match(second, /^Bearer mock-at-/); }); + +test("inbuxa MA-B: adding an account asks the server's page to sign in again", async () => { + // With nobody in front, add=1 is an ordinary sign-in + let res = await call("/api/auth/oauth/start?username=demo@example.com&add=1"); + assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false); + + await signIn(); + res = await call("/api/auth/oauth/start?username=demo@example.com&add=1"); + assert.equal(res.status, 302); + const signInPage = new URL(res.headers.get("location")!); + assert.equal(signInPage.searchParams.get("prompt"), "login", "the server's page must not reuse the first sign-in"); + + // The mock's page signs the same account in again: it stays one account + const approved = await fetch(signInPage, { redirect: "manual" }); + const back = new URL(approved.headers.get("location")!); + res = await call(`/api/auth/callback${back.search}`); + assert.equal(res.headers.get("location"), "/"); + const list = await jsonOf(await call("/api/auth/accounts")); + assert.deepEqual(list.accounts.map((a: { username: string }) => a.username), ["demo@example.com"]); +}); diff --git a/server/src/oauth.ts b/server/src/oauth.ts index e9c81d0..9cd7598 100644 --- a/server/src/oauth.ts +++ b/server/src/oauth.ts @@ -97,6 +97,8 @@ interface Pending { base: string; username: string; remember: boolean; + /** MA-B: signing in a second account beside the one in front. */ + adding: boolean; createdAt: number; } @@ -114,13 +116,13 @@ function challengeOf(verifier: string): string { * Begin a sign-in. Returns where to send the browser, and the state to bind * to it in a cookie. */ -export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> { +export async function start(params: { username: string; base: string; remember: boolean; adding?: boolean }): Promise<{ location: string; state: string }> { const metadata = await metadataFor(params.base); sweepPending(); if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503); const state = randomToken(24); const verifier = randomToken(48); - pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() }); + pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, adding: Boolean(params.adding), createdAt: Date.now() }); const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" "); const url = new URL(metadata.authorizationEndpoint); url.searchParams.set("response_type", "code"); @@ -131,6 +133,9 @@ export async function start(params: { username: string; base: string; remember: url.searchParams.set("code_challenge", challengeOf(verifier)); url.searchParams.set("code_challenge_method", "S256"); if (params.username) url.searchParams.set("login_hint", params.username); + // MA-B: ask again, rather than let the server's page reuse the sign-in of + // the account already in front + if (params.adding) url.searchParams.set("prompt", "login"); return { location: url.toString(), state }; } @@ -177,7 +182,7 @@ export class SignInError extends Error { * Finish a sign-in: `state` as it came back in the URL, `boundState` as the * browser's cookie holds it. Each state is good for one attempt. */ -export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> { +export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean; adding: boolean }> { const p = pending.get(params.state); if (!p || !params.boundState || params.boundState !== params.state) { throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again."); @@ -192,7 +197,7 @@ export async function finish(params: { state: string; boundState: string | undef redirect_uri: redirectUri(), }); if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again."); - return { tokens, base: p.base, username: p.username, remember: p.remember }; + return { tokens, base: p.base, username: p.username, remember: p.remember, adding: p.adding }; } /** diff --git a/web/src/store/__tests__/account-switcher.test.ts b/web/src/store/__tests__/account-switcher.test.ts new file mode 100644 index 0000000..b5022f5 --- /dev/null +++ b/web/src/store/__tests__/account-switcher.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSession } from "@/store/session"; + +/** + * inbuxa MA-B: the accounts signed in in this browser. The server lists them + * and swaps the one in front; the web app clears what it cached for the + * previous account and reloads, and signing out of one reloads into the next. + */ + +let reload: ReturnType; +let calls: { url: string; method: string }[]; +let answers: Record; + +beforeEach(() => { + reload = vi.fn(); + Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } }); + calls = []; + answers = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const path = String(url); + calls.push({ url: path, method: init?.method ?? "GET" }); + const key = Object.keys(answers).find((k) => path.endsWith(k)); + const body = key ? answers[key] : { ok: true }; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response; + }), + ); + localStorage.setItem("ihasmail:cached-thing", "from the account in front"); + useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("account switcher", () => { + it("lists the accounts and whether one more may be added", async () => { + answers["/api/auth/accounts"] = { + accounts: [ + { id: "a", username: "first@example.com", front: true }, + { id: "b", username: "second@example.com", front: false }, + ], + canAdd: true, + }; + await useSession.getState().loadSignedIn(); + expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["first@example.com", "second@example.com"]); + expect(useSession.getState().canAddAccount).toBe(true); + }); + + it("switching asks the server, forgets the cache and reloads", async () => { + await useSession.getState().switchTo("b"); + expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true); + expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("signing out of one reloads into the next when there is one", async () => { + answers["/api/auth/logout"] = { ok: true, next: true }; + await useSession.getState().logout(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("signing out of the last one, or of all, ends at the sign-in page", async () => { + answers["/api/auth/logout-all"] = { ok: true }; + await useSession.getState().logoutAll(); + expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true); + expect(reload).not.toHaveBeenCalled(); + expect(useSession.getState().status).toBe("anonymous"); + // The next ordinary sign-out goes back to signing out of one + useSession.setState({ status: "authenticated" }); + answers["/api/auth/logout"] = { ok: true }; + await useSession.getState().logout(); + expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1); + }); +}); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 1a9582f..9f46b14 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -28,6 +28,10 @@ interface SessionState { viewing: Id | null; /** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */ sharedMail: SharedMailAccount[]; + /** inbuxa MA-B: the accounts signed in in this browser, the one in front first. */ + signedIn: SignedInAccount[]; + /** Whether one more may be added (the cap, and both organizations' addAccounts). */ + canAddAccount: boolean; /** The name of an account the reader lost while it was in view. */ delegationEnded: string | null; error: string | null; @@ -36,7 +40,14 @@ interface SessionState { pushState: PushState; bootstrap(): Promise; login(username: string, password: string, totp: string, remember: boolean): Promise; + /** Signs out of the account in front; another signed-in one comes forward. */ logout(): Promise; + /** inbuxa MA-B: ends every account signed in in this browser. */ + logoutAll(): Promise; + /** inbuxa MA-B: finds the other accounts signed in here. */ + loadSignedIn(): Promise; + /** inbuxa MA-B: brings another signed-in account to the front, and reloads. */ + switchTo(sessionId: string): Promise; refresh(): Promise; setAccount(id: Id): void; /** Show a delegated account's or shared mailbox's mail, or the reader's own with null. */ @@ -59,12 +70,24 @@ interface SessionState { let refreshing: Promise | null = null; +/** What a signed-in account looks like in the switcher (MA-B). */ +export interface SignedInAccount { + id: string; + username: string; + front: boolean; +} + +/** Which sign-out: the account in front, or every one (MA-B). */ +let signOutPath = "/api/auth/logout"; + export const useSession = create((set, get) => ({ status: "loading", session: null, accountId: null, viewing: null, sharedMail: [], + signedIn: [], + canAddAccount: false, delegationEnded: null, error: null, pushConnected: false, @@ -116,8 +139,11 @@ export const useSession = create((set, get) => ({ } catch { /* never block signing out over this */ } + const path = signOutPath; + signOutPath = "/api/auth/logout"; + let next = false; try { - await apiFetch("/api/auth/logout", { method: "POST" }); + next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next); } catch { /* ignore */ } @@ -127,7 +153,34 @@ export const useSession = create((set, get) => ({ // problem next -- and the address book cached here is the same argument. clearSignedInData(); client.session = null; - set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); + // inbuxa MA-B: another signed-in account is in front now + if (next) { + window.location.reload(); + return; + } + set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false }); + }, + + async logoutAll() { + // The same care as signing out of one, then every account ends + signOutPath = "/api/auth/logout-all"; + await get().logout(); + }, + + async loadSignedIn() { + try { + const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts"); + set({ signedIn: answer.accounts, canAddAccount: answer.canAdd }); + } catch { + set({ signedIn: [], canAddAccount: false }); + } + }, + + async switchTo(sessionId) { + await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" }); + // What was cached belongs to the account that was in front + clearSignedInData(); + window.location.reload(); }, refresh() { @@ -213,6 +266,7 @@ function applySession(s: JmapSession, set: (p: Partial) => void) { const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null; set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null }); void useSession.getState().loadSharedMail(); + void useSession.getState().loadSignedIn(); } client.onUnauthenticated(() => { diff --git a/web/src/views/AddAccountDialog.tsx b/web/src/views/AddAccountDialog.tsx new file mode 100644 index 0000000..2f76250 --- /dev/null +++ b/web/src/views/AddAccountDialog.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Dialog } from "@/ui/dialog"; +import { apiFetch } from "@/jmap/client"; +import { withBase } from "@/lib/basePath"; +import { clearSignedInData, isDeviceTrusted } from "@/lib/storage"; +import { t } from "@/lib/i18n"; + +/** + * inbuxa MA-B: sign a second account in beside the one in front. + * + * With sign-in on the mail server's own page and one mail server, there is + * nothing to ask here: the browser goes straight to that page, which asks for + * the account. With several servers the address comes first, to pick one; with + * the password form, so does the password. Either way the account joins the + * others and comes to the front, and the app reloads into it. + * + * It is remembered on this device exactly as the first one was (MA-7). + */ +export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const [mode, setMode] = useState<"oauth" | "oauth-address" | "password" | null>(null); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const remember = isDeviceTrusted(); + + useEffect(() => { + if (!open) return; + setError(null); + let live = true; + fetch(withBase("/api/config")) + .then((r) => (r.ok ? r.json() : null)) + .then((c) => { + if (!live) return; + const oauth = c?.signIn === "oauth"; + const next = oauth ? (c?.signInDirect === true ? "oauth" : "oauth-address") : "password"; + setMode(next); + // Nothing to ask: off to the mail server's page + if (next === "oauth") goToServer(""); + }) + .catch(() => live && setMode("password")); + return () => { + live = false; + }; + // goToServer only reads `remember`, fixed for the dialog's life + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + function goToServer(address: string) { + setBusy(true); + // What is cached belongs to the account in front, which won't be + clearSignedInData(); + const params = new URLSearchParams({ add: "1", ...(address ? { username: address } : {}), ...(remember ? { remember: "1" } : {}) }); + window.location.assign(withBase(`/api/auth/oauth/start?${params}`)); + } + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!username.trim()) return; + if (mode === "oauth-address") return goToServer(username.trim()); + if (!password) return; + setBusy(true); + setError(null); + try { + await apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: username.trim(), password, remember, add: true }), + }); + clearSignedInData(); + window.location.reload(); + } catch (err) { + setError((err as Error).message || t("That account couldn't be added.")); + setBusy(false); + } + }; + + if (mode === "oauth") return null; + return ( + + + + + } + > +
void submit(e)}> +

{t("Both accounts stay signed in here; switch between them from this menu.")}

+
+ + setUsername(e.target.value)} autoFocus /> +
+ {mode === "password" && ( +
+ + setPassword(e.target.value)} /> +
+ )} + {error &&

{error}

} +
+
+ ); +} diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index a0a28a5..b032e04 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -11,6 +11,7 @@ import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose"; import { delegatedAccounts } from "@/lib/delegation"; import { toast } from "@/ui/toast"; import { DelegatedBar } from "./DelegatedBar"; +import { AddAccountDialog } from "./AddAccountDialog"; import { Avatar, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Splitter } from "@/ui/Splitter"; @@ -73,6 +74,35 @@ export function AppShell({ children }: { children: ReactNode }) { const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); + // inbuxa MA-B: the other accounts signed in here, and adding one + const signedIn = useSession((s) => s.signedIn); + const canAddAccount = useSession((s) => s.canAddAccount); + const [addingAccount, setAddingAccount] = useState(false); + const bringForward = (sessionId: string) => { + acctMenu.close(); + // A message being written belongs to the account it was started in + if (useCompose.getState().drafts.length) { + toast.show(t("Send or close the message you're writing first.")); + return; + } + void useSession.getState().switchTo(sessionId); + }; + // A refused add comes back on the address (see the server's /auth/callback) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const why = params.get("account_error"); + if (!why) return; + toast.show( + why === "add_full" + ? t("You can't add more accounts here.") + : why === "add_other_server" + ? t("That account is on another mail server. Only accounts on this server can be added.") + : t("Your organization doesn't allow adding other accounts here."), + ); + params.delete("account_error"); + const rest = params.toString(); + window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : "")); + }, []); const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const acctMenu = useMenu(); const administers = hasAdministration(usePermissions()); @@ -220,6 +250,33 @@ export function AppShell({ children }: { children: ReactNode }) { + {/* inbuxa MA-B: each account signed in here, the one in front ticked */} + {(signedIn.length > 1 || canAddAccount) && ( + <> + {signedIn.length > 1 &&
{t("Accounts")}
} + {signedIn.length > 1 && + signedIn.map((account) => ( + : } + label={{account.username}} + active={account.front} + onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))} + /> + ))} + {canAddAccount && ( + } + label={t("Add account")} + onClick={() => { + acctMenu.close(); + setAddingAccount(true); + }} + /> + )} + + + )} {delegated.length + sharedMail.length > 0 && ( <>
{t("Mail to show")}
@@ -277,7 +334,11 @@ export function AppShell({ children }: { children: ReactNode }) { )} } label={t("Refresh")} onClick={() => window.location.reload()} /> } label={t("Sign out")} onClick={() => void logout()} /> + {signedIn.length > 1 && ( + } label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} /> + )} + setAddingAccount(false)} />