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.
This commit is contained in:
shukiv
2026-07-27 05:20:53 +03:00
parent 9c04950a94
commit 246df49c03
3 changed files with 66 additions and 2 deletions
@@ -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;
}