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.',
+ })}
+
+ )}