diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index e5e86585..6aa341d8 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react'; import { useLocation } from 'react-router'; -import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev'; +import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev'; import { usePermissions } from 'hooks/usePermissions'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { ScreenErrorFallback } from './ScreenErrorFallback'; @@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { return (
+ {/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header; + it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts b/src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts new file mode 100644 index 00000000..ae084bf6 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts @@ -0,0 +1,55 @@ +/** + * Being somebody else, and getting back. + * + * ── Why the way back is a stashed token and not a server route ── + * + * Returning to yourself is putting back the token you already had. There is no server state to expire, + * leak or get wrong, and no route that mints an owner token — which would be a far more dangerous thing + * to own than this whole feature. The cost is that clearing browser storage strands you as the other + * person until their hour runs out; signing in again fixes it, and that is the right trade against a + * route that hands out owner sessions. + * + * ── Why `localStorage` and not `sessionStorage` ── + * + * The same storage the client reads its token from, so the two cannot disagree, and so a second tab + * opened mid-support sees the same account rather than silently being the owner. + */ + +const TOKEN_KEYS = ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN'] as const; +/** Where the owner's own token waits. Distinct from every key the client reads, so it is never picked up. */ +const STASH_KEY = 'officer:impersonation:owner-token'; + +const writeToken = (token: string) => { + for (const key of TOKEN_KEYS) localStorage.setItem(key, token); + (window as { officerBearerToken?: string }).officerBearerToken = token; +}; + +/** True while this browser is wearing somebody else's session. */ +export const isImpersonating = (): boolean => Boolean(localStorage.getItem(STASH_KEY)); + +/** + * Swap to the target's token, keeping your own. + * + * A full reload rather than a re-render: every query in flight, every socket and every cached page + * belongs to the previous account. Refetching them piecemeal is how a support view ends up showing two + * people's data at once. + */ +export const startImpersonation = (token: string) => { + const mine = localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ?? localStorage.getItem('BEARER_TOKEN'); + // Refuse rather than stash nothing: without the owner's token there is no way back, and discovering + // that after the swap is the worst possible moment. + if (!mine) throw new Error('Could not find your own session to return to'); + // Never overwrite an existing stash — that would be a second hop overwriting the way home. + if (!localStorage.getItem(STASH_KEY)) localStorage.setItem(STASH_KEY, mine); + writeToken(token); + window.location.assign('/'); +}; + +/** Put your own token back and reload. Safe to call when not impersonating: it does nothing. */ +export const endImpersonation = () => { + const mine = localStorage.getItem(STASH_KEY); + if (!mine) return; + localStorage.removeItem(STASH_KEY); + writeToken(mine); + window.location.assign('/'); +}; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index bdc88a1a..ce0b4804 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -139,3 +139,7 @@ export type { StoreItem as AppStoreItem } from './apps/AppStore'; // docs/localization.md's open question and is not answered by this. export { panelName } from './locales/panelName'; export { useTranslation } from 'i18n'; + +// Impersonation: the banner the owner cannot miss, and the token swap behind it. See impersonation.ts. +export { ImpersonationBanner } from './apps/Impersonation/ImpersonationBanner'; +export { startImpersonation, endImpersonation, isImpersonating } from './apps/Impersonation/impersonation';