From 90f23b47d607d44a742269c3ea4dc8ae643b6530 Mon Sep 17 00:00:00 2001 From: Damien LAGAE Date: Thu, 28 May 2026 21:32:45 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20ImpersonateBanner=20navig?= =?UTF-8?q?ation=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #16. A fixed-top warning banner that signals the current user is impersonating someone, with a clear exit link. Ships the CSS trick that adjusts a Bootstrap fixed-top navbar to sit 28 px below the banner and bumps --app-navbar-offset to 108 px via body:has(). Pairs with the upcoming Enabel:Ux:ImpersonateDropdown (#15). --- config/services.yaml | 4 + docs/Navigation/impersonateBanner.md | 78 +++++++++++ docs/index.md | 1 + .../Navigation/ImpersonateBanner.php | 50 +++++++ .../navigation/impersonate_banner.html.twig | 31 +++++ .../Navigation/ImpersonateBannerTest.php | 128 ++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 docs/Navigation/impersonateBanner.md create mode 100644 src/Component/Navigation/ImpersonateBanner.php create mode 100644 templates/navigation/impersonate_banner.html.twig create mode 100644 tests/Component/Navigation/ImpersonateBannerTest.php diff --git a/config/services.yaml b/config/services.yaml index 2c885ea..e1a88ed 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -44,6 +44,10 @@ services: tags: - { name: 'twig.component', key: 'Enabel:Ux:Tab', template: '@EnabelUx/navigation/tab.html.twig', expose_public_props: true } + Enabel\Ux\Component\Navigation\ImpersonateBanner: + tags: + - { name: 'twig.component', key: 'Enabel:Ux:ImpersonateBanner', template: '@EnabelUx/navigation/impersonate_banner.html.twig', expose_public_props: true } + Enabel\Ux\Component\Callout\Callout: tags: - { name: 'twig.component', key: 'Enabel:Ux:Callout', template: '@EnabelUx/callout/callout.html.twig', expose_public_props: true } diff --git a/docs/Navigation/impersonateBanner.md b/docs/Navigation/impersonateBanner.md new file mode 100644 index 0000000..2eca756 --- /dev/null +++ b/docs/Navigation/impersonateBanner.md @@ -0,0 +1,78 @@ +# ImpersonateBanner Component + +## Description + +A fixed-top warning banner that signals the current user is impersonating someone else, with a clear "Exit impersonation" link. Pairs with `Enabel:Ux:ImpersonateDropdown` (issue #15) which provides the entry point on the navbar side. + +The component renders an `28 px` band pinned to the top of the viewport (`position: fixed; z-index: 1031`), above any Bootstrap `.navbar.fixed-top`. It also ships the small CSS trick that makes the rest of the page lay out correctly when the banner is present: + +```css +.impersonate-banner ~ .navbar.fixed-top { top: 28px; } +body:has(.impersonate-banner) { --app-navbar-offset: 108px; } +``` + +Without that pair, the `min-vh-100` content below the navbar ends up either crammed under the banner or leaves a large empty gap. + +## Parameters + +| Parameter | Type | Description | Default | +|:------------|:---------|:-------------------------------------------------------------------------|:------------------------| +| `message` | `string` | Banner text — typically translated and parameterised with the user name (**required**) | — | +| `exitUrl` | `string` | Link target for the exit-impersonation control (**required**) | — | +| `exitLabel` | `string` | Label of the exit link | `'Exit impersonation'` | +| `icon` | `string` | Icon identifier passed to `ux_icon()` | `'fa6-solid:user-secret'` | + +`message` is rendered with `|raw` so the call site can include simple HTML formatting (e.g. `` around the impersonated user name). Make sure any user-provided substring is escaped before passing it in. + +## Usage + +### Wired with Symfony's impersonation security feature + +```twig +{# templates/base.html.twig — before the navbar #} +{% if is_granted('IS_IMPERSONATOR') %} + {{ component('Enabel:Ux:ImpersonateBanner', { + message: 'nav.impersonate.banner'|trans({'%name%': app.user.displayName}), + exitUrl: path('app_home', { _switch_user: '_exit' }), + exitLabel: 'nav.impersonate.exit'|trans, + }) }} +{% endif %} + + +``` + +The `{% if is_granted('IS_IMPERSONATOR') %}` gate keeps the banner — and the CSS layout offset — completely out of the page when nobody is impersonating. + +### Localized banner with French message + +```twig +{{ component('Enabel:Ux:ImpersonateBanner', { + message: 'Vous incarnez actuellement %name%'|trans({'%name%': app.user.displayName|e}), + exitUrl: path('app_home', { _switch_user: '_exit' }), + exitLabel: 'Quitter l\\'incarnation', +}) }} +``` + +Note the explicit `|e` filter on the substituted user name — the component renders `message` with `|raw` to allow the `` wrapper. + +### Custom icon + +```twig +{{ component('Enabel:Ux:ImpersonateBanner', { + message: 'Impersonating ' ~ app.user.displayName, + exitUrl: path('app_home', { _switch_user: '_exit' }), + icon: 'fa6-solid:mask', +}) }} +``` + +Any [Iconify](https://icones.js.org/) ID works (the same syntax as the other Enabel UX components: `Enabel:Ux:Callout`, `Enabel:Ux:Widget`, …). + +## CSS variable convention + +The `--app-navbar-offset` variable is set on `` via `body:has(.impersonate-banner)`. If your project layout reads that variable to set `padding-top` (e.g. `body { padding-top: var(--app-navbar-offset, 80px); }`), the banner-on-top-of-a-fixed-navbar case will lay out correctly out of the box. + +If you don't use that variable, the banner still renders fine — but you may want to either bump your fixed body padding by 28 px when the banner is present, or adopt the variable in your layout. + +## Pairs with + +- [ImpersonateDropdown](impersonateDropdown.md) — issue #15, the navbar-side widget that lets ROLE_ALLOWED_TO_SWITCH users enter impersonation mode. diff --git a/docs/index.md b/docs/index.md index c71ec38..c8b4d8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ https://github.com/Enabel/Ux - [UserMenu](Navigation/userMenu.md) - User menu with avatar (photo or auto-derived colored initials) and dropdown - [LocaleSwitcher](Navigation/localeSwitcher.md) - A locale switcher dropdown for Bootstrap navbar - [Tab](Navigation/tab.md) - A Bootstrap nav-tabs/nav-pills component for creating tabbed navigation + - [ImpersonateBanner](Navigation/impersonateBanner.md) - A fixed-top banner for Symfony's user-impersonation feature ## Layouts diff --git a/src/Component/Navigation/ImpersonateBanner.php b/src/Component/Navigation/ImpersonateBanner.php new file mode 100644 index 0000000..ba02035 --- /dev/null +++ b/src/Component/Navigation/ImpersonateBanner.php @@ -0,0 +1,50 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Component\Navigation; + +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +class ImpersonateBanner +{ + public string $message; + public string $exitUrl; + public string $exitLabel; + public string $icon; + + /** + * @param array $data + * + * @return array + */ + #[PreMount] + public function preMount(array $data): array + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + return $resolver->resolve($data) + $data; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setIgnoreUndefined(); + $resolver->setRequired(['message', 'exitUrl']); + $resolver->setDefaults([ + 'exitLabel' => 'Exit impersonation', + 'icon' => 'fa6-solid:user-secret', + ]); + + $resolver->setAllowedTypes('message', 'string'); + $resolver->setAllowedTypes('exitUrl', 'string'); + $resolver->setAllowedTypes('exitLabel', 'string'); + $resolver->setAllowedTypes('icon', 'string'); + } +} diff --git a/templates/navigation/impersonate_banner.html.twig b/templates/navigation/impersonate_banner.html.twig new file mode 100644 index 0000000..09c039b --- /dev/null +++ b/templates/navigation/impersonate_banner.html.twig @@ -0,0 +1,31 @@ +{# Impersonate Banner component #} + +
+ + {{ ux_icon(icon, {class: 'me-1', height: '1rem'}) }} + {{ message|raw }} + + {{ exitLabel }} +
diff --git a/tests/Component/Navigation/ImpersonateBannerTest.php b/tests/Component/Navigation/ImpersonateBannerTest.php new file mode 100644 index 0000000..7fb3394 --- /dev/null +++ b/tests/Component/Navigation/ImpersonateBannerTest.php @@ -0,0 +1,128 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Tests\Component\Navigation; + +use Enabel\Ux\Component\Navigation\ImpersonateBanner; +use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; + +class ImpersonateBannerTest extends TestCase +{ + public function testComponentCanBeInstantiatedWithRequiredParameters(): void + { + $component = new ImpersonateBanner(); + $data = $component->preMount([ + 'message' => 'Impersonating Jane Doe', + 'exitUrl' => '?_switch_user=_exit', + ]); + + $component->message = $data['message']; + $component->exitUrl = $data['exitUrl']; + $component->exitLabel = $data['exitLabel']; + $component->icon = $data['icon']; + + $this->assertSame('Impersonating Jane Doe', $component->message); + $this->assertSame('?_switch_user=_exit', $component->exitUrl); + $this->assertSame('Exit impersonation', $component->exitLabel); + $this->assertSame('fa6-solid:user-secret', $component->icon); + } + + public function testComponentCanBeInstantiatedWithCustomParameters(): void + { + $component = new ImpersonateBanner(); + $data = $component->preMount([ + 'message' => 'Vous incarnez Jane Doe', + 'exitUrl' => '/?_switch_user=_exit', + 'exitLabel' => 'Quitter', + 'icon' => 'fa6-solid:mask', + ]); + + $this->assertSame('Vous incarnez Jane Doe', $data['message']); + $this->assertSame('/?_switch_user=_exit', $data['exitUrl']); + $this->assertSame('Quitter', $data['exitLabel']); + $this->assertSame('fa6-solid:mask', $data['icon']); + } + + public function testMissingMessageThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount(['exitUrl' => '?_switch_user=_exit']); + } + + public function testMissingExitUrlThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount(['message' => 'Impersonating']); + } + + public function testInvalidMessageTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 123, + 'exitUrl' => '?_switch_user=_exit', + ]); + } + + public function testInvalidExitUrlTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => null, + ]); + } + + public function testInvalidExitLabelTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => '?_switch_user=_exit', + 'exitLabel' => false, + ]); + } + + public function testInvalidIconTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => '?_switch_user=_exit', + 'icon' => 42, + ]); + } + + public function testPreMountPreservesAdditionalData(): void + { + $component = new ImpersonateBanner(); + $data = $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => '?_switch_user=_exit', + 'custom_attribute' => 'value', + ]); + + $this->assertArrayHasKey('custom_attribute', $data); + $this->assertSame('value', $data['custom_attribute']); + } +} From efca92b96fc1e03f54cb4251a2c07f2c9038114d Mon Sep 17 00:00:00 2001 From: Damien LAGAE Date: Thu, 28 May 2026 21:44:23 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Document=20that=20inline=20=20wins=20over=20consumer=20stylesheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Navigation/impersonateBanner.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/Navigation/impersonateBanner.md b/docs/Navigation/impersonateBanner.md index 2eca756..0129d61 100644 --- a/docs/Navigation/impersonateBanner.md +++ b/docs/Navigation/impersonateBanner.md @@ -73,6 +73,10 @@ The `--app-navbar-offset` variable is set on `` via `body:has(.impersonate If you don't use that variable, the banner still renders fine — but you may want to either bump your fixed body padding by 28 px when the banner is present, or adopt the variable in your layout. +## Adopting the component when you already have impersonate-banner CSS + +The component inlines its CSS through a `