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
}