diff --git a/.changeset/29161-impersonation-banner-admin-name.md b/.changeset/29161-impersonation-banner-admin-name.md new file mode 100644 index 00000000000..9c2cfc0b3bc --- /dev/null +++ b/.changeset/29161-impersonation-banner-admin-name.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): the impersonation banner names the administrator by name or email, never by a raw user id + +Clause-②: yes (widening) +Metadata-protocol: none + +An impersonated session carries the administrator only as `session.impersonatedBy`, a `sys_user` id, and the banner printed that id ("Started by administrator 963Jhis…"). Neither the administrator nor anyone watching the screen could tell who started the impersonation. + +- **Named from the user's row.** The banner reads the administrator's `sys_user` row once (`GET /api/v1/data/sys_user/ID`, sent as the session the console holds) and names them by their name, else their email: "Started by administrator Dev Admin." +- **An honest sentence when the row cannot be read.** The read is made as the impersonated user, whose row scope may not reach the administrator's row. A refused, missing or failed read, or a row with neither a name nor an email, shows "Started by an administrator." The raw id is never shown, and the sentence is held back until the read answers. +- **The remediation overlay is unchanged.** It shares the exit with the banner but names nobody, so it issues no read. +- **New locale key** in every `@object-ui/i18n` pack: `impersonation.banner.startedByUnnamed`. diff --git a/ui/packages/app-shell/src/layout/ImpersonationBanner.tsx b/ui/packages/app-shell/src/layout/ImpersonationBanner.tsx index 3a640450cc9..df5e50f08f4 100644 --- a/ui/packages/app-shell/src/layout/ImpersonationBanner.tsx +++ b/ui/packages/app-shell/src/layout/ImpersonationBanner.tsx @@ -23,6 +23,24 @@ * whose `/home` showed no sign of impersonation at all, so a home that could * not carry the indicator would have reproduced the bug. * + * ## The administrator is named by a person's name, never by an id + * + * The session carries the administrator only as `impersonatedBy`, a `sys_user` + * id, which tells neither the administrator nor anyone watching the screen who + * started this. The banner therefore reads that one `sys_user` row and names it + * by its name, else its email. The read is made AS the impersonated user (that + * is the session the console holds), and their row scope may not reach the + * administrator's row: a refused, missing or failed read says "an + * administrator" instead. ⛔ The raw id is never shown, and nothing is shown for + * the administrator until the read has answered, so the sentence never flips + * from an id or a guess to the name. + * + * The read is a raw authenticated `GET` on the `/api/v1` lane, the lane the + * exit uses: this banner mounts in `ConsoleShell`, above `AdapterProvider`, so + * the data adapter does not exist here. It lives in the banner rather than in + * `useImpersonationExit` because the remediation overlay shares that hook and + * names nobody, so it must not issue the read. + * * ## The exit fails LOUDLY * * `POST /auth/admin/stop-impersonating` restores the administrator from the @@ -34,11 +52,62 @@ * the operator would go back to ordinary work believing they were themselves. */ +import { useEffect, useState } from 'react'; import { UserCog } from 'lucide-react'; import { Button } from '@object-ui/components'; +import { createAuthenticatedFetch } from '@object-ui/auth'; +import { GetDataResponseSchema } from '@objectstack/spec/api'; import { useObjectTranslation } from '@object-ui/i18n'; +import { PERSON_DISPLAY_FIELDS, rowLabel } from '../utils/approverIdentity.js'; import { useImpersonationExit } from './useImpersonationExit.js'; +/** + * How the banner names the administrator: `{ name }` once the `sys_user` read + * answered with a name or email, `null` once it answered without one (refused, + * missing, failed), `undefined` while it is still being asked. + */ +type AdministratorName = { name: string } | null | undefined; + +/** + * Read the administrator's `sys_user` row once per administrator id. See the + * module header for why the read is made here and how a refusal reads. + */ +function useAdministratorName(adminId: string | undefined): AdministratorName { + // The answer is kept with the id it answers for, so a later impersonation by + // another administrator never shows the previous one's name. + const [answer, setAnswer] = useState<{ id: string; name: { name: string } | null } | null>(null); + + useEffect(() => { + if (!adminId) return; + let cancelled = false; + void (async () => { + let name: string | null = null; + try { + const baseUrl = import.meta.env.VITE_SERVER_URL || ''; + const res = await createAuthenticatedFetch()( + `${baseUrl}/api/v1/data/sys_user/${encodeURIComponent(adminId)}`, + { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' }, + ); + if (res.ok) { + // The data route answers the spec's `GetDataResponse` body; anything + // else names nobody. + const body = GetDataResponseSchema.safeParse(await res.json()); + if (body.success) name = rowLabel(body.data.record, PERSON_DISPLAY_FIELDS) || null; + } + } catch { + // A failed read names nobody; the banner says "an administrator". + } + if (!cancelled) setAnswer({ id: adminId, name: name ? { name } : null }); + })(); + return () => { + cancelled = true; + }; + }, [adminId]); + + if (!adminId || answer === null || answer.id !== adminId) return undefined; + return answer.name; +} + export function ImpersonationBanner() { const { t } = useObjectTranslation(); // The exit itself — request, token adoption, outcome — is shared with the @@ -46,6 +115,7 @@ export function ImpersonationBanner() { // (objectui#13208). See `useImpersonationExit`. const { impersonatedBy, impersonatedName, stop, stopping, stopError, notRestored } = useImpersonationExit(); + const administrator = useAdministratorName(impersonatedBy); // Nothing to say on an ordinary session. Below every hook, so the hook order // is stable across the transition out of impersonation. @@ -65,12 +135,18 @@ export function ImpersonationBanner() { user: impersonatedName, })} {' '} - - {t('impersonation.banner.startedBy', { - defaultValue: 'Started by administrator {{admin}}.', - admin: impersonatedBy, - })} - + {administrator === undefined ? null : ( + + {administrator + ? t('impersonation.banner.startedBy', { + defaultValue: 'Started by administrator {{admin}}.', + admin: administrator.name, + }) + : t('impersonation.banner.startedByUnnamed', { + defaultValue: 'Started by an administrator.', + })} + + )}