From 8adf9ea857e29ed0dffe9c5fc0dbaf0d91ff25a1 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 9 Jun 2026 14:10:18 +0530 Subject: [PATCH] fix(dashboard): graceful UX when an impersonation grant is revoked mid-session Revoking (or expiring) an in-progress impersonation left the dead impersonation token installed: every request 401d but the page just rendered a stuck error band under a half-loaded surface while the "End impersonation" banner lingered. Detect the case (a 401 while the active token carries the act_sub impersonation claim - a durable signal, since prod keeps the 401 body opaque) in the global query/mutation error hook and route to a calm full-screen "Impersonation ended" terminal page that clears the dead token via "Back to sign in". Mirrors the existing tenant-deactivated flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/dashboard/src/lib/api-client.ts | 24 +++++ clients/dashboard/src/lib/query-client.ts | 37 +++++-- .../src/pages/impersonation-ended.tsx | 94 ++++++++++++++++ clients/dashboard/src/routes.tsx | 14 +++ .../impersonation/impersonation-ended.spec.ts | 102 ++++++++++++++++++ 5 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 clients/dashboard/src/pages/impersonation-ended.tsx create mode 100644 clients/dashboard/tests/impersonation/impersonation-ended.spec.ts diff --git a/clients/dashboard/src/lib/api-client.ts b/clients/dashboard/src/lib/api-client.ts index f28e9792d3..6ee8bedecc 100644 --- a/clients/dashboard/src/lib/api-client.ts +++ b/clients/dashboard/src/lib/api-client.ts @@ -1,5 +1,6 @@ import { env } from "@/env"; import { tokenStore } from "@/auth/token-store"; +import { decodeJwt } from "@/auth/jwt"; export type ApiError = { status: number; @@ -38,6 +39,29 @@ export function isTenantDeactivatedError(error: unknown): boolean { return detail.toLowerCase().includes("tenant has been deactivated"); } +/** + * True when an error is a 401 fired against an *impersonation* session — i.e. + * the operator's grant was revoked (via /impersonation/revoke) or the + * short-lived impersonation token expired. Both surface as a 401 from the + * server's OnTokenValidated hook (ConfigureJwtBearerOptions). + * + * Detection is intentionally message-agnostic: in Production the 401 body is + * opaque (the "Impersonation grant revoked or ended" reason is dev-only), so we + * key off the durable shape instead — a 401 while the *currently installed* + * access token carries the `act_sub` (impersonation) claim. Impersonation + * sessions never hold a refresh token (token-store drops the refresh slot on + * beginImpersonation), so such a 401 always propagates here rather than being + * silently refreshed-and-retried by apiFetch. A global query/mutation error + * hook (query-client.ts) uses this to route to the /impersonation-ended + * terminal page instead of leaving a dead error banner under a half-loaded + * dashboard — mirrors isTenantDeactivatedError. + */ +export function isImpersonationRevokedError(error: unknown): boolean { + if (!(error instanceof ApiRequestError) || error.status !== 401) return false; + const claims = decodeJwt(tokenStore.getAccessToken()); + return claims?.act_sub != null; +} + type RequestInitEx = RequestInit & { skipAuth?: boolean; /** diff --git a/clients/dashboard/src/lib/query-client.ts b/clients/dashboard/src/lib/query-client.ts index 5247c67ac8..e37c144c43 100644 --- a/clients/dashboard/src/lib/query-client.ts +++ b/clients/dashboard/src/lib/query-client.ts @@ -1,17 +1,38 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; -import { ApiRequestError, isTenantDeactivatedError } from "@/lib/api-client"; +import { + ApiRequestError, + isImpersonationRevokedError, + isTenantDeactivatedError, +} from "@/lib/api-client"; import { router } from "@/routes"; const TENANT_DEACTIVATED_PATH = "/tenant-deactivated"; +const IMPERSONATION_ENDED_PATH = "/impersonation-ended"; -// Once a tenant is deactivated every request 403s, so this hook can fire from -// many queries/mutations at once. Route to the dedicated page from the first -// one and no-op the rest by guarding on the current location. Navigation goes -// through the data router instance directly because this runs outside React. +// Both terminal states share the same shape: once they trip, *every* request +// fails the same way, so this hook fires from many queries/mutations at once. +// We route from the first occurrence and no-op the rest by guarding on the +// current location. Navigation goes through the data router instance directly +// because this runs outside React. The dead token is intentionally NOT cleared +// here — clearing flips isAuthenticated false and lets ProtectedRoute race us +// to /login; the terminal pages clear it on their "Back to sign in" action. function handleGlobalError(error: unknown) { - if (!isTenantDeactivatedError(error)) return; - if (router.state.location.pathname === TENANT_DEACTIVATED_PATH) return; - void router.navigate(TENANT_DEACTIVATED_PATH, { replace: true }); + if (isTenantDeactivatedError(error)) { + if (router.state.location.pathname === TENANT_DEACTIVATED_PATH) return; + void router.navigate(TENANT_DEACTIVATED_PATH, { replace: true }); + return; + } + + if (isImpersonationRevokedError(error)) { + if (router.state.location.pathname === IMPERSONATION_ENDED_PATH) return; + // Surface the dev-only rejection reason on the page when present; prod + // blanks it, so the page copy must stand on its own without it. + const reason = + error instanceof ApiRequestError && typeof error.problem?.reason === "string" + ? error.problem.reason + : undefined; + void router.navigate(IMPERSONATION_ENDED_PATH, { replace: true, state: { reason } }); + } } export const queryClient = new QueryClient({ diff --git a/clients/dashboard/src/pages/impersonation-ended.tsx b/clients/dashboard/src/pages/impersonation-ended.tsx new file mode 100644 index 0000000000..0f38606911 --- /dev/null +++ b/clients/dashboard/src/pages/impersonation-ended.tsx @@ -0,0 +1,94 @@ +import { useLocation, useNavigate } from "react-router-dom"; +import { LogIn, ShieldOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/auth/use-auth"; + +/** + * ImpersonationEndedPage — calm centered terminal-state card shown when an + * operator's impersonation grant is revoked (or the short-lived impersonation + * token expires) mid-session. + * + * The server's OnTokenValidated hook (ConfigureJwtBearerOptions) starts + * rejecting the impersonation token with a 401 the moment its grant is + * revoked/ended. Impersonation sessions carry no refresh token, so that 401 + * propagates straight through the api client; a global query/mutation error + * hook (query-client.ts) detects it (isImpersonationRevokedError) and routes + * here — replacing the dead error banner that would otherwise sit under a + * half-loaded dashboard while the impersonation banner lingered. + * + * Mounted as a top-level route (outside ProtectedRoute / AppShell): the access + * token is still technically decodable, but nothing the impersonated identity + * can do will work, so there is no shell to render. The single action clears + * the dead session and returns to sign-in. Mirrors tenant-deactivated.tsx's + * empty-state vocabulary: one muted icon tile, an Outfit headline, a soft body + * line, and a single primary affordance. + */ +export function ImpersonationEndedPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { logout } = useAuth(); + + // The global error hook passes the dev-only JwtBearer rejection reason via + // router state. Production blanks it, so the body copy stands on its own and + // this only renders as a subtle diagnostic line when present. + const reason = + typeof (location.state as { reason?: unknown } | null)?.reason === "string" + ? (location.state as { reason: string }).reason + : undefined; + + const backToSignIn = () => { + // Drop the now-useless impersonation token so /login starts clean and a + // stale token can't bounce the user straight back into a 401 loop. + logout(); + navigate("/login", { replace: true }); + }; + + return ( +
+ {/* Atmospheric background — same rose/saffron orbs as the auth pages */} +
+
+
+
+ +
+ {/* Icon tile — same muted bg + size-14 rounded-2xl as the not-found page */} +
+ +
+ +

+ Impersonation ended +

+

+ The operator's access to this session was revoked or has expired. Sign + in with your own account to continue. +

+ + {/* Primary action */} + + + {/* Dev-only diagnostic — the JwtBearer rejection reason. Hidden in prod + (the server blanks it) and even when present is whispered, not shouted. */} + {import.meta.env.DEV && reason && ( +

+ {reason} +

+ )} +
+
+ ); +} diff --git a/clients/dashboard/src/routes.tsx b/clients/dashboard/src/routes.tsx index 78e70c1024..5abe829164 100644 --- a/clients/dashboard/src/routes.tsx +++ b/clients/dashboard/src/routes.tsx @@ -64,6 +64,10 @@ const TenantDeactivatedPage = lazyNamed( () => import("@/pages/tenant-deactivated"), "TenantDeactivatedPage", ); +const ImpersonationEndedPage = lazyNamed( + () => import("@/pages/impersonation-ended"), + "ImpersonationEndedPage", +); const SettingsLayout = lazyNamed( () => import("@/pages/settings/settings-layout"), "SettingsLayout", @@ -171,6 +175,16 @@ export const router = createBrowserRouter([ element: withSuspense(), errorElement: , }, + { + // Terminal state when an operator's impersonation grant is revoked (or its + // short-lived token expires) mid-session. Top-level (outside + // ProtectedRoute/AppShell) — the token still decodes but every request + // 401s, so there is no shell to render. query-client.ts routes here on + // detecting the impersonation-revoked 401. + path: "/impersonation-ended", + element: withSuspense(), + errorElement: , + }, { element: , errorElement: , diff --git a/clients/dashboard/tests/impersonation/impersonation-ended.spec.ts b/clients/dashboard/tests/impersonation/impersonation-ended.spec.ts new file mode 100644 index 0000000000..907929d10b --- /dev/null +++ b/clients/dashboard/tests/impersonation/impersonation-ended.spec.ts @@ -0,0 +1,102 @@ +// E2E coverage for the impersonation-revoked terminal flow. +// +// When an operator's impersonation grant is revoked (or its short-lived token +// expires) mid-session, the server starts rejecting the impersonation token +// with a 401. Impersonation sessions carry no refresh token, so that 401 +// propagates straight through apiFetch; a global query/mutation error hook +// (query-client.ts → isImpersonationRevokedError) routes to the dedicated +// /impersonation-ended page instead of leaving a dead error banner under a +// half-loaded dashboard. +// +// Browser: chromium only, run against the already-running Vite dev server. + +import { expect, test, type Page } from "@playwright/test"; +import { mockProblemDetails } from "../helpers/api-mocks"; +import { installShellMocks } from "../helpers/shell-mocks"; + +const ACCESS_KEY = "fsh.dashboard.accessToken"; +const REFRESH_KEY = "fsh.dashboard.refreshToken"; +const TENANT_KEY = "fsh.dashboard.tenant"; + +/** + * Seed an IMPERSONATION session into localStorage before React boots: an + * access token carrying the `act_sub` actor claim, a target tenant, and — + * critically — NO refresh token (token-store drops the refresh slot on + * beginImpersonation). The missing refresh token is what makes a 401 propagate + * to the global error hook rather than triggering a silent refresh-and-retry. + */ +async function seedImpersonationSession(page: Page): Promise { + const b64url = (obj: unknown) => + btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + const payload = { + sub: "u-impersonated-1", + email: "dan@acme.com", + name: "Dan Mueller", + tenant: "acme", + // Actor claims — the original operator's identity. Their presence is what + // marks this token as an impersonation session. + act_sub: "op-root-1", + act_tenant: "root", + act_name: "Root Operator", + permissions: [], + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }; + const accessToken = [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); + + await page.addInitScript( + ({ access, accessKey, refreshKey, tenantKey }) => { + localStorage.setItem(accessKey, access); + // Defensive: ensure no refresh token lingers from a prior session. + localStorage.removeItem(refreshKey); + localStorage.setItem(tenantKey, "acme"); + }, + { access: accessToken, accessKey: ACCESS_KEY, refreshKey: REFRESH_KEY, tenantKey: TENANT_KEY }, + ); +} + +test.beforeEach(async ({ page }) => { + await seedImpersonationSession(page); + await installShellMocks(page); +}); + +test.describe("impersonation revoked mid-session", () => { + test("a 401 on an impersonation session routes to the terminal page", async ({ page }) => { + // The products list 401s the moment the grant is revoked. The dev build + // surfaces the JwtBearer rejection reason on the ProblemDetails. + await mockProblemDetails(page, "**/api/v1/catalog/products**", 401, { + title: "Unauthorized", + detail: "Authentication is required to access this resource.", + }); + + await page.goto("/catalog/products"); + + // Lands on the dedicated terminal page rather than showing an inline + // error band under the half-loaded catalog. + await expect(page).toHaveURL(/\/impersonation-ended$/); + await expect( + page.getByRole("heading", { name: /impersonation ended/i }), + ).toBeVisible(); + await expect( + page.getByText(/revoked or has expired/i), + ).toBeVisible(); + }); + + test("'Back to sign in' clears the dead token and routes to /login", async ({ page }) => { + await mockProblemDetails(page, "**/api/v1/catalog/products**", 401, { + title: "Unauthorized", + detail: "Authentication is required to access this resource.", + }); + + await page.goto("/catalog/products"); + await expect(page).toHaveURL(/\/impersonation-ended$/); + + await page.getByRole("button", { name: /back to sign in/i }).click(); + + await expect(page).toHaveURL(/\/login$/); + // The dead impersonation token is cleared so a stale token can't bounce + // the user straight back into a 401 loop. + const accessToken = await page.evaluate((key) => localStorage.getItem(key), ACCESS_KEY); + expect(accessToken).toBeNull(); + }); +});