diff --git a/static/css/settings.css b/static/css/settings.css index c2ad429..d9d6c17 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -615,6 +615,7 @@ position: relative; display: flex; align-items: center; + width: 100%; } .settings-password-wrapper .settings-input { @@ -624,21 +625,46 @@ .settings-password-toggle-btn { position: absolute; right: var(--s-space-8); + top: 50%; + transform: translateY(-50%); background: transparent; border: none; color: var(--s-color-muted); cursor: pointer; - padding: var(--s-space-6); - display: flex; + padding: 0; + display: inline-flex; align-items: center; justify-content: center; border-radius: var(--s-radius-sm); - min-width: 32px; - min-height: 32px; + width: 32px; + height: 32px; + transition: color 0.15s ease, background-color 0.15s ease; } .settings-password-toggle-btn:hover { color: var(--s-color-text); + background-color: var(--s-color-hover); +} + +.settings-password-toggle-btn:focus-visible { + outline: 2px solid var(--s-color-primary); + outline-offset: 2px; +} + +.settings-password-toggle-btn .icon-eye { + display: block; +} + +.settings-password-toggle-btn .icon-eye-off { + display: none; +} + +.settings-password-toggle-btn[aria-pressed="true"] .icon-eye { + display: none; +} + +.settings-password-toggle-btn[aria-pressed="true"] .icon-eye-off { + display: block; } /* Password Strength Meter */ diff --git a/static/css/styles.css b/static/css/styles.css index 98869e6..0f3520c 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1056,6 +1056,70 @@ a { color: var(--text-dim); } +/* Password Toggle Wrapper & Action Button (Auth Pages) */ +.password-input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.password-input-wrapper .form-input { + padding-right: 2.75rem; +} + +.password-toggle-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-xs); + color: var(--text-dim); + cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease; +} + +.password-toggle-btn:hover { + color: var(--text-primary); + background-color: rgba(15, 23, 42, 0.05); +} + +.password-toggle-btn:focus-visible { + outline: 2px solid var(--brand-navy); + outline-offset: 1px; +} + +.password-toggle-icon { + width: 1.125rem; + height: 1.125rem; + stroke-width: 1.6; + pointer-events: none; +} + +.password-toggle-btn .icon-eye { + display: block; +} + +.password-toggle-btn .icon-eye-off { + display: none; +} + +.password-toggle-btn[aria-pressed="true"] .icon-eye { + display: none; +} + +.password-toggle-btn[aria-pressed="true"] .icon-eye-off { + display: block; +} + .alert-danger { padding: 0.75rem 1rem; border-radius: var(--radius-sm); diff --git a/static/img/icons/settings-sprite.svg b/static/img/icons/settings-sprite.svg index 165c287..f27f532 100644 --- a/static/img/icons/settings-sprite.svg +++ b/static/img/icons/settings-sprite.svg @@ -26,4 +26,12 @@ + + + + + + + + diff --git a/static/js/main.js b/static/js/main.js index d71a339..c08bf4c 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -5,6 +5,7 @@ document.addEventListener('DOMContentLoaded', () => { initStitchSimulator(); initVaultSearchShortcut(); + initPasswordToggles(); }); function initVaultSearchShortcut() { @@ -113,3 +114,25 @@ function initStitchSimulator() { }); } } + +/** + * Show/Hide Password Toggle for Auth Pages + */ +function initPasswordToggles() { + const buttons = document.querySelectorAll('.password-toggle-btn'); + buttons.forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const wrapper = btn.closest('.password-input-wrapper'); + if (!wrapper) return; + const input = wrapper.querySelector('input'); + if (!input) return; + + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + btn.setAttribute('aria-pressed', isPassword ? 'true' : 'false'); + btn.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password'); + }); + }); +} + diff --git a/static/js/settings.js b/static/js/settings.js index 02cffb8..f70c5ec 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -640,6 +640,7 @@ function initPasswordToggles() { const isPassword = input.type === 'password'; input.type = isPassword ? 'text' : 'password'; + btn.setAttribute('aria-pressed', isPassword ? 'true' : 'false'); btn.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password'); }); }); diff --git a/templates/accounts/login.html b/templates/accounts/login.html index 006afc8..45b400f 100644 --- a/templates/accounts/login.html +++ b/templates/accounts/login.html @@ -45,7 +45,19 @@

