Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/impersonation-banner-4467.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

The console shows a standing impersonation banner, with an exit that fails loudly (#4467).

While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH
parties — the impersonated user, whose name every write is recorded under, and the
administrator who started it — plus a stop affordance. It derives from the session rather
than from client memory of the click, so it survives a full SPA reboot, a new tab and a
browser restart, and it cannot disagree with who the server thinks is acting. An ordinary
session renders `null` and its chrome is unchanged.

The exit calls `POST /auth/admin/stop-impersonating` over the same data lane and then
awaits a session refresh. The server restores the administrator from the `admin_session`
COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and
stays up instead of appearing to succeed, which would leave the operator doing ordinary
work under someone else's identity.

Ten locale packs carry the banner's copy.
37 changes: 37 additions & 0 deletions .changeset/impersonation-data-lane-rotation-4467.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@object-ui/auth': minor
---

The data lane now honors `set-auth-token`, so impersonation takes effect at all (#4467).

The console injects the same localStorage bearer from two lanes: the AUTH lane
(`createBearerFetch` inside `createAuthClient`) and the DATA lane
(`createAuthenticatedFetch` — the adapter, `provider: 'api'` data sources, and every
metadata `type: 'api'` action). better-auth's server-side bearer plugin hands a ROTATED
session token back in the `set-auth-token` response header on whichever lane the call
arrived over, and only the auth lane read it. A rotation issued to a data-lane call was
discarded and the browser kept sending the old token.

`POST /auth/admin/impersonate-user` is exactly such a call — an ordinary metadata action.
The impersonated session token was dropped on the floor while the server's bearer plugin
kept overwriting the impersonation cookie with the admin bearer the console kept sending,
so impersonation was a complete no-op in the console rather than merely an invisible one.
Support staff believed they were seeing a user's view while acting entirely as themselves.

Published behaviour that moves: a data-lane response carrying `set-auth-token` now
replaces the stored session token, on any API call this lane authenticated (untrusted
targets remain the `sameOriginOnly` option's job — it short-circuits before any header
work). The accepted cost, recorded on the card: while impersonating, the administrator's
own token is replaced in localStorage for the duration, and a client that misses the stop
rotation is stranded until re-login.

Also in this release, all additive:

- `AuthContextValue.refreshSession()` re-resolves `user`/`session` from the server in
place, without raising `isLoading` — the transitions that change WHO the session is
without going through `signIn`/`signOut`.
- `TokenStorage.subscribeRotation()` notifies when a token already in hand is replaced by
a different one. First store, `clear()`, and re-storing the same value stay silent:
those transitions have an owner that updates identity itself.
- `AuthClientSession.impersonatedBy?: string` — optional, set by better-auth's admin
plugin for the life of an impersonated session.
8 changes: 8 additions & 0 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { ThemeProvider } from '../chrome/ThemeProvider';
import { LoadingScreen } from '../chrome/LoadingScreen';
import { RemediationOverlay } from './RemediationOverlay';
import { ImpersonationBanner } from '../layout/ImpersonationBanner';

// The console's every pre-React / pre-auth gate (Suspense fallback, adapter
// not ready, org/auth loading) renders this. It used to be a bare, unbranded
Expand Down Expand Up @@ -146,6 +147,13 @@ export function ConsoleShell({ children }: { children: ReactNode }) {
<FavoritesProvider>
<RecentItemsProvider>
<FlowPaletteRecentsProvider>
{/* objectui#4467 — the impersonation indicator. Above the
routes, so it is chrome for EVERY console page (home has
its own layout and would otherwise carry no indicator),
and in flow rather than overlaid, so it never covers the
header it warns about. Renders null on every ordinary
session. */}
<ImpersonationBanner />
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
Expand Down
161 changes: 161 additions & 0 deletions packages/app-shell/src/layout/ImpersonationBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* ImpersonationBanner — the console's standing "you are acting as someone else"
* chrome (objectui#4467).
*
* ## What raises it
*
* `session.impersonatedBy`, and nothing else. That field is set by better-auth's
* admin plugin for the life of an impersonated session and comes back on every
* `GET /auth/get-session`, so the banner is a property of the SESSION rather
* than a memory of the click that started it: it survives a full SPA reboot, a
* new tab, and a browser restart, and it cannot disagree with who the server
* thinks is acting. An ordinary session has no such field and renders nothing
* here — not an empty element, `null`.
*
* ## Where it mounts, and why it is not a page-level bar
*
* `ConsoleShell` — the one provider stack every console route passes through
* (home, `/apps/*`, `/organizations`, `/ai`, `/studio`). Its siblings there are
* the other global surfaces with a single home: `RemediationOverlay`,
* `NotificationSnackbar`, `NotificationAlerts`. The page-level bars it most
* resembles visually — `DraftPreviewBar` and `UnpublishedAppBar` — mount inside
* `ConsoleLayout`, which only wraps `/apps/*`; the card was filed on a console
* whose `/home` showed no sign of impersonation at all, so a home that could
* not carry the indicator would have reproduced the bug.
*
* ## The exit fails LOUDLY
*
* `POST /auth/admin/stop-impersonating` restores the administrator from the
* `admin_session` COOKIE the impersonation call left behind. A deployment that
* blocks cookies (a cross-site console, a hardened browser profile) therefore
* cannot exit this way, and the failure must be visible: a stop that leaves the
* session impersonated keeps the banner up and states what happened. Silently
* appearing to succeed would be strictly worse than the original defect —
* the operator would go back to ordinary work believing they were themselves.
*/

import { useCallback, useMemo, useState } from 'react';
import { UserCog } from 'lucide-react';
import { toast } from 'sonner';
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
import { Button } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';

export function ImpersonationBanner() {
const { user, session, refreshSession } = useAuth();
const { t } = useObjectTranslation();
const [stopping, setStopping] = useState(false);
const [stopError, setStopError] = useState<string | null>(null);
const [stopAttempted, setStopAttempted] = useState(false);

// The DATA lane — the same wrapper the impersonate action itself goes
// through. It adopts the restored administrator token the server hands back
// in `set-auth-token` (#4467's lane fix); calling `stop-impersonating` on a
// bare `fetch` would leave the browser holding the impersonated token and
// strand the operator exactly where the exit was meant to release them.
const authFetch = useMemo(() => createAuthenticatedFetch(), []);

const impersonatedBy = session?.impersonatedBy;

const stop = useCallback(async () => {
setStopping(true);
setStopError(null);
try {
const baseUrl = import.meta.env.VITE_SERVER_URL || '';
const res = await authFetch(`${baseUrl}/api/v1/auth/admin/stop-impersonating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The administrator is resolved from the `admin_session` cookie, not
// from the bearer we are sending — which is the impersonated user's.
credentials: 'include',
body: '{}',
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as
| { message?: string; error?: { message?: string } }
| null;
throw new Error(payload?.error?.message ?? payload?.message ?? `HTTP ${res.status}`);
}
// Adopt the restored identity: the rotated administrator token is already
// in TokenStorage (captured by `authFetch` above), so this re-resolves as
// the administrator. Awaited, so the check below reads a settled session.
await refreshSession();
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
setStopError(reason);
toast.error(
t('impersonation.banner.stopFailed', {
defaultValue: 'Could not stop impersonating: {{reason}}',
reason,
}),
);
} finally {
setStopAttempted(true);
setStopping(false);
}
}, [authFetch, refreshSession, t]);

// Nothing to say on an ordinary session. Below every hook, so the hook order
// is stable across the transition out of impersonation.
if (!impersonatedBy) return null;

const impersonatedName = user?.name || user?.email || user?.id || '';
// A stop that RESOLVED and left us still impersonating is the cookie-blocked
// deployment: the request was accepted-looking but the administrator was
// never restored. Say so instead of leaving a dead button.
const notRestored = stopAttempted && !stopping && !stopError;

return (
<div
role="status"
data-testid="impersonation-banner"
className="sticky top-0 z-50 flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-red-300/70 bg-red-50 px-4 py-2 text-sm text-red-900 dark:border-red-800/60 dark:bg-red-950/50 dark:text-red-100"
>
<UserCog className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1">
<span className="font-medium">
{t('impersonation.banner.message', {
defaultValue: 'You are impersonating {{user}} — every action is recorded as them.',
user: impersonatedName,
})}
</span>{' '}
<span className="opacity-80">
{t('impersonation.banner.startedBy', {
defaultValue: 'Started by administrator {{admin}}.',
admin: impersonatedBy,
})}
</span>
</p>
<Button
size="sm"
variant="outline"
onClick={stop}
disabled={stopping}
data-testid="impersonation-stop"
>
{stopping
? t('impersonation.banner.stopping', { defaultValue: 'Stopping…' })
: t('impersonation.banner.stop', { defaultValue: 'Stop impersonating' })}
</Button>
{stopError ? (
<p role="alert" data-testid="impersonation-stop-error" className="w-full font-medium">
{t('impersonation.banner.stopFailed', {
defaultValue: 'Could not stop impersonating: {{reason}}',
reason: stopError,
})}
</p>
) : null}
{notRestored ? (
<p role="alert" data-testid="impersonation-not-restored" className="w-full font-medium">
{t('impersonation.banner.notRestored', {
defaultValue:
'The server accepted the request but did not restore your administrator session — you are still impersonating {{user}}. Sign out and sign in again to end it.',
user: impersonatedName,
})}
</p>
) : null}
</div>
);
}

ImpersonationBanner.displayName = 'ImpersonationBanner';
Loading
Loading