Skip to content

Commit c42e2b0

Browse files
committed
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.
1 parent fdc4615 commit c42e2b0

8 files changed

Lines changed: 398 additions & 15 deletions

File tree

src/main/resources/application.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ user:
123123
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.
124124
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.
125125
defaultAction: deny # The default action for all requests. This can be either deny or allow.
126-
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.
126+
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.
127127
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
128128
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.
129129

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Auth methods utility - fetches and caches the user's authentication methods.
3+
*/
4+
import { getCsrfToken, getCsrfHeaderName } from '/js/user/webauthn-utils.js';
5+
6+
let cachedAuthMethods = null;
7+
8+
/**
9+
* Fetch the user's authentication methods from the server.
10+
* Caches the result for the page load unless forceRefresh is true.
11+
*/
12+
export async function getAuthMethods(forceRefresh = false) {
13+
if (cachedAuthMethods && !forceRefresh) {
14+
return cachedAuthMethods;
15+
}
16+
17+
const response = await fetch('/user/auth-methods', {
18+
headers: {
19+
[getCsrfHeaderName()]: getCsrfToken()
20+
}
21+
});
22+
23+
if (!response.ok) {
24+
throw new Error('Failed to fetch auth methods');
25+
}
26+
27+
const json = await response.json();
28+
cachedAuthMethods = json.data;
29+
return cachedAuthMethods;
30+
}
31+
32+
/**
33+
* Invalidate the cached auth methods so the next call fetches fresh data.
34+
*/
35+
export function invalidateAuthMethodsCache() {
36+
cachedAuthMethods = null;
37+
}

src/main/resources/static/js/user/register.js

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
initPasswordStrengthMeter,
1313
initPasswordRequirements,
1414
} from "/js/utils/password-validation.js";
15+
import { isWebAuthnSupported } from "/js/user/webauthn-utils.js";
16+
17+
let isPasswordlessMode = false;
1518

