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:
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
|
|||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
|
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
|
|||||||
<TourProvider>
|
<TourProvider>
|
||||||
<ProtocolLaunchHandlerProvider>
|
<ProtocolLaunchHandlerProvider>
|
||||||
<ProInterfaceRedirect />
|
<ProInterfaceRedirect />
|
||||||
|
<ImpersonationReconciler />
|
||||||
{children}
|
{children}
|
||||||
<PluginDialogHost />
|
<PluginDialogHost />
|
||||||
<PluginConsentDialog />
|
<PluginConsentDialog />
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
|
|||||||
* Master-user impersonation via signed JWT. The token carries the target
|
* Master-user impersonation via signed JWT. The token carries the target
|
||||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||||
* master credentials from env, then mints the same session cookies the
|
* 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.
|
* 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
|
* 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.
|
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 303,
|
status: 303,
|
||||||
headers: { Location: '/' },
|
headers: { Location: '/?impersonated=1' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user