fix: keep password form visible on validation error

The password setup/change form used hx-target="#password-section" with
hx-swap="innerHTML", but that div wraps the form itself. On a validation
error the route returns only an alert div, so the swap replaced the entire
form — the password inputs disappeared. Most visible during registration's
"set password" step.

Retarget the form to a dedicated #password-error div outside the form
(mirrors the working login form's #login-error pattern), so the form and
its inputs survive errors while messages still render inside #password-section.

Also fix pre-existing broken e2e tests: they omitted the required
current_password fill and used passwords below the zxcvbn strength
threshold (score 1 < MIN_PASSWORD_STRENGTH=2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-03 16:22:01 +02:00
parent fb133f9cba
commit bcfe3a2a15
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 26 additions and 6 deletions

View file

@ -38,7 +38,8 @@
{% else %} {% else %}
<p>No password set.</p> <p>No password set.</p>
{% endif %} {% endif %}
<form hx-post="/manage/credentials/password" hx-target="#password-section" hx-swap="innerHTML"> <div id="password-error"></div>
<form hx-post="/manage/credentials/password" hx-target="#password-error" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}">
{% if has_password %} {% if has_password %}
<div> <div>

View file

@ -42,6 +42,7 @@ test.describe('Credentials page', () => {
test.describe('Password validation', () => { test.describe('Password validation', () => {
test('shows mismatch error', async ({ page }) => { test('shows mismatch error', async ({ page }) => {
await page.fill('#current_password', fixtures.cred_password);
await page.fill('#password', 'newpassword1'); await page.fill('#password', 'newpassword1');
await page.fill('#confirm', 'newpassword2'); await page.fill('#confirm', 'newpassword2');
await page.click('#password-section button[type="submit"]'); await page.click('#password-section button[type="submit"]');
@ -51,6 +52,23 @@ test.describe('Credentials page', () => {
await expect(alert).toContainText('do not match'); await expect(alert).toContainText('do not match');
}); });
test('keeps the password form visible after a validation error', async ({ page }) => {
await page.fill('#current_password', fixtures.cred_password);
await page.fill('#password', 'newpassword1');
await page.fill('#confirm', 'newpassword2');
await page.click('#password-section button[type="submit"]');
const alert = page.locator('#password-section [role="alert"]');
await expect(alert).toBeVisible({ timeout: 5000 });
// Regression: the form and its inputs must NOT disappear on error.
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('#confirm')).toBeVisible();
await expect(
page.locator('#password-section button[type="submit"]'),
).toBeVisible();
});
test('password input has minlength="8"', async ({ page }) => { test('password input has minlength="8"', async ({ page }) => {
await expect(page.locator('#password')).toHaveAttribute('minlength', '8'); await expect(page.locator('#password')).toHaveAttribute('minlength', '8');
}); });
@ -62,8 +80,9 @@ test.describe('Credentials page', () => {
test.describe('Password change', () => { test.describe('Password change', () => {
test('succeeds with matching passwords', async ({ page }) => { test('succeeds with matching passwords', async ({ page }) => {
await page.fill('#password', 'newpassword123'); await page.fill('#current_password', fixtures.cred_password);
await page.fill('#confirm', 'newpassword123'); await page.fill('#password', 'purple-tiger-mountain-42');
await page.fill('#confirm', 'purple-tiger-mountain-42');
await page.click('#password-section button[type="submit"]'); await page.click('#password-section button[type="submit"]');
const status = page.locator('#password-section [role="status"]'); const status = page.locator('#password-section [role="status"]');

View file

@ -30,8 +30,8 @@ test.describe('Full user journey', () => {
await expect(passwordInput).toBeVisible(); await expect(passwordInput).toBeVisible();
await expect(confirmInput).toBeVisible(); await expect(confirmInput).toBeVisible();
await passwordInput.fill('mypassword123'); await passwordInput.fill('purple-tiger-mountain-42');
await confirmInput.fill('mypassword123'); await confirmInput.fill('purple-tiger-mountain-42');
await page.click('#password-section button[type="submit"]'); await page.click('#password-section button[type="submit"]');
// Wait for success message // Wait for success message
@ -51,7 +51,7 @@ test.describe('Full user journey', () => {
// ---- Step 4: Login with the password we just set ---- // ---- Step 4: Login with the password we just set ----
await page.fill('#username', fixtures.register_username); await page.fill('#username', fixtures.register_username);
await page.fill('#password', 'mypassword123'); await page.fill('#password', 'purple-tiger-mountain-42');
await page.click('form[hx-post="/login/password"] button[type="submit"]'); await page.click('form[hx-post="/login/password"] button[type="submit"]');
// Wait for redirect to credentials page // Wait for redirect to credentials page