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' diff --git a/playwright/src/pages/EventDetailsPage.ts b/playwright/src/pages/EventDetailsPage.ts index 161de95..50d81ed 100644 --- a/playwright/src/pages/EventDetailsPage.ts +++ b/playwright/src/pages/EventDetailsPage.ts @@ -74,28 +74,42 @@ export class EventDetailsPage extends BasePage { /** * Register for the event. + * @returns true if the server confirmed registration, false if it failed */ - async register(): Promise { - // Set up dialog handler for the alert and verify it appears + async register(): Promise { + // Set up dialog handler for the alert const dialogPromise = this.page.waitForEvent('dialog', { timeout: 5000 }); await this.registerButton.click(); const dialog = await dialogPromise; - await dialog.accept(); - // Wait for page to reload + const succeeded = dialog.message().toLowerCase().includes('successful'); + // Accept dialog, which causes page JS to call location.reload(). + // Listen for the next 'load' event BEFORE accepting so we don't miss the reload. + await Promise.all([ + this.page.waitForEvent('load'), + dialog.accept(), + ]); await this.page.waitForLoadState('networkidle'); + return succeeded; } /** * Unregister from the event. + * @returns true if the server confirmed unregistration, false if it failed */ - async unregister(): Promise { - // Set up dialog handler for the alert and verify it appears + async unregister(): Promise { + // Set up dialog handler for the alert const dialogPromise = this.page.waitForEvent('dialog', { timeout: 5000 }); await this.unregisterButton.click(); const dialog = await dialogPromise; - await dialog.accept(); - // Wait for page to reload + const succeeded = dialog.message().toLowerCase().includes('successful'); + // Accept dialog, which causes page JS to call location.reload(). + // Listen for the next 'load' event BEFORE accepting so we don't miss the reload. + await Promise.all([ + this.page.waitForEvent('load'), + dialog.accept(), + ]); await this.page.waitForLoadState('networkidle'); + return succeeded; } /** 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/e2e/complete-user-journey.spec.ts b/playwright/tests/e2e/complete-user-journey.spec.ts index 9653ece..7abb7d3 100644 --- a/playwright/tests/e2e/complete-user-journey.spec.ts +++ b/playwright/tests/e2e/complete-user-journey.spec.ts @@ -111,6 +111,7 @@ test.describe('Complete User Journey', () => { // ========================================== // Step 6: Register for an event (if events exist) // ========================================== + let registeredForEvent = false; await test.step('Register for event', async () => { await eventListPage.goto(); await page.waitForLoadState('networkidle'); @@ -121,14 +122,15 @@ test.describe('Complete User Journey', () => { await page.waitForLoadState('networkidle'); if (await eventDetailsPage.canRegister()) { - await eventDetailsPage.register(); - await page.waitForLoadState('networkidle'); - - // Wait for page to update and show unregister button - await page.locator('button:has-text("Unregister")').waitFor({ state: 'visible', timeout: 5000 }); - - // Verify registered - expect(await eventDetailsPage.canUnregister()).toBe(true); + registeredForEvent = await eventDetailsPage.register(); + if (registeredForEvent) { + // Server-side rendered page may show stale state under concurrent load; + // if the API confirmed success but the page hasn't caught up, reload once. + if (!await eventDetailsPage.canUnregister()) { + await page.reload({ waitUntil: 'networkidle' }); + } + expect(await eventDetailsPage.canUnregister()).toBe(true); + } } } }); @@ -137,13 +139,11 @@ test.describe('Complete User Journey', () => { // Step 7: Unregister from event (if registered) // ========================================== await test.step('Unregister from event', async () => { - // If on event details page and registered - if (await eventDetailsPage.canUnregister()) { - await eventDetailsPage.unregister(); - await page.waitForLoadState('networkidle'); - - // Verify unregistered - expect(await eventDetailsPage.canRegister()).toBe(true); + if (registeredForEvent && await eventDetailsPage.canUnregister()) { + const unregistered = await eventDetailsPage.unregister(); + if (unregistered) { + expect(await eventDetailsPage.canRegister()).toBe(true); + } } }); 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'); + }); + }); +}); 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); 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); 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..febc403 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,131 @@ 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 += `${escapeHtml(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); + const section = document.getElementById('auth-methods-section'); + if (section) { + section.innerHTML = '
Unable to load authentication methods.
'; + } + } +} + +/** + * 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 +417,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..dbf9eef 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... +
+
+ +
+ +
+
+ + + +