Account switcher: more than one account signed in at once
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
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, <name>_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/<id>/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.
This commit is contained in:
1 parent
9f53759462
commit
34baca4365
10 files changed
+716
-9
No files matched your search
@@ -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<typeof vi.fn>;
|
||||
let calls: { url: string; method: string }[];
|
||||
let answers: Record<string, unknown>;
|
||||
|
||||
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: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
canAdd: true,
|
||||
};
|
||||
await useSession.getState().loadSignedIn();
|
||||
expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["[email protected]", "[email protected]"]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
|
||||
/** Signs out of the account in front; another signed-in one comes forward. */
|
||||
logout(): Promise<void>;
|
||||
/** inbuxa MA-B: ends every account signed in in this browser. */
|
||||
logoutAll(): Promise<void>;
|
||||
/** inbuxa MA-B: finds the other accounts signed in here. */
|
||||
loadSignedIn(): Promise<void>;
|
||||
/** inbuxa MA-B: brings another signed-in account to the front, and reloads. */
|
||||
switchTo(sessionId: string): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
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<void> | 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<SessionState>((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<SessionState>((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<SessionState>((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<SessionState>) => 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(() => {
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("Add an account")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-primary" form="add-account" type="submit" disabled={busy || !username.trim() || (mode === "password" && !password)}>
|
||||
{busy ? t("Working…") : t("Add account")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="add-account" onSubmit={(e) => void submit(e)}>
|
||||
<p className="hint">{t("Both accounts stay signed in here; switch between them from this menu.")}</p>
|
||||
<div className="field">
|
||||
<label htmlFor="add-account-user">{t("Email address")}</label>
|
||||
<input id="add-account-user" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
</div>
|
||||
{mode === "password" && (
|
||||
<div className="field">
|
||||
<label htmlFor="add-account-pw">{t("Password")}</label>
|
||||
<input id="add-account-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</div>
|
||||
<MenuSep />
|
||||
{/* inbuxa MA-B: each account signed in here, the one in front ticked */}
|
||||
{(signedIn.length > 1 || canAddAccount) && (
|
||||
<>
|
||||
{signedIn.length > 1 && <div className="hint" style={{ padding: "4px 10px" }}>{t("Accounts")}</div>}
|
||||
{signedIn.length > 1 &&
|
||||
signedIn.map((account) => (
|
||||
<MenuItem
|
||||
key={account.id}
|
||||
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
|
||||
label={<span className="notranslate" translate="no">{account.username}</span>}
|
||||
active={account.front}
|
||||
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
|
||||
/>
|
||||
))}
|
||||
{canAddAccount && (
|
||||
<MenuItem
|
||||
icon={<Plus size={16} />}
|
||||
label={t("Add account")}
|
||||
onClick={() => {
|
||||
acctMenu.close();
|
||||
setAddingAccount(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuSep />
|
||||
</>
|
||||
)}
|
||||
{delegated.length + sharedMail.length > 0 && (
|
||||
<>
|
||||
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
|
||||
@@ -277,7 +334,11 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
||||
{signedIn.length > 1 && (
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} />
|
||||
)}
|
||||
</Popover>
|
||||
<AddAccountDialog open={addingAccount} onClose={() => setAddingAccount(false)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in new issue
Block a user