diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 1f7bc92..0a1e464 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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, `
Failed to create default collections
`) + } + 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 +} diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index c566cd1..4075224 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -12,6 +12,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) @@ -848,6 +849,14 @@ func (h *CollectionHandler) PreviewCollection(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } + _, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}) + if err != nil { + if err == pgx.ErrNoRows { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if req.Limit <= 0 || req.Limit > 100 { req.Limit = 20 } diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index d11ef3d..245e417 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -103,6 +103,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { var req struct { CollectionName string `json:"collection_name"` + ResetType string `json:"reset_type"` // "full" or "keep_books" } if err := c.Bind(&req); err != nil { @@ -113,6 +114,10 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"}) } + if req.ResetType == "" { + req.ResetType = "full" + } + validCollections := map[string]bool{ "continue-reading": true, "recently-added": true, @@ -123,7 +128,11 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"}) } - err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName) + if req.ResetType != "full" && req.ResetType != "keep_books" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "reset_type must be 'full' or 'keep_books'"}) + } + + err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName, req.ResetType) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"}) } diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 1f18056..ec3e6e2 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" "context" "encoding/json" + "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -142,7 +143,7 @@ func (s *DashboardService) GetDashboardSections( ) ([]DashboardSection, error) { var results []DashboardSection - systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) + systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { return nil, err } @@ -371,7 +372,36 @@ func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, param return s.db.UpsertDashboardPreferences(ctx, params) } -func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error { +func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error { + defaultMetadata := map[string]struct { + Description string + Icon string + Color string + Priority int32 + QueryType string + }{ + "continue-reading": {"Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", 1, "continue-reading"}, + "recently-added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"}, + "recently-read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"}, + "not-started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"}, + } + + meta, exists := defaultMetadata[collectionName] + if !exists { + return fmt.Errorf("unknown collection: %s", collectionName) + } + + if resetType == "keep_books" { + return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Name: collectionName, + Description: pgtype.Text{String: meta.Description, Valid: true}, + Icon: pgtype.Text{String: meta.Icon, Valid: true}, + Color: pgtype.Text{String: meta.Color, Valid: true}, + Priority: pgtype.Int4{Int32: meta.Priority, Valid: true}, + }) + } + err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Name: collectionName, @@ -380,5 +410,15 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u return err } - return nil + _, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Name: collectionName, + Description: pgtype.Text{String: meta.Description, Valid: true}, + Icon: pgtype.Text{String: meta.Icon, Valid: true}, + Color: pgtype.Text{String: meta.Color, Valid: true}, + ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, + QueryType: pgtype.Text{String: meta.QueryType, Valid: true}, + Priority: pgtype.Int4{Int32: meta.Priority, Valid: true}, + }) + return err }