Access Personal Vault

Forgot Password? - {{ form.password }} +
+ {{ form.password }} + +
{% if form.password.errors %} {{ form.password.errors.0 }} {% endif %} diff --git a/templates/accounts/password_reset_confirm.html b/templates/accounts/password_reset_confirm.html index 4af7db6..41d4690 100644 --- a/templates/accounts/password_reset_confirm.html +++ b/templates/accounts/password_reset_confirm.html @@ -38,7 +38,19 @@

Set New Master Passphrase

- {{ form.password1 }} +
+ {{ form.password1 }} + +
{% if form.password1.errors %} {{ form.password1.errors.0 }} {% else %} @@ -48,7 +60,19 @@

Set New Master Passphrase

- {{ form.password2 }} +
+ {{ form.password2 }} + +
{% if form.password2.errors %} {{ form.password2.errors.0 }} {% endif %} diff --git a/templates/accounts/register.html b/templates/accounts/register.html index 627fa96..8834b6c 100644 --- a/templates/accounts/register.html +++ b/templates/accounts/register.html @@ -53,7 +53,19 @@

Create Personal Vault

- {{ form.password1 }} +
+ {{ form.password1 }} + +
{% if form.password1.errors %} {{ form.password1.errors.0 }} {% else %} @@ -63,7 +75,19 @@

Create Personal Vault

- {{ form.password2 }} +
+ {{ form.password2 }} + +
{% if form.password2.errors %} {{ form.password2.errors.0 }} {% endif %} diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index edb2e44..b96dda3 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -285,7 +285,13 @@

Remove profile photo?
- +
+ + +
Enter your current password to authorize this sensitive change. {% if username_form.current_password.errors %} @@ -445,7 +451,13 @@

Check your email

- +
+ + +
Enter your current password to authorize this sensitive change. {% if email_request_form.current_password.errors %} {{ email_request_form.current_password.errors.0 }} @@ -514,8 +526,9 @@

Check your email

-
@@ -534,8 +547,9 @@

Check your email

-
{% if password_set_new_form.confirm_password.errors %} @@ -626,8 +640,9 @@

Enter password reset code

-
Enter your current password to authorize change. @@ -640,8 +655,9 @@

Enter password reset code

-
@@ -660,8 +676,9 @@

Enter password reset code

