Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
82 changes: 82 additions & 0 deletions docs/Navigation/impersonateBanner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 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. `<strong>` 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 %}

<nav class="navbar fixed-top">…</nav>
```

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 <strong>%name%</strong>'|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 `<strong>` 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 `<body>` 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.

## Adopting the component when you already have impersonate-banner CSS

The component inlines its CSS through a `<style>` tag inside the rendered template, which means it loads **after** any stylesheet linked in `<head>`. Source-order wins for equal-specificity selectors, so if your app already carries hand-rolled `.impersonate-banner` rules in its `app.css` (typical of projects that pre-dated this component), the component's inline styles take over automatically — you can drop the duplicated rules from your stylesheet at the same time as the bespoke markup, no transition flicker.

## Pairs with

- [ImpersonateDropdown](impersonateDropdown.md) — issue #15, the navbar-side widget that lets ROLE_ALLOWED_TO_SWITCH users enter impersonation mode.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions src/Component/Navigation/ImpersonateBanner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* 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<string, mixed> $data
*
* @return array<string, mixed>
*/
#[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');
}
}
31 changes: 31 additions & 0 deletions templates/navigation/impersonate_banner.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{# Impersonate Banner component #}
<style>
.impersonate-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 28px;
z-index: 1031;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0 1rem;
background-color: #ffc107;
color: #000;
font-size: 0.875rem;
line-height: 1;
}
.impersonate-banner a { color: #000; text-decoration: underline; }
.impersonate-banner a:hover { text-decoration: none; }
.impersonate-banner ~ .navbar.fixed-top { top: 28px; }
body:has(.impersonate-banner) { --app-navbar-offset: 108px; }
</style>
<div class="impersonate-banner {{ attributes.render('class') }}" role="status" {{ attributes.defaults({}) }}>
<span>
{{ ux_icon(icon, {class: 'me-1', height: '1rem'}) }}
{{ message|raw }}
</span>
<a href="{{ exitUrl }}">{{ exitLabel }}</a>
</div>
128 changes: 128 additions & 0 deletions tests/Component/Navigation/ImpersonateBannerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* 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']);
}
}
Loading