From 65cf6f70f86356ff81fe3f609cb189765e2d0daa Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 2 Nov 2026 09:51:50 +0000
Subject: [PATCH 1/3] fix(app-shell): name the administrator in the
impersonation banner, never the raw user id
The banner read the administrator from session.impersonatedBy, a sys_user
id, and printed it. It now reads that one sys_user row (as the session the
console holds) and names it by name, else email; a refused, missing or
failed read says "an administrator". The remediation overlay, which shares
the exit hook, issues no read.
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01QsR2jTfuF6dXc7H9Ajpbej
---
.../29161-impersonation-banner-admin-name.md | 16 ++++
.../src/layout/ImpersonationBanner.tsx | 86 +++++++++++++++++--
.../__tests__/ImpersonationBanner.test.tsx | 74 +++++++++++++++-
ui/packages/i18n/src/locales/ar.ts | 1 +
ui/packages/i18n/src/locales/de.ts | 1 +
ui/packages/i18n/src/locales/en.ts | 1 +
ui/packages/i18n/src/locales/es.ts | 1 +
ui/packages/i18n/src/locales/fr.ts | 1 +
ui/packages/i18n/src/locales/ja.ts | 1 +
ui/packages/i18n/src/locales/ko.ts | 1 +
ui/packages/i18n/src/locales/pt.ts | 1 +
ui/packages/i18n/src/locales/ru.ts | 1 +
ui/packages/i18n/src/locales/zh.ts | 1 +
13 files changed, 176 insertions(+), 10 deletions(-)
create mode 100644 .changeset/29161-impersonation-banner-admin-name.md
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..5278f3630fa 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,60 @@
* 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 { 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's envelope: `{ success, data: { object, id, record } }`.
+ const body = (await res.json()) as { data?: { record?: unknown } } | null;
+ 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 +113,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 +133,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.',
+ })}
+
+ )}