feat: add user preferences dashboard

- Add /preferences route and preferences.html template for user settings
- Implement username, email, password, and theme update functionality
- Add account deletion feature with confirmation
- Add navigation link to preferences from dashboard
- Create API endpoints:
  - PUT /api/user/username - Update username
  - PUT /api/user/email - Update email address
  - PUT /api/user/password - Change password with verification
  - DELETE /api/user/account - Delete user account
- Add database queries for user updates and account deletion
- Create Bruno API testing files for all user preference endpoints
- Add proper validation, error handling, and security checks
This commit is contained in:
2026-01-23 09:30:15 -05:00
parent c29183a14f
commit 2dba1d6eb9
11 changed files with 628 additions and 15 deletions
+180
View File
@@ -408,6 +408,186 @@ func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder removed"})
}
type UpdateThemeRequest struct {
Theme string `json:"theme" validate:"required"`
}
// UpdateTheme handles PUT /api/auth/theme
func (h *AuthHandler) UpdateTheme(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdateThemeRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
err = h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Theme: pgtype.Text{String: req.Theme, Valid: req.Theme != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"})
}
type UpdateUsernameRequest struct {
Username string `json:"username" validate:"required,min=3,max=50"`
}
// UpdateUsername handles PUT /api/user/username
func (h *AuthHandler) UpdateUsername(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdateUsernameRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if username is already taken by another user
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
// Update username
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"})
}
type UpdateEmailRequest struct {
Email string `json:"email" validate:"required,email"`
}
// UpdateEmail handles PUT /api/user/email
func (h *AuthHandler) UpdateEmail(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdateEmailRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if email is already taken by another user
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
// Update email
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"})
}
type UpdatePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=6"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
// UpdatePassword handles PUT /api/user/password
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdatePasswordRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if new passwords match
if req.NewPassword != req.ConfirmPassword {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new passwords do not match"})
}
// Get current user's password hash
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user"})
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
// Update password
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
PasswordHash: string(hashedPassword),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
}
// DeleteAccount handles DELETE /api/user/account
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
// Delete user (this will cascade to delete all related data)
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "account deleted successfully"})
}
func (h *AuthHandler) generateJWT(userID string) (string, error) {
claims := jwtgo.MapClaims{
"user_id": userID,