Files
bookhoard/internal/handlers/auth.go
T
john-okeefe d3b728c458 fix: resolve registration database connection error
- Fix database authentication error by exposing actual database error messages
- Update error handling to follow pgx v5 standards with detailed error reporting
- Restore token environment variable management in Register User.bru for subsequent requests
- Enable proper debugging of database connection issues during user registration

The registration API now provides detailed error messages instead of generic 'failed to check existing users'
when database connection or authentication fails, making debugging easier.
2026-01-28 20:57:15 -05:00

863 lines
30 KiB
Go

package handlers
import (
"bookmann/internal/database"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
type AuthHandler struct {
db *database.Queries
jwtKey []byte
}
func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
return &AuthHandler{
db: db,
jwtKey: []byte(jwtSecret),
}
}
type RegisterRequest struct {
Email string `form:"email" json:"email" validate:"required,email"`
Username string `form:"username" json:"username" validate:"required,min=3,max=50"`
Password string `form:"password" json:"password" validate:"required,min=6"`
FirstName string `form:"first_name" json:"first_name,omitempty"`
LastName string `form:"last_name" json:"last_name,omitempty"`
Role string `form:"role" json:"role,omitempty"`
}
type LoginRequest struct {
Login string `form:"login" json:"login" validate:"required"` // email or username
Password string `form:"password" json:"password" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User UserProfile `json:"user"`
}
type UserProfile struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
}
type UpdateProfileRequest struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
}
// Register handles POST /api/auth/register
func (h *AuthHandler) Register(c echo.Context) error {
// Try form data first (HTMX), then JSON (Bruno)
email := c.FormValue("email")
username := c.FormValue("username")
password := c.FormValue("password")
firstName := c.FormValue("first_name")
lastName := c.FormValue("last_name")
role := c.FormValue("role")
if email == "" || username == "" || password == "" {
// Fallback to JSON binding
req := RegisterRequest{}
if err := c.Bind(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid request</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
email = req.Email
username = req.Username
password = req.Password
firstName = req.FirstName
lastName = req.LastName
role = req.Role
}
req := RegisterRequest{Email: email, Username: username, Password: password, FirstName: firstName, LastName: lastName, Role: role}
if err := c.Validate(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if user already exists
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div class="text-red-500">Email already exists</div>`)
}
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
}
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div class="text-red-500">Username already exists</div>`)
}
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
}
// Check if this is the first user - if so, make them admin regardless of request
users, err := h.db.ListUsers(c.Request().Context())
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to check existing users: `+err.Error()+`</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users: " + err.Error()})
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to hash password</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
// Check if any admin users already exist
adminExists := false
for _, u := range users {
if u.Role == "admin" {
adminExists = true
break
}
}
// Set role - first user is always admin, otherwise validate requested role based on existing admins
var userRole string
if len(users) == 0 {
userRole = "admin" // First user is always admin
} else {
userRole = req.Role
if userRole == "" {
userRole = "user" // Default to regular user if not specified
}
// Validate role for subsequent users
if userRole != "user" && userRole != "admin" {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid role. Must be 'user' or 'admin'</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
}
// Role-based restrictions: only admins can create admin users if any admin already exists
if userRole == "admin" && adminExists {
// Check if current user is admin (requires authentication)
userID := c.Get("user_id")
if userID == nil {
// Not authenticated - cannot create admin user if admins exist
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Only existing administrators can create admin accounts</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "only existing administrators can create admin accounts"})
}
// User is authenticated - check their role
userRoleAuth := c.Get("user_role").(string)
if userRoleAuth != "admin" {
// Authenticated but not admin - cannot create admin accounts
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Only administrators can create admin accounts</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "only administrators can create admin accounts"})
}
}
}
// Create user
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
Email: req.Email,
Username: req.Username,
PasswordHash: string(hashedPassword),
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
Theme: pgtype.Text{String: "tokyo-night", Valid: true}, // default theme
Role: userRole,
})
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Generate JWT with user details
token, err := h.generateJWTWithAllClaims(
uuid.UUID(user.ID.Bytes).String(),
user.Role,
user.Email,
user.Username,
)
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate token</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
}
// Check if request is from HTMX
if c.Request().Header.Get("HX-Request") == "true" {
// Return HTML with script to set token and redirect
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('user', JSON.stringify(%s));
document.cookie = 'token=%s; path=/; max-age=86400';
window.location.href = '/api/dashboard';
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username), token)
return c.HTML(http.StatusCreated, html)
}
if user.FirstName.Valid {
firstName = user.FirstName.String
}
if user.LastName.Valid {
lastName = user.LastName.String
}
return c.JSON(http.StatusCreated, AuthResponse{
Token: token,
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
},
})
}
// Login handles POST /api/auth/login
func (h *AuthHandler) Login(c echo.Context) error {
// Debug logging
fmt.Printf("Login request - Content-Type: %s\n", c.Request().Header.Get("Content-Type"))
fmt.Printf("Form values - login: %s, password: %s\n", c.FormValue("login"), c.FormValue("password"))
// Try form data first (HTMX), then JSON (Bruno)
login := c.FormValue("login")
password := c.FormValue("password")
if login == "" || password == "" {
fmt.Printf("Form values empty, trying JSON bind\n")
// Fallback to JSON binding
req := LoginRequest{}
if err := c.Bind(&req); err != nil {
fmt.Printf("JSON bind error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid request</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
fmt.Printf("Validation error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
login = req.Login
password = req.Password
fmt.Printf("JSON bind success - login: %s\n", login)
}
req := LoginRequest{Login: login, Password: password}
if err := c.Validate(&req); err != nil {
fmt.Printf("Final validation error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Get user by email or username (includes password hash for verification)
user, err := h.db.GetUserForLogin(c.Request().Context(), req.Login)
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
// Generate JWT with user details
token, err := h.generateJWTWithAllClaims(
uuid.UUID(user.ID.Bytes).String(),
user.Role,
user.Email,
user.Username,
)
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate token</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
}
// Check if request is from HTMX
if c.Request().Header.Get("HX-Request") == "true" {
// Return HTML with script to set token and redirect
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('user', JSON.stringify(%s));
document.cookie = 'token=%s; path=/; max-age=86400';
window.location.href = '/api/dashboard';
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), token)
return c.HTML(http.StatusOK, html)
}
firstName := ""
if user.FirstName.Valid {
firstName = user.FirstName.String
}
lastName := ""
if user.LastName.Valid {
lastName = user.LastName.String
}
return c.JSON(http.StatusOK, AuthResponse{
Token: token,
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
},
})
}
// GetProfile handles GET /api/auth/profile
func (h *AuthHandler) GetProfile(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"})
}
user, err := h.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
firstName := ""
if user.FirstName.Valid {
firstName = user.FirstName.String
}
lastName := ""
if user.LastName.Valid {
lastName = user.LastName.String
}
return c.JSON(http.StatusOK, UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
})
}
// UpdateProfile handles PUT /api/auth/profile
func (h *AuthHandler) UpdateProfile(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 UpdateProfileRequest
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: pgtype.UUID{Bytes: userUUID, Valid: true},
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()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
}
// ListUsers handles GET /api/auth/users
func (h *AuthHandler) ListUsers(c echo.Context) error {
users, err := h.db.ListUsers(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type UserList struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Theme string `json:"theme"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
var userList []UserList
for _, u := range users {
theme := ""
if u.Theme.Valid {
theme = u.Theme.String
}
firstName := ""
if u.FirstName.Valid {
firstName = u.FirstName.String
}
lastName := ""
if u.LastName.Valid {
lastName = u.LastName.String
}
createdAt := ""
if u.CreatedAt.Valid {
createdAt = u.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00")
}
updatedAt := ""
if u.UpdatedAt.Valid {
updatedAt = u.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00")
}
userList = append(userList, UserList{
ID: uuid.UUID(u.ID.Bytes).String(),
Email: u.Email,
Username: u.Username,
FirstName: firstName,
LastName: lastName,
Theme: theme,
Role: u.Role,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
return c.JSON(http.StatusOK, userList)
}
type AddEbookFolderRequest struct {
FolderPath string `json:"folder_path" validate:"required"`
}
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
func normalizePath(path string) string {
fmt.Printf("normalizePath input: '%s'\n", path)
var cleaned string
// Handle home directory expansion (~)
if strings.HasPrefix(path, "~/") {
// Keep the original path for ~ to preserve user's formatting
// Just normalize separators and that's it
cleaned = strings.ReplaceAll(path, "\\", "/")
} else {
// Clean the path to remove redundant separators, ., .. etc.
cleaned = filepath.Clean(path)
// Convert to consistent path separators (use forward slashes for storage)
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
// Remove trailing slash unless it's root path
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
cleaned = strings.TrimSuffix(cleaned, "/")
}
}
fmt.Printf("normalizePath output: '%s'\n", cleaned)
return cleaned
}
type EbookFolderResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
FolderPath string `json:"folder_path"`
CreatedAt string `json:"created_at"`
}
type DeleteEbookFolderRequest struct {
FolderPath string `json:"folder_path" validate:"required"`
}
// AddEbookFolder handles POST /api/auth/ebook-folders (DEPRECATED - use libraries instead)
func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "Ebook folders are deprecated. Please use the library system instead."})
}
// GetEbookFolders handles GET /api/auth/ebook-folders (DEPRECATED - use libraries instead)
func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "Ebook folders are deprecated. Please use library system instead."})
}
// DeleteEbookFolder handles DELETE /api/auth/ebook-folders (DEPRECATED - use libraries instead)
func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "Ebook folders are deprecated. Please use library system instead."})
}
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/auth/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/auth/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/auth/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 {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
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/auth/account
// Supports self-deletion or admin deletion of other users
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
// Get target user ID from query parameter (for admin override) or use current user
targetUserID := c.QueryParam("user_id")
userID := c.Get("user_id").(string)
// If admin override is used, validate admin and use target
if targetUserID != "" {
// Admin override mode - check if current user is admin
userRole := c.Get("user_role").(string)
if userRole != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
userID = targetUserID
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
// Check if this is the last admin user - prevent deletion
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"})
}
// Count admin users and identify the user to be deleted
adminCount := 0
targetUserRole := ""
for _, user := range users {
if user.Role == "admin" {
adminCount++
}
// Find target user details
userUUIDStr := uuid.UUID(user.ID.Bytes).String()
if userUUIDStr == userID {
targetUserRole = user.Role
}
}
// Prevent deletion if target user is admin and this is the last admin
if targetUserRole == "admin" && adminCount == 1 {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Cannot delete the last admin account</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last admin account"})
}
// 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 {
if err == pgx.ErrNoRows {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
}
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to delete account</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Create success message based on context
var message string
if targetUserID != "" && userID != c.Get("user_id").(string) {
message = "user account deleted successfully"
} else {
message = "account deleted successfully"
}
return c.JSON(http.StatusOK, map[string]string{"message": message})
}
type UpdateScanSettingsRequest struct {
ScanFrequencyMinutes int32 `json:"scan_frequency_minutes" validate:"required,min=15,max=1440"`
AutoScanEnabled bool `json:"auto_scan_enabled"`
}
// UpdateScanSettings handles PUT /api/library/scan-settings
func (h *AuthHandler) UpdateScanSettings(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 UpdateScanSettingsRequest
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.UpdateScanSettings(c.Request().Context(), database.UpdateScanSettingsParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
ScanFrequencyMinutes: pgtype.Int4{Int32: req.ScanFrequencyMinutes, Valid: true},
AutoScanEnabled: pgtype.Bool{Bool: req.AutoScanEnabled, 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": "scan settings updated successfully"})
}
// GetScanSettings handles GET /api/library/scan-settings
func (h *AuthHandler) GetScanSettings(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"})
}
settings, err := h.db.GetScanSettings(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
// If no settings found, return defaults
return c.JSON(http.StatusOK, map[string]interface{}{
"scan_frequency_minutes": 60,
"auto_scan_enabled": true,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"scan_frequency_minutes": settings.ScanFrequencyMinutes.Int32,
"auto_scan_enabled": settings.AutoScanEnabled.Bool,
})
}
// AdminMiddleware checks if the user has admin role
func AdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
userRole, exists := c.Get("user_role").(string)
if !exists || userRole != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
return next(c)
}
}
func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, userUsername string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"user_role": userRole,
"user_email": userEmail,
"user_username": userUsername,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(h.jwtKey)
}
func (h *AuthHandler) generateJWTWithRole(userID, userRole string) (string, error) {
return h.generateJWTWithAllClaims(userID, userRole, "", "")
}
func (h *AuthHandler) generateJWT(userID string) (string, error) {
return h.generateJWTWithRole(userID, "user")
}