From c42e2b095e2e6d9c645f78a40e8dbf927cd0f380 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 23 Feb 2026 21:50:22 -0700 Subject: [PATCH 1/7] feat: add passwordless passkey-only account UI (#53) Add UI support for passwordless registration, password removal, set-password mode, and auth methods display. New auth-methods.js utility, passwordless toggle on registration page, set-password mode on change-password page, and auth methods card with remove password flow on profile page. --- src/main/resources/application.yml | 2 +- .../resources/static/js/user/auth-methods.js | 37 +++++ src/main/resources/static/js/user/register.js | 102 ++++++++++++- .../static/js/user/update-password.js | 68 ++++++++- .../static/js/user/webauthn-manage.js | 137 +++++++++++++++++- .../resources/templates/user/register.html | 16 ++ .../templates/user/update-password.html | 5 +- .../resources/templates/user/update-user.html | 46 +++++- 8 files changed, 398 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/static/js/user/auth-methods.js diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ebf93b1..e5d5538 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -123,7 +123,7 @@ user: bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31. testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. defaultAction: deny # The default action for all requests. This can be either deny or allow. - unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. + unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. diff --git a/src/main/resources/static/js/user/auth-methods.js b/src/main/resources/static/js/user/auth-methods.js new file mode 100644 index 0000000..dbcf44e --- /dev/null +++ b/src/main/resources/static/js/user/auth-methods.js @@ -0,0 +1,37 @@ +/** + * Auth methods utility - fetches and caches the user's authentication methods. + */ +import { getCsrfToken, getCsrfHeaderName } from '/js/user/webauthn-utils.js'; + +let cachedAuthMethods = null; + +/** + * Fetch the user's authentication methods from the server. + * Caches the result for the page load unless forceRefresh is true. + */ +export async function getAuthMethods(forceRefresh = false) { + if (cachedAuthMethods && !forceRefresh) { + return cachedAuthMethods; + } + + const response = await fetch('/user/auth-methods', { + headers: { + [getCsrfHeaderName()]: getCsrfToken() + } + }); + + if (!response.ok) { + throw new Error('Failed to fetch auth methods'); + } + + const json = await response.json(); + cachedAuthMethods = json.data; + return cachedAuthMethods; +} + +/** + * Invalidate the cached auth methods so the next call fetches fresh data. + */ +export function invalidateAuthMethodsCache() { + cachedAuthMethods = null; +} diff --git a/src/main/resources/static/js/user/register.js b/src/main/resources/static/js/user/register.js index dfce5e0..df84e1e 100644 --- a/src/main/resources/static/js/user/register.js +++ b/src/main/resources/static/js/user/register.js @@ -12,6 +12,9 @@ import { initPasswordStrengthMeter, initPasswordRequirements, } from "/js/utils/password-validation.js"; +import { isWebAuthnSupported } from "/js/user/webauthn-utils.js"; + +let isPasswordlessMode = false; document.addEventListener("DOMContentLoaded", () => { const form = document.querySelector("#registerForm"); @@ -24,6 +27,42 @@ document.addEventListener("DOMContentLoaded", () => { form.addEventListener("submit", (event) => handleRegistration(event)); + // Show registration mode toggle if WebAuthn is supported + if (isWebAuthnSupported()) { + const toggleContainer = document.querySelector("#registrationModeToggle"); + if (toggleContainer) { + toggleContainer.classList.remove("d-none"); + } + } + + // Registration mode toggle handlers + const modePasswordBtn = document.querySelector("#modePassword"); + const modePasswordlessBtn = document.querySelector("#modePasswordless"); + const passwordFieldsDiv = document.querySelector("#passwordFields"); + const passwordlessInfo = document.querySelector("#passwordlessInfo"); + + if (modePasswordBtn && modePasswordlessBtn) { + modePasswordBtn.addEventListener("click", () => { + isPasswordlessMode = false; + modePasswordBtn.classList.add("active"); + modePasswordlessBtn.classList.remove("active"); + passwordFieldsDiv.classList.remove("d-none"); + passwordlessInfo.classList.add("d-none"); + passwordField.setAttribute("required", ""); + matchPasswordField.setAttribute("required", ""); + }); + + modePasswordlessBtn.addEventListener("click", () => { + isPasswordlessMode = true; + modePasswordlessBtn.classList.add("active"); + modePasswordBtn.classList.remove("active"); + passwordFieldsDiv.classList.add("d-none"); + passwordlessInfo.classList.remove("d-none"); + passwordField.removeAttribute("required"); + matchPasswordField.removeAttribute("required"); + }); + } + // Real-time password matching validation [passwordField, matchPasswordField].forEach((field) => { field.addEventListener("input", () => { @@ -56,6 +95,61 @@ async function handleRegistration(event) { signUpButton.disabled = true; clearErrors(); + // Validate terms and conditions + const termsCheckbox = document.querySelector("#terms"); + if (!termsCheckbox.checked) { + alert("You must agree to the Terms and Conditions to register."); + signUpButton.disabled = false; + return; + } + + if (isPasswordlessMode) { + // Passwordless registration - minimal payload + const firstName = document.querySelector("#firstName").value; + const lastName = document.querySelector("#lastName").value; + const email = document.querySelector("#email").value; + + const payload = { firstName, lastName, email }; + + try { + const response = await fetch("/user/registration/passwordless", { + method: "POST", + headers: { + "Content-Type": "application/json", + [document.querySelector("meta[name='_csrf_header']").content]: + document.querySelector("meta[name='_csrf']").content, + }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + window.location.href = data.redirectUrl; + } else if (data.errors) { + const errorMessages = Object.entries(data.errors) + .map(([field, message]) => `${field}: ${message}`) + .join("
"); + showMessage(globalError, errorMessages, "alert-danger"); + } else { + const errorMessage = + data.messages?.join(" ") || data.message || "Registration failed. Please try again."; + showMessage(globalError, errorMessage, "alert-danger"); + } + } catch (error) { + console.error("Request failed:", error); + showMessage( + globalError, + "An unexpected error occurred. Please try again later.", + "alert-danger" + ); + } finally { + signUpButton.disabled = false; + } + return; + } + + // Standard password registration const password = document.querySelector("#password").value; const matchPassword = document.querySelector("#matchPassword").value; @@ -69,14 +163,6 @@ async function handleRegistration(event) { return; } - // Validate terms and conditions - const termsCheckbox = document.querySelector("#terms"); - if (!termsCheckbox.checked) { - alert("You must agree to the Terms and Conditions to register."); - signUpButton.disabled = false; - return; - } - // Prepare JSON payload const formData = Object.fromEntries(new FormData(form).entries()); diff --git a/src/main/resources/static/js/user/update-password.js b/src/main/resources/static/js/user/update-password.js index 21869cd..83d6122 100644 --- a/src/main/resources/static/js/user/update-password.js +++ b/src/main/resources/static/js/user/update-password.js @@ -4,8 +4,11 @@ import { initPasswordStrengthMeter, initPasswordRequirements, } from "/js/utils/password-validation.js"; +import { getAuthMethods } from "/js/user/auth-methods.js"; -document.addEventListener("DOMContentLoaded", () => { +let isSetPasswordMode = false; + +document.addEventListener("DOMContentLoaded", async () => { const form = document.querySelector("#updatePasswordForm"); const globalMessage = document.querySelector("#globalMessage"); const currentPasswordField = document.querySelector("#currentPassword"); @@ -13,6 +16,31 @@ document.addEventListener("DOMContentLoaded", () => { const confirmPasswordField = document.querySelector("#confirmPassword"); const confirmPasswordError = document.querySelector("#confirmPasswordError"); + // Check if user has a password; if not, switch to "set password" mode + try { + const auth = await getAuthMethods(); + if (!auth.hasPassword) { + const currentPasswordSection = document.querySelector("#currentPasswordSection"); + if (currentPasswordSection) { + currentPasswordSection.classList.add("d-none"); + } + if (currentPasswordField) { + currentPasswordField.removeAttribute("required"); + } + const setPasswordInfo = document.querySelector("#setPasswordInfo"); + if (setPasswordInfo) { + setPasswordInfo.classList.remove("d-none"); + } + const pageTitle = document.querySelector("#pageTitle"); + if (pageTitle) { + pageTitle.textContent = "Set a Password"; + } + isSetPasswordMode = true; + } + } catch (error) { + console.error("Failed to check auth methods:", error); + } + // Initialize password strength meter for new password field const passwordStrength = document.getElementById("password-strength"); const strengthLevel = document.getElementById("strengthLevel"); @@ -28,7 +56,6 @@ document.addEventListener("DOMContentLoaded", () => { event.preventDefault(); clearErrors(); - const currentPassword = currentPasswordField.value; const newPassword = newPasswordField.value; const confirmPassword = confirmPasswordField.value; @@ -38,6 +65,43 @@ document.addEventListener("DOMContentLoaded", () => { return; } + if (isSetPasswordMode) { + // Set password mode - no old password needed + const requestData = { + newPassword: newPassword, + confirmPassword: confirmPassword, + }; + + try { + const response = await fetch("/user/setPassword", { + method: "POST", + headers: { + "Content-Type": "application/json", + [document.querySelector("meta[name='_csrf_header']").content]: + document.querySelector("meta[name='_csrf']").content, + }, + body: JSON.stringify(requestData), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showMessage(globalMessage, data.messages.join(" "), "alert-success"); + form.reset(); + } else { + const errorMessage = data.messages?.join(" ") || "Unable to set your password."; + showMessage(globalMessage, errorMessage, "alert-danger"); + } + } catch (error) { + console.error("Request failed:", error); + showMessage(globalMessage, "An unexpected error occurred. Please try again later.", "alert-danger"); + } + return; + } + + // Standard update password mode + const currentPassword = currentPasswordField.value; + // Prepare JSON payload const requestData = { oldPassword: currentPassword, diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index acddb48..34f9c29 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -4,10 +4,12 @@ import { getCsrfToken, getCsrfHeaderName, isWebAuthnSupported, escapeHtml } from '/js/user/webauthn-utils.js'; import { registerPasskey } from '/js/user/webauthn-register.js'; import { showMessage } from '/js/shared.js'; +import { getAuthMethods, invalidateAuthMethodsCache } from '/js/user/auth-methods.js'; const csrfHeader = getCsrfHeaderName(); const csrfToken = getCsrfToken(); let renameModalInstance; +let removePasswordModalInstance; /** * Load and display user's passkeys. @@ -168,7 +170,9 @@ function renamePasskey(credentialId, currentLabel) { if (globalMessage) { showMessage(globalMessage, 'Passkey renamed successfully.', 'alert-success'); } + invalidateAuthMethodsCache(); loadPasskeys(); + updateAuthMethodsUI(); } catch (error) { console.error('Failed to rename passkey:', error); errorEl.textContent = error.message; @@ -220,11 +224,13 @@ async function deletePasskey(credentialId) { if (globalMessage) { showMessage(globalMessage, 'Passkey deleted successfully.', 'alert-success'); } + invalidateAuthMethodsCache(); loadPasskeys(); + updateAuthMethodsUI(); } catch (error) { console.error('Failed to delete passkey:', error); if (globalMessage) { - showMessage(globalMessage, 'Failed to delete passkey. Please try again.', 'alert-danger'); + showMessage(globalMessage, error.message || 'Failed to delete passkey. Please try again.', 'alert-danger'); } } } @@ -243,7 +249,9 @@ async function handleRegisterPasskey() { showMessage(globalMessage, 'Passkey registered successfully!', 'alert-success'); } if (labelInput) labelInput.value = ''; + invalidateAuthMethodsCache(); loadPasskeys(); + updateAuthMethodsUI(); } catch (error) { console.error('Registration error:', error); if (globalMessage) { @@ -252,6 +260,127 @@ async function handleRegisterPasskey() { } } +/** + * Update the Authentication Methods UI card with current state. + */ +async function updateAuthMethodsUI() { + const section = document.getElementById('auth-methods-section'); + if (!section) return; + + try { + const auth = await getAuthMethods(true); + section.classList.remove('d-none'); + + // Build badges + const badgesContainer = document.getElementById('auth-method-badges'); + let badges = ''; + if (auth.hasPassword) { + badges += 'Password'; + } else { + badges += 'No Password'; + } + if (auth.hasPasskeys) { + badges += `Passkeys (${auth.passkeysCount})`; + } + if (auth.provider && auth.provider !== 'LOCAL') { + badges += `${auth.provider}`; + } + badgesContainer.innerHTML = badges; + + // Show/hide Remove Password button (only if has password AND passkeys) + const removeContainer = document.getElementById('removePasswordContainer'); + if (removeContainer) { + removeContainer.classList.toggle('d-none', !(auth.hasPassword && auth.hasPasskeys)); + } + + // Show/hide Set Password link (only if no password) + const setContainer = document.getElementById('setPasswordContainer'); + if (setContainer) { + setContainer.classList.toggle('d-none', auth.hasPassword); + } + + // Update Change Password link text + const changePasswordLink = document.getElementById('changePasswordLink'); + if (changePasswordLink) { + changePasswordLink.textContent = auth.hasPassword ? 'Change Password' : 'Set a Password'; + } + } catch (error) { + console.error('Failed to update auth methods UI:', error); + } +} + +/** + * Wire up the Remove Password button and modal. + */ +function initRemovePassword() { + const removeBtn = document.getElementById('removePasswordBtn'); + const confirmInput = document.getElementById('removePasswordConfirmInput'); + const confirmBtn = document.getElementById('confirmRemovePasswordBtn'); + const errorEl = document.getElementById('removePasswordError'); + + if (!removeBtn) return; + + removeBtn.addEventListener('click', () => { + if (!removePasswordModalInstance) { + removePasswordModalInstance = new bootstrap.Modal(document.getElementById('removePasswordModal')); + } + confirmInput.value = ''; + confirmBtn.disabled = true; + errorEl.classList.add('d-none'); + removePasswordModalInstance.show(); + }); + + // Enable confirm button only when "REMOVE" is typed + confirmInput.addEventListener('input', () => { + confirmBtn.disabled = confirmInput.value.trim() !== 'REMOVE'; + }); + + confirmBtn.addEventListener('click', async () => { + if (confirmInput.value.trim() !== 'REMOVE') return; + + confirmBtn.disabled = true; + confirmBtn.textContent = 'Removing...'; + errorEl.classList.add('d-none'); + + try { + const response = await fetch('/user/webauthn/password', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + } + }); + + if (!response.ok) { + let msg = 'Failed to remove password'; + try { + const data = await response.json(); + msg = data.message || msg; + } catch { + const text = await response.text(); + if (text) msg = text; + } + throw new Error(msg); + } + + removePasswordModalInstance.hide(); + const globalMessage = document.getElementById('globalMessage'); + if (globalMessage) { + showMessage(globalMessage, 'Password removed successfully. You are now passwordless.', 'alert-success'); + } + invalidateAuthMethodsCache(); + updateAuthMethodsUI(); + } catch (error) { + console.error('Failed to remove password:', error); + errorEl.textContent = error.message; + errorEl.classList.remove('d-none'); + } finally { + confirmBtn.disabled = false; + confirmBtn.textContent = 'Remove Password'; + } + }); +} + // Initialize on page load document.addEventListener('DOMContentLoaded', async () => { const passkeySection = document.getElementById('passkey-section'); @@ -284,6 +413,10 @@ document.addEventListener('DOMContentLoaded', async () => { registerBtn.addEventListener('click', handleRegisterPasskey); } - // Load existing passkeys + // Initialize remove password functionality + initRemovePassword(); + + // Load existing passkeys and auth methods loadPasskeys(); + updateAuthMethodsUI(); }); diff --git a/src/main/resources/templates/user/register.html b/src/main/resources/templates/user/register.html index 0420914..e4396ab 100644 --- a/src/main/resources/templates/user/register.html +++ b/src/main/resources/templates/user/register.html @@ -40,6 +40,20 @@
Register

or

+ +
+
+ + +
+
+ + +
+ + You'll register with just your name and email, then set up a passkey to sign in securely without a password. +
+
Register
+
+
diff --git a/src/main/resources/templates/user/update-password.html b/src/main/resources/templates/user/update-password.html index aa5c6eb..45a1f5f 100644 --- a/src/main/resources/templates/user/update-password.html +++ b/src/main/resources/templates/user/update-password.html @@ -10,16 +10,18 @@
-

Update Password

+

Update Password

+
You don't have a password yet. Set one to enable password login alongside your passkeys.
+
@@ -28,6 +30,7 @@

Update Password

+
diff --git a/src/main/resources/templates/user/update-user.html b/src/main/resources/templates/user/update-user.html index ef8f0a4..e748342 100644 --- a/src/main/resources/templates/user/update-user.html +++ b/src/main/resources/templates/user/update-user.html @@ -45,6 +45,50 @@

Update Profile

+ +
+
+
Authentication Methods
+
+
+
+ Loading... +
+
+ +
+ +
+
+ + + +
From 2116abdc6413a2ebc65f8c3efb08351597102585 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 23 Feb 2026 21:56:55 -0700 Subject: [PATCH 2/7] chore: update library dependency to 4.2.1-SNAPSHOT for passwordless support --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6ccf147..ceb6a76 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.0' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.1-SNAPSHOT' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' From 679170c32945d0948437db011dbfd63843e48651 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 23 Feb 2026 22:06:14 -0700 Subject: [PATCH 3/7] fix: delete DemoUserProfile before user in TestDataController The deleteTestUser endpoint was failing with FK constraint violation because demo_user_profile rows weren't cleaned up before deleting the user_account. This caused all Playwright tests that create users to fail during cleanup. --- .../spring/demo/test/api/TestDataController.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java b/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java index b860933..5516d2b 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfileRepository; import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; @@ -47,6 +48,7 @@ public class TestDataController { private final PasswordResetTokenRepository passwordResetTokenRepository; private final RoleRepository roleRepository; private final PasswordEncoder passwordEncoder; + private final DemoUserProfileRepository demoUserProfileRepository; /** * Check if a user exists by email. @@ -228,9 +230,9 @@ public ResponseEntity> deleteTestUser(@RequestParam String e return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); } - // Delete related tokens first to avoid foreign key constraints - // Note: Event registrations and other related entities are not deleted. - // If the user has event registrations, this may fail with foreign key constraint violation. + // Delete related entities first to avoid foreign key constraints + demoUserProfileRepository.findById(user.getId()).ifPresent(demoUserProfileRepository::delete); + VerificationToken verificationToken = verificationTokenRepository.findByUser(user); if (verificationToken != null) { verificationTokenRepository.delete(verificationToken); From c0167180cdc9ad96185345ba4fcdffe035c1c804 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 23 Feb 2026 22:13:40 -0700 Subject: [PATCH 4/7] test: add Playwright tests for passwordless UI and auth methods - Registration page: toggle visibility, password field hiding, required attribute toggling, correct endpoint targeting, active state switching - Profile page: auth methods card display, password badge, remove password button visibility, set password link visibility, passkey management section, empty passkeys message - Change password page: current password field for password users - Navigation: change password link text verification --- .../auth/passwordless-registration.spec.ts | 195 +++++++++++++++++ playwright/tests/profile/auth-methods.spec.ts | 197 ++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 playwright/tests/auth/passwordless-registration.spec.ts create mode 100644 playwright/tests/profile/auth-methods.spec.ts diff --git a/playwright/tests/auth/passwordless-registration.spec.ts b/playwright/tests/auth/passwordless-registration.spec.ts new file mode 100644 index 0000000..536484e --- /dev/null +++ b/playwright/tests/auth/passwordless-registration.spec.ts @@ -0,0 +1,195 @@ +import { test, expect, generateTestUser } from '../../src/fixtures'; + +test.describe('Passwordless Registration', () => { + test.describe('Registration Mode Toggle', () => { + test('should show passwordless toggle on registration page', async ({ + registerPage, + }) => { + await registerPage.goto(); + await registerPage.page.waitForLoadState('networkidle'); + + // The toggle should be visible (WebAuthn is supported in Chromium) + const toggle = registerPage.page.locator('#registrationModeToggle'); + await expect(toggle).toBeVisible(); + + // Both mode buttons should be present + const passwordBtn = registerPage.page.locator('#modePassword'); + const passwordlessBtn = registerPage.page.locator('#modePasswordless'); + await expect(passwordBtn).toBeVisible(); + await expect(passwordlessBtn).toBeVisible(); + + // Password mode should be active by default + await expect(passwordBtn).toHaveClass(/active/); + await expect(passwordlessBtn).not.toHaveClass(/active/); + }); + + test('should hide password fields when passwordless mode is selected', async ({ + registerPage, + }) => { + await registerPage.goto(); + await registerPage.page.waitForLoadState('networkidle'); + + // Password fields should be visible initially + const passwordFields = registerPage.page.locator('#passwordFields'); + await expect(passwordFields).toBeVisible(); + await expect(registerPage.passwordInput).toBeVisible(); + await expect(registerPage.confirmPasswordInput).toBeVisible(); + + // Click passwordless mode button + await registerPage.page.locator('#modePasswordless').click(); + + // Password fields should be hidden + await expect(passwordFields).toBeHidden(); + + // Passwordless info alert should be visible + const passwordlessInfo = registerPage.page.locator('#passwordlessInfo'); + await expect(passwordlessInfo).toBeVisible(); + }); + + test('should show password fields when switching back to password mode', async ({ + registerPage, + }) => { + await registerPage.goto(); + await registerPage.page.waitForLoadState('networkidle'); + + // Switch to passwordless + await registerPage.page.locator('#modePasswordless').click(); + const passwordFields = registerPage.page.locator('#passwordFields'); + await expect(passwordFields).toBeHidden(); + + // Switch back to password mode + await registerPage.page.locator('#modePassword').click(); + + // Password fields should be visible again + await expect(passwordFields).toBeVisible(); + await expect(registerPage.passwordInput).toBeVisible(); + await expect(registerPage.confirmPasswordInput).toBeVisible(); + + // Passwordless info alert should be hidden + const passwordlessInfo = registerPage.page.locator('#passwordlessInfo'); + await expect(passwordlessInfo).toBeHidden(); + }); + + test('should toggle active state on mode buttons', async ({ + registerPage, + }) => { + await registerPage.goto(); + await registerPage.page.waitForLoadState('networkidle'); + + const passwordBtn = registerPage.page.locator('#modePassword'); + const passwordlessBtn = registerPage.page.locator('#modePasswordless'); + + // Switch to passwordless + await passwordlessBtn.click(); + await expect(passwordlessBtn).toHaveClass(/active/); + await expect(passwordBtn).not.toHaveClass(/active/); + + // Switch back to password + await passwordBtn.click(); + await expect(passwordBtn).toHaveClass(/active/); + await expect(passwordlessBtn).not.toHaveClass(/active/); + }); + + test('should keep name and email fields visible in passwordless mode', async ({ + registerPage, + }) => { + await registerPage.goto(); + await registerPage.page.waitForLoadState('networkidle'); + + // Switch to passwordless + await registerPage.page.locator('#modePasswordless').click(); + + // Name and email fields should still be visible + await expect(registerPage.firstNameInput).toBeVisible(); + await expect(registerPage.lastNameInput).toBeVisible(); + await expect(registerPage.emailInput).toBeVisible(); + + // Terms checkbox should still be visible + await expect(registerPage.termsCheckbox).toBeVisible(); + }); + + test('should remove required attribute from password fields in passwordless mode', async ({ + registerPage, + }) => { + await registerPage.goto(); + await registerPage.page.waitForLoadState('networkidle'); + + // Password fields should be required initially + await expect(registerPage.passwordInput).toHaveAttribute('required', ''); + await expect(registerPage.confirmPasswordInput).toHaveAttribute('required', ''); + + // Switch to passwordless + await registerPage.page.locator('#modePasswordless').click(); + + // Password fields should no longer be required + await expect(registerPage.passwordInput).not.toHaveAttribute('required', ''); + await expect(registerPage.confirmPasswordInput).not.toHaveAttribute('required', ''); + + // Switch back - should be required again + await registerPage.page.locator('#modePassword').click(); + await expect(registerPage.passwordInput).toHaveAttribute('required', ''); + await expect(registerPage.confirmPasswordInput).toHaveAttribute('required', ''); + }); + }); + + test.describe('Passwordless Form Submission', () => { + test('should send passwordless registration request to correct endpoint', async ({ + page, + registerPage, + }) => { + await registerPage.goto(); + await page.waitForLoadState('networkidle'); + + // Switch to passwordless mode + await page.locator('#modePasswordless').click(); + + // Fill name and email + await registerPage.firstNameInput.fill('Test'); + await registerPage.lastNameInput.fill('User'); + await registerPage.emailInput.fill('test-pwless-endpoint@example.com'); + await registerPage.acceptTerms(); + + // Intercept the fetch request to verify it goes to the right endpoint + const requestPromise = page.waitForRequest( + request => request.url().includes('/user/registration/passwordless') && request.method() === 'POST' + ); + + await registerPage.submit(); + + // Verify the request was sent to the passwordless endpoint + const request = await requestPromise; + expect(request.url()).toContain('/user/registration/passwordless'); + + // Verify the payload contains only name and email (no password) + const postData = JSON.parse(request.postData() || '{}'); + expect(postData.firstName).toBe('Test'); + expect(postData.lastName).toBe('User'); + expect(postData.email).toBe('test-pwless-endpoint@example.com'); + expect(postData.password).toBeUndefined(); + expect(postData.matchingPassword).toBeUndefined(); + }); + + test('should send standard registration request when in password mode', async ({ + page, + registerPage, + }) => { + await registerPage.goto(); + await page.waitForLoadState('networkidle'); + + // Stay in password mode (default) + await registerPage.fillForm('Test', 'User', 'test-standard@example.com', 'Test@Pass123!'); + await registerPage.acceptTerms(); + + // Intercept the fetch request + const requestPromise = page.waitForRequest( + request => request.url().includes('/user/registration') && request.method() === 'POST' + ); + + await registerPage.submit(); + + // Verify the request goes to the standard endpoint (not passwordless) + const request = await requestPromise; + expect(request.url()).not.toContain('passwordless'); + }); + }); +}); diff --git a/playwright/tests/profile/auth-methods.spec.ts b/playwright/tests/profile/auth-methods.spec.ts new file mode 100644 index 0000000..214dc42 --- /dev/null +++ b/playwright/tests/profile/auth-methods.spec.ts @@ -0,0 +1,197 @@ +import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fixtures'; + +test.describe('Authentication Methods', () => { + test.describe('Profile Page Auth Methods Card', () => { + test('should display auth methods section for logged-in user', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('auth-methods-display'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Auth methods section should become visible after JS loads + const authSection = page.locator('#auth-methods-section'); + await authSection.waitFor({ state: 'visible', timeout: 5000 }); + await expect(authSection).toBeVisible(); + }); + + test('should show password badge for user with password', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('auth-methods-badges'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Wait for auth methods to load + const badgesContainer = page.locator('#auth-method-badges'); + await badgesContainer.locator('.badge').first().waitFor({ state: 'visible', timeout: 5000 }); + + // Should show a "Password" badge + const passwordBadge = badgesContainer.locator('.badge:has-text("Password")'); + await expect(passwordBadge).toBeVisible(); + }); + + test('should hide remove password button when user has no passkeys', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('auth-methods-no-passkey'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Wait for auth methods section to load + const authSection = page.locator('#auth-methods-section'); + await authSection.waitFor({ state: 'visible', timeout: 5000 }); + + // Remove password button should be hidden (user has no passkeys) + const removePasswordContainer = page.locator('#removePasswordContainer'); + await expect(removePasswordContainer).toBeHidden(); + }); + + test('should hide set password link for user with password', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('auth-methods-set-pass'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Wait for auth methods section to load + const authSection = page.locator('#auth-methods-section'); + await authSection.waitFor({ state: 'visible', timeout: 5000 }); + + // Set password container should be hidden (user already has password) + const setPasswordContainer = page.locator('#setPasswordContainer'); + await expect(setPasswordContainer).toBeHidden(); + }); + }); + + test.describe('Passkey Management Section', () => { + test('should display passkey management section', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('passkey-section'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Passkey section should be visible + const passkeySection = page.locator('#passkey-section'); + await expect(passkeySection).toBeVisible(); + + // Add passkey button should be present + const registerBtn = page.locator('#registerPasskeyBtn'); + await expect(registerBtn).toBeVisible(); + + // Label input should be present + const labelInput = page.locator('#passkeyLabel'); + await expect(labelInput).toBeVisible(); + }); + + test('should show empty passkeys message when user has no passkeys', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('passkey-empty'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Wait for passkeys to load + const passkeysList = page.locator('#passkeys-list'); + await passkeysList.locator('p, .card').first().waitFor({ state: 'visible', timeout: 5000 }); + + // Should show "No passkeys registered yet" message + const emptyMessage = passkeysList.locator('text=No passkeys registered yet'); + await expect(emptyMessage).toBeVisible(); + }); + }); + + test.describe('Change Password Page Adaptation', () => { + test('should show current password field for user with password', async ({ + page, + updatePasswordPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('change-pass-has-pass'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updatePasswordPage.goto(); + await page.waitForLoadState('networkidle'); + + // Current password section should be visible + const currentPasswordSection = page.locator('#currentPasswordSection'); + await expect(currentPasswordSection).toBeVisible(); + + // Page title should say "Update" (not "Set") + const pageTitle = page.locator('#pageTitle'); + const titleText = await pageTitle.textContent(); + expect(titleText).not.toContain('Set a Password'); + + // Set password info alert should be hidden + const setPasswordInfo = page.locator('#setPasswordInfo'); + await expect(setPasswordInfo).toBeHidden(); + }); + }); + + test.describe('Navigation Links', () => { + test('should show change password link on profile page', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('auth-nav-links'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + await updateUserPage.goto(); + await page.waitForLoadState('networkidle'); + + // Wait for auth methods to load so link text updates + const authSection = page.locator('#auth-methods-section'); + await authSection.waitFor({ state: 'visible', timeout: 5000 }); + + // Change password link should be visible + const changePasswordLink = page.locator('#changePasswordLink'); + await expect(changePasswordLink).toBeVisible(); + + // For a user with a password, it should say "Change Password" + const linkText = await changePasswordLink.textContent(); + expect(linkText).toContain('Change Password'); + }); + }); +}); From 16a0fa1ac3f9e897454ca7932d7d9bb8327f4da5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 24 Feb 2026 06:46:00 -0700 Subject: [PATCH 5/7] fix(test): use response interception for wrong-password test reliability Replace unreliable waitForLoadState('networkidle') with explicit waitForResponse() in the "should reject change with wrong current password" test. Since the form uses fetch() (AJAX), networkidle can resolve before the DOM is updated from the response, causing the #globalMessage visibility check to fail intermittently. The new approach: - Intercepts the server response to /user/updatePassword - Asserts the server correctly returns success=false - Waits for the #globalMessage div to become visible --- .../tests/profile/change-password.spec.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/playwright/tests/profile/change-password.spec.ts b/playwright/tests/profile/change-password.spec.ts index 3b32cb0..ac0f438 100644 --- a/playwright/tests/profile/change-password.spec.ts +++ b/playwright/tests/profile/change-password.spec.ts @@ -92,10 +92,24 @@ test.describe('Change Password', () => { await page.waitForLoadState('networkidle'); // Try to change password with wrong current password + // Listen for the server response to verify error handling + const responsePromise = page.waitForResponse( + response => response.url().includes('/user/updatePassword') && response.request().method() === 'POST' + ); + await updatePasswordPage.changePassword('wrongCurrentPassword123!', 'NewTest@Pass123!'); - await page.waitForLoadState('networkidle'); - // Should show error or stay on page + // Wait for the server response + const response = await responsePromise; + const responseBody = await response.json(); + + // Server should indicate failure + expect(responseBody.success).toBe(false); + + // Wait for the error message to appear in the DOM + await updatePasswordPage.waitForMessage(5000); + + // Should show error const isError = await updatePasswordPage.isErrorMessage() || await updatePasswordPage.hasCurrentPasswordError(); expect(isError).toBe(true); From 43d2b41043e128ceb6b5254acfc6be6b7e604181 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 24 Feb 2026 07:25:29 -0700 Subject: [PATCH 6/7] fix: address PR review feedback for passwordless UI - Escape auth.provider in HTML to prevent XSS (A1) - Add aria-label to remove-password confirm input (A2) - Show user-facing error when auth methods fail to load (A3) --- src/main/resources/static/js/user/webauthn-manage.js | 6 +++++- src/main/resources/templates/user/update-user.html | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index 34f9c29..febc403 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -283,7 +283,7 @@ async function updateAuthMethodsUI() { badges += `Passkeys (${auth.passkeysCount})`; } if (auth.provider && auth.provider !== 'LOCAL') { - badges += `${auth.provider}`; + badges += `${escapeHtml(auth.provider)}`; } badgesContainer.innerHTML = badges; @@ -306,6 +306,10 @@ async function updateAuthMethodsUI() { } } catch (error) { console.error('Failed to update auth methods UI:', error); + const section = document.getElementById('auth-methods-section'); + if (section) { + section.innerHTML = '
Unable to load authentication methods.
'; + } } } diff --git a/src/main/resources/templates/user/update-user.html b/src/main/resources/templates/user/update-user.html index e748342..dbf9eef 100644 --- a/src/main/resources/templates/user/update-user.html +++ b/src/main/resources/templates/user/update-user.html @@ -78,7 +78,7 @@