feat(handlers): implement consolidated user profile endpoints
Implement DeleteUser, ResetUserPassword, and UpdateUserAdmin handlers. Update collections handler to check soft-deleted users. Update dashboard service to exclude deleted users from statistics.
This commit is contained in:
+231
-38
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/middleware"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -74,8 +75,20 @@ type UserProfile struct {
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
@@ -210,6 +223,13 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
|
||||
}
|
||||
|
||||
accessToken, err := h.generateJWTWithAllClaims(
|
||||
uuid.UUID(user.ID.Bytes).String(),
|
||||
user.Role,
|
||||
@@ -448,25 +468,133 @@ func (h *AuthHandler) GetProfile(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfile handles PUT /api/auth/profile
|
||||
// UpdateProfile handles PUT /api/auth/profile (self-edit) and PUT /api/auth/profile/:id (admin edit)
|
||||
// Combined handler for both self-service and admin profile updates
|
||||
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
currentUser := MustGetAuthenticatedUser(c)
|
||||
|
||||
var req UpdateProfileRequest
|
||||
// Determine target user: URL param (admin mode) or current user (self-edit)
|
||||
targetUserID := c.Param("id")
|
||||
var targetUserUUID pgtype.UUID
|
||||
isAdminMode := targetUserID != ""
|
||||
|
||||
if isAdminMode {
|
||||
// Admin editing another user - validate admin role
|
||||
if currentUser.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
parsedUUID, err := uuid.Parse(targetUserID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||
} else {
|
||||
// Self-edit mode
|
||||
targetUserUUID = currentUser.ID
|
||||
}
|
||||
|
||||
var req AdminUpdateUserRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
|
||||
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
|
||||
ID: user.ID,
|
||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
|
||||
// Handle role change (admin mode only)
|
||||
if req.Role != "" && isAdminMode {
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"})
|
||||
}
|
||||
|
||||
// Check if this is the last admin (preventing demotion)
|
||||
users, err := h.db.ListUsers(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
|
||||
}
|
||||
|
||||
adminCount := 0
|
||||
currentTargetUserRole := ""
|
||||
for _, user := range users {
|
||||
if user.Role == "admin" {
|
||||
adminCount++
|
||||
}
|
||||
if user.ID.Bytes == targetUserUUID.Bytes {
|
||||
currentTargetUserRole = user.Role
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent demoting the last admin
|
||||
if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"})
|
||||
}
|
||||
|
||||
// Update role
|
||||
_, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{
|
||||
ID: targetUserUUID,
|
||||
Role: req.Role,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Update username (if provided)
|
||||
if req.Username != "" {
|
||||
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
|
||||
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
|
||||
}
|
||||
|
||||
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
|
||||
ID: targetUserUUID,
|
||||
Username: req.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Update email (if provided)
|
||||
if req.Email != "" {
|
||||
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
||||
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
|
||||
}
|
||||
|
||||
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
|
||||
ID: targetUserUUID,
|
||||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Update first/last name (if provided)
|
||||
if req.FirstName != "" || req.LastName != "" {
|
||||
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
|
||||
ID: targetUserUUID,
|
||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Update theme (if provided)
|
||||
if req.Theme != "" {
|
||||
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
|
||||
ID: targetUserUUID,
|
||||
Theme: pgtype.Text{String: req.Theme, 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": "profile updated successfully"})
|
||||
}
|
||||
|
||||
// ListUsers handles GET /api/auth/users
|
||||
@@ -604,11 +732,38 @@ type UpdatePasswordRequest struct {
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdatePassword handles PUT /api/auth/password
|
||||
// UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset)
|
||||
// Combined handler for both self-service password change and admin password reset
|
||||
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
currentUser := MustGetAuthenticatedUser(c)
|
||||
|
||||
var req UpdatePasswordRequest
|
||||
// Determine target user: URL param (admin mode) or current user (self-change)
|
||||
targetUserID := c.Param("id")
|
||||
var targetUserUUID pgtype.UUID
|
||||
isAdminMode := targetUserID != ""
|
||||
|
||||
if isAdminMode {
|
||||
// Admin resetting another user's password - validate admin role
|
||||
if currentUser.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
parsedUUID, err := uuid.Parse(targetUserID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||
} else {
|
||||
// Self-change mode
|
||||
targetUserUUID = currentUser.ID
|
||||
}
|
||||
|
||||
type PasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
var req PasswordRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
@@ -616,23 +771,28 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
||||
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"})
|
||||
}
|
||||
// Self-change mode: require current password
|
||||
if !isAdminMode {
|
||||
if req.CurrentPassword == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"})
|
||||
}
|
||||
|
||||
// Get current user's password hash
|
||||
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
// Get current password hash
|
||||
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"})
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
|
||||
}
|
||||
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"})
|
||||
// Validate new password matches confirmation
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
@@ -643,7 +803,7 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
||||
|
||||
// Update password
|
||||
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
|
||||
ID: user.ID,
|
||||
ID: targetUserUUID,
|
||||
PasswordHash: string(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -653,18 +813,17 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
|
||||
}
|
||||
|
||||
// DeleteAccount handles DELETE /api/auth/account
|
||||
// Supports self-deletion or admin deletion of other users
|
||||
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
// DeleteUser handles DELETE /api/auth/profile (self-deletion) and DELETE /api/auth/profile/:id (admin deletion)
|
||||
// Combined handler for both self-deletion and admin deletion of users
|
||||
func (h *AuthHandler) DeleteUser(c echo.Context) error {
|
||||
currentUser := MustGetAuthenticatedUser(c)
|
||||
|
||||
// Get target user ID from query parameter (for admin override) or use current user
|
||||
targetUserID := c.QueryParam("user_id")
|
||||
// Get target user ID from URL param (admin mode) or use current user (self-deletion)
|
||||
targetUserID := c.Param("id")
|
||||
var targetUserUUID pgtype.UUID
|
||||
|
||||
// If admin override is used, validate admin and use target
|
||||
if targetUserID != "" {
|
||||
// Admin override mode - check if current user is admin
|
||||
// Admin deletion mode
|
||||
if currentUser.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
@@ -674,6 +833,7 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
}
|
||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||
} else {
|
||||
// Self-deletion mode
|
||||
targetUserUUID = currentUser.ID
|
||||
}
|
||||
|
||||
@@ -802,3 +962,36 @@ func (h *AuthHandler) generateJWTWithRole(userID, userRole string) (string, erro
|
||||
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||
return h.generateJWTWithRole(userID, "user")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
defaultCollections := []struct {
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
Color string
|
||||
QueryType string
|
||||
Priority int32
|
||||
}{
|
||||
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: userID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: col.Color, Valid: true},
|
||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
||||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user