From 8b164c556ed7d0b5283cde5177ce43225f560844 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Sun, 3 May 2026 17:47:46 -1000 Subject: [PATCH 1/6] fix: thread per-account cookie slot through OAuth flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-account refresh-token cookie slot wiring was half-implemented: every account's refresh token ended up on slot 0, so "+ Add Account" silently clobbered the previous account's `jmap_rt` cookie. On page refresh, only the most-recently-added account had a working refresh token; the others bounced to login. Three coordinated changes: 1. `app/[locale]/login/page.tsx` (handleOAuthLogin): write the next-free cookie slot to `sessionStorage['oauth_cookie_slot']` before redirecting to the IdP. `loginWithOAuth` already reads this key but it was never written, so it always defaulted to 0. 2. `stores/auth-store.ts` (loginWithOAuth): distinguish "no value set" (`rawSlot === null`) from "value is 0". Previously `parseInt(getItem(...) || '0')` collapsed both cases, making the `getNextCookieSlot()` fallback unreachable. 3. `stores/auth-store.ts` (loginWithServerSso) + `app/api/auth/sso/complete/route.ts`: pass the slot through the body of the POST and use it for `refreshTokenCookieName(slot)`. Same pattern as the existing `/api/auth/token POST` that already accepts a slot. The server defaults to 0 for back-compat with any caller that omits it. After the fix, signing in with multiple accounts produces distinct `jmap_rt`, `jmap_rt_1`, `jmap_rt_2`, ... cookies (matching the cookieSlot field in account-store) and all accounts survive a page refresh. Repro before the fix: - Sign in with one account, refresh — works. - Click "+ Add Account", sign in with a second account, refresh — second account vanishes from the dropdown; switching to the first account in the dropdown still shows the second account's identity in the From box. --- app/[locale]/login/page.tsx | 11 ++++++++++ app/api/auth/sso/complete/route.ts | 12 ++++++++--- stores/auth-store.ts | 32 +++++++++++++++++++++--------- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 8e0872cb..5bdd1938 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -7,6 +7,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuthStore } from "@/stores/auth-store"; +import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; @@ -460,6 +461,16 @@ export default function LoginPage() { sessionStorage.setItem("oauth_add_account_mode", "true"); } + // Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts) + // writes the refresh token to the correct per-account jmap_rt_ cookie. + // loginWithOAuth reads this key but it was previously never written, so every + // OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh + // tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for + // first sign-in) and the lowest unused slot otherwise (correct for "+ Add + // Account"). + const nextSlot = useAccountStore.getState().getNextCookieSlot(); + sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString()); + const authUrl = new URL(oauthMetadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", oauthClientId); diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index fcbf6d33..eba9a7b7 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -13,12 +13,18 @@ export async function POST(request: NextRequest) { const cookieStore = await cookies(); try { - const { code, state } = await request.json(); + const { code, state, slot: bodySlot } = await request.json(); if (!code || !state) { return NextResponse.json({ error: 'Missing code or state' }, { status: 400 }); } + // Per-account refresh-token cookie slot. Without this the route hardcoded + // slot 0, so the "+ Add Account" flow overwrote the first account's + // refresh-token cookie. Default to 0 for back-compat with any caller that + // omits slot. Mirrors the validation in /api/auth/token POST. + const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; + // Read and decrypt the pending SSO cookie const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; if (!pendingCookie) { @@ -58,9 +64,9 @@ export async function POST(request: NextRequest) { // Exchange code for tokens const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri); - // Store refresh token + // Store refresh token in the per-account cookie slot. if (tokens.refresh_token) { - const cookieName = refreshTokenCookieName(0); + const cookieName = refreshTokenCookieName(slot); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 008b020f..04955b59 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -601,12 +601,21 @@ export const useAuthStore = create()( set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null }); try { - // Determine slot for this account (use slot from sessionStorage if re-adding) + // Determine slot for this account (use slot from sessionStorage if re-adding). + // Note: `parseInt(getItem(...) || '0')` collapses "no value set" and + // "value is 0" into the same case, so the fallback to getNextCookieSlot() + // never fired for the common "+ Add Account" path — every OAuth account + // ended up on slot 0 and overwrote earlier accounts' refresh-token cookies. + // Distinguishing rawSlot === null from a parsed 0 fixes that. The page + // also writes oauth_cookie_slot before redirecting to the IdP. const accountStore = useAccountStore.getState(); - const pendingSlot = typeof window !== 'undefined' - ? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10) - : 0; - const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot(); + const rawSlot = typeof window !== 'undefined' + ? sessionStorage.getItem('oauth_cookie_slot') + : null; + const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN; + const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot <= 4 + ? pendingSlot + : accountStore.getNextCookieSlot(); const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, { method: 'POST', @@ -718,12 +727,19 @@ export const useAuthStore = create()( set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null }); try { - // Server-side SSO: the server holds the PKCE verifier in an encrypted cookie + // Server-side SSO: the server holds the PKCE verifier in an encrypted cookie. + // Pass the next-free cookie slot so /api/auth/sso/complete writes the refresh + // token to the correct per-account jmap_rt_ cookie. Without this the + // route hardcoded slot 0, which broke "+ Add Account" by overwriting the + // first account's refresh-token cookie. + const accountStore = useAccountStore.getState(); + const slot = accountStore.getNextCookieSlot(); + const ssoRes = await apiFetch('/api/auth/sso/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ code, state }), + body: JSON.stringify({ code, state, slot }), }); if (!ssoRes.ok) { @@ -741,8 +757,6 @@ export const useAuthStore = create()( throw new Error('Server URL not configured'); } - const accountStore = useAccountStore.getState(); - const refreshFn = get().refreshAccessToken; const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn()); await client.connect(); From f68e41d81a0f94331cf5dff1a83c8bddd404127a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 4 May 2026 11:24:05 +0200 Subject: [PATCH 2/6] fix: enhance sharing functionality by renaming state --- .../settings/share-collection-dialog.tsx | 25 +++++++++++-------- lib/jmap/client.ts | 6 +++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/components/settings/share-collection-dialog.tsx b/components/settings/share-collection-dialog.tsx index 4ef3022a..e05ea947 100644 --- a/components/settings/share-collection-dialog.tsx +++ b/components/settings/share-collection-dialog.tsx @@ -79,7 +79,7 @@ export function ShareCollectionDialog({ const t = useTranslations("sharing"); const tCommon = useTranslations("common"); const modalRef = useRef(null); - const [principals, setPrincipals] = useState([]); + const [allPrincipals, setAllPrincipals] = useState([]); const [loadingPrincipals, setLoadingPrincipals] = useState(true); const [search, setSearch] = useState(""); const [savingId, setSavingId] = useState(null); @@ -91,23 +91,28 @@ export function ShareCollectionDialog({ setLoadingPrincipals(true); client.getPrincipals().then((list) => { if (cancelled) return; - // Exclude the user themselves and any principal that already has a share - const existing = new Set(Object.keys(shareWith || {})); - const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id)); - setPrincipals(filtered); + setAllPrincipals(list); setLoadingPrincipals(false); }).catch(() => { if (!cancelled) setLoadingPrincipals(false); }); return () => { cancelled = true; }; - }, [client, ownAccountId, shareWith]); + }, [client]); - // Map principal id -> Principal for displayed shares + // Map of every fetched principal by id, used for name/description lookups in + // the shared list. Must include principals that already have a share so the + // list shows their name rather than the raw id. const allPrincipalsById = useMemo(() => { const map = new Map(); - for (const p of principals) map.set(p.id, p); + for (const p of allPrincipals) map.set(p.id, p); return map; - }, [principals]); + }, [allPrincipals]); + + // Principals available to add: exclude self and anyone already shared with. + const principals = useMemo(() => { + const existing = new Set(Object.keys(shareWith || {})); + return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id)); + }, [allPrincipals, ownAccountId, shareWith]); // Close on Escape, focus trap, click outside useEffect(() => { @@ -155,8 +160,6 @@ export function ShareCollectionDialog({ setSavingId(principal.id); try { await onShare(principal.id, rights); - // Move principal out of the "to add" list - setPrincipals((prev) => prev.filter((p) => p.id !== principal.id)); setShowAdd(false); setSearch(""); toast.success(t("share_added")); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 3faf65dc..45d83f2a 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3314,6 +3314,9 @@ export class JMAPClient implements IJMAPClient { const err = result.notUpdated[calendarId]; throw new Error(err.description || "Failed to update calendar share"); } + if (!result?.updated || !(calendarId in result.updated)) { + throw new Error("Server did not confirm the share update"); + } } /** @@ -3339,6 +3342,9 @@ export class JMAPClient implements IJMAPClient { const err = result.notUpdated[addressBookId]; throw new Error(err.description || "Failed to update address book share"); } + if (!result?.updated || !(addressBookId in result.updated)) { + throw new Error("Server did not confirm the share update"); + } } private async fetchPaginatedContacts( From 1a50788c915ef5ec76cf41a40656cafc61e37e5d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 4 May 2026 12:09:58 +0200 Subject: [PATCH 3/6] fix: ensure cookieSlot consistency during account updates in auth store --- stores/auth-store.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 04955b59..610ec4be 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -668,6 +668,12 @@ export const useAuthStore = create()( hasError: false, isDefault: accountStore.accounts.length === 0, }); + // The refresh-token cookie was written to `slot`. Force the stored + // cookieSlot to match: addAccount preserves the prior slot when + // re-adding an existing account, and recomputes via getNextCookieSlot + // for new accounts (which may disagree if another tab claimed a slot + // mid-flow). Either way, the cookie's slot is the source of truth. + accountStore.updateAccount(accountId, { cookieSlot: slot }); accountStore.setActiveAccount(accountId); await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot); @@ -793,10 +799,13 @@ export const useAuthStore = create()( hasError: false, isDefault: accountStore.accounts.length === 0, }); + // The refresh-token cookie was written to `slot` by /api/auth/sso/complete. + // Force the stored cookieSlot to match — see loginWithOAuth above for the + // re-add and concurrent-tab cases this guards against. + accountStore.updateAccount(accountId, { cookieSlot: slot }); accountStore.setActiveAccount(accountId); - const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0; - await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot); + await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot); set({ isAuthenticated: true, From 07367a8a5df4f67a0eb33bd0074a8215f1db284e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 4 May 2026 12:27:44 +0200 Subject: [PATCH 4/6] fix: update email viewer styles to improve overflow handling --- components/email/email-viewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 81c7ed42..f92b435a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3881,7 +3881,7 @@ export function EmailViewer({ )} {/* Email Content Area */} -
+
{/* === SENDER INFO (Desktop) === */}
From 8c50abe2212d8f6154b0965ed83e414d459dcbe6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 4 May 2026 12:31:51 +0200 Subject: [PATCH 5/6] fix: synchronize mobile submenu view with browser history for better navigation --- app/[locale]/settings/page.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index eeda92b8..543c3b24 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -213,6 +213,22 @@ export default function SettingsPage() { } }, [initialCheckDone, isAuthenticated, authLoading]); + // Sync the mobile submenu view with browser history so the system back + // button (or gesture) returns to the settings list before exiting /settings. + useEffect(() => { + if (isDesktop) return; + if (typeof window === 'undefined') return; + if (!mobileShowContent) return; + + window.history.pushState({ __settingsSubmenu: true }, ''); + + const handlePop = () => { + setMobileShowContent(false); + }; + window.addEventListener('popstate', handlePop); + return () => window.removeEventListener('popstate', handlePop); + }, [isDesktop, mobileShowContent]); + if (!isAuthenticated) { return null; } @@ -321,7 +337,7 @@ export default function SettingsPage() {
diff --git a/VERSION b/VERSION index ce6a70b9..2eda823f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0 \ No newline at end of file +1.6.1 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 212f7601..ad31f5b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.6.0", + "version": "1.6.1", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.24", diff --git a/package.json b/package.json index 7599ac72..ca349352 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.6.0", + "version": "1.6.1", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only",