// backend/user_settings.go package main import ( "encoding/json" "errors" "net/http" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/bcrypt" ) type updateSettingsAccountRequest struct { DisplayName string `json:"displayName"` Username string `json:"username"` Email string `json:"email"` Avatar string `json:"avatar"` Unit string `json:"unit"` Group string `json:"group"` } type updateSettingsPasswordRequest struct { CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` ConfirmPassword string `json:"confirmPassword"` } type confirmPasswordRequest struct { Password string `json:"password"` } func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Request) { userID, ok := s.currentUserIDFromRequest(r) if !ok { writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") return } var payload updateSettingsAccountRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") return } username := normalizeUsername(payload.Username) email := normalizeEmail(payload.Email) displayName := strings.TrimSpace(payload.DisplayName) avatar := strings.TrimSpace(payload.Avatar) unit := strings.TrimSpace(payload.Unit) group := strings.TrimSpace(payload.Group) if username == "" { writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich") return } if email == "" { writeSettingsError(w, http.StatusBadRequest, "E-Mail-Adresse ist erforderlich") return } if displayName == "" { displayName = username } if group == "" { group = "user" } commandTag, err := s.db.Exec( r.Context(), ` UPDATE users SET username = $1, display_name = $2, email = $3, avatar = $4, unit = $5, user_group = $6, updated_at = now() WHERE id = $7 `, username, displayName, email, avatar, unit, group, userID, ) if isPostgresUniqueViolation(err) { writeSettingsError(w, http.StatusConflict, "Benutzername oder E-Mail-Adresse wird bereits verwendet") return } if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Profil konnte nicht gespeichert werden") return } if commandTag.RowsAffected() == 0 { writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") return } user, err := s.getUserByID(r.Context(), userID) if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Profil wurde gespeichert, konnte aber nicht neu geladen werden") return } s.publishUserProfileEvent("user.updated", user) writeSettingsJSON(w, http.StatusOK, map[string]any{ "message": "Profil gespeichert", "user": user, }) } func (s *Server) handleUpdateSettingsPassword(w http.ResponseWriter, r *http.Request) { userID, ok := s.currentUserIDFromRequest(r) if !ok { writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") return } var payload updateSettingsPasswordRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") return } payload.CurrentPassword = strings.TrimSpace(payload.CurrentPassword) payload.NewPassword = strings.TrimSpace(payload.NewPassword) payload.ConfirmPassword = strings.TrimSpace(payload.ConfirmPassword) if payload.CurrentPassword == "" { writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist erforderlich") return } if len(payload.NewPassword) < 8 { writeSettingsError(w, http.StatusBadRequest, "Das neue Passwort muss mindestens 8 Zeichen lang sein") return } if payload.NewPassword != payload.ConfirmPassword { writeSettingsError(w, http.StatusBadRequest, "Die neuen Passwörter stimmen nicht überein") return } var currentPasswordHash string err := s.db.QueryRow( r.Context(), ` SELECT password_hash FROM users WHERE id = $1 LIMIT 1 `, userID, ).Scan(¤tPasswordHash) if errors.Is(err, pgx.ErrNoRows) { writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") return } if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht geprüft werden") return } if err := bcrypt.CompareHashAndPassword([]byte(currentPasswordHash), []byte(payload.CurrentPassword)); err != nil { writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist nicht korrekt") return } nextPasswordHash, err := bcrypt.GenerateFromPassword([]byte(payload.NewPassword), bcrypt.DefaultCost) if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht vorbereitet werden") return } commandTag, err := s.db.Exec( r.Context(), ` UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2 `, string(nextPasswordHash), userID, ) if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden") return } if commandTag.RowsAffected() == 0 { writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") return } writeSettingsJSON(w, http.StatusOK, map[string]any{ "message": "Passwort gespeichert", }) } func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Request) { userID, ok := s.currentUserIDFromRequest(r) if !ok { writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") return } currentSessionID, ok := s.currentSessionIDFromRequest(r) if !ok { writeSettingsError(w, http.StatusUnauthorized, "Aktuelle Sitzung wurde nicht gefunden") return } var payload confirmPasswordRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") return } if err := s.verifyUserPassword(r, userID, payload.Password); err != nil { writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt") return } _, err := s.db.Exec( r.Context(), ` UPDATE user_sessions SET revoked_at = now() WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL `, userID, currentSessionID, ) if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht abgemeldet werden") return } writeSettingsJSON(w, http.StatusOK, map[string]any{ "message": "Andere Sitzungen wurden abgemeldet", }) } func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) { userID, ok := s.currentUserIDFromRequest(r) if !ok { writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") return } var payload confirmPasswordRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") return } if err := s.verifyUserPassword(r, userID, payload.Password); err != nil { writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden") return } defer tx.Rollback(r.Context()) commandTag, err := tx.Exec( r.Context(), ` DELETE FROM users WHERE id = $1 `, userID, ) if err != nil { writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden") return } if commandTag.RowsAffected() == 0 { writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") return } if err := tx.Commit(r.Context()); err != nil { writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden") return } http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: false, }) writeSettingsJSON(w, http.StatusOK, map[string]any{ "message": "Konto gelöscht", }) } func (s *Server) verifyUserPassword(r *http.Request, userID string, password string) error { password = strings.TrimSpace(password) if password == "" { return errors.New("password required") } var passwordHash string err := s.db.QueryRow( r.Context(), ` SELECT password_hash FROM users WHERE id = $1 LIMIT 1 `, userID, ).Scan(&passwordHash) if err != nil { return err } return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) } func (s *Server) currentUserIDFromRequest(r *http.Request) (string, bool) { user, ok := r.Context().Value(userContextKey).(User) if !ok || user.ID == "" { return "", false } return user.ID, true } func isPostgresUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } func writeSettingsJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeSettingsError(w http.ResponseWriter, status int, message string) { writeSettingsJSON(w, status, map[string]any{ "error": message, }) }