Skip to content

✨ Add ImpersonateDropdown navigation component#25

Merged
damienlagae merged 2 commits into
mainfrom
feat/impersonate-dropdown-component
May 28, 2026
Merged

✨ Add ImpersonateDropdown navigation component#25
damienlagae merged 2 commits into
mainfrom
feat/impersonate-dropdown-component

Conversation

@damienlagae
Copy link
Copy Markdown
Member

Summary

Adds an ImpersonateDropdown navigation component that lets ROLE_ALLOWED_TO_SWITCH users start impersonating someone else through a debounced JSON-search dropdown in the navbar.

  • Renders a <li class="nav-item dropdown"> with an icon toggle, a search input, and a result list — drop it inside Enabel:Ux:Menu like any other navbar item
  • Ships a Stimulus controller (assets/dist/impersonate_dropdown.js) registered lazy in assets/package.json, so the JS is only fetched when an element with data-controller is on the page (i.e. for users who actually have the role)
  • Mutualises three slightly-different copies of the same logic that lived in SymfonyBase, InformationPortal and Impala — single shipped controller, single contract
  • Pairs naturally with Add an ImpersonateBanner component (paired with #15) #16 Enabel:Ux:ImpersonateBanner (which signals an active impersonation session)

Endpoint contract (consumer-side)

The component issues GET {searchUrl}?q={query} after a 250 ms debounce, expecting:

[
    { "email": "jane.doe@enabel.be", "displayName": "Jane Doe", "initials": "JD" }
]

Click navigates to the current URL with ?_switch_user=<email> appended — the contract Symfony's Security component listens for.

XSS safety

All rendered fields go through textContent, never innerHTML. The click-target URL is built via new URL() + URLSearchParams.set, not string concatenation. A hostile JSON payload from the search endpoint cannot inject markup or scripts.

API

Param Type Required Default
searchUrl string yes
searchPlaceholder string no ''
noResultsLabel string no 'No results'
icon string no 'fa6-solid:user-secret'
title ?string no null
exitParameter string no '_switch_user'
debounce int no 250

exitParameter and debounce are not in the original issue spec — added so projects with custom firewall.context.switch_user.parameter configs or slower backends don't have to override the template.

Test plan

  • PHPUnit: 245/245 (9 new tests: required searchUrl, defaults, custom params, type validation)
  • php-cs-fixer: clean
  • phpstan: no errors
  • Manual smoke test in a consumer app — render inside the navbar, type a query, click a result, confirm impersonation kicks in. Also verify the JS is fetched lazily (Network tab) and that data-controller is not present for users without ROLE_ALLOWED_TO_SWITCH

Closes #15.

Closes #15.

A navbar dropdown that lets ROLE_ALLOWED_TO_SWITCH users start
impersonating someone else. A debounced search input queries a
project-side JSON endpoint and renders the results as clickable rows
that navigate to ?_switch_user=<email>.

Ships a Stimulus controller (assets/dist/impersonate_dropdown.js,
registered lazy in assets/package.json so the JS only loads when the
dropdown is actually present on the page). Results are rendered with
textContent, in-flight requests are aborted when the query changes,
and no fetch happens under 2 characters.

Pairs with #16 ImpersonateBanner — the dropdown lets the user enter
impersonation, the banner signals it is active.
@damienlagae
Copy link
Copy Markdown
Member Author

Smoke-tested in Impala — rendered the dropdown inside the Enabel navbar, wired a stub /_smoke/impersonate-dropdown/search JSON endpoint, ran the full interaction.

What I verified

  • Dropdown UX: clicking the navbar icon opens the panel with the search input; typing ja after 250 ms debounce hits /search?q=ja and renders Jane Doe (JD) jane.doe@enabel.be in the list.
  • Custom labels: searchPlaceholder ("Search a user…") and noResultsLabel ("No user matches") both honoured; the latter appeared as expected on a zzzzz query.
  • XSS safety: dropped a hostile entry in the pool with displayName: '<img src=x onerror=alert(1)>' and email: 'evil"><script>alert(1)</script>@enabel.be'. After evil query → both fields rendered as literal text, no <img> element created, no script executed, no alert(). textContent + URL/URLSearchParams claim holds.
  • URL construction: the result <a href> resolves to https://127.0.0.1:8000/en/_smoke/impersonate-dropdown?_switch_user=jane.doe%40enabel.be — current URL preserved, email properly percent-encoded.
  • Lazy load: confirmed via performance.getEntriesByType('resource') that impersonate_dropdown-OjbZGOR.js is fetched as an initiator-type script after page render (not from the importmap eagerly). assets/controllers.json correctly carries fetch: 'lazy' after composer update.
  • AbortController: typing fast in succession does not leave stale requests — fast switch from jaevil shows only the latest result, no race.

One tiny doc nit (optional)

The assets/dist/impersonate_dropdown.js controller has a minLength: 2 default but it's not in the PR's API table — easy gotcha if someone wonders why a single-character search does nothing. Worth a row in the table or a one-liner in the doc.

LGTM, ready to tag.

The Stimulus controller already supported a minLength value (default 2)
but it was hardcoded on the JS side. Lift it to the PHP component so
projects can override it from the call site, consistent with debounce
and exitParameter.
@damienlagae
Copy link
Copy Markdown
Member Author

Addressed in 8eaf408minLength lifted from a hardcoded Stimulus value to a first-class component parameter (default still 2), so projects can override it from the call site like debounce and exitParameter. Added to the API table and to the endpoint-contract paragraph.

Tests bumped to 246/246 (one extra assertion in the defaults/custom tests + one new invalid-type test).

@damienlagae damienlagae merged commit 7867330 into main May 28, 2026
7 checks passed
@damienlagae damienlagae deleted the feat/impersonate-dropdown-component branch May 28, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an ImpersonateDropdown navigation component

1 participant