From 246df49c039f41ed2a59477196183ff89e9f9f57 Mon Sep 17 00:00:00 2001 From: shukiv Date: Mon, 27 Jul 2026 05:20:53 +0300 Subject: [PATCH] fix(impersonation): reconcile stale persisted account chip after handoff After a master-user impersonation handoff (GET /api/auth/impersonate) the server swaps the slot-0 session cookie but the client's persisted account registry (account-registry / auth-storage in localStorage) still lists the previous account, so the top-left account chip keeps showing the old mailbox until a manual sign-out. Redirect impersonation to /?impersonated=1 and add a headless ImpersonationReconciler that drops the stale persisted account/auth state (and server-derived caches) then reloads to a clean URL, so the app rehydrates empty and re-derives the single account from the fresh session. Cookies untouched, so the just-granted session survives. Runs exactly once. Reported downstream: shukiv/jabali-panel#646. --- app/(main)/[locale]/layout.tsx | 2 + app/api/auth/impersonate/route.ts | 5 +- .../impersonation-reconciler.tsx | 61 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 components/impersonation/impersonation-reconciler.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 8c3126af..e91cab21 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast- import { TourProvider } from "@/components/tour/tour-provider"; import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider"; import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; +import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; @@ -39,6 +40,7 @@ export default async function LocaleLayout({ + {children} diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 7a5dca04..858ccb8e 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -39,7 +39,8 @@ function impersonationCookieOptions() { * Master-user impersonation via signed JWT. The token carries the target * mailbox; Bulwark verifies the signature, resolves the configured Stalwart * master credentials from env, then mints the same session cookies the - * password-login path produces. The browser is redirected to "/" and the + * password-login path produces. The browser is redirected to "/?impersonated=1" (see + * ImpersonationReconciler, GH #646) and the * SPA hydrates as if the user had just logged in with master@target%master. * * Returns 404 when the feature is not configured so an unconfigured @@ -136,6 +137,6 @@ export async function GET(request: NextRequest) { // when running behind a reverse proxy that doesn't set X-Forwarded-Host. return new NextResponse(null, { status: 303, - headers: { Location: '/' }, + headers: { Location: '/?impersonated=1' }, }); } diff --git a/components/impersonation/impersonation-reconciler.tsx b/components/impersonation/impersonation-reconciler.tsx new file mode 100644 index 00000000..cd34673b --- /dev/null +++ b/components/impersonation/impersonation-reconciler.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useEffect } from 'react'; + +import { evictAll } from '@/lib/account-state-manager'; + +/** + * After a master-user impersonation handoff (`GET /api/auth/impersonate`), the + * server swaps the slot-0 session cookie but the client's *persisted* account + * registry still lists the PREVIOUS account — so the top-left account chip keeps + * showing the old mailbox even though the message list is correctly the new one. + * Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes + * it, because that state lives in localStorage and the impersonation redirect + * never reconciles it. (Reported downstream: jabali-panel #646.) + * + * The impersonate route now redirects to `/?impersonated=1`. Here we drop the + * stale persisted account + auth state (and the server-derived caches) and + * reload to a clean URL, so the app rehydrates empty and re-derives the single + * account from the fresh session cookie — the same result as the manual + * sign-out-then-reopen, done automatically. Cookies are untouched, so the + * just-granted impersonation session survives the reload. + */ +const STALE_KEYS = [ + 'account-registry', + 'auth-storage', + 'identity-storage', + 'contact-storage', + 'calendar-storage', + 'calendar-notification-storage', +]; + +export function ImpersonationReconciler() { + useEffect(() => { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + if (params.get('impersonated') !== '1') return; + + try { + evictAll(); + } catch { + /* in-memory snapshots are best-effort */ + } + for (const key of STALE_KEYS) { + try { + window.localStorage.removeItem(key); + } catch { + /* ignore storage access errors */ + } + } + + // Reload to a clean URL (drop the marker) so the now-empty persisted stores + // rehydrate and the app reconnects + re-derives the impersonated account + // from the session cookie. The marker is gone on the second load, so this + // runs exactly once. + params.delete('impersonated'); + const query = params.toString(); + window.location.replace(window.location.pathname + (query ? `?${query}` : '')); + }, []); + + return null; +}