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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
13 changes: 13 additions & 0 deletions demo/symfony7/templates/demo/form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
14 changes: 5 additions & 9 deletions demo/symfony7/templates/form/toggle_password_widget.html.twig
Original file line number Diff line number Diff line change
@@ -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).
#}

<div class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}">
Expand All @@ -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') }}');
}
">
<i class="ti ti-eye-off icon-hidden icon-base"></i>
<i class="ti ti-eye icon-visible d-none icon-base"></i>
<i class="ti ti-eye icon-visible icon-base"></i>
</span>
</div>
{%- endblock toggle_password_widget -%}

13 changes: 13 additions & 0 deletions demo/symfony8-php85/templates/demo/form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
#}

<div class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}">
Expand All @@ -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') }}');
}
">
<i class="ti ti-eye-off icon-hidden icon-base"></i>
<i class="ti ti-eye icon-visible d-none icon-base"></i>
<i class="ti ti-eye icon-visible icon-base"></i>
</span>
</div>
{%- endblock toggle_password_widget -%}

13 changes: 13 additions & 0 deletions demo/symfony8/templates/demo/form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
14 changes: 5 additions & 9 deletions demo/symfony8/templates/form/toggle_password_widget.html.twig
Original file line number Diff line number Diff line change
@@ -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).
#}

<div class="input-group input-group-merge form-password-toggle {{ (toggle_container_classes ?? [])|join(' ') }}">
Expand All @@ -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') }}');
}
">
<i class="ti ti-eye-off icon-hidden icon-base"></i>
<i class="ti ti-eye icon-visible d-none icon-base"></i>
<i class="ti ti-eye icon-visible icon-base"></i>
</span>
</div>
{%- endblock toggle_password_widget -%}

4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment on lines +5 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep a single Unreleased section.

docs/UPGRADING.md already has another ## Unreleased heading at Line 109. The new section at Line 5 splits current guidance into two locations and makes the upgrade order ambiguous. Merge these bullets into one section or rename the existing future-release section without inventing a version.

As per path instructions, preserve document structure and tone, and keep CHANGELOG and UPGRADING aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/UPGRADING.md` around lines 5 - 10, Keep only one “## Unreleased” section
in docs/UPGRADING.md by merging the CSP-safe icon visibility bullets into the
existing Unreleased guidance at the later heading, or consolidating the later
content under the new heading. Preserve the document’s structure and tone, avoid
inventing a release version, and keep the resulting guidance aligned with
CHANGELOG.

Source: Path instructions

## 2.0.5

- **No Twig / `PasswordType` config changes** for normal use.
Expand Down
19 changes: 19 additions & 0 deletions src/Resources/public/css/toggle_password.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
36 changes: 30 additions & 6 deletions src/Resources/public/css/toggle_password.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
14 changes: 7 additions & 7 deletions src/Resources/views/Form/toggle_password_widget.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 -%}
Expand All @@ -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') }}');
}
"
Expand All @@ -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' -%}
<span class="password-toggle-icon-missing text-muted small" title="Run: composer require symfony/ux-icons symfony/http-client &amp;&amp; php bin/console ux:icons:lock">[icons missing]</span>
Expand Down
5 changes: 3 additions & 2 deletions tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +22 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject all inline display: none forms.

The test only excludes the exact Twig string style: 'display: none'. It does not reject literal HTML such as style="display: none" or spacing and quote variants. Use a regex or parsed-attribute assertion that detects any inline style containing display: none.

As per path instructions, PHPUnit tests for this package must add or update tests for new behaviour and avoid trivial assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Unit/Twig/TogglePasswordWidgetTemplateTest.php` around lines 22 - 23,
Strengthen the assertions in TogglePasswordWidgetTemplateTest so they reject any
inline style attribute containing display: none, regardless of HTML attribute
quoting or whitespace, rather than only the exact Twig string. Use a regex or
parsed-attribute assertion while retaining the existing style.display check.

Source: Path instructions

$this->assertStringContainsString('is-password-visible', $content);
$this->assertStringContainsString('password-toggle-icon-missing', $content);
}
}
Loading