feat(account): add editable phone and password change with validation

- Add editable phone field in /account General tab with UK phone
  validation
- Create PUT /api/user/change-password endpoint in backend
- Add zxcvbn password strength meter to change password modal
- Add "passwords don't match" validation message to both /account and
  /register
- Fix navbar logout reactivity with invalidateAll and $derived values
- Fix a11y warnings: add labels, roles, and keyboard handlers
- Remove unused CSS from account page
This commit is contained in:
2026-02-20 20:17:38 +00:00
parent 41dc839830
commit 5a4cd29b44
7 changed files with 285 additions and 141 deletions
+67
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -486,3 +487,69 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
return
}
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "current password and new password are required", http.StatusBadRequest)
return
}
if len(req.NewPassword) < 8 {
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
return
}
if len(req.NewPassword) > 72 {
http.Error(w, "password must be less than 72 characters", http.StatusBadRequest)
return
}
var passwordHash string
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch password hash for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Failed to hash new password for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
_, err = db.DB.Exec(r.Context(), `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), userID)
if err != nil {
log.Printf("Failed to update password for user %s: %v", userID, err)
http.Error(w, "failed to update password", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
+1
View File
@@ -139,6 +139,7 @@ func main() {
r.Get("/user/profile", user.GetProfileHandler)
r.Put("/user/profile", user.UpdateProfileHandler)
r.Put("/user/change-password", user.ChangePasswordHandler)
r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler)