From bb15d0b5d4923c6eafff229c3a2c0df0e8cb5b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Franco?= Date: Wed, 12 Aug 2026 11:07:18 +0200 Subject: [PATCH] fix: CSP-safe password toggle icon visibility Hide/show eye icons via is-password-visible + stylesheet rules instead of inline style / element.style.display, which browsers ignore when style-src includes a nonce. --- README.md | 2 ++ demo/symfony7/templates/demo/form.html.twig | 13 +++++++ .../form/toggle_password_widget.html.twig | 14 +++----- .../templates/demo/form.html.twig | 13 +++++++ .../form/toggle_password_widget.html.twig | 14 +++----- demo/symfony8/templates/demo/form.html.twig | 13 +++++++ .../form/toggle_password_widget.html.twig | 14 +++----- docs/CHANGELOG.md | 4 +++ docs/UPGRADING.md | 6 ++++ src/Resources/public/css/toggle_password.css | 19 ++++++++++ src/Resources/public/css/toggle_password.scss | 36 +++++++++++++++---- .../Form/toggle_password_widget.html.twig | 14 ++++---- .../Twig/TogglePasswordWidgetTemplateTest.php | 5 +-- 13 files changed, 125 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 84ff8cc..c2b980a 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ The bundle uses these CSS classes that you can style: - `.input-group.input-group-merge` - Container - `.input-group-text.cursor-pointer` - Toggle button - `.icon-base` - Icon classes +- `.icon-hidden` / `.icon-visible` - Eye icons (`.icon-visible` is hidden by default in `toggle_password.css`) +- `.is-password-visible` - Added to the toggle button when the password is shown (CSP-safe; do not use inline `style.display`) Example custom styles: diff --git a/demo/symfony7/templates/demo/form.html.twig b/demo/symfony7/templates/demo/form.html.twig index 406aa69..a40dd26 100644 --- a/demo/symfony7/templates/demo/form.html.twig +++ b/demo/symfony7/templates/demo/form.html.twig @@ -58,6 +58,19 @@ height: 1.25rem; transition: opacity 0.2s ease-in-out; } + /* One eye at a time (CSP-safe; mirrors toggle_password.css) */ + .form-password-toggle .icon-visible, + .input-group-text.cursor-pointer .icon-visible { + display: none; + } + .form-password-toggle .is-password-visible .icon-hidden, + .input-group-text.cursor-pointer.is-password-visible .icon-hidden { + display: none; + } + .form-password-toggle .is-password-visible .icon-visible, + .input-group-text.cursor-pointer.is-password-visible .icon-visible { + display: block; + } .alert { margin-top: 20px; } diff --git a/demo/symfony7/templates/form/toggle_password_widget.html.twig b/demo/symfony7/templates/form/toggle_password_widget.html.twig index 0b68f58..d250868 100644 --- a/demo/symfony7/templates/form/toggle_password_widget.html.twig +++ b/demo/symfony7/templates/form/toggle_password_widget.html.twig @@ -1,7 +1,8 @@ {%- block toggle_password_widget -%} {# Template for password field with visibility toggle (Demo version) - This is a simplified version that works without Symfony UX Icon + This is a simplified version that works without Symfony UX Icon. + Icon visibility uses is-password-visible + toggle_password.css (CSP-safe). #}
@@ -13,24 +14,19 @@ aria-label="{{ visible_label|default('Show password') }}" onclick=" const input = this.previousElementSibling; - const iconHidden = this.querySelector('.icon-hidden'); - const iconVisible = this.querySelector('.icon-visible'); if (input.type === 'password') { input.type = 'text'; - iconHidden.classList.add('d-none'); - iconVisible.classList.remove('d-none'); + this.classList.add('is-password-visible'); this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}'); } else { input.type = 'password'; - iconHidden.classList.remove('d-none'); - iconVisible.classList.add('d-none'); + this.classList.remove('is-password-visible'); this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}'); } "> - +
{%- endblock toggle_password_widget -%} - diff --git a/demo/symfony8-php85/templates/demo/form.html.twig b/demo/symfony8-php85/templates/demo/form.html.twig index 406aa69..a40dd26 100644 --- a/demo/symfony8-php85/templates/demo/form.html.twig +++ b/demo/symfony8-php85/templates/demo/form.html.twig @@ -58,6 +58,19 @@ height: 1.25rem; transition: opacity 0.2s ease-in-out; } + /* One eye at a time (CSP-safe; mirrors toggle_password.css) */ + .form-password-toggle .icon-visible, + .input-group-text.cursor-pointer .icon-visible { + display: none; + } + .form-password-toggle .is-password-visible .icon-hidden, + .input-group-text.cursor-pointer.is-password-visible .icon-hidden { + display: none; + } + .form-password-toggle .is-password-visible .icon-visible, + .input-group-text.cursor-pointer.is-password-visible .icon-visible { + display: block; + } .alert { margin-top: 20px; } diff --git a/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig b/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig index 0b68f58..d250868 100644 --- a/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig +++ b/demo/symfony8-php85/templates/form/toggle_password_widget.html.twig @@ -1,7 +1,8 @@ {%- block toggle_password_widget -%} {# Template for password field with visibility toggle (Demo version) - This is a simplified version that works without Symfony UX Icon + This is a simplified version that works without Symfony UX Icon. + Icon visibility uses is-password-visible + toggle_password.css (CSP-safe). #}
@@ -13,24 +14,19 @@ aria-label="{{ visible_label|default('Show password') }}" onclick=" const input = this.previousElementSibling; - const iconHidden = this.querySelector('.icon-hidden'); - const iconVisible = this.querySelector('.icon-visible'); if (input.type === 'password') { input.type = 'text'; - iconHidden.classList.add('d-none'); - iconVisible.classList.remove('d-none'); + this.classList.add('is-password-visible'); this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}'); } else { input.type = 'password'; - iconHidden.classList.remove('d-none'); - iconVisible.classList.add('d-none'); + this.classList.remove('is-password-visible'); this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}'); } "> - +
{%- endblock toggle_password_widget -%} - diff --git a/demo/symfony8/templates/demo/form.html.twig b/demo/symfony8/templates/demo/form.html.twig index 406aa69..a40dd26 100644 --- a/demo/symfony8/templates/demo/form.html.twig +++ b/demo/symfony8/templates/demo/form.html.twig @@ -58,6 +58,19 @@ height: 1.25rem; transition: opacity 0.2s ease-in-out; } + /* One eye at a time (CSP-safe; mirrors toggle_password.css) */ + .form-password-toggle .icon-visible, + .input-group-text.cursor-pointer .icon-visible { + display: none; + } + .form-password-toggle .is-password-visible .icon-hidden, + .input-group-text.cursor-pointer.is-password-visible .icon-hidden { + display: none; + } + .form-password-toggle .is-password-visible .icon-visible, + .input-group-text.cursor-pointer.is-password-visible .icon-visible { + display: block; + } .alert { margin-top: 20px; } diff --git a/demo/symfony8/templates/form/toggle_password_widget.html.twig b/demo/symfony8/templates/form/toggle_password_widget.html.twig index 0b68f58..d250868 100644 --- a/demo/symfony8/templates/form/toggle_password_widget.html.twig +++ b/demo/symfony8/templates/form/toggle_password_widget.html.twig @@ -1,7 +1,8 @@ {%- block toggle_password_widget -%} {# Template for password field with visibility toggle (Demo version) - This is a simplified version that works without Symfony UX Icon + This is a simplified version that works without Symfony UX Icon. + Icon visibility uses is-password-visible + toggle_password.css (CSP-safe). #}
@@ -13,24 +14,19 @@ aria-label="{{ visible_label|default('Show password') }}" onclick=" const input = this.previousElementSibling; - const iconHidden = this.querySelector('.icon-hidden'); - const iconVisible = this.querySelector('.icon-visible'); if (input.type === 'password') { input.type = 'text'; - iconHidden.classList.add('d-none'); - iconVisible.classList.remove('d-none'); + this.classList.add('is-password-visible'); this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}'); } else { input.type = 'password'; - iconHidden.classList.remove('d-none'); - iconVisible.classList.add('d-none'); + this.classList.remove('is-password-visible'); this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}'); } "> - +
{%- endblock toggle_password_widget -%} - diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9207a69..25d60c9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **CSP-safe icon visibility:** hide/show eye icons via `is-password-visible` + `toggle_password.css` instead of inline `style="display: none"` / `element.style.display`. When `style-src` includes a nonce, browsers ignore `'unsafe-inline'`, so both icons were visible under strict CSP (e.g. Beacon). Demos aligned to the same class toggle (no Bootstrap `d-none`). + ## [2.1.0] - 2026-08-04 ### Added diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index ea2975b..bb3494c 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -2,6 +2,12 @@ This document describes how to upgrade between major versions of Password Toggle Bundle. +## Unreleased (CSP-safe icon visibility) + +- **Load the bundle CSS** (`asset('css/toggle_password.css', 'nowo_password_toggle')` or import the SCSS). Icon show/hide now depends on `.icon-visible` / `.is-password-visible` rules in that stylesheet. +- **Custom form theme overrides:** if you copied `toggle_password_widget.html.twig`, stop using inline `style.display` / `style: 'display: none'` / Bootstrap `d-none`. Toggle `is-password-visible` on the button (see the bundle widget) and keep both icons in the markup without inline hide styles. +- **Hosts with strict `script-src` (no `'unsafe-inline'`):** the default widget still uses inline `onclick` for Live Component compatibility; override the form theme with a Stimulus (or other non-inline) controller if needed — same as before. + ## 2.0.5 - **No Twig / `PasswordType` config changes** for normal use. diff --git a/src/Resources/public/css/toggle_password.css b/src/Resources/public/css/toggle_password.css index d2df355..b7a663f 100644 --- a/src/Resources/public/css/toggle_password.css +++ b/src/Resources/public/css/toggle_password.css @@ -48,3 +48,22 @@ transition: opacity 0.2s ease-in-out; } +/* + * One eye at a time via stylesheet (CSP-safe). + * style-src with a nonce ignores 'unsafe-inline', so style="display:none" + * / element.style.display would leave both icons visible. + */ +.form-password-toggle .icon-visible, +.input-group-text.cursor-pointer .icon-visible { + display: none; +} + +.form-password-toggle .is-password-visible .icon-hidden, +.input-group-text.cursor-pointer.is-password-visible .icon-hidden { + display: none; +} + +.form-password-toggle .is-password-visible .icon-visible, +.input-group-text.cursor-pointer.is-password-visible .icon-visible { + display: block; +} diff --git a/src/Resources/public/css/toggle_password.scss b/src/Resources/public/css/toggle_password.scss index db265d9..cea2f4e 100644 --- a/src/Resources/public/css/toggle_password.scss +++ b/src/Resources/public/css/toggle_password.scss @@ -5,13 +5,13 @@ * place the toggle button next to the input */ -// Contenedor con input-group +// Container with input-group .input-group.input-group-merge { - // Ya tiene los estilos de Bootstrap - // Solo añadimos estilos específicos si es necesario + // Already has Bootstrap styles + // Only add specific styles if necessary } -// Botón de toggle (span.input-group-text) +// Toggle button (span.input-group-text) .input-group-text.cursor-pointer { cursor: pointer; user-select: none; @@ -27,13 +27,13 @@ transform: scale(0.95); } - // Focus para accesibilidad + // Focus for accessibility &:focus-visible { outline: 2px solid var(--bs-primary, #696cff); outline-offset: 2px; } - // Icono dentro del botón + // Icon inside the button svg { width: 1.25rem; height: 1.25rem; @@ -46,5 +46,29 @@ height: 1.25rem; transition: opacity 0.2s ease-in-out; } + + // One eye at a time (CSP-safe; see toggle_password.css) + .icon-visible { + display: none; + } + + &.is-password-visible .icon-hidden { + display: none; + } + + &.is-password-visible .icon-visible { + display: block; + } } +.form-password-toggle .icon-visible { + display: none; +} + +.form-password-toggle .is-password-visible .icon-hidden { + display: none; +} + +.form-password-toggle .is-password-visible .icon-visible { + display: block; +} diff --git a/src/Resources/views/Form/toggle_password_widget.html.twig b/src/Resources/views/Form/toggle_password_widget.html.twig index 45f125a..8e53e18 100644 --- a/src/Resources/views/Form/toggle_password_widget.html.twig +++ b/src/Resources/views/Form/toggle_password_widget.html.twig @@ -14,6 +14,10 @@ NATIVE TOGGLE: Uses inline onclick to avoid conflicts with LiveComponent Does NOT use Stimulus - Pure JavaScript for maximum compatibility + + Icon visibility uses class {@code is-password-visible} + toggle_password.css + (not inline style attributes or JS style mutation). CSP style-src with a nonce + ignores 'unsafe-inline', so an inline display:none would show both eye icons. #} {%- set toggle_enabled = toggle ?? true -%} @@ -29,18 +33,14 @@ aria-label="{{ visible_label|default('Show password') }}" onclick=" const input = this.previousElementSibling; - const iconHidden = this.querySelector('.icon-hidden'); - const iconVisible = this.querySelector('.icon-visible'); if (input && input.type === 'password') { input.type = 'text'; - if (iconHidden) iconHidden.style.display = 'none'; - if (iconVisible) iconVisible.style.display = ''; + this.classList.add('is-password-visible'); this.setAttribute('aria-label', '{{ hidden_label|default('Hide password') }}'); } else if (input) { input.type = 'password'; - if (iconHidden) iconHidden.style.display = ''; - if (iconVisible) iconVisible.style.display = 'none'; + this.classList.remove('is-password-visible'); this.setAttribute('aria-label', '{{ visible_label|default('Show password') }}'); } " @@ -55,7 +55,7 @@ {{ ux_icon(visible_icon|default('tabler:eye-off'), {class: 'icon-hidden icon-base ti tabler'}) }} {%- endif -%} {%- if hidden_icon is defined and hidden_icon -%} - {{ ux_icon(hidden_icon|default('tabler:eye'), {class: 'icon-visible icon-base ti tabler', style: 'display: none'}) }} + {{ ux_icon(hidden_icon|default('tabler:eye'), {class: 'icon-visible icon-base ti tabler'}) }} {%- endif -%} {%- elseif app.environment is defined and app.environment == 'dev' -%} [icons missing] diff --git a/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php b/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php index dfdd259..1e6cd04 100644 --- a/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php +++ b/tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php @@ -19,8 +19,9 @@ public function testTemplateContainsGracefulIconFallback(): void $this->assertStringContainsString('icons_available', $content); $this->assertStringNotContainsString('ux_icon is defined', $content); $this->assertStringNotContainsString('d-none', $content); - $this->assertStringContainsString('iconHidden.style.display', $content); - $this->assertStringContainsString("style: 'display: none'", $content); + $this->assertStringNotContainsString('style.display', $content); + $this->assertStringNotContainsString("style: 'display: none'", $content); + $this->assertStringContainsString('is-password-visible', $content); $this->assertStringContainsString('password-toggle-icon-missing', $content); } }