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
24 changes: 24 additions & 0 deletions clients/dashboard/src/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { env } from "@/env";
import { tokenStore } from "@/auth/token-store";
import { decodeJwt } from "@/auth/jwt";

export type ApiError = {
status: number;
Expand Down Expand Up @@ -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;
/**
Expand Down
37 changes: 29 additions & 8 deletions clients/dashboard/src/lib/query-client.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
94 changes: 94 additions & 0 deletions clients/dashboard/src/pages/impersonation-ended.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[var(--color-background)] px-5 py-8 sm:py-12">
{/* Atmospheric background — same rose/saffron orbs as the auth pages */}
<div className="pointer-events-none absolute inset-0" aria-hidden>
<div
className="absolute -top-[25%] -left-[15%] h-[70vw] w-[70vw] rounded-full blur-[140px]"
style={{ backgroundColor: "oklch(from var(--color-primary) l c h / 0.05)" }}
/>
<div
className="absolute -bottom-[20%] -right-[10%] h-[55vw] w-[55vw] rounded-full blur-[120px]"
style={{ backgroundColor: "oklch(from var(--color-saffron) l c h / 0.07)" }}
/>
</div>

<div className="relative z-10 flex w-full max-w-[460px] flex-col items-center text-center fsh-enter fsh-enter-1">
{/* Icon tile — same muted bg + size-14 rounded-2xl as the not-found page */}
<div className="mb-5 grid size-14 place-items-center rounded-2xl bg-[var(--color-muted)]">
<ShieldOff className="size-6 text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.5)]" />
</div>

<h1 className="mb-2 font-display text-display-stat font-semibold tracking-tight text-[var(--color-foreground)]">
Impersonation ended
</h1>
<p className="mb-7 max-w-[380px] text-[14px] leading-relaxed text-[var(--color-muted-foreground)]">
The operator's access to this session was revoked or has expired. Sign
in with your own account to continue.
</p>

{/* Primary action */}
<Button
type="button"
onClick={backToSignIn}
className="group h-11 px-5 text-[14px] font-semibold"
>
<LogIn className="size-4" />
<span>Back to sign in</span>
</Button>

{/* 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 && (
<p className="mt-6 max-w-[380px] font-mono text-[11px] leading-relaxed text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.6)]">
{reason}
</p>
)}
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions clients/dashboard/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
() => import("@/pages/tenant-deactivated"),
"TenantDeactivatedPage",
);
const ImpersonationEndedPage = lazyNamed(
() => import("@/pages/impersonation-ended"),
"ImpersonationEndedPage",
);
const SettingsLayout = lazyNamed(
() => import("@/pages/settings/settings-layout"),
"SettingsLayout",
Expand Down Expand Up @@ -112,7 +116,7 @@
* placeholder, and three card placeholders. Uses the modernized
* `.skeleton` shimmer so it feels like part of the same surface family.
*/
function RouteFallback() {

Check warning on line 119 in clients/dashboard/src/routes.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build (dashboard)

Fast refresh only works when a file only exports components. Move your component(s) to a separate file
return (
<div className={cn("space-y-6 fsh-enter")} role="status" aria-busy="true">
<span className="sr-only">Loading…</span>
Expand Down Expand Up @@ -171,6 +175,16 @@
element: withSuspense(<TenantDeactivatedPage />),
errorElement: <RouteError />,
},
{
// 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(<ImpersonationEndedPage />),
errorElement: <RouteError />,
},
{
element: <ProtectedRoute />,
errorElement: <RouteError />,
Expand Down
102 changes: 102 additions & 0 deletions clients/dashboard/tests/impersonation/impersonation-ended.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
});
});
Loading