-
{% if password_normal_form.confirm_password.errors %} diff --git a/tests/unit/test_password_toggle.py b/tests/unit/test_password_toggle.py new file mode 100644 index 0000000..9490a57 --- /dev/null +++ b/tests/unit/test_password_toggle.py @@ -0,0 +1,148 @@ +""" +Unit & Template Integration Tests for Show/Hide Password Toggle Buttons. +Validates that every password input across the application includes an +accessible password toggle button with proper ARIA attributes, SVG iconography, +and input-wrapper hierarchy. +""" + +from django.test import TestCase, Client +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone + +from accounts.models import PasswordResetToken +from accounts.services.password_service import PASSWORD_RESET_SESSION_KEY +from tests.unit import get_test_password + +User = get_user_model() + + +class PasswordToggleMarkupTests(TestCase): + """Test suite ensuring all 12 password fields across 4 views contain toggle markup.""" + + def setUp(self): + self.client = Client() + self.username = "vaultmaster" + self.email = "vaultmaster@passli.dev" + self.password = get_test_password() + self.user = User.objects.create_user( + username=self.username, + email=self.email, + password=self.password, + ) + + def test_login_page_password_toggle_markup(self): + """Verify login view renders toggle button and dual eye SVGs for login-password.""" + response = self.client.get(reverse("login")) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertIn('class="password-input-wrapper"', content) + self.assertIn('id="login-password"', content) + self.assertIn('class="password-toggle-btn"', content) + self.assertIn('aria-controls="login-password"', content) + self.assertIn('aria-label="Show password"', content) + self.assertIn('aria-pressed="false"', content) + self.assertIn("icon-eye", content) + self.assertIn("icon-eye-off", content) + + def test_register_page_password_toggles_markup(self): + """Verify registration view renders toggle buttons for both password fields.""" + response = self.client.get(reverse("register")) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + # Must have at least two wrappers + self.assertGreaterEqual(content.count('class="password-input-wrapper"'), 2) + + # First password field + self.assertIn('id="reg-password1"', content) + self.assertIn('aria-controls="reg-password1"', content) + + # Second password confirmation field + self.assertIn('id="reg-password2"', content) + self.assertIn('aria-controls="reg-password2"', content) + + # Toggle buttons count + self.assertGreaterEqual(content.count('class="password-toggle-btn"'), 2) + self.assertIn('aria-label="Show password"', content) + self.assertIn('aria-pressed="false"', content) + self.assertIn("icon-eye", content) + self.assertIn("icon-eye-off", content) + + def test_password_reset_confirm_toggles_markup(self): + """Verify password reset confirmation view renders toggle buttons for both fields.""" + _, raw_token = PasswordResetToken.create_token_for_user(self.user) + url = reverse("password_reset_confirm", kwargs={"token": raw_token}) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertGreaterEqual(content.count('class="password-input-wrapper"'), 2) + self.assertIn('id="reset-password1"', content) + self.assertIn('aria-controls="reset-password1"', content) + self.assertIn('id="reset-password2"', content) + self.assertIn('aria-controls="reset-password2"', content) + self.assertGreaterEqual(content.count('class="password-toggle-btn"'), 2) + self.assertIn("icon-eye", content) + self.assertIn("icon-eye-off", content) + + def test_settings_page_default_password_toggles_markup(self): + """Verify settings view renders toggle buttons for username, email, and normal password change.""" + self.client.force_login(self.user) + response = self.client.get(reverse("account_settings")) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + # 1. Username current password + self.assertIn('id="settings-username-password"', content) + self.assertIn('aria-controls="settings-username-password"', content) + + # 2. Email current password + self.assertIn('id="settings-email-password"', content) + self.assertIn('aria-controls="settings-email-password"', content) + + # 3. Normal change: current password + self.assertIn('id="settings-current-password"', content) + self.assertIn('aria-controls="settings-current-password"', content) + + # 4. Normal change: new password + self.assertIn('id="settings-new-password"', content) + self.assertIn('aria-controls="settings-new-password"', content) + + # 5. Normal change: confirm password + self.assertIn('id="settings-confirm-password"', content) + self.assertIn('aria-controls="settings-confirm-password"', content) + + # Check toggle button class, SVGs, and attributes + self.assertIn('class="settings-password-toggle-btn"', content) + self.assertIn("#icon-eye", content) + self.assertIn("#icon-eye-off", content) + self.assertIn('aria-label="Show password"', content) + self.assertIn('aria-pressed="false"', content) + + def test_settings_page_otp_reset_password_toggles_markup(self): + """Verify settings view in authorized OTP reset state renders toggle buttons for new/confirm.""" + self.client.force_login(self.user) + session = self.client.session + session["pwd_reset_otp_requested"] = False + session[PASSWORD_RESET_SESSION_KEY] = timezone.now().isoformat() + session.save() + + response = self.client.get(reverse("account_settings")) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + # OTP new password + self.assertIn('id="settings-otp-new-password"', content) + self.assertIn('aria-controls="settings-otp-new-password"', content) + + # OTP confirm password + self.assertIn('id="settings-otp-confirm-password"', content) + self.assertIn('aria-controls="settings-otp-confirm-password"', content) + + self.assertIn('class="settings-password-wrapper"', content) + self.assertIn('class="settings-password-toggle-btn"', content) + self.assertIn("#icon-eye", content) + self.assertIn("#icon-eye-off", content)