Feedback

Impersonation Banner

The bar support staff sees while acting as a customer — whose account this is, who is inside it, what the grant may write, a live countdown to the automatic cut-off, and an exit that is never blocked.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Clock, Eye, LoaderCircle, LogOut, ShieldUser, SquarePen, TriangleAlert } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

/** ISO string, epoch ms or Date — everything is normalised to one absolute instant. */
export type ImpersonationInstant = string | number | Date

/**
 * What this session is allowed to do — **as reported by the server that granted it**.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/impersonation-banner.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ImpersonationBanner" component using
lucide-react, the shadcn Button / Badge / Tooltip primitives and the local cn()
helper (clsx + tailwind-merge). It is the bar a support agent sees while acting
as a customer: whose account this is, who is inside it, what this grant is
allowed to do, how long it has left, and the way out.

Contract
- forwardRef<HTMLDivElement>; props extend
  Omit<React.HTMLAttributes<HTMLDivElement>, "children">, the rest are spread on
  the root and className is merged through cn().
- Required: subject { name, email, id }, actor { name },
  startedAt, expiresAt (each string | number | Date, normalised by one toInstant()
  that returns number | null for anything non-finite),
  mode: "read-only" | "read-write", onExit: () => void.
- Optional: onExpire?: () => void; exiting = false; warnBeforeSeconds = 300;
  now?: instant (first paint only); locale?: string;
  labels?: Partial<Labels> merged over a DEFAULT_LABELS bag of strings and
  string-returning functions — every visible word and every announcement goes
  through it, so the component can be translated without a fork.
- Export the phase union "active" | "ending" | "expired". It is derived from the
  clock, never accepted as a prop: two sources for one truth is how a bar ends up
  claiming a session is live after it lapsed.
- `mode` is REPORTED, not enforced. Document that: the banner renders the
  capability the server granted, and shipping "read-only" while writes actually
  succeed is the one failure this component cannot detect for you.

Behavior
- Layout is the feature. The root is `sticky top-0 z-50`, i.e. IN FLOW: it takes
  up its own height and pushes the page down, and it stays pinned while its scroll
  container moves. No fixed positioning, no ResizeObserver, no spacer, no
  padding-top for the host to keep in sync.
- The clock lives in state, not in render. `nowMs === null` means "not read yet":
  the server render and the first client frame print an em dash instead of a
  number hydration would then contradict. Injecting `now` skips that frame; the
  interval takes over at mount, and a changed `now` is applied during render
  (adjust-state-on-props) so a stepped clock never paints the old value first.
- One interval, 500ms, recomputing `expiresAt − Date.now()` every tick — never
  "last value − 1", which drops nearly every tick in a throttled background tab
  and returns wrong. Also recompute on `visibilitychange`, because a suspended tab
  can come back minutes stale. Cancel the rAF, clear the interval and remove the
  listener on unmount AND before every re-arm.
- Re-arm the timer on (startedAt, expiresAt, warnBeforeSeconds, mode,
  subject.name). Re-arming on mode is deliberate: a grant elevated from read-only
  to read-write mid-session is new authority over someone else's data and has to
  be announced again.
- Three announcements, each exactly once, into ONE permanently mounted sr-only
  region that is EMPTY on the first paint (role="alert", aria-live="assertive",
  aria-atomic): (1) a frame after mount — "You are now acting as X, anything you
  save is written to their account, it ends automatically in 46 minutes"; (2) the
  rising edge into the warning window; (3) the deadline passing. Assertive is the
  right politeness here — silently acting as another person is the failure this
  component exists to prevent. The ticking number never enters the region.
  Mounting already inside the warning window consumes the escalation edge (the
  opening line already carried the number); mounting already past the deadline
  skips the welcome entirely and says the expired line instead.
- onExpire fires ONCE PER DEADLINE, not once per timer arm: key the "already
  fired" flag on the expiresAt value itself, or elevating the mode of an
  already-lapsed grant re-arms the effect, resets a per-arm flag and asks the
  host to release the same window a second time. The banner never ends the
  session itself — it does not own the grant.
- After the deadline the interval SLOWS to 30s instead of stopping. There is
  nothing left to count down, but "Started N ago" is still counting up while the
  operator sits in the account, and a number that freezes at the deadline is a
  stale claim the bar keeps printing. It stops for real only when there is no
  start instant to count from — then nothing on the bar can change.
- Expired state: the mode badge becomes "Expired", the consequence line stops
  claiming anything about writes and says the window has closed, the icon swaps to
  a shield, and the exit stays exactly where it was. A session whose exit vanishes
  at expiry is a trap.
- The exit is never disabled, never hidden and never behind a confirm — it is the
  SAFE direction, and friction on the way out of someone else's account is
  backwards. While `exiting` is true it shows a spinner and "Returning…", and
  repeat presses are swallowed by a guard inside the handler, using aria-disabled
  rather than the native attribute (a natively disabled button leaves the tab
  order and drops focus to <body>).
- Refusals instead of invented numbers: an unparsable expiresAt renders "No end
  time set" and no countdown; an unparsable startedAt renders "Start time
  unavailable"; the elapsed hairline is dropped unless expiresAt > startedAt.
  Elapsed time is FLOORED ("less than a minute", never "1 min") — a bar may
  understate how long someone has been inside an account, never overstate it.
- Absolute instants (started at / ends at) live only inside the Tooltip, which
  Radix mounts on open — i.e. on the client, in the reader's own time zone.
  Formatting them during SSR would print the server's zone and mismatch on
  hydration. Wrap Intl.DateTimeFormat construction in try/catch with an
  ("en-US") fallback: a malformed locale tag throws RangeError, and a banner that
  crashes on its way to saying "you are inside a customer's account" is the worst
  outcome available.

Rendering & styling
- Semantic tokens only. read-only = border-primary/30 bg-primary/10 with a
  bg-primary/15 text-primary icon plate; read-write = the same shape on
  destructive; expired = destructive at a heavier weight. Body copy is
  text-foreground, supporting copy text-muted-foreground, the id chip
  bg-foreground/5 font-mono, the elapsed hairline bg-foreground/10 with a
  tone-coloured fill.
- Contrast trap, since every chip here sits on an already-tinted surface: do NOT
  stack a /10 destructive chip on a /10 destructive bar — `text-destructive` lands
  around 3.9:1 in light mode. The mode badge takes a SOLID destructive fill with
  background-coloured text (and an explicit dark:bg-destructive, or tailwind-merge
  keeps the variant's dark:bg-destructive/20 and inverts the pair), and the
  escalated countdown is carried by font-weight plus a destructive GLYPH — icons
  are non-text and only owe 3:1 — while the digits stay on text-foreground.
- The same trap catches small MUTED text, not just tinted text. Two strings opt
  out of text-muted-foreground and take text-foreground: the account-id chip
  (11px, on a /5 fill over an already-tinted bar it measures ~3.5:1 in light
  mode, and its whole job is to be read character by character and pasted into a
  ticket) and the consequence line WHILE EXPIRED (muted copy on the heaviest
  destructive/15 surface is the worst pair on the bar at ~3.4:1). Keep the chip's
  /5 fill — it is the text colour that has to carry the contrast, not the fill.
- One row that wraps: icon plate → a `min-w-0 grow basis-60` column (headline +
  email + mode badge / consequence line / id · staff · started · countdown) →
  a shrink-0 solid Button. Use `grow`, NOT `flex-1`: the shorthand also writes
  flex-basis: 0% and Tailwind emits it after `basis-*`, so the 15rem base size —
  the thing that pushes the button onto its own line below ~480px — would be
  silently discarded. Nothing truncates except the email.
- Countdown is tabular-nums so the row does not jitter; the hairline has NO width
  transition, because returning from a suspended tab is a genuine jump and easing
  it would animate a lie.
- The only motion is a pulsing warning glyph inside the warning window, with
  motion-reduce:animate-none; with motion off every number and state still lands.
- Accessibility: role="region" + aria-label naming the subject; the countdown chip
  is a real focusable button (it discloses, it does not act) carrying an
  aria-label that spells the remaining time in words; focus-visible rings on both
  controls; decorative glyphs aria-hidden.

Customization levers
- Placement: `sticky top-0` is one class on the root. Under a fixed app header,
  pass top-[var(--header-h)] through className. If an ancestor with overflow or
  transform breaks sticky, switch that class to `fixed inset-x-0 top-0` and give
  the host a spacer of the same height — the component stays in one DOM node
  either way.
- Density: px-3 py-2 text-sm is the only sizing. Drop the consequence line or the
  metadata line for a one-line strip; both are single blocks in the column.
- Tone: TONE maps mode → { root, plate, meter }. Point read-only at
  var(--chart-1..5) if your support palette is separate from primary/destructive,
  or make both modes destructive if your policy is that any impersonation is loud.
- Escalation: warnBeforeSeconds sets the single threshold. For a milestone ladder
  (30 / 10 / 5 minutes), keep a Set of consumed milestones next to
  announcedEndingRef and announce each once.
- Wording and i18n: DEFAULT_LABELS holds every string plus spokenDuration();
  route them through your t() and the state machine is untouched.
- Extra identity: the mode badge and the id chip are siblings — add a
  ticket-number chip or a region flag next to them without touching layout.
- Exit semantics: keep onExit doing the server round trip and let the host unmount
  the bar on success. If your policy requires a confirm, wrap the host's handler
  in an AlertDialog — do not put one inside the banner, where it would delay the
  safe direction.

Concepts

  • Capability reported, not enforcedmode renders what the server granted; the bar never blocks a write itself. Saying "read-only" while writes actually succeed is the one failure it cannot detect, so the consequence line spells out what the flag means instead of implying a guarantee.
  • Consequence before the action — the second line says where the next click lands ("written to Nadia Okafor's account, as if they did it") rather than labelling the session and leaving the reader to infer it; at expiry that claim is withdrawn instead of left standing.
  • In-flow pinningsticky top-0 occupies its own height, so the app's first row starts below the bar and still slides under it on scroll: no fixed overlay covering a header, no measured spacer for the host to keep in sync.
  • Assertive mount announcement — a permanently mounted, empty live region is filled one frame after paint, so acting as another person is spoken, not silent; the ticking countdown never enters it, and the escalation and expiry edges each fire exactly once.
  • Always-live exit — the way back to your own account is never disabled, never confirmed and never removed, including after expiry. Friction belongs on the way into someone else's account, not on the way out.
  • Refusal over a plausible number — an unreadable deadline prints "No end time set" and no clock, an inverted window drops the elapsed hairline, and elapsed time is floored, because a bar that understates a session is recoverable and one that invents a number is not. Past the deadline the clock slows to 30s rather than stopping: "Started 41 min ago" frozen at the moment the grant lapsed is the same invented number by another route.

On This Page