1619
document.addEventListener("DOMContentLoaded", () => {
1720
const form = document.querySelector("#registerForm");
@@ -24,6 +27,42 @@ document.addEventListener("DOMContentLoaded", () => {
2427

2528
form.addEventListener("submit", (event) => handleRegistration(event));
2629

30+
// Show registration mode toggle if WebAuthn is supported
31+
if (isWebAuthnSupported()) {
32+
const toggleContainer = document.querySelector("#registrationModeToggle");
33+
if (toggleContainer) {
34+
toggleContainer.classList.remove("d-none");
35+
}
36+
}
37+
38+
// Registration mode toggle handlers
39+
const modePasswordBtn = document.querySelector("#modePassword");
40+
const modePasswordlessBtn = document.querySelector("#modePasswordless");
41+
const passwordFieldsDiv = document.querySelector("#passwordFields");
42+
const passwordlessInfo = document.querySelector("#passwordlessInfo");
43+
44+
if (modePasswordBtn && modePasswordlessBtn) {
45+
modePasswordBtn.addEventListener("click", () => {
46+
isPasswordlessMode = false;
47+
modePasswordBtn.classList.add("active");
48+
modePasswordlessBtn.classList.remove("active");
49+
passwordFieldsDiv.classList.remove("d-none");
50+
passwordlessInfo.classList.add("d-none");
51+
passwordField.setAttribute("required", "");
52+
matchPasswordField.setAttribute("required", "");
53+
});
54+
55+
modePasswordlessBtn.addEventListener("click", () => {
56+
isPasswordlessMode = true;
57+
modePasswordlessBtn.classList.add("active");
58+
modePasswordBtn.classList.remove("active");
59+
passwordFieldsDiv.classList.add("d-none");
60+
passwordlessInfo.classList.remove("d-none");
61+
passwordField.removeAttribute("required");
62+
matchPasswordField.removeAttribute("required");
63+
});
64+
}
65+
2766
// Real-time password matching validation
2867
[passwordField, matchPasswordField].forEach((field) => {
2968
field.addEventListener("input", () => {
@@ -56,6 +95,61 @@ async function handleRegistration(event) {
5695
signUpButton.disabled = true;
5796
clearErrors();
5897

98+
// Validate terms and conditions
99+
const termsCheckbox = document.querySelector("#terms");
100+
if (!termsCheckbox.checked) {
101+
alert("You must agree to the Terms and Conditions to register.");
102+
signUpButton.disabled = false;
103+
return;
104+
}
105+
106+
if (isPasswordlessMode) {
107+
// Passwordless registration - minimal payload
108+
const firstName = document.querySelector("#firstName").value;
109+
const lastName = document.querySelector("#lastName").value;
110+
const email = document.querySelector("#email").value;
111+
112+
const payload = { firstName, lastName, email };
113+
114+
try {
115+
const response = await fetch("/user/registration/passwordless", {
116+
method: "POST",
117+
headers: {
118+
"Content-Type": "application/json",
119+
[document.querySelector("meta[name='_csrf_header']").content]:
120+
document.querySelector("meta[name='_csrf']").content,
121+
},
122+
body: JSON.stringify(payload),
123+
});
124+
125+
const data = await response.json();
126+
127+
if (response.ok && data.success) {
128+
window.location.href = data.redirectUrl;
129+
} else if (data.errors) {
130+
const errorMessages = Object.entries(data.errors)
131+
.map(([field, message]) => `${field}: ${message}`)
132+
.join("<br>");
133+
showMessage(globalError, errorMessages, "alert-danger");
134+
} else {
135+
const errorMessage =
136+
data.messages?.join(" ") || data.message || "Registration failed. Please try again.";
137+
showMessage(globalError, errorMessage, "alert-danger");
138+
}
139+
} catch (error) {
140+
console.error("Request failed:", error);
141+
showMessage(
142+
globalError,
143+
"An unexpected error occurred. Please try again later.",
144+
"alert-danger"
145+
);
146+
} finally {
147+
signUpButton.disabled = false;
148+
}
149+
return;
150+
}
151+
152+
// Standard password registration
59153
const password = document.querySelector("#password").value;
60154
const matchPassword = document.querySelector("#matchPassword").value;
61155

@@ -69,14 +163,6 @@ async function handleRegistration(event) {
69163
return;
70164
}
71165

72-
// Validate terms and conditions
73-
const termsCheckbox = document.querySelector("#terms");
74-
if (!termsCheckbox.checked) {
75-
alert("You must agree to the Terms and Conditions to register.");
76-
signUpButton.disabled = false;
77-
return;
78-
}
79-
80166
// Prepare JSON payload
81167
const formData = Object.fromEntries(new FormData(form).entries());
82168

src/main/resources/static/js/user/update-password.js

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,43 @@ import {
44
initPasswordStrengthMeter,
55
initPasswordRequirements,
66
} from "/js/utils/password-validation.js";
7+
import { getAuthMethods } from "/js/user/auth-methods.js";
78

8-
document.addEventListener("DOMContentLoaded", () => {
9+
let isSetPasswordMode = false;
10+
11+
document.addEventListener("DOMContentLoaded", async () => {
912
const form = document.querySelector("#updatePasswordForm");
1013
const globalMessage = document.querySelector("#globalMessage");
1114
const currentPasswordField = document.querySelector("#currentPassword");
1215
const newPasswordField = document.querySelector("#newPassword");
1316
const confirmPasswordField = document.querySelector("#confirmPassword");
1417
const confirmPasswordError = document.querySelector("#confirmPasswordError");
1518

19+
// Check if user has a password; if not, switch to "set password" mode
20+
try {
21+
const auth = await getAuthMethods();
22+
if (!auth.hasPassword) {
23+
const currentPasswordSection = document.querySelector("#currentPasswordSection");
24+
if (currentPasswordSection) {
25+
currentPasswordSection.classList.add("d-none");
26+
}
27+
if (currentPasswordField) {
28+
currentPasswordField.removeAttribute("required");
29+
}
30+
const setPasswordInfo = document.querySelector("#setPasswordInfo");
31+
if (setPasswordInfo) {
32+
setPasswordInfo.classList.remove("d-none");
33+
}
34+
const pageTitle = document.querySelector("#pageTitle");
35+
if (pageTitle) {
36+
pageTitle.textContent = "Set a Password";
37+
}
38+
isSetPasswordMode = true;
39+
}
40+
} catch (error) {
41+
console.error("Failed to check auth methods:", error);
42+
}
43+
1644
// Initialize password strength meter for new password field
1745
const passwordStrength = document.getElementById("password-strength");
1846
const strengthLevel = document.getElementById("strengthLevel");
@@ -28,7 +56,6 @@ document.addEventListener("DOMContentLoaded", () => {
2856
event.preventDefault();
2957
clearErrors();
3058

31-
const currentPassword = currentPasswordField.value;
3259
const newPassword = newPasswordField.value;
3360
const confirmPassword = confirmPasswordField.value;
3461

@@ -38,6 +65,43 @@ document.addEventListener("DOMContentLoaded", () => {
3865
return;
3966
}
4067

68+
if (isSetPasswordMode) {
69+
// Set password mode - no old password needed
70+
const requestData = {
71+
newPassword: newPassword,
72+
confirmPassword: confirmPassword,
73+
};
74+
75+
try {
76+
const response = await fetch("/user/setPassword", {
77+
method: "POST",
78+
headers: {
79+
"Content-Type": "application/json",
80+
[document.querySelector("meta[name='_csrf_header']").content]:
81+
document.querySelector("meta[name='_csrf']").content,
82+
},
83+
body: JSON.stringify(requestData),
84+
});
85+
86+
const data = await response.json();
87+
88+
if (response.ok && data.success) {
89+
showMessage(globalMessage, data.messages.join(" "), "alert-success");
90+
form.reset();
91+
} else {
92+
const errorMessage = data.messages?.join(" ") || "Unable to set your password.";
93+
showMessage(globalMessage, errorMessage, "alert-danger");
94+
}
95+
} catch (error) {
96+
console.error("Request failed:", error);
97+
showMessage(globalMessage, "An unexpected error occurred. Please try again later.", "alert-danger");
98+
}
99+
return;
100+
}
101+
102+
// Standard update password mode
103+
const currentPassword = currentPasswordField.value;
104+
41105
// Prepare JSON payload
42106
const requestData = {
43107
oldPassword: currentPassword,

0 commit comments

Comments
 (0)