TestDocsMatchRoutes failed on users/get_profile.md: the example carries
default_audio_speed but the response struct tagged it omitempty, so the
gate's zero-value marshal never sees the key and reads the doc as
inventing it. The tag was semantically dead: the column is NOT NULL
DEFAULT 1.0 (schema.sql 44) and writes validate 0.25-4, so a stored 0
is out of domain and every real profile emits the field. Response-only
struct, no form-bind impact. The key was added to the doc example in
ef827a3a without a gate run - this is exactly the class of drift the
gate exists to catch; make test now passes again.
1237 lines
46 KiB
Go
1237 lines
46 KiB
Go
// Package handlers provides HTTP request/response handlers for the bookhoard application.
|
||
// It includes handlers for authentication, libraries, media items, reading, collections,
|
||
// dashboards, devices, analytics, and various system features.
|
||
package handlers
|
||
|
||
import (
|
||
"bookhoard/internal/database"
|
||
"bookhoard/internal/middleware"
|
||
"bookhoard/internal/rls"
|
||
"bookhoard/internal/setupstatus"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"html/template"
|
||
"math"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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/v5"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
const (
|
||
// DefaultSessionDuration is the fallback session duration used when no
|
||
// settings registry is wired (matches the historical 7-day value).
|
||
DefaultSessionDuration = 7 * 24 * time.Hour
|
||
)
|
||
|
||
// SessionDurationSec is retained for backward compatibility; new code uses the
|
||
// registry via AuthHandler.sessionDuration().
|
||
var SessionDurationSec = int(DefaultSessionDuration.Seconds())
|
||
|
||
// SetSettings wires the tunable settings registry (optional).
|
||
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||
|
||
// defaultTimezone returns the system-wide default timezone for new users.
|
||
// Falls back to UTC when no registry is wired, the setting is empty, or
|
||
// the value is not a valid IANA location.
|
||
func (h *AuthHandler) defaultTimezone() string {
|
||
if h.settings == nil {
|
||
return "UTC"
|
||
}
|
||
tz := h.settings.DefaultTimezone()
|
||
if tz == "" {
|
||
return "UTC"
|
||
}
|
||
if _, err := time.LoadLocation(tz); err != nil {
|
||
return "UTC"
|
||
}
|
||
return tz
|
||
}
|
||
|
||
// sessionDuration returns the active session duration from the registry.
|
||
func (h *AuthHandler) sessionDuration() time.Duration {
|
||
if h.settings != nil {
|
||
return h.settings.SessionDuration()
|
||
}
|
||
return DefaultSessionDuration
|
||
}
|
||
|
||
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
|
||
// session duration), with a compiled-default fallback.
|
||
func (h *AuthHandler) refreshTokenTTL() time.Duration {
|
||
if h.settings != nil {
|
||
return h.settings.SessionDuration()
|
||
}
|
||
return DefaultSessionDuration
|
||
}
|
||
|
||
var secure = os.Getenv("COOKIE_SECURE")
|
||
|
||
type AuthHandler struct {
|
||
db *database.Queries
|
||
jwtKey []byte
|
||
loginAttemptTracker *middleware.LoginAttemptTracker
|
||
settings *database.SettingsRegistry
|
||
}
|
||
|
||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||
return &AuthHandler{
|
||
db: db,
|
||
jwtKey: []byte(jwtSecret),
|
||
loginAttemptTracker: loginAttemptTracker,
|
||
}
|
||
}
|
||
|
||
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,passwordcomplex"`
|
||
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:"access_token"`
|
||
RefreshToken string `json:"refresh_token,omitempty"`
|
||
TokenType string `json:"token_type"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
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"`
|
||
Theme string `json:"theme,omitempty"`
|
||
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||
// Response-only struct: omit nothing — default_audio_speed is NOT NULL
|
||
// DEFAULT 1.0 (writes validate 0.25–4), so a real profile always emits
|
||
// it; omitempty here would drop a contract field from the docs gate.
|
||
DefaultAudioSpeed float64 `json:"default_audio_speed"` // Whether the web reader/player should hold a Screen Wake Lock.
|
||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||
// Resume rewind: seconds before the saved position the audio
|
||
// player starts (open-resume and every genuine unpause). 0 is a
|
||
// real value (exact resume) — never omitted.
|
||
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
|
||
}
|
||
|
||
type ProfileUpdateRequest struct {
|
||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||
// "true"/"false" — the profile dropdown always sends it; JSON clients
|
||
// that omit it decode as "" and leave the column untouched.
|
||
KeepScreenAwake string `json:"keep_screen_awake,omitempty" form:"keep_screen_awake" validate:"omitempty,oneof=true false"`
|
||
// Per the SPEEDS list bounds; the profile select always sends it,
|
||
// JSON clients omit it (0 = untouched).
|
||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||
// Resume rewind seconds, as the form select sends it ("0"/"5"/"10").
|
||
// The string shape (keep_screen_awake precedent) lets the form
|
||
// select an explicit 0=Off while JSON clients that omit it decode
|
||
// as "" and leave the column untouched.
|
||
AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"`
|
||
}
|
||
|
||
// Register handles POST /api/auth/register
|
||
func (h *AuthHandler) Register(c *echo.Context) error {
|
||
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 == "" {
|
||
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()})
|
||
}
|
||
|
||
req.Username = strings.TrimSpace(req.Username)
|
||
if req.Username == "" {
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Username cannot be empty or whitespace</div>`)
|
||
}
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
|
||
}
|
||
|
||
if req.Role != "" {
|
||
req.Role = strings.ToLower(req.Role)
|
||
}
|
||
|
||
// Public registration can be disabled by admins. The giveaway exception
|
||
// is an unconfigured server: with zero admins the first-user-becomes-
|
||
// admin bootstrap path must stay reachable (mirrors setupRedirect-
|
||
// Middleware's "users exist" reasoning). Admin account creation via the
|
||
// admin API never rides this route, so it is unaffected regardless.
|
||
if h.settings != nil && !h.settings.RegistrationEnabled() {
|
||
count, countErr := h.db.CountAdmins(c.Request().Context())
|
||
if countErr != nil || count > 0 {
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Registration is disabled on this server</div>`)
|
||
}
|
||
return c.JSON(http.StatusForbidden, map[string]string{"error": "registration is disabled on this server"})
|
||
}
|
||
}
|
||
|
||
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"})
|
||
}
|
||
|
||
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()})
|
||
}
|
||
|
||
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"})
|
||
}
|
||
|
||
adminExists := false
|
||
for _, u := range users {
|
||
if u.Role == "admin" {
|
||
adminExists = true
|
||
break
|
||
}
|
||
}
|
||
|
||
var userRole string
|
||
if !adminExists {
|
||
userRole = "admin"
|
||
} else {
|
||
userRole = req.Role
|
||
if userRole == "" {
|
||
userRole = "user"
|
||
}
|
||
|
||
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'"})
|
||
}
|
||
|
||
if userRole == "admin" && adminExists {
|
||
user, ok := c.Get("user").(database.Users)
|
||
if !ok || user.Role != "admin" {
|
||
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 administrators can create admin accounts"})
|
||
}
|
||
}
|
||
}
|
||
|
||
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},
|
||
Role: userRole,
|
||
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
|
||
})
|
||
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()})
|
||
}
|
||
|
||
// A new user may have changed the admin count (e.g. first user becomes
|
||
// admin), so refresh the setup-status cache.
|
||
setupstatus.Invalidate()
|
||
|
||
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,
|
||
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"})
|
||
}
|
||
|
||
// Set HTTP-only cookie for browser-based authentication
|
||
cookie := &http.Cookie{
|
||
Name: "token",
|
||
Value: accessToken,
|
||
Path: "/",
|
||
HttpOnly: true,
|
||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||
SameSite: http.SameSiteLaxMode,
|
||
MaxAge: int(h.sessionDuration().Seconds()),
|
||
}
|
||
c.SetCookie(cookie)
|
||
|
||
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
|
||
if err != nil {
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||
}
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate refresh token"})
|
||
}
|
||
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
// CSP: see the login handler — data attributes, not inline scripts.
|
||
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
|
||
accessToken)
|
||
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: accessToken,
|
||
RefreshToken: refreshToken,
|
||
TokenType: "Bearer",
|
||
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||
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
|
||
// invalidCredentialsHTML is the htmx error partial for failed logins;
|
||
// web/src/auth_forms.ts swaps 4xx/5xx HTML responses into the form target.
|
||
const invalidCredentialsHTML = `<div class="text-red-500">Invalid email/username or password.</div>`
|
||
|
||
func (h *AuthHandler) Login(c *echo.Context) error {
|
||
login := c.FormValue("login")
|
||
password := c.FormValue("password")
|
||
|
||
if login == "" || password == "" {
|
||
req := LoginRequest{}
|
||
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()})
|
||
}
|
||
login = req.Login
|
||
password = req.Password
|
||
}
|
||
|
||
req := LoginRequest{Login: login, Password: password}
|
||
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()})
|
||
}
|
||
|
||
ip := c.RealIP()
|
||
if ip == "" {
|
||
ip = c.Request().RemoteAddr
|
||
}
|
||
|
||
locked, remainingTime := h.loginAttemptTracker.IsLocked(login)
|
||
if locked {
|
||
errMsg := fmt.Sprintf("Account locked. Try again in %d minutes", int(remainingTime.Minutes())+1)
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusTooManyRequests, `<div class="text-red-500">`+errMsg+`</div>`)
|
||
}
|
||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg})
|
||
}
|
||
|
||
user, err := h.db.GetUserForLogin(c.Request().Context(), req.Login)
|
||
if err != nil {
|
||
locked, remainingTime := h.loginAttemptTracker.RecordFailedAttempt(login)
|
||
if locked {
|
||
errMsg := fmt.Sprintf("Too many failed attempts. Account locked for %d minutes", int(remainingTime.Minutes())+1)
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusTooManyRequests, `<div class="text-red-500">`+errMsg+`</div>`)
|
||
}
|
||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg})
|
||
}
|
||
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
|
||
}
|
||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||
}
|
||
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||
locked, remainingTime := h.loginAttemptTracker.RecordFailedAttempt(login)
|
||
if locked {
|
||
errMsg := fmt.Sprintf("Too many failed attempts. Account locked for %d minutes", int(remainingTime.Minutes())+1)
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusTooManyRequests, `<div class="text-red-500">`+errMsg+`</div>`)
|
||
}
|
||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg})
|
||
}
|
||
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
|
||
}
|
||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||
}
|
||
|
||
h.loginAttemptTracker.ClearAttempts(login)
|
||
|
||
accessToken, 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"})
|
||
}
|
||
|
||
// Set HTTP-only cookie for browser-based authentication
|
||
cookie := &http.Cookie{
|
||
Name: "token",
|
||
Value: accessToken,
|
||
Path: "/",
|
||
HttpOnly: true,
|
||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||
SameSite: http.SameSiteLaxMode,
|
||
MaxAge: int(h.sessionDuration().Seconds()),
|
||
}
|
||
c.SetCookie(cookie)
|
||
|
||
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
|
||
if err != nil {
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||
}
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate refresh token"})
|
||
}
|
||
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
// CSP: hash-mode script-src refuses inline scripts, so the htmx
|
||
// partial carries data attributes instead; web/src/auth_forms.ts
|
||
// stashes the token and navigates on htmx:afterSwap. The redirect
|
||
// target is path-only (open-redirect hygiene) and the old
|
||
// 'user'/'refreshToken' localStorage writes are gone — nothing
|
||
// ever read them.
|
||
redirect := c.FormValue("redirect")
|
||
if redirect == "" || !strings.HasPrefix(redirect, "/") {
|
||
redirect = "/dashboard"
|
||
}
|
||
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
|
||
accessToken, html.EscapeString(redirect))
|
||
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: accessToken,
|
||
RefreshToken: refreshToken,
|
||
TokenType: "Bearer",
|
||
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||
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 {
|
||
ctxUser := MustGetAuthenticatedUser(c)
|
||
// The context user is a JWT-claims stub (id/email/username/role) —
|
||
// it carries no theme. Read the full row: clients (the Android app)
|
||
// sync their app-chrome theme from this field.
|
||
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
|
||
}
|
||
|
||
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,
|
||
Theme: user.Theme.String,
|
||
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
||
KeepScreenAwake: user.KeepScreenAwake,
|
||
AudioResumeRewind: int(user.AudioResumeRewindSeconds),
|
||
})
|
||
}
|
||
|
||
// 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 {
|
||
currentUser := MustGetAuthenticatedUser(c)
|
||
|
||
// 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: parsedUUID, Valid: true}
|
||
} else {
|
||
// Self-edit mode
|
||
targetUserUUID = currentUser.ID
|
||
}
|
||
|
||
var req ProfileUpdateRequest
|
||
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()})
|
||
}
|
||
|
||
// 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()})
|
||
}
|
||
|
||
// Role changes can affect the admin count, so refresh the setup-status cache.
|
||
setupstatus.Invalidate()
|
||
}
|
||
|
||
// 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 timezone
|
||
if req.Timezone != "" {
|
||
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||
"error": "Invalid timezone",
|
||
})
|
||
}
|
||
err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{
|
||
ID: targetUserUUID,
|
||
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 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()})
|
||
}
|
||
}
|
||
|
||
// Update the default audiobook playback speed (plan §8). The form
|
||
// select always sends it; JSON clients that omit it decode as 0 and
|
||
// leave the column untouched.
|
||
if req.DefaultAudioSpeed != 0 {
|
||
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
|
||
}
|
||
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
|
||
ID: targetUserUUID,
|
||
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
|
||
})
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
}
|
||
}
|
||
|
||
// Resume-rewind preference. The form select always sends it
|
||
// ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave
|
||
// the column untouched. Strict allow-list — the three choices the
|
||
// UI offers are the only values the API accepts.
|
||
if req.AudioResumeRewind != "" {
|
||
rewind, err := strconv.Atoi(req.AudioResumeRewind)
|
||
if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"})
|
||
}
|
||
err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{
|
||
ID: targetUserUUID,
|
||
AudioResumeRewindSeconds: int32(rewind),
|
||
})
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
}
|
||
}
|
||
|
||
// Keep-screen-awake preference. The form dropdown always sends it
|
||
// ("true"/"false"); JSON clients that omit it decode as "" and leave
|
||
// the column untouched.
|
||
if req.KeepScreenAwake != "" {
|
||
if req.KeepScreenAwake != "true" && req.KeepScreenAwake != "false" {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_screen_awake must be 'true' or 'false'"})
|
||
}
|
||
err := h.db.UpdateUserKeepScreenAwake(c.Request().Context(), database.UpdateUserKeepScreenAwakeParams{
|
||
ID: targetUserUUID,
|
||
KeepScreenAwake: req.KeepScreenAwake == "true",
|
||
})
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
}
|
||
}
|
||
|
||
// Browser path (htmx modal + profile page): refresh so every rendered
|
||
// value (timezone select, table rows, header) reflects the save and the
|
||
// modal closes. A bare JSON body swapped into #profile-result read as
|
||
// "nothing happened" — the save DID land, invisibly.
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
c.Response().Header().Set("HX-Refresh", "true")
|
||
return c.HTML(http.StatusOK, `<div class="text-green-500">Profile updated.</div>`)
|
||
}
|
||
|
||
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
|
||
}
|
||
|
||
// 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()})
|
||
}
|
||
return c.JSON(http.StatusOK, users)
|
||
}
|
||
|
||
// GetPasswordRules handles GET /api/auth/password-rules (public — the
|
||
// register page is pre-auth). Exposes the ACTIVE complexity configuration
|
||
// so the UI requirement checkers mirror the real rules instead of
|
||
// hardcoding defaults. The server remains the enforcer.
|
||
func (h *AuthHandler) GetPasswordRules(c *echo.Context) error {
|
||
minLength, upper, lower, number, special := 8, true, true, true, true
|
||
if h.settings != nil {
|
||
r := h.settings.PasswordRules()
|
||
minLength, upper, lower, number, special =
|
||
r.MinLength, r.Upper, r.Lower, r.Number, r.Special
|
||
}
|
||
if minLength < 1 {
|
||
minLength = 8
|
||
}
|
||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||
"min_length": minLength,
|
||
"require_upper": upper,
|
||
"require_lower": lower,
|
||
"require_number": number,
|
||
"require_special": special,
|
||
})
|
||
}
|
||
|
||
// AdminCreateUser handles POST /api/auth/users (AdminMiddleware-gated).
|
||
// The admin's counterpart to public registration: creating an account for
|
||
// someone else must work even when self-service registration is disabled
|
||
// (the family/self-hosted default). The requester's admin role comes from
|
||
// the route group, and the new account NEVER auto-logins — no tokens are
|
||
// issued here, unlike Register.
|
||
func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
|
||
var req RegisterRequest
|
||
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()})
|
||
}
|
||
|
||
req.Username = strings.TrimSpace(req.Username)
|
||
if req.Username == "" {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
|
||
}
|
||
|
||
role := strings.ToLower(req.Role)
|
||
if role == "" {
|
||
role = "user"
|
||
}
|
||
if role != "user" && role != "admin" {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
|
||
}
|
||
|
||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
|
||
}
|
||
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
|
||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
|
||
}
|
||
|
||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||
}
|
||
|
||
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},
|
||
Role: role,
|
||
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
|
||
})
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
}
|
||
|
||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
|
||
}
|
||
|
||
// The admin UI posts this via HTMX: refresh the page so the new user
|
||
// appears in the table, and confirm inline.
|
||
if c.Request().Header.Get("HX-Request") == "true" {
|
||
c.Response().Header().Set("HX-Refresh", "true")
|
||
return c.HTML(http.StatusCreated, `<div class="text-green-500">User "`+template.HTMLEscapeString(user.Username)+`" created.</div>`)
|
||
}
|
||
|
||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||
"id": uuid.UUID(user.ID.Bytes).String(),
|
||
"email": user.Email,
|
||
"username": user.Username,
|
||
"role": user.Role,
|
||
"timezone": user.Timezone,
|
||
"created_at": user.CreatedAt,
|
||
})
|
||
}
|
||
|
||
type UpdateThemeRequest struct {
|
||
Theme string `json:"theme" validate:"required"`
|
||
}
|
||
|
||
// UpdateTheme handles PUT /api/auth/theme
|
||
func (h *AuthHandler) UpdateTheme(c *echo.Context) error {
|
||
user := MustGetAuthenticatedUser(c)
|
||
|
||
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: user.ID,
|
||
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 UpdatePasswordRequest struct {
|
||
CurrentPassword string `json:"current_password" validate:"required"`
|
||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||
}
|
||
|
||
// 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 {
|
||
currentUser := MustGetAuthenticatedUser(c)
|
||
|
||
// 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: parsedUUID, Valid: true}
|
||
} else {
|
||
// Self-change mode
|
||
targetUserUUID = currentUser.ID
|
||
}
|
||
|
||
type PasswordRequest struct {
|
||
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||
ConfirmPassword string `json:"confirm_password" form:"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"})
|
||
}
|
||
if err := c.Validate(&req); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||
}
|
||
|
||
// 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 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"})
|
||
}
|
||
}
|
||
|
||
// 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
|
||
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: targetUserUUID,
|
||
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"})
|
||
}
|
||
|
||
// 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 URL param (admin mode) or use current user (self-deletion)
|
||
targetUserID := c.Param("id")
|
||
var targetUserUUID pgtype.UUID
|
||
|
||
if targetUserID != "" {
|
||
// Admin deletion mode
|
||
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: parsedUUID, Valid: true}
|
||
} else {
|
||
// Self-deletion mode
|
||
targetUserUUID = currentUser.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 := ""
|
||
userFound := false
|
||
|
||
for _, user := range users {
|
||
if user.Role == "admin" {
|
||
adminCount++
|
||
}
|
||
// Find target user details
|
||
if user.ID.Bytes == targetUserUUID.Bytes {
|
||
targetUserRole = user.Role
|
||
userFound = true
|
||
// Can't break here - still need to count all admins for last admin check
|
||
}
|
||
}
|
||
|
||
// Check if target user exists in the database
|
||
if !userFound {
|
||
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"})
|
||
}
|
||
|
||
// 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"})
|
||
}
|
||
|
||
// If deleting an admin, reassign their libraries and media items to another admin
|
||
// before deletion to prevent ON DELETE SET NULL from orphaning ownership
|
||
if targetUserRole == "admin" {
|
||
successor, err := h.db.GetFirstAdminExclude(c.Request().Context(), targetUserUUID)
|
||
if err == nil {
|
||
_ = h.db.ReassignLibraries(c.Request().Context(), database.ReassignLibrariesParams{
|
||
CreatedByAdminID: targetUserUUID,
|
||
CreatedByAdminID_2: successor.ID,
|
||
})
|
||
_ = h.db.ReassignMediaItems(c.Request().Context(), database.ReassignMediaItemsParams{
|
||
AddedByAdminID: targetUserUUID,
|
||
AddedByAdminID_2: successor.ID,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Delete user (this will cascade to delete all related data)
|
||
err = h.db.DeleteUser(c.Request().Context(), targetUserUUID)
|
||
if err != nil {
|
||
if errors.Is(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()})
|
||
}
|
||
|
||
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||
setupstatus.Invalidate()
|
||
|
||
// Create success message based on context
|
||
var message string
|
||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||
message = "user account deleted successfully"
|
||
} else {
|
||
message = "account deleted successfully"
|
||
}
|
||
|
||
return c.JSON(http.StatusOK, map[string]string{"message": message})
|
||
}
|
||
|
||
type UpdateUserMaxDevicesRequest struct {
|
||
MaxDevices int32 `json:"max_devices" validate:"required,min=1,max=100"`
|
||
}
|
||
|
||
// UpdateUserMaxDevices handles PUT /api/auth/users/:id/max-devices (admin only)
|
||
func (h *AuthHandler) UpdateUserMaxDevices(c *echo.Context) error {
|
||
userID := c.Param("id")
|
||
if userID == "" {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"})
|
||
}
|
||
|
||
var req UpdateUserMaxDevicesRequest
|
||
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()})
|
||
}
|
||
|
||
userUUID, err := uuid.Parse(userID)
|
||
if err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||
}
|
||
|
||
user, err := h.db.UpdateUserMaxDevices(c.Request().Context(), database.UpdateUserMaxDevicesParams{
|
||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||
MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true},
|
||
})
|
||
if err != nil {
|
||
if errors.Is(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()})
|
||
}
|
||
|
||
_ = user // Suppress unused variable warning when sqlc returns user
|
||
return c.JSON(http.StatusOK, map[string]string{"message": "max devices updated"})
|
||
}
|
||
|
||
// 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{
|
||
"jti": uuid.New().String(),
|
||
"user_id": userID,
|
||
"user_role": userRole,
|
||
"user_email": userEmail,
|
||
"user_username": userUsername,
|
||
"exp": time.Now().Add(h.sessionDuration()).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")
|
||
}
|
||
|
||
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||
// Registration runs BEFORE the JWT exists — the request context
|
||
// carries no identity, and the collections RLS insert policy
|
||
// (2026-10-31) would reject the five system-collection seeds.
|
||
// Declare the new user as the actor: owner == actor, policy passes.
|
||
ctx = rls.WithUser(ctx, userID)
|
||
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},
|
||
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||
}
|
||
|
||
for _, col := range defaultCollections {
|
||
// Idempotent by the STABLE identity (user + query_type): a
|
||
// preserved or renamed row heals to canonical metadata instead of
|
||
// duplicating (the naming-drift bug). Insert only when absent.
|
||
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||
UserID: userID,
|
||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||
})
|
||
if err == nil {
|
||
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||
ID: existing.ID,
|
||
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},
|
||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||
}); uerr != nil {
|
||
return uerr
|
||
}
|
||
continue
|
||
}
|
||
_, 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
|
||
}
|