diff --git a/.changeset/impersonation-banner-4467.md b/.changeset/impersonation-banner-4467.md new file mode 100644 index 0000000000..096f8bc2b0 --- /dev/null +++ b/.changeset/impersonation-banner-4467.md @@ -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. diff --git a/.changeset/impersonation-data-lane-rotation-4467.md b/.changeset/impersonation-data-lane-rotation-4467.md new file mode 100644 index 0000000000..0449555e1d --- /dev/null +++ b/.changeset/impersonation-data-lane-rotation-4467.md @@ -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. diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index a5a468a662..a52d9d477e 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -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 @@ -146,6 +147,13 @@ export function ConsoleShell({ children }: { children: ReactNode }) { + {/* 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. */} + }>{children} {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} diff --git a/packages/app-shell/src/layout/ImpersonationBanner.tsx b/packages/app-shell/src/layout/ImpersonationBanner.tsx new file mode 100644 index 0000000000..b3a2e24c5f --- /dev/null +++ b/packages/app-shell/src/layout/ImpersonationBanner.tsx @@ -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(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 ( +
+
+ ); +} + +ImpersonationBanner.displayName = 'ImpersonationBanner'; diff --git a/packages/app-shell/src/layout/__tests__/ImpersonationBanner.test.tsx b/packages/app-shell/src/layout/__tests__/ImpersonationBanner.test.tsx new file mode 100644 index 0000000000..ce92a280b8 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/ImpersonationBanner.test.tsx @@ -0,0 +1,200 @@ +/** + * objectui#4467 — the impersonation indicator and its exit. + * + * ## The harness, and why it is shaped this way + * + * These cases ride the REAL identity flow: `AuthProvider` resolves the session + * from a client whose `getSession` answers **as a function of the token in + * `TokenStorage`** — exactly what the server does — and the banner reads that + * session out of the auth context. Nothing injects context state, and nothing + * mocks `impersonatedBy` into a session the console could not otherwise reach. + * + * That shape makes the exit case a live check on the lane fix as well: the stop + * call's response carries the restored administrator token in `set-auth-token`, + * and only `createAuthenticatedFetch` adopting it (the #4467 repair) makes the + * following `getSession` resolve the administrator. Revert the three-line + * capture in `createAuthenticatedFetch.ts` and 'stop restores the administrator' + * goes red here — the token stays the impersonated one, so the banner never + * clears. + * + * ## What this harness CANNOT drive (stated, not faked) + * + * jsdom/happy-dom has no server, so the half of the mechanism that lives in + * cookies is out of reach: the `admin_session` cookie `stop-impersonating` + * resolves the administrator from, better-auth's server-side bearer plugin + * OVERWRITING the request's session cookie with our bearer (the reason + * impersonation was a no-op), and the signed-cookie round trip generally. Those + * were measured live against a running stack on the card (issue #4467 and the + * ruling update, comment 5273408139); what is pinned here is everything the + * BROWSER owns — which token is sent, which token is adopted, which identity is + * resolved, and what the console renders for it. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AuthProvider, TokenStorage } from '@object-ui/auth'; +import type { AuthClient } from '@object-ui/auth'; +import { I18nProvider } from '@object-ui/i18n'; +import { ImpersonationBanner } from '../ImpersonationBanner'; + +const ADMIN_TOKEN = 'admin-token'; +const IMPERSONATED_TOKEN = 'impersonated-token'; + +const ADMIN_SESSION = { + user: { id: 'usr_admin', name: 'Dev Admin', email: 'admin@objectos.ai' }, + session: { token: ADMIN_TOKEN }, +}; +const IMPERSONATED_SESSION = { + user: { id: 'usr_dave', name: 'RT8 Dave', email: 'rt8-dave@example.com' }, + session: { token: IMPERSONATED_TOKEN, impersonatedBy: 'usr_admin' }, +}; + +/** + * A client that resolves identity FROM THE STORED TOKEN, the way the server + * does. Whatever rotates `TokenStorage` changes who the console is — which is + * the whole mechanism under test. + */ +function tokenBoundClient(): AuthClient { + return { + getSession: vi.fn(async () => { + const token = TokenStorage.get(); + if (token === IMPERSONATED_TOKEN) return IMPERSONATED_SESSION; + if (token === ADMIN_TOKEN) return ADMIN_SESSION; + return null; + }), + // The provider loads organizations once a user resolves; answer emptily so + // the org effects are quiet. (Same one-seam doubling as AuthProvider.test.tsx.) + listOrganizations: vi.fn(async () => []), + getActiveOrganization: vi.fn(async () => null), + getActiveMember: vi.fn(async () => null), + } as unknown as AuthClient; +} + +/** Stub `fetch` for the stop-impersonating call. */ +function stubStopEndpoint( + respond: (url: string) => Response, +): { urls: string[]; bearers: (string | null)[] } { + const urls: string[] = []; + const bearers: (string | null)[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + urls.push(url); + bearers.push(new Headers(init?.headers).get('Authorization')); + return respond(url); + }), + ); + return { urls, bearers }; +} + +/** + * Renders under the REAL locale packs (no `t` double), so the copy asserted + * below is the copy the console ships and the `{{user}}` / `{{admin}}` holes + * are filled by i18next rather than by the test. + */ +function renderBanner() { + return render( + + + + + , + ); +} + +describe('ImpersonationBanner (#4467)', () => { + beforeEach(() => { + TokenStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + TokenStorage.clear(); + }); + + it('renders NOTHING for an ordinary session', async () => { + TokenStorage.set(ADMIN_TOKEN); + const { container } = renderBanner(); + + // Wait for the session to resolve, so this is "resolved and still absent" + // rather than "not rendered yet". + await waitFor(() => expect(TokenStorage.get()).toBe(ADMIN_TOKEN)); + await new Promise((r) => setTimeout(r, 0)); + + expect(screen.queryByTestId('impersonation-banner')).toBeNull(); + expect(container.innerHTML).toBe(''); + }); + + it('names BOTH parties while the session is impersonated', async () => { + TokenStorage.set(IMPERSONATED_TOKEN); + renderBanner(); + + const banner = await screen.findByTestId('impersonation-banner'); + // The impersonated user — whose name every write is recorded under. + expect(banner.textContent).toMatch(/RT8 Dave/); + // The administrator who started it, as the session reports them. + expect(banner.textContent).toMatch(/usr_admin/); + // And an exit. + expect(screen.getByTestId('impersonation-stop')).toBeTruthy(); + }); + + it('stop: calls the endpoint, adopts the restored token, refreshes identity, banner goes', async () => { + TokenStorage.set(IMPERSONATED_TOKEN); + const seen = stubStopEndpoint(() => + // The server hands the ADMINISTRATOR's session back the same way it + // handed the impersonated one out: a rotation on the response header. + new Response('{}', { status: 200, headers: { 'set-auth-token': ADMIN_TOKEN } }), + ); + renderBanner(); + + await screen.findByTestId('impersonation-banner'); + await userEvent.click(screen.getByTestId('impersonation-stop')); + + await waitFor(() => expect(screen.queryByTestId('impersonation-banner')).toBeNull()); + expect(seen.urls.some((u) => u.endsWith('/api/v1/auth/admin/stop-impersonating'))).toBe(true); + // The request went out AS the impersonated user (that is the session we + // hold); the administrator is restored from the `admin_session` cookie. + expect(seen.bearers[0]).toBe(`Bearer ${IMPERSONATED_TOKEN}`); + // The rotation was adopted — this is the lane fix doing the work. + expect(TokenStorage.get()).toBe(ADMIN_TOKEN); + }); + + it('stop REJECTED: says so loudly and keeps the banner up', async () => { + TokenStorage.set(IMPERSONATED_TOKEN); + stubStopEndpoint(() => + new Response(JSON.stringify({ message: 'admin session not found' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + renderBanner(); + + await screen.findByTestId('impersonation-banner'); + await userEvent.click(screen.getByTestId('impersonation-stop')); + + const error = await screen.findByTestId('impersonation-stop-error'); + expect(error.textContent).toMatch(/admin session not found/); + // Still impersonating — the indicator must not disappear on a failed exit. + expect(screen.getByTestId('impersonation-banner')).toBeTruthy(); + expect(TokenStorage.get()).toBe(IMPERSONATED_TOKEN); + }); + + it('stop ACCEPTED but nothing restored (cookie-blocked deployment): still fails loudly', async () => { + TokenStorage.set(IMPERSONATED_TOKEN); + // 200 with no rotation: the endpoint answered, the administrator was never + // restored — the shape a deployment that cannot carry `admin_session` + // produces. Appearing to succeed here would be worse than the original bug. + stubStopEndpoint(() => new Response('{}', { status: 200 })); + renderBanner(); + + await screen.findByTestId('impersonation-banner'); + await userEvent.click(screen.getByTestId('impersonation-stop')); + + const stranded = await screen.findByTestId('impersonation-not-restored'); + expect(stranded.textContent).toMatch(/RT8 Dave/); + expect(screen.getByTestId('impersonation-banner')).toBeTruthy(); + }); +}); diff --git a/packages/app-shell/src/layout/index.ts b/packages/app-shell/src/layout/index.ts index 7b3cad4b98..b277a4f44b 100644 --- a/packages/app-shell/src/layout/index.ts +++ b/packages/app-shell/src/layout/index.ts @@ -1,5 +1,6 @@ export { ConsoleLayout } from './ConsoleLayout'; export { ConsoleNotificationBanners } from './ConsoleNotificationBanners'; +export { ImpersonationBanner } from './ImpersonationBanner'; export { AppHeader } from './AppHeader'; export { AppSidebar } from './AppSidebar'; export { UnifiedSidebar } from './UnifiedSidebar'; diff --git a/packages/app-shell/src/views/metadata-admin/external/api.test.ts b/packages/app-shell/src/views/metadata-admin/external/api.test.ts index 773858a821..f6dfb7a209 100644 --- a/packages/app-shell/src/views/metadata-admin/external/api.test.ts +++ b/packages/app-shell/src/views/metadata-admin/external/api.test.ts @@ -10,13 +10,24 @@ import { type ObjectDraft, } from './api'; -/** Build a minimal Response-like object the client's `jsonOrThrow` accepts. */ +/** + * Build a minimal Response-like object the client's `jsonOrThrow` accepts. + * + * `headers` is real (objectui#4467): these calls go out through + * `createAuthenticatedFetch`, which reads `set-auth-token` off every API + * response to adopt a session rotation the server declares — the same capture + * the auth lane has always done. A fake that omits `headers` is not a + * `Response`, and the cast is the only reason the compiler ever believed it + * was; an empty `Headers` keeps the fake honest without asserting anything + * about rotation. + */ function jsonResponse(body: unknown, init: { status?: number; ok?: boolean } = {}): Response { const status = init.status ?? 200; return { ok: init.ok ?? status < 400, status, statusText: 'STATUS', + headers: new Headers(), json: async () => body, } as unknown as Response; } diff --git a/packages/auth/src/AuthContext.ts b/packages/auth/src/AuthContext.ts index 773b6bd773..b3b6a78235 100644 --- a/packages/auth/src/AuthContext.ts +++ b/packages/auth/src/AuthContext.ts @@ -40,6 +40,21 @@ export interface AuthContextValue { signUp: (name: string, email: string, password: string) => Promise<{ requiresVerification: boolean }>; /** Sign out the current user */ signOut: () => Promise; + /** + * objectui#4467 — re-resolve `user`/`session` from the server, in place. + * + * The same loader the provider runs on mount, exposed for the transitions + * that change WHO the session is without going through `signIn`/`signOut`: + * starting and stopping impersonation. Deliberately does NOT raise + * `isLoading` — a refresh must not blank the console it is running under. + * + * Callers rarely need it: a session rotation observed on the wire refreshes + * identity on its own (see `TokenStorage.subscribeRotation`). Call it when + * you must AWAIT the new identity before deciding what to show — the + * impersonation banner's exit does, so a stop that did not restore the + * administrator can fail loudly instead of appearing to succeed. + */ + refreshSession: () => Promise; /** Update user profile */ updateUser: (data: Partial) => Promise; /** Request password reset */ diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index ebd7c3a94e..adb0772690 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -6,11 +6,11 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { authGateEvents } from './auth-gate-events'; import type { AuthUser, AuthClient, AuthProviderOptions, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions } from './types'; import { AuthCtx, type AuthContextValue } from './AuthContext'; -import { createAuthClient } from './createAuthClient'; +import { createAuthClient, TokenStorage } from './createAuthClient'; import { ActiveOrganizationStorage } from './createAuthenticatedFetch'; export interface AuthProviderProps extends AuthProviderOptions { @@ -89,6 +89,84 @@ export function AuthProvider({ ? user !== null && session !== null : true; + // True while `loadSession` is awaiting the server. Read by the rotation + // subscription below: a rotation the server performs INSIDE our own + // `getSession` is already reflected in the answer we are about to apply, and + // re-entering on it would loop this provider against the server forever. + const sessionLoadInFlight = useRef(false); + + /** + * The ONE session loader. Runs on mount (below) and on every later + * re-resolution — `refreshSession` on the context, and the rotation + * subscription — so "how the console learns who it is" has a single + * implementation rather than one per caller. + * + * `isCancelled` mirrors `refreshOrganizations` further down: an unmount or a + * superseding call must not write state, and must not clear the loading flag + * on behalf of a newer load. + */ + const loadSession = useCallback(async (isCancelled?: () => boolean) => { + sessionLoadInFlight.current = true; + try { + const result = await client.getSession(); + if (isCancelled?.()) return; + if (result) { + setUser(result.user); + setSession(result.session); + } else { + // A re-resolution that comes back empty means the session ENDED + // (revoked, expired, signed out in another tab). On mount both are + // already null, so this changes nothing there. + setUser(null); + setSession(null); + } + } catch (err) { + if (isCancelled?.()) return; + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + sessionLoadInFlight.current = false; + if (!isCancelled?.()) { + setIsLoading(false); + } + } + }, [client]); + + /** + * objectui#4467 — re-resolve identity in place. Never raises `isLoading`: + * the console stays on screen while the answer is fetched (`loadSession` + * only ever lowers the flag). Guest / preview identities are synthetic and + * have no server to ask, so this is a no-op there. + */ + const refreshSession = useCallback(async () => { + if (!enabled || isPreviewMode) return; + await loadSession(); + }, [enabled, isPreviewMode, loadSession]); + + /** + * objectui#4467 — an OWNERLESS session rotation re-resolves identity. + * + * `TokenStorage` notifies only when a token we already held is replaced by a + * different one (see its header). Sign-in / sign-out do not qualify: those + * callers update identity themselves. What does qualify is a rotation + * observed on the wire by code that has no idea it just changed who the user + * is — `createAuthenticatedFetch` capturing `set-auth-token` from a generic + * metadata action, which is exactly how the console starts and stops + * impersonation. + * + * This is the honest seam for the refresh. The console's action runtime + * executes `type: 'api'` actions generically; it cannot know that one + * particular endpoint was auth-relevant without hard-coding that endpoint + * into a generic runtime. The rotation IS the signal, and it is the server's + * own declaration rather than our guess about the URL. + */ + useEffect(() => { + if (!enabled || isPreviewMode) return; + return TokenStorage.subscribeRotation(() => { + if (sessionLoadInFlight.current) return; + void loadSession(); + }); + }, [enabled, isPreviewMode, loadSession]); + // Load session on mount (only if auth is enabled and not in preview mode) useEffect(() => { if (isPreviewMode) { @@ -131,28 +209,9 @@ export function AuthProvider({ } let cancelled = false; - - async function loadSession() { - try { - const result = await client.getSession(); - if (cancelled) return; - if (result) { - setUser(result.user); - setSession(result.session); - } - } catch (err) { - if (cancelled) return; - setError(err instanceof Error ? err : new Error(String(err))); - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - } - - loadSession(); + loadSession(() => cancelled); return () => { cancelled = true; }; - }, [client, enabled, isPreviewMode, previewMode]); + }, [client, enabled, isPreviewMode, previewMode, loadSession]); // Notify on auth state changes useEffect(() => { @@ -679,6 +738,7 @@ export function AuthProvider({ signIn, signUp, signOut, + refreshSession, updateUser, forgotPassword, sendVerificationEmail, @@ -721,7 +781,7 @@ export function AuthProvider({ }), [ user, session, isAuthenticated, isAuthEnabled, isLoading, error, isPreviewMode, previewMode, - signIn, signUp, signOut, updateUser, forgotPassword, sendVerificationEmail, resetPassword, changePassword, setInitialPassword, hasLocalPassword, getAuthConfig, signInWithProvider, + signIn, signUp, signOut, refreshSession, updateUser, forgotPassword, sendVerificationEmail, resetPassword, changePassword, setInitialPassword, hasLocalPassword, getAuthConfig, signInWithProvider, sendPhoneOtp, signInWithPhoneOtp, signInWithPhonePassword, requestPhonePasswordReset, resetPasswordWithPhoneOtp, remediationRequired, enrollTotp, verifyTotp, organizations, activeOrganization, activeMember, isOrganizationsLoading, switchOrganization, createOrganization, refreshOrganizations, diff --git a/packages/auth/src/__tests__/impersonation-lane-4467.test.tsx b/packages/auth/src/__tests__/impersonation-lane-4467.test.tsx new file mode 100644 index 0000000000..6276876330 --- /dev/null +++ b/packages/auth/src/__tests__/impersonation-lane-4467.test.tsx @@ -0,0 +1,282 @@ +/** + * objectui#4467 — the two credential lanes and the rotation they must both honor. + * + * ## What this file pins, and why it is the PRIMARY pin + * + * The console injects the same localStorage bearer from two places: + * + * auth lane — `createBearerFetch` inside `createAuthClient`, used by + * sign-in / get-session / the auth endpoints; + * data lane — `createAuthenticatedFetch`, used by the adapter, by + * `provider: 'api'` view data sources, and by EVERY metadata + * `type: 'api'` action (`useConsoleActionRuntime`'s `apiHandler`). + * + * Only the auth lane read the `set-auth-token` response header better-auth's + * bearer plugin uses to hand back a ROTATED session token. The data lane + * dropped it on the floor — so any endpoint that rotates the session while + * being called through the data lane left the browser still holding, and still + * sending, the OLD token. + * + * Impersonation is that endpoint. `POST /auth/admin/impersonate-user` runs as a + * metadata action → data lane → the impersonated session token arrives in + * `set-auth-token` and is discarded, while the server-side bearer plugin keeps + * overwriting the impersonation COOKIE with the admin bearer we keep sending. + * Every subsequent request resolves to the admin: impersonation was not merely + * invisible in the console, it was a complete NO-OP (issue #4467, ruling update + * comment 5273408139). + * + * So the pin is lane-level, not impersonation-level: a data-lane response that + * declares a rotation must rotate `TokenStorage`, exactly as the auth lane + * already does. There is no impersonation-specific logic in either lane — the + * server declares a rotation, the client honors it, whatever endpoint rotated. + * + * A test that mocked a session carrying `impersonatedBy` and asserted on a + * banner would have been a phantom pin: production could not reach that state + * at all, because the token that produces it was being thrown away here. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { createAuthenticatedFetch } from '../createAuthenticatedFetch'; +import { TokenStorage, createAuthClient } from '../createAuthClient'; +import { AuthProvider } from '../AuthProvider'; +import { useAuth } from '../useAuth'; +import type { AuthClient } from '../types'; + +const API_URL = 'http://localhost/api/v1/auth/admin/impersonate-user'; + +/** + * Stub the global fetch with a response carrying the given headers — the shape + * better-auth's bearer plugin answers with when it issues a session token. + */ +function stubFetch(headers: Record = {}) { + const calls: string[] = []; + const mock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + calls.push(url); + return new Response('{}', { status: 200, headers }); + }); + vi.stubGlobal('fetch', mock); + return calls; +} + +describe('set-auth-token rotation — lane symmetry (#4467)', () => { + beforeEach(() => { + TokenStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + TokenStorage.clear(); + }); + + it('DATA lane adopts a rotated set-auth-token (the #4467 defect)', async () => { + TokenStorage.set('admin-token'); + stubFetch({ 'set-auth-token': 'impersonated-token' }); + + await createAuthenticatedFetch()(API_URL, { method: 'POST' }); + + expect(TokenStorage.get()).toBe('impersonated-token'); + }); + + it('AUTH lane adopts a rotated set-auth-token (unchanged — the half that always worked)', async () => { + TokenStorage.set('admin-token'); + const fetchFn = vi.fn(async () => + new Response('[]', { status: 200, headers: { 'set-auth-token': 'impersonated-token' } }), + ) as unknown as typeof fetch; + + // `hasLocalPassword()` is the thinnest real call through `createBearerFetch` + // (a bare GET /list-accounts) — it exercises the auth lane's capture without + // going through better-auth's own client machinery. + const client = createAuthClient({ baseURL: 'http://localhost/api/v1/auth', fetchFn }); + await client.hasLocalPassword(); + + expect(TokenStorage.get()).toBe('impersonated-token'); + }); + + it('leaves the stored token alone on a data-lane response WITHOUT the header', async () => { + TokenStorage.set('admin-token'); + stubFetch(); + + await createAuthenticatedFetch()('http://localhost/api/v1/meta/object/account'); + + expect(TokenStorage.get()).toBe('admin-token'); + }); + + it('adopts the rotation on a cross-origin-suppressed lane only when the request was made', async () => { + // `sameOriginOnly` short-circuits to the bare global fetch BEFORE any header + // work, so a third-party host can neither see our bearer nor rotate it. + TokenStorage.set('admin-token'); + stubFetch({ 'set-auth-token': 'attacker-token' }); + + await createAuthenticatedFetch({ sameOriginOnly: true })('https://third-party.example/api/v1/x'); + + expect(TokenStorage.get()).toBe('admin-token'); + }); +}); + +describe('TokenStorage rotation notifications (#4467)', () => { + beforeEach(() => { + TokenStorage.clear(); + }); + + afterEach(() => { + TokenStorage.clear(); + }); + + it('does NOT notify on the FIRST store — sign-in owns its own identity update', () => { + const seen = vi.fn(); + const unsubscribe = TokenStorage.subscribeRotation(seen); + try { + TokenStorage.set('fresh-sign-in-token'); + expect(seen).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('notifies when a token already in hand is REPLACED by a different one', () => { + TokenStorage.set('admin-token'); + const seen = vi.fn(); + const unsubscribe = TokenStorage.subscribeRotation(seen); + try { + TokenStorage.set('impersonated-token'); + expect(seen).toHaveBeenCalledTimes(1); + } finally { + unsubscribe(); + } + }); + + it('does not notify when the same token is re-stored (get-session keeping storage in sync)', () => { + TokenStorage.set('admin-token'); + const seen = vi.fn(); + const unsubscribe = TokenStorage.subscribeRotation(seen); + try { + TokenStorage.set('admin-token'); + expect(seen).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('does not notify on clear() — sign-out owns its own identity update', () => { + TokenStorage.set('admin-token'); + const seen = vi.fn(); + const unsubscribe = TokenStorage.subscribeRotation(seen); + try { + TokenStorage.clear(); + expect(seen).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('stops notifying after unsubscribe', () => { + TokenStorage.set('admin-token'); + const seen = vi.fn(); + TokenStorage.subscribeRotation(seen)(); + TokenStorage.set('impersonated-token'); + expect(seen).not.toHaveBeenCalled(); + }); +}); + +/** + * The half the card actually reported: STARTING impersonation. + * + * Nothing on that path can call a refresh. `POST /auth/admin/impersonate-user` + * is an ordinary metadata `type: 'api'` action — the console's action runtime + * executes it exactly like "approve this record", and cannot know that this one + * endpoint changed who the user is without hard-coding the endpoint into a + * generic runtime. The ROTATION is the signal, and it is the server's own + * declaration rather than our guess about a URL. + */ +describe('identity follows an ownerless rotation (#4467)', () => { + const ADMIN_TOKEN = 'admin-token'; + const IMPERSONATED_TOKEN = 'impersonated-token'; + + /** Resolves identity from the stored token, the way the server does. */ + function tokenBoundClient(): AuthClient { + return { + getSession: vi.fn(async () => { + const token = TokenStorage.get(); + if (token === IMPERSONATED_TOKEN) { + return { + user: { id: 'usr_dave', name: 'RT8 Dave', email: 'rt8-dave@example.com' }, + session: { token: IMPERSONATED_TOKEN, impersonatedBy: 'usr_admin' }, + }; + } + if (token === ADMIN_TOKEN) { + return { + user: { id: 'usr_admin', name: 'Dev Admin', email: 'admin@objectos.ai' }, + session: { token: ADMIN_TOKEN }, + }; + } + return null; + }), + listOrganizations: vi.fn(async () => []), + getActiveOrganization: vi.fn(async () => null), + getActiveMember: vi.fn(async () => null), + } as unknown as AuthClient; + } + + function Identity() { + const { user, session } = useAuth(); + return ( +
+ {user?.name ?? 'none'} + {session?.impersonatedBy ?? 'none'} +
+ ); + } + + beforeEach(() => { + TokenStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + TokenStorage.clear(); + }); + + it('a DATA-lane rotation re-resolves the console identity (impersonation takes effect)', async () => { + TokenStorage.set(ADMIN_TOKEN); + render( + + + , + ); + await waitFor(() => expect(screen.getByTestId('who').textContent).toBe('Dev Admin')); + expect(screen.getByTestId('impersonated-by').textContent).toBe('none'); + + // What the console really does: a generic api action POSTs the impersonate + // endpoint through the data lane. Nobody on this path knows it is auth. + stubFetch({ 'set-auth-token': IMPERSONATED_TOKEN }); + await createAuthenticatedFetch()(API_URL, { method: 'POST' }); + + await waitFor(() => expect(screen.getByTestId('who').textContent).toBe('RT8 Dave')); + expect(screen.getByTestId('impersonated-by').textContent).toBe('usr_admin'); + }); + + it('leaves identity alone when the same token is re-stored', async () => { + TokenStorage.set(ADMIN_TOKEN); + const client = tokenBoundClient(); + render( + + + , + ); + await waitFor(() => expect(screen.getByTestId('who').textContent).toBe('Dev Admin')); + const callsAfterBoot = (client.getSession as ReturnType).mock.calls.length; + + // A response that hands back the token we already hold — what an ordinary + // authenticated request looks like. No rotation, so no re-resolution. + stubFetch({ 'set-auth-token': ADMIN_TOKEN }); + await createAuthenticatedFetch()('http://localhost/api/v1/meta/object/account'); + await new Promise((r) => setTimeout(r, 0)); + + expect((client.getSession as ReturnType).mock.calls.length).toBe(callsAfterBoot); + expect(screen.getByTestId('who').textContent).toBe('Dev Admin'); + }); +}); diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index 8bd1cadc95..d7c5de77b8 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -18,9 +18,26 @@ const TOKEN_STORAGE_KEY = 'auth-session-token'; /** * Simple token storage backed by localStorage. * Falls back to in-memory storage when localStorage is unavailable (SSR, tests). + * + * ## Rotation notifications (objectui#4467) + * + * `subscribeRotation` fires when a token WE ALREADY HELD is replaced by a + * different one — i.e. the server rotated the session under a request nobody + * was watching. That is the only case with no owner: sign-in, sign-up and + * sign-out all set/clear this storage from `AuthProvider`, which updates its + * own identity state in the same breath, so those transitions deliberately + * stay silent (first store: no notify; `clear()`: no notify; re-storing the + * same value, as `getSession` does on every boot: no notify). + * + * A rotation with no owner is exactly what impersonation produces — the + * console POSTs `/auth/admin/impersonate-user` through a generic metadata + * action, the server hands back a NEW session token in `set-auth-token`, and + * nothing in that code path knows the identity just changed. See + * `AuthProvider`'s subscription and `__tests__/impersonation-lane-4467.test.tsx`. */ export const TokenStorage = { _memoryToken: null as string | null, + _rotationListeners: new Set<() => void>(), get(): string | null { try { @@ -32,12 +49,21 @@ export const TokenStorage = { }, set(token: string): void { + const previous = this.get(); this._memoryToken = token; try { if (typeof localStorage !== 'undefined') { localStorage.setItem(TOKEN_STORAGE_KEY, token); } } catch { /* SSR / test */ } + // Rotation = a token we were already using got replaced. See the header. + if (previous && previous !== token) { + for (const listener of [...this._rotationListeners]) { + try { + listener(); + } catch { /* a listener must never break the write that triggered it */ } + } + } }, clear(): void { @@ -48,6 +74,17 @@ export const TokenStorage = { } } catch { /* SSR / test */ } }, + + /** + * Observe session-token ROTATIONS (see the header for what does and does not + * count as one). Returns an unsubscribe function. + */ + subscribeRotation(listener: () => void): () => void { + this._rotationListeners.add(listener); + return () => { + this._rotationListeners.delete(listener); + }; + }, }; /** diff --git a/packages/auth/src/createAuthenticatedFetch.ts b/packages/auth/src/createAuthenticatedFetch.ts index 32d355ecd9..aed8b7ae15 100644 --- a/packages/auth/src/createAuthenticatedFetch.ts +++ b/packages/auth/src/createAuthenticatedFetch.ts @@ -131,6 +131,39 @@ export function createAuthenticatedFetch( } } const response = await fetch(input, { ...init, headers }); + // Adopt a session rotation the server declares on the response, exactly as + // the auth lane already does (`createBearerFetch` in createAuthClient.ts). + // + // ## Why this lane needs it too (objectui#4467) + // + // The console injects the same localStorage bearer from two places: the + // AUTH lane (sign-in / get-session / the auth endpoints) and THIS one — the + // adapter, `provider: 'api'` data sources, and every metadata `type: 'api'` + // action (`useConsoleActionRuntime`'s `apiHandler`). better-auth's + // server-side bearer plugin hands a rotated session token back in + // `set-auth-token` on whichever lane the call arrived over. Only the auth + // lane read it, so a rotation issued to a data-lane call was dropped on the + // floor and the browser kept sending the OLD token. + // + // Impersonation is exactly that call: `POST /auth/admin/impersonate-user` + // runs as an ordinary metadata action, so the impersonated session token + // arrived here and was discarded — while the server's bearer plugin kept + // overwriting the impersonation cookie with the admin bearer we kept + // sending. Impersonation was a complete no-op in the console, not merely an + // invisible one. + // + // The rule is the server's declared contract and carries no knowledge of + // which endpoint rotated: one contract, one answer, on both lanes. Gated on + // `isApiCall` — the same condition that decided we authenticated this + // request at all — so a response we never sent the bearer to cannot rotate + // the session. Untrusted targets are the `sameOriginOnly` option's job (it + // short-circuits above, before any header work). + if (isApiCall) { + const rotatedToken = response.headers.get('set-auth-token'); + if (rotatedToken) { + TokenStorage.set(rotatedToken); + } + } // ADR-0069 — surface an auth-policy gate (expired password / required MFA) // to the remediation overlay. Clone so the caller still reads the body. if (isApiCall && response.status === 403) { diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 37fa24ba2e..d9bf80e432 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -107,6 +107,18 @@ export interface AuthClientSession { expiresAt?: Date; /** Refresh token */ refreshToken?: string; + /** + * objectui#4467 — set by better-auth's admin plugin while this session is an + * IMPERSONATION: the id of the administrator who started it. Present on + * `GET /auth/get-session` for the whole life of the impersonated session, so + * a console that reads it survives full SPA reboots (it is a property of the + * session, not a memory of the click that created it). + * + * Its presence is what raises the impersonation banner — see + * `@object-ui/app-shell`'s `ImpersonationBanner`. Absent on every ordinary + * session, which is why the banner costs a normal session nothing. + */ + impersonatedBy?: string; } /** Authentication state */ diff --git a/packages/auth/src/useAuth.ts b/packages/auth/src/useAuth.ts index 150e4876c2..cf1d800e12 100644 --- a/packages/auth/src/useAuth.ts +++ b/packages/auth/src/useAuth.ts @@ -39,6 +39,7 @@ export function useAuth(): AuthContextValue { signIn: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, signUp: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, signOut: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + refreshSession: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, updateUser: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, forgotPassword: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, sendVerificationEmail: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index fbf66ec0d8..d76f336bb6 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1,4 +1,18 @@ const ar = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'أنت تنتحل هوية {{user}} — يُسجَّل كل إجراء باسمه.', + startedBy: 'بدأها المسؤول {{admin}}.', + stop: 'إنهاء انتحال الهوية', + stopping: 'جارٍ الإنهاء…', + stopFailed: 'تعذّر إنهاء انتحال الهوية: {{reason}}', + notRestored: 'قبل الخادم الطلب لكنه لم يستعد جلسة المسؤول — ما زلت تنتحل هوية {{user}}. سجّل الخروج ثم الدخول مرة أخرى لإنهائها.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index bfe75fec8f..41ecdf822d 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1,4 +1,18 @@ const de = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'Sie handeln als {{user}} — jede Aktion wird dieser Person zugeschrieben.', + startedBy: 'Gestartet von Administrator {{admin}}.', + stop: 'Identitätswechsel beenden', + stopping: 'Wird beendet…', + stopFailed: 'Identitätswechsel konnte nicht beendet werden: {{reason}}', + notRestored: 'Der Server hat die Anfrage angenommen, aber Ihre Administratorsitzung nicht wiederhergestellt — Sie handeln weiterhin als {{user}}. Melden Sie sich ab und erneut an, um dies zu beenden.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index d24b898a25..7d0cf73cdd 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2,6 +2,20 @@ * English (en) - Default language pack for Object UI */ const en = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'You are impersonating {{user}} — every action is recorded as them.', + startedBy: 'Started by administrator {{admin}}.', + stop: 'Stop impersonating', + stopping: 'Stopping…', + stopFailed: 'Could not stop impersonating: {{reason}}', + notRestored: '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.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from // the sys_capability registry; only these group titles are UI strings). capability: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 7640cb2129..1e8b7119ae 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1,4 +1,18 @@ const es = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'Está suplantando a {{user}}: todas las acciones se registran a su nombre.', + startedBy: 'Iniciado por el administrador {{admin}}.', + stop: 'Dejar de suplantar', + stopping: 'Finalizando…', + stopFailed: 'No se pudo dejar de suplantar: {{reason}}', + notRestored: 'El servidor aceptó la solicitud pero no restauró su sesión de administrador: sigue suplantando a {{user}}. Cierre sesión y vuelva a iniciarla para terminar.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 4f31c36156..efd0e13bd9 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1,4 +1,18 @@ const fr = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'Vous incarnez {{user}} — chaque action est enregistrée en son nom.', + startedBy: 'Démarré par l’administrateur {{admin}}.', + stop: 'Arrêter l’usurpation', + stopping: 'Arrêt en cours…', + stopFailed: 'Impossible d’arrêter l’usurpation : {{reason}}', + notRestored: 'Le serveur a accepté la demande mais n’a pas restauré votre session administrateur — vous incarnez toujours {{user}}. Déconnectez-vous puis reconnectez-vous pour y mettre fin.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 09905adcd9..9e75a79744 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1,4 +1,18 @@ const ja = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: '{{user}} になりすまして操作しています。すべての操作はこのユーザーとして記録されます。', + startedBy: '管理者 {{admin}} が開始しました。', + stop: 'なりすましを終了', + stopping: '終了しています…', + stopFailed: 'なりすましを終了できませんでした: {{reason}}', + notRestored: 'サーバーはリクエストを受け付けましたが、管理者セッションを復元しませんでした。まだ {{user}} として操作しています。サインアウトして再度サインインして終了してください。', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4505a1739b..436ea93339 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1,4 +1,18 @@ const ko = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: '{{user}} 계정을 대행하고 있습니다. 모든 작업이 해당 사용자로 기록됩니다.', + startedBy: '관리자 {{admin}}이(가) 시작했습니다.', + stop: '대행 종료', + stopping: '종료하는 중…', + stopFailed: '대행을 종료할 수 없습니다: {{reason}}', + notRestored: '서버가 요청을 받았지만 관리자 세션을 복원하지 않았습니다. 여전히 {{user}} 계정을 대행하고 있습니다. 로그아웃 후 다시 로그인하여 종료하세요.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 10c08af027..0645660448 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1,4 +1,18 @@ const pt = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'Você está personificando {{user}} — todas as ações são registradas em nome dele.', + startedBy: 'Iniciado pelo administrador {{admin}}.', + stop: 'Parar personificação', + stopping: 'Encerrando…', + stopFailed: 'Não foi possível parar a personificação: {{reason}}', + notRestored: 'O servidor aceitou a solicitação, mas não restaurou sua sessão de administrador — você ainda está personificando {{user}}. Saia e entre novamente para encerrar.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 25e0d02415..8e1736e1db 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1,4 +1,18 @@ const ru = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: 'Вы работаете от имени {{user}} — все действия записываются на этого пользователя.', + startedBy: 'Начато администратором {{admin}}.', + stop: 'Прекратить работу от чужого имени', + stopping: 'Завершение…', + stopFailed: 'Не удалось прекратить работу от чужого имени: {{reason}}', + notRestored: 'Сервер принял запрос, но не восстановил сеанс администратора — вы всё ещё работаете от имени {{user}}. Выйдите и войдите снова, чтобы завершить.', + }, + }, // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). capability: { label: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 802b8a1fee..e04dad096b 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2,6 +2,20 @@ * 中文 (zh) - Chinese language pack for Object UI */ const zh = { + // objectui#4467 — the impersonation banner (app-shell ImpersonationBanner). + // Raised by `session.impersonatedBy`, so it survives SPA reboots; it names + // BOTH parties because the audit trail attributes the work to the impersonated + // user, and the exit states its own failure rather than appearing to succeed. + impersonation: { + banner: { + message: '您正在以 {{user}} 的身份操作 —— 所有操作都会记录为该用户。', + startedBy: '由管理员 {{admin}} 发起。', + stop: '结束模拟登录', + stopping: '正在结束…', + stopFailed: '无法结束模拟登录:{{reason}}', + notRestored: '服务器接受了请求,但没有恢复您的管理员会话 —— 您仍在以 {{user}} 的身份操作。请退出登录后重新登录以结束。', + }, + }, // objectui#2600 B5 — 能力选择器的作用域分组标题(能力标签本身来自 // sys_capability 注册表,这里只本地化分组标题这类 UI 字符串)。 capability: {