feat: login with username or email + change password

Backend:
- Login handler accepts username OR email via the same field
- New PUT /auth/me/password endpoint (verifies current password,
  requires min 8 chars for new password)

Frontend:
- Login form shows EMAIL OR USERNAME field (single field for both)
- Auth store updated with changePassword function
- UserSettings has new CHANGE PASSWORD section
This commit is contained in:
2026-06-29 14:00:19 -04:00
parent fec0eb1917
commit 9f138deb9d
4 changed files with 300 additions and 74 deletions
+73 -2
View File
@@ -38,6 +38,7 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
r.Get("/auth/me", h.Me)
r.Patch("/auth/me", h.UpdateProfile)
r.Put("/auth/me/password", h.ChangePassword)
}
type registerRequest struct {
@@ -49,6 +50,7 @@ type registerRequest struct {
type loginRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
@@ -59,6 +61,11 @@ type updateProfileRequest struct {
StatusText string `json:"status_text"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
type UserProfile struct {
ID string `json:"id"`
Username string `json:"username"`
@@ -150,10 +157,19 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return
}
login := strings.TrimSpace(req.Email)
if login == "" {
login = strings.TrimSpace(req.Username)
}
if login == "" || req.Password == "" {
http.Error(w, `{"error":"email/username and password required"}`, http.StatusBadRequest)
return
}
var userID, hash string
err := h.db.QueryRowContext(r.Context(), `
SELECT id, password_hash FROM users WHERE email = $1
`, req.Email).Scan(&userID, &hash)
SELECT id, password_hash FROM users WHERE email = $1 OR username = $1
`, login).Scan(&userID, &hash)
if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
@@ -295,3 +311,58 @@ func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, e
func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) {
SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration)
}
// ChangePassword handles PUT /auth/me/password.
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, `{"error":"current and new password required"}`, http.StatusBadRequest)
return
}
if len(req.NewPassword) < 8 {
http.Error(w, `{"error":"password must be at least 8 characters"}`, http.StatusBadRequest)
return
}
// Verify current password
var currentHash string
err := h.db.QueryRowContext(r.Context(), "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&currentHash)
if err != nil {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
ok, err = VerifyPassword(req.CurrentPassword, currentHash)
if err != nil || !ok {
http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized)
return
}
// Hash new password
newHash, err := HashPassword(req.NewPassword)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
_, err = h.db.ExecContext(r.Context(), "UPDATE users SET password_hash = $1 WHERE id = $2", newHash, userID)
if err != nil {
http.Error(w, `{"error":"failed to update password"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
}