Files
bookhoard/API_CONSOLIDATION_PLAN.md
T
john-okeefe ff25454901 docs: update API documentation for consolidated user management
Add documentation for delete_user and reset_user_password endpoints.
Update update_profile to reflect consolidated endpoint. Remove obsolete
documentation for individual update operations. Add profile guide for
end-users. Update API_CONSOLIDATION_PLAN.md with implementation status.
2026-02-22 01:59:52 -05:00

99 KiB

API Consolidation Implementation Plan (REVISED - UPDATED)

Goal: Consolidate fragmented user profile endpoints using simple handler composition (copy/paste existing logic)
Principle: Surgical, line-by-line changes with verification at each step
Rule #1: NO OVER-ENGINEERING - Keep It Simple, Copy/Paste Existing Logic
Strategy: Combine handlers using URL param pattern - same handler for self-edit and admin modes


🎯 FINAL API DESIGN (Reference)

Self-Service:
  GET    /api/auth/profile          - Get own profile (UNCHANGED)
  PUT    /api/auth/profile          - Update own profile {username, email, first_name, last_name, theme}
  PUT    /api/auth/password         - Change own password {current_password, new_password, confirm_password}
  PUT    /api/auth/theme            - Quick theme toggle {theme} (UNCHANGED)
  DELETE /api/auth/profile          - Delete your own account

Admin:
  GET    /api/auth/users            - List all users (UNCHANGED)
  PUT    /api/auth/profile/:id      - Update user {username, email, first_name, last_name, theme, role?}
  PUT    /api/auth/password/:id     - Reset user password {new_password, confirm_password}
  PUT    /api/auth/users/:id/max-devices - Update device limit (UNCHANGED)
  DELETE /api/auth/profile/:id      - Delete user (with last-admin check)

Remove (routes only, handlers kept for reuse):
  PUT /api/auth/username            - Route removed, handler kept for UpdateUser to call
  PUT /api/auth/email               - Route removed, handler kept for UpdateUser to call

🧪 Available Test Helpers (From cmd/server/tests/test_helpers.go)

Setup Helpers:

  • setupTestServer(t) - Creates complete test server with automatic cleanup via t.Cleanup()
  • setupDeviceTest(t) - Creates test environment for device tests (includes server + user + token)

User Creation:

  • createTestUserOnce(t, db) - Creates test user with deterministic credentials (returns UserTestData)
  • createRegularUserOnce(t, db) - Creates a regular (non-admin) test user with unique credentials (returns UserTestData)
    • Email: unique (e.g., "regularuser-{uuid}@example.com")
    • Username: unique (e.g., "regularuser-{uuid}")
    • Password: "Test@Pass123!"
    • Role: "user"
    • Use this for testing admin operations on regular users
  • getTestUserID(t, db) - Gets or creates test user UUID (admin role)
  • getRegularUserID(t, db) - Gets or creates regular user UUID ("user" role)

Type Conversion:

  • uuidToPGType(u uuid.UUID) pgtype.UUID - Converts uuid.UUID to pgtype.UUID for database operations

Authentication:

  • loginTestUser(t, ts, db) - Logs in test user, returns JWT token
  • loginUserWithCredentials(t, ts, email, password) - Login with custom credentials
  • loginRegularUser(t, ts, db) - Login as regular user (role="user")

Database Verification:

  • verifyUserField(t, db, userID, field, expected) - Verify user fields in DB (supports: email, first_name, last_name, username, theme)
  • verifyDeviceCreated(t, db, deviceID, name, type, identifier) - Verify device exists
  • verifyDeviceDeleted(t, db, deviceID) - Verify device deleted
  • verifyMediaItemInDB(t, db, mediaID) - Verify media item exists
  • verifyMediaItemDeleted(t, db, mediaID) - Verify media item deleted

Library Creation:

  • createTestLibraryWithFolder(t, ts, token, name, withFolder) - Creates library with optional folder

Media Item Creation:

  • createTestMediaItemID(t, ts) - Creates test media item and returns its ID

Other:

  • runConcurrent(t, maxConcurrent, fns) - Runs functions concurrently for testing

📋 PHASE OVERVIEW

  1. Phase 1: Database Query Addition
  2. Phase 2: Extend UpdateProfile, Create Admin Handlers (copy/paste logic)
  3. Phase 3: Rename DeleteAccount to DeleteUser (use URL param)
  4. Phase 4: Router Updates (remove/add routes)
  5. Phase 5: Frontend (Profile page, Header updates, Login updates, Profile modal, Admin users list - 14 steps)
  6. Phase 6: Test Overhaul (add helpers, remove obsolete, add new - 7 steps)
  7. Phase 7: Bruno YAML Tests
  8. Phase 8: Documentation

PHASE 1: Database Query Addition

Goal: Add UpdateUserRole query
Files: 1 file, 5 lines added
Risk: Lowest (no schema changes)

Step 1.1: Add UpdateUserRole Query

File: internal/database/queries/queries.sql

Location: After line 334 (after UpdatePassword query)

Action: Add the following lines:

-- name: UpdateUserRole :one
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;

Verification:

# Read back the added lines
sed -n '335,340p' internal/database/queries/queries.sql

Expected output:

-- name: UpdateUserRole :one
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;

Step 1.2: Regenerate Database Code

Command:

make sqlc

Verification:

# Check that UpdateUserRole was added to queries.sql.go
grep -n "UpdateUserRole" internal/database/queries.sql.go

# Check that UpdateUserRole was added to querier.go interface
grep -n "UpdateUserRole" internal/database/querier.go

# Verify the query function signature
grep -A 5 "func (q \*Queries) UpdateUserRole" internal/database/queries.sql.go | head -1

Expected:

  • Line in queries.sql.go: func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) {
  • Line in querier.go: UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)

Commit:

git add internal/database/
git commit -m "feat(db): add UpdateUserRole query for admin user management"

PHASE 2: Extend UpdateProfile and Create Admin Handlers

Goal: Extend UpdateProfile for all fields, create UpdateUser and ResetUserPassword handlers Files: 1 file, ~230 lines modified Risk: Medium (modifying existing UpdateProfile handler, adding new handlers) Approach: Copy/paste conflict checking logic (NO function calls, keep it simple)

Step 2.1: Read Existing Handlers to Copy/Paste Logic

Read UpdateUsername to understand conflict checking pattern:

sed -n '538,565p' internal/handlers/auth.go

Read UpdateEmail to understand conflict checking pattern:

sed -n '572,599p' internal/handlers/auth.go

Read UpdatePassword to understand password hashing logic:

sed -n '608,654p' internal/handlers/auth.go

NOTE: We will copy/paste the logic from these handlers, NOT call them as functions. This keeps handlers simple and avoids over-engineering.

Step 2.2: Extend UpdateProfileRequest Struct

Location: Lines 76-79 in internal/handlers/auth.go

Current code:

type UpdateProfileRequest struct {
	FirstName string `json:"first_name,omitempty"`
	LastName  string `json:"last_name,omitempty"`
}

Replace with:

type UpdateProfileRequest 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"`
}

Step 2.3: Create AdminUpdateUserRequest Struct

Location: After UpdateProfileRequest struct in internal/handlers/auth.go

Add after line 79:

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"`
}

Step 2.4: Replace UpdateProfile Handler (Combined Self + Admin)

Location: Lines 452-470 in internal/handlers/auth.go

Read current implementation:

sed -n '452,470p' internal/handlers/auth.go

Current code:

// UpdateProfile handles PUT /api/auth/profile
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
	user := MustGetAuthenticatedUser(c)

	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:        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()})
	}

	return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
}

Replace with:

// 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: [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"})
	}
	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()})
		}
	}

	// 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"})
}

Step 2.5: Extend UpdatePassword Handler (Combined Self + Admin)

Location: Find the existing UpdatePassword handler in internal/handlers/auth.go

Find existing handler:

grep -n "func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go

Modify to handle both self-change (requires current password) and admin reset (no current 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 {
	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: [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"})
	}
	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"})
}

Verification:

# Compile check
go build ./internal/handlers

# Verify handlers exist
grep -n "func (h \*AuthHandler) UpdateProfile\|func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go

Commit:

git add internal/handlers/auth.go
git commit -m "feat(auth): combine self-edit and admin handlers

- UpdateProfile now handles both self-edit and admin edit (via URL param)
- UpdatePassword now handles both self-change and admin reset (via URL param)
- Single source of truth for profile and password update logic
- Role field only allowed in admin mode (URL param present)
- Admin mode validates admin role before allowing operations
- Last-admin protection for role demotion
- Removes code duplication between self-service and admin handlers
"

PHASE 3: Rename DeleteAccount to DeleteUser

Goal: Rename handler, modify to use URL param instead of query param (already combined pattern) Files: 1 file, ~10 lines modified Risk: Low (simple rename and param change)

Note: This handler already follows the combined pattern - no URL param = self-delete, with URL param = admin delete.

Step 3.1: Rename DeleteAccount to DeleteUser

Location: Lines 658-731 in internal/handlers/auth.go

Read current implementation:

sed -n '658,731p' internal/handlers/auth.go

Step 3.1.1: Change function signature (line 658)

From:

func (h *AuthHandler) DeleteAccount(c echo.Context) error {

To:

func (h *AuthHandler) DeleteUser(c echo.Context) error {

Step 3.2: Modify Query Param to URL Param (lines 661-678)

Location: Lines 658-731 in internal/handlers/auth.go

Read current implementation:

sed -n '658,731p' internal/handlers/auth.go

Current code:

	// Get target user ID from query parameter (for admin override) or use current user
	targetUserID := c.QueryParam("user_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
		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 {
		targetUserUUID = currentUser.ID
	}

Replace with:

	// 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: [16]byte(parsedUUID), Valid: true}
	} else {
		// Self-deletion mode
		targetUserUUID = currentUser.ID
	}

Rest of function (lines 680-730) remains unchanged

Verification:

# Compile check
go build ./internal/handlers

# Verify function was renamed
grep -n "func (h \*AuthHandler) DeleteUser" internal/handlers/auth.go

Commit:

git add internal/handlers/auth.go
git commit -m "refactor(auth): rename DeleteAccount to DeleteUser

- Rename DeleteAccount handler to DeleteUser
- Modify DeleteUser to use URL param (:id) instead of query param (?user_id=)
- Keep all last-admin protection logic
- Self-deletion: DELETE /api/auth/profile (no ID)
- Admin deletion: DELETE /api/auth/profile/:id (with user ID)
"

PHASE 4: Router Updates

Goal: Register new routes, remove obsolete routes
Files: 1 file, ~8 lines removed, ~8 lines added
Risk: Low (route registration only)

Step 4.1: Read Current Router

cat internal/router/auth.go

Step 4.2: Remove Obsolete Routes

Delete line 24:

protected.PUT("/auth/profile", cfg.AuthHandler.UpdateProfile)

Delete lines 34-35:

authGroup.PUT("/email", cfg.AuthHandler.UpdateEmail)
authGroup.PUT("/username", cfg.AuthHandler.UpdateUsername)

Step 4.3: Add Consolidated Routes

Add after line 37 (after theme route):

	// Profile management (combined handlers - self-edit)
	authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
	authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)

Add after line 42 (after max-devices route):

	// Admin routes (same handlers, with URL param for target user)
	admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
	admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
	admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)

Step 4.4: Verify Router File

Complete file should look like:

package router

import (
	"bookhoard/internal/handlers"

	"github.com/labstack/echo/v4"
)

func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
	e := cfg.Echo

	// Auth routes (no auth required, but rate limited)
	e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register))
	e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login))

	// JWT middleware for protected routes
	jwtMiddleware := createJWTMiddleware(cfg)

	// Create protected route group
	protected := e.Group("/api", jwtMiddleware)

	// Protected auth routes
	protected.GET("/auth/profile", cfg.AuthHandler.GetProfile)

	// Refresh token endpoint (no authentication required - uses refresh token from body)
	e.POST("/api/auth/refresh", cfg.AuthHandler.RefreshAccessToken)

	// Logout endpoint (optional authentication - can revoke tokens if provided)
	e.POST("/api/auth/logout", cfg.AuthHandler.Logout)

	// Auth update routes
	authGroup := e.Group("/api/auth", createJWTMiddleware(cfg))
	authGroup.PUT("/password", cfg.AuthHandler.UpdatePassword)
	authGroup.PUT("/theme", cfg.AuthHandler.UpdateTheme)

	// Profile management (combined handlers - self-edit)
	authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
	authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)

	// Admin-only routes (same handlers with URL param)
	admin := protected.Group("/auth", handlers.AdminMiddleware)
	admin.GET("/users", cfg.AuthHandler.ListUsers)
	admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
	admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
	admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
	admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
}

Verification:

# Compile check
go build ./internal/router

# Count routes (should be 10)
grep -E "\.(GET|POST|PUT|DELETE)\(" internal/router/auth.go | wc -l

# Verify handlers are reused (same handler, different routes)
grep "UpdateProfile\|UpdatePassword\|DeleteUser" internal/router/auth.go

Expected: UpdateProfile, UpdatePassword, DeleteUser each appear twice (self + admin routes)

Commit:

git add internal/router/auth.go
git commit -m "refactor(router): consolidate auth routes with combined handlers

- Remove PUT /api/auth/email (merged into /profile)
- Remove PUT /api/auth/username (merged into /profile)
- Add PUT /api/auth/profile (self-update)
- Add DELETE /api/auth/profile (self-deletion)
- Add PUT /api/auth/profile/:id (admin update - same handler)
- Add PUT /api/auth/password/:id (admin reset - same handler)
- Add DELETE /api/auth/profile/:id (admin delete - same handler)
- Keep PUT /api/auth/theme unchanged (header quick-toggle)
- Combined handlers reduce code duplication
"

PHASE 5: Frontend Routes & Templates

Goal: Create /profile page, create /admin/users page with modal editing, update header links Files: 7 files (3 new: profile.templ, profile_modal.templ, admin_users.templ; 4 modified) Risk: Medium (template changes, HTMX modal pattern)

Step 5.1: Extend templates.User Struct

File: templates/types.go

Current User struct:

type User struct {
    ID       string
    Username string
    Email    string
    Role     string
    Theme    string
}

Replace with:

type User struct {
    ID        string
    Username  string
    Email     string
    Role      string
    Theme     string
    FirstName string
    LastName  string
    CreatedAt time.Time
}

Add import at top of file (if not present):

import "time"

Step 5.2: Create Profile Template

File: templates/profile.templ (NEW FILE)

Create with following content:

package templates

templ Profile(user User) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Profile Settings - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/toast.js"></script>
        <script src="/static/header.js"></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "/profile")

        <main class="max-w-4xl mx-auto px-4 py-8">
            <div class="mb-8">
                <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1>
                <p style="color: var(--text-secondary)">Manage your account information and preferences</p>
            </div>

            <div class="space-y-8">
                <!-- Account Information & Password - uses reusable ProfileForm -->
                <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
                    @ProfileForm(user, "/api/auth/profile", true, false, false)
                </div>

                <!-- Danger Zone -->
                <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
                    <h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Danger Zone</h3>
                    <p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p>
                    
                    <button onclick="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
                        Remove My Account
                    </button>
                </div>
            </div>
        </main>

        <script>
            function confirmDeleteAccount() {
                if (confirm("Are you sure? All preferences and devices will be deleted. This action cannot be undone.")) {
                    fetch('/api/auth/profile', {
                        method: 'DELETE',
                        headers: {
                            'Authorization': 'Bearer ' + localStorage.getItem('token')
                        }
                    })
                    .then(response => response.json())
                    .then(data => {
                        localStorage.removeItem('token');
                        localStorage.removeItem('user');
                        window.location.href = '/login?deleted=true';
                    })
                    .catch(error => {
                        alert('Failed to delete account: ' + error.message);
                    });
                }
            }

            document.addEventListener('DOMContentLoaded', function() {
                loadTheme();
            });
        </script>
    </body>
    </html>
}

Step 5.3: Update Header Template

File: templates/header.templ

Find the settings link (around line 117-118):

Current:

<a href="/settings" class="...">Settings</a>

Change to:

<a href="/profile" class="...">Profile</a>

Regenerate template:

templ generate

Step 5.4: Update Admin Template

File: templates/admin.templ

Find the profile link (around line 39-40):

Current:

<a href="/admin/profile">Profile Settings</a>

Change to:

<a href="/profile">Profile Settings</a>

Regenerate template:

templ generate

Step 5.5: Delete Admin Profile Template

File: templates/admin_profile.templ

Delete the entire file:

rm templates/admin_profile.templ

Regenerate templates:

templ generate

Step 5.6: Add Frontend Route for Profile Page

File: internal/router/frontend.go

Add after line 385 (in the frontendProtected routes section):

// Profile page (all users)
frontendProtected.GET("/profile", func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return renderErrorPage(c, "Error loading user", "user_load_error")
    }

    var buf bytes.Buffer
    err = templates.Profile(user).Render(c.Request().Context(), &buf)
    if err != nil {
        return err
    }
    return c.HTML(http.StatusOK, buf.String())
})

Step 5.7: Update Login Template

File: templates/login.templ

Line 3: Change signature from:

templ Login(sessionExpired bool) {

To:

templ Login(sessionExpired bool, deleted bool) {

After the sessionExpired block (around line 40), add:

if deleted {
    <div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
        <p style="color: var(--text-primary);">
            Your account has been deleted successfully.
        </p>
    </div>
}

Regenerate template:

templ generate

Step 5.8: Update Login Frontend Route

File: internal/router/frontend.go

Find the login route (around line 53):

grep -n "GET.*login" internal/router/frontend.go

Modify to read deleted query parameter:

Current:

e.GET("/login", func(c echo.Context) error {
    var buf bytes.Buffer
    sessionExpired := c.QueryParam("session") == "expired"
    err := templates.Login(sessionExpired).Render(c.Request().Context(), &buf)

Change to:

e.GET("/login", func(c echo.Context) error {
    var buf bytes.Buffer
    sessionExpired := c.QueryParam("session") == "expired"
    deleted := c.QueryParam("deleted") == "true"
    err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)

Verification:

# Compile check
go build ./internal/router

Commit (for Steps 5.1-5.7):

git add templates/ internal/router/frontend.go
git commit -m "feat(frontend): add universal /profile page, remove admin-only profile

- Create templates/profile.templ for all users
- Update header link: /settings → /profile
- Update admin template: /admin/profile → /profile
- Remove templates/admin_profile.templ (no longer needed)
- Add GET /profile route in frontend.go
- Add account deletion with confirmation
- Update Login template to show account deleted message
- Update login route to pass deleted parameter
"

Step 5.9: Create Reusable Profile Form Component

Goal: Create a reusable profile form component that can be used both for self-editing (Profile page) and admin editing other users (ProfileModal)

File: templates/profile_form.templ (NEW FILE)

Create with following content:

package templates

// ProfileForm is a reusable form component for editing user profiles
// - actionURL: The endpoint to submit the form to (e.g., "/api/auth/profile" or "/api/auth/profile/{id}")
// - requireCurrentPassword: If true, show current password field (self-edit); if false, admin reset mode
// - showRoleField: If true, show role dropdown (admin editing other users)
// - showCancelButton: If true, show cancel button that closes modal (modal context); if false, no cancel button (page context)
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
    <form hx-put={ actionURL }
          hx-target="#profile-result"
          hx-swap="innerHTML"
          hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
          class="space-y-6">

        <!-- Account Information -->
        <div>
            <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Account Information</h3>
            
            <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                <div>
                    <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
                    <input type="text" name="username" value={user.Username}
                           class="w-full px-3 py-2 border rounded"
                           style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                </div>

                <div>
                    <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email Address</label>
                    <input type="email" name="email" value={user.Email}
                           class="w-full px-3 py-2 border rounded"
                           style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                </div>

                <div>
                    <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
                    <input type="text" name="first_name" value={user.FirstName} placeholder="Optional"
                           class="w-full px-3 py-2 border rounded"
                           style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                </div>

                <div>
                    <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
                    <input type="text" name="last_name" value={user.LastName} placeholder="Optional"
                           class="w-full px-3 py-2 border rounded"
                           style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                </div>

                <div>
                    <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Theme</label>
                    <select name="theme"
                            class="w-full px-3 py-2 border rounded"
                            style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        <option value="tokyo-night" selected?={user.Theme == "tokyo-night"}>Tokyo Night</option>
                        <option value="dracula" selected?={user.Theme == "dracula"}>Dracula</option>
                        <option value="nord" selected?={user.Theme == "nord"}>Nord</option>
                        <option value="solarized-dark" selected?={user.Theme == "solarized-dark"}>Solarized Dark</option>
                        <option value="monokai" selected?={user.Theme == "monokai"}>Monokai</option>
                        <option value="one-dark-pro" selected?={user.Theme == "one-dark-pro"}>One Dark Pro</option>
                        <option value="material-dark" selected?={user.Theme == "material-dark"}>Material Dark</option>
                        <option value="wood-light" selected?={user.Theme == "wood-light"}>Wood Light</option>
                        <option value="wood-dark" selected?={user.Theme == "wood-dark"}>Wood Dark</option>
                        <option value="wood-mahogany" selected?={user.Theme == "wood-mahogany"}>Wood Mahogany</option>
                    </select>
                </div>

                if showRoleField {
                    <div>
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Role</label>
                        <select name="role"
                                class="w-full px-3 py-2 border rounded"
                                style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                            <option value="user" selected?={user.Role == "user"}>User</option>
                            <option value="admin" selected?={user.Role == "admin"}>Admin</option>
                        </select>
                    </div>
                }
            </div>
        </div>

        <!-- Change Password Section -->
        <div>
            <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Change Password</h3>
            
            if requireCurrentPassword {
                <!-- Self-edit: require current password -->
                <div class="space-y-4 max-w-md">
                    <div>
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
                        <input type="password" name="current_password"
                               class="w-full px-3 py-2 border rounded"
                               style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                               required>
                    </div>

                    <div>
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
                        <input type="password" name="new_password"
                               class="w-full px-3 py-2 border rounded"
                               style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                               minlength="6">
                    </div>

                    <div>
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
                        <input type="password" name="confirm_password"
                               class="w-full px-3 py-2 border rounded"
                               style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                               minlength="6">
                    </div>

                    <button type="button" 
                            hx-put="/api/auth/password"
                            hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
                            hx-target="#password-result"
                            hx-swap="innerHTML"
                            hx-include="closest form"
                            class="px-4 py-2 rounded"
                            style="background-color: var(--accent); color: white;">
                        Update Password
                    </button>
                    <div id="password-result" class="mt-2"></div>
                </div>
            } else {
                <!-- Admin edit: no current password required -->
                <p class="text-sm italic mb-4" style="color: var(--text-secondary);">
                    As an admin, you can change this user's password without knowing their current password.
                </p>

                <div class="space-y-4 max-w-md">
                    <div>
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
                        <input type="password" name="new_password"
                               class="w-full px-3 py-2 border rounded"
                               style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                               minlength="6">
                    </div>

                    <div>
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
                        <input type="password" name="confirm_password"
                               class="w-full px-3 py-2 border rounded"
                               style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                               minlength="6">
                    </div>

                    <button type="button"
                            hx-put={ "/api/auth/password/" + user.ID }
                            hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
                            hx-target="#password-result"
                            hx-swap="innerHTML"
                            hx-include="closest form"
                            class="px-4 py-2 rounded"
                            style="background-color: var(--accent); color: white;">
                        Reset Password
                    </button>
                    <div id="password-result" class="mt-2"></div>
                </div>
            }
        </div>

        <!-- Actions -->
        <div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);">
            if showCancelButton {
                <button type="button"
                        onclick="closeProfileModal()"
                        class="px-4 py-2 rounded"
                        style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);">
                    Cancel
                </button>
            }
            <button type="submit" 
                    class="px-4 py-2 rounded"
                    style="background-color: var(--accent); color: white;">
                Save Changes
            </button>
        </div>

        <div id="profile-result" class="mt-2"></div>
    </form>
}

Step 5.10: Create Profile Modal Component

Goal: Modal wrapper for admin editing other users, using the reusable ProfileForm

File: templates/profile_modal.templ (NEW FILE)

Create with following content:

package templates

// ProfileModal is used by admins to edit other users
// Always uses: actionURL = "/api/auth/profile/{user.ID}", requireCurrentPassword = false, showRoleField = true, showCancelButton = true
templ ProfileModal(user User) {
    <div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" style="background-color: rgba(0,0,0,0.5);">
        <div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);">
            <!-- Modal Header -->
            <div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
                <div>
                    <h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2>
                    <p class="text-sm mt-1" style="color: var(--text-secondary);">
                        { user.Username } ({ user.Email })
                    </p>
                </div>
                <button
                    onclick="closeProfileModal()"
                    class="text-2xl"
                    style="color: var(--text-secondary);"
                >
                    &times;
                </button>
            </div>

            <!-- Modal Body - uses reusable form -->
            <div class="p-6">
                @ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
            </div>
        </div>
    </div>

    <script>
        function closeProfileModal() {
            const modal = document.getElementById('profile-modal');
            if (modal) {
                modal.remove();
            }
        }

        // Close modal on escape key
        document.addEventListener('keydown', function(e) {
            if (e.key === 'Escape') {
                closeProfileModal();
            }
        });
    </script>
}

Generate template:

templ generate

Step 5.11: Add Profile Modal Route

File: internal/router/frontend.go

Add after the profile route (around line 400):

// Admin: Get profile modal for editing user
frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return renderErrorPage(c, "Error loading user", "user_load_error")
    }

    // Get target user ID from URL
    targetUserID := c.Param("id")
    parsedUUID, err := uuid.Parse(targetUserID)
    if err != nil {
        return c.HTML(http.StatusBadRequest, "<div>Invalid user ID</div>")
    }

    // Fetch target user
    targetUser, err := cfg.Queries.GetUser(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true})
    if err != nil {
        return c.HTML(http.StatusNotFound, "<div>User not found</div>")
    }

    // Convert to template user
    templateUser := toTemplateUser(targetUser)

    // Render modal (admin editing other user)
    var buf bytes.Buffer
    err = templates.ProfileModal(templateUser).Render(c.Request().Context(), &buf)
    if err != nil {
        return err
    }
    return c.HTML(http.StatusOK, buf.String())
}))

Note: This adds a new admin-only endpoint that returns the profile modal HTML for any user. Server validates admin before returning modal.

Step 5.12: Create Admin Users List Page

Goal: Admin page to view and manage all users with role toggles, delete actions, and modal profile editing

File: templates/admin_users.templ (NEW FILE)

Create with following content:

package templates

templ AdminUsers(users []User, adminCount int, currentUserID string) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Users - Bookhoard Admin</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/toast.js"></script>
        <script src="/static/header.js"></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-tokyo-night">
        @Header(User{Username: "Admin", Role: "admin"}, "/admin/users")

        <!-- Modal Container (populated by HTMX) -->
        <div id="modal-container"></div>

        <main class="max-w-7xl mx-auto px-4 py-8">
            <div class="mb-8">
                <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
                <p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
            </div>

            <!-- Users Table -->
            <div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
                <table class="w-full">
                    <thead style="background-color: var(--bg-primary)">
                        <tr>
                            <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th>
                            <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th>
                            <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th>
                            <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th>
                            <th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
                        </tr>
                    </thead>
                    <tbody class="divide-y" style="divide-color: var(--border)">
                        @for _, user := range users {
                            <tr id="user-{user.ID}" class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
                                <!-- Username -->
                                <td class="px-6 py-4 whitespace-nowrap">
                                    <div class="flex items-center">
                                        <div>
                                            <div class="text-sm font-medium" style="color: var(--text-primary)">{user.Username}</div>
                                            if user.ID == currentUserID {
                                                <span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
                                            }
                                        </div>
                                    </div>
                                </td>

                                <!-- Email -->
                                <td class="px-6 py-4 whitespace-nowrap">
                                    <div class="text-sm" style="color: var(--text-primary)">{user.Email}</div>
                                </td>

                                <!-- Role Toggle (with last-admin protection) -->
                                <td class="px-6 py-4 whitespace-nowrap">
                                    @if user.Role == "admin" && adminCount == 1 {
                                        <!-- Last admin - disabled -->
                                        <div class="relative">
                                            <select
                                                disabled
                                                class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50"
                                                style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
                                                title="Cannot demote the last admin"
                                            >
                                                <option value="user">User</option>
                                                <option value="admin" selected>Admin</option>
                                            </select>
                                            <span class="ml-2" title="Cannot demote the last admin">⚠️</span>
                                        </div>
                                    } else {
                                        <!-- Normal role toggle - HTMX with page reload -->
                                        <form hx-put="/api/auth/profile/{user.ID}"
                                              hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
                                              hx-on::after-request="if(event.detail.successful) window.location.reload()">
                                            <select
                                                name="role"
                                                onchange="this.form.requestSubmit()"
                                                class="text-sm rounded px-2 py-1 cursor-pointer"
                                                style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
                                            >
                                                <option value="user" selected?={user.Role == "user"}>User</option>
                                                <option value="admin" selected?={user.Role == "admin"}>Admin</option>
                                            </select>
                                        </form>
                                    }
                                </td>

                                <!-- Created Date -->
                                <td class="px-6 py-4 whitespace-nowrap text-sm" style="color: var(--text-secondary)">
                                    {user.CreatedAt.Format("2006-01-02")}
                                </td>

                                <!-- Actions -->
                                <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
                                    <div class="flex justify-end gap-2">
                                        <!-- Edit Profile Button - HTMX loads modal -->
                                        <button
                                            hx-get="/admin/users/{user.ID}/profile-modal"
                                            hx-target="#modal-container"
                                            hx-swap="innerHTML"
                                            class="px-3 py-1 rounded text-xs"
                                            style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
                                        >
                                            Edit
                                        </button>

                                        <!-- Delete Button (with last-admin protection) -->
                                        @if user.Role == "admin" && adminCount == 1 {
                                            <!-- Last admin - disabled -->
                                            <button
                                                disabled
                                                class="px-3 py-1 rounded text-xs cursor-not-allowed opacity-50"
                                                style="background-color: #dc2626; color: white;"
                                                title="Cannot delete the last admin"
                                            >
                                                Delete
                                            </button>
                                        } else {
                                            <!-- Normal delete button - HTMX with page reload -->
                                            <button
                                                hx-delete="/api/auth/profile/{user.ID}"
                                                hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
                                                hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
                                                hx-on::after-request="if(event.detail.successful) window.location.reload()"
                                                class="px-3 py-1 rounded text-xs"
                                                style="background-color: #dc2626; color: white;"
                                            >
                                                Delete
                                            </button>
                                        }
                                    </div>
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>

            <!-- Info Note -->
            <div class="mt-4 p-4 rounded border" style="background-color: var(--bg-secondary); border-color: var(--border);">
                <p class="text-sm" style="color: var(--text-secondary);">
                    <strong>⚠️ Note:</strong> The system must always have at least one admin user. The last admin cannot be demoted or deleted.
                </p>
            </div>
        </main>

        <script>
            document.addEventListener('DOMContentLoaded', function() {
                loadTheme();
            });
        </script>
    </body>
    </html>
}

Generate template:

templ generate

Step 5.13: Add Admin Users Route

File: internal/router/frontend.go

Add after the /profile route (around line 395):

// Admin users page
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return renderErrorPage(c, "Error loading user", "user_load_error")
    }

    // Fetch all users
    users, err := cfg.Queries.ListUsers(c.Request().Context())
    if err != nil {
        return renderErrorPage(c, "Error loading users", "users_load_error")
    }

    // Count admins for UI protection
    adminCount := 0
    for _, u := range users {
        if u.Role == "admin" {
            adminCount++
        }
    }

    // Get current user ID for "You" badge
    currentUserID := user.ID

    var buf bytes.Buffer
    err = templates.AdminUsers(toTemplateUsers(users), adminCount, currentUserID).Render(c.Request().Context(), &buf)
    if err != nil {
        return err
    }
    return c.HTML(http.StatusOK, buf.String())
}))

Note: You'll need helper functions to convert database users to template users. Add these in internal/router/frontend.go:

import "github.com/google/uuid"

// Convert single database user (from GetUser) to template user
func toTemplateUser(dbUser database.GetUserRow) templates.User {
    return templates.User{
        ID:        uuid.UUID(dbUser.ID.Bytes).String(),
        Username:  dbUser.Username,
        Email:     dbUser.Email,
        Role:      dbUser.Role,
        FirstName: dbUser.FirstName.String,
        LastName:  dbUser.LastName.String,
        Theme:     dbUser.Theme.String,
        CreatedAt: dbUser.CreatedAt.Time,
    }
}

// Convert single database user (from ListUsers) to template user
func toTemplateUserFromList(dbUser database.ListUsersRow) templates.User {
    return templates.User{
        ID:        uuid.UUID(dbUser.ID.Bytes).String(),
        Username:  dbUser.Username,
        Email:     dbUser.Email,
        Role:      dbUser.Role,
        FirstName: dbUser.FirstName.String,
        LastName:  dbUser.LastName.String,
        Theme:     dbUser.Theme.String,
        CreatedAt: dbUser.CreatedAt.Time,
    }
}

// Convert slice of database users (from ListUsers) to template users
func toTemplateUsers(dbUsers []database.ListUsersRow) []templates.User {
    users := make([]templates.User, len(dbUsers))
    for i, u := range dbUsers {
        users[i] = toTemplateUserFromList(u)
    }
    return users
}

File: templates/admin.templ

Add link to users page (around line 40):

Find this section:

<div>
    <a href="/profile">Profile Settings</a>
</div>

Add after it:

<div>
    <a href="/admin/users">Users</a>
</div>

Regenerate template:

templ generate

Verification:

# Compile check
go build ./internal/router

# Verify template exists
ls templates/admin_users.templ

# Verify route exists
grep "admin.GET.*users" internal/router/frontend.go

Commit:

git add templates/ internal/router/frontend.go
git commit -m "feat(frontend): add admin users management with modal profile editing

- Create templates/profile_form.templ (reusable form component)
- Create templates/profile_modal.templ (admin-only modal using ProfileForm)
- Add GET /admin/users/:id/profile-modal route (admin only, server validates)
- Create templates/admin_users.templ with user management table
- Add GET /admin/users route with SSR initial data fetch
- HTMX Edit button loads modal (no page navigation, secure)
- HTMX role toggles with page reload after success
- HTMX delete buttons with page reload after success
- Last-admin protection in UI (disabled states for role/delete)
- ProfileForm supports both self-edit and admin-edit modes via parameters
- Progressive enhancement - page reloads after successful actions
- Server-side security - admin-only access validated before modal sent
"

PHASE 6: Test Overhaul

Goal: Remove obsolete tests, add comprehensive tests
Files: 1 file, ~50 tests removed, ~40 tests added
Risk: Highest (test changes must be thorough)

Step 6.1: Backup Current Tests

git stash push -m "Backup tests before API consolidation overhaul"

Step 6.2: Read Current Test Structure

# Count tests in user_test.go
grep -c "t.Run(" cmd/server/tests/user_test.go

# List all test functions
grep "^func Test" cmd/server/tests/user_test.go

Step 6.3: Add New Test Helper Functions

File: cmd/server/tests/test_helpers.go

Add these helper functions to the file:

// uuidToPGType converts uuid.UUID to pgtype.UUID for database operations
func uuidToPGType(u uuid.UUID) pgtype.UUID {
	return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
}

// createRegularUserOnce creates a regular (non-admin) test user with unique credentials
// Use this for testing admin operations on regular users
func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
	ctx := context.Background()
	uniqueID := uuid.New().String()[:8]

	email := "regular-" + uniqueID + "@example.com"
	username := "regular-" + uniqueID
	password := "Test@Pass123!"

	// Hash the password
	passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	require.NoError(t, err, "Failed to hash password")

	// Create user with regular role
	pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
	newUser, err := db.CreateUser(ctx, database.CreateUserParams{
		ID:           pgUserID,
		Email:        email,
		Username:     username,
		PasswordHash: string(passwordHash),
		Role:         "user",
	})
	require.NoError(t, err, "Failed to create regular user")

	userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
	require.NoError(t, err, "Failed to convert user ID to UUID")

	return UserTestData{
		ID:       userUUID,
		Email:    email,
		Username: username,
		Password: password,
	}
}

Step 6.4: Remove Obsolete Tests

File: cmd/server/tests/user_test.go

Find and delete these test blocks:

  1. PUT /api/auth/email tests - Find lines:
grep -n "PUT /api/auth/email" cmd/server/tests/user_test.go

Delete all tests under this category (approximately lines 82-166)

  1. PUT /api/auth/username tests - Find lines:
grep -n "PUT /api/auth/username" cmd/server/tests/user_test.go

Delete all tests under this category (approximately lines 167-215)

Keep: GET profile, PUT profile (will modify), PUT password, PUT theme, DELETE account (will modify)

Step 6.5: Modify Existing Tests

Modify PUT /api/auth/profile test:

Find the test:

grep -n "PUT /api/auth/profile - Update profile" cmd/server/tests/user_test.go

Update the test body to include new fields:

	t.Run("PUT /api/auth/profile - Update profile with all fields", func(t *testing.T) {
		username := "updateduser"
		email := "updated@example.com"
		firstName := "Updated"
		lastName := "User"
		theme := "dracula"

		jsonData, _ := json.Marshal(map[string]interface{}{
			"username":   username,
			"email":      email,
			"first_name": firstName,
			"last_name":  lastName,
			"theme":      theme,
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Mock handler logic
			w.Write([]byte(`{"message":"profile updated successfully"}`))
		})

		handler.ServeHTTP(rec, req)
		assert.Equal(t, http.StatusOK, rec.Code)
	})

Modify DELETE /api/auth/account tests to use new endpoints:

Find all occurrences:

grep -n "DELETE /api/auth/account" cmd/server/tests/user_test.go

Replace all:

req := httptest.NewRequest("DELETE", "/api/auth/account", nil)

With:

req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)

Replace all:

req := httptest.NewRequest("DELETE", "/api/auth/account?user_id="+userID.String(), nil)

With:

req := httptest.NewRequest("DELETE", "/api/auth/profile/"+userID.String(), nil)

Step 6.6: Add New Complete Tests

NOTE: createTestUserOnce() creates an admin user (role="admin"). For admin tests:

  • Use createTestUserOnce(t, setup.DB) for the admin
  • Use createRegularUserOnce(t, setup.DB) for a regular user (role="user") to test admin operations on regular users
  • Use uuidToPGType(user.ID) to convert uuid.UUID to pgtype.UUID for database operations

Add at end of file before final closing brace:

// TestUpdateProfileConsolidated tests the consolidated profile update endpoint
func TestUpdateProfileConsolidated(t *testing.T) {
	t.Run("PUT /api/auth/profile - Update username only", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create test user using helper
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		username := "newusername"
		jsonData, _ := json.Marshal(map[string]interface{}{
			"username": username,
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify username was updated
		updatedUser, err := setup.DB.GetUserByUsername(context.Background(), username)
		assert.NoError(t, err)
		assert.Equal(t, username, updatedUser.Username)
	})

	t.Run("PUT /api/auth/profile - Update email only", func(t *testing.T) {
		setup := setupTestServer(t)

		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		email := "newemail@example.com"
		jsonData, _ := json.Marshal(map[string]interface{}{
			"email": email,
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify email was updated
		updatedUser, err := setup.DB.GetUserByEmail(context.Background(), email)
		assert.NoError(t, err)
		assert.Equal(t, email, updatedUser.Email)
	})

	t.Run("PUT /api/auth/profile - Update theme only", func(t *testing.T) {
		setup := setupTestServer(t)

		user := createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		theme := "dracula"
		jsonData, _ := json.Marshal(map[string]interface{}{
			"theme": theme,
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify theme was updated
		updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
		assert.NoError(t, err)
		assert.Equal(t, theme, updatedUser.Theme.String)
	})

	t.Run("PUT /api/auth/profile - Update all fields", func(t *testing.T) {
		setup := setupTestServer(t)

		user := createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"username":   "newalluser",
			"email":      "newall@example.com",
			"first_name": "NewAll",
			"last_name":  "User",
			"theme":      "nord",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify all fields updated
		updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
		assert.NoError(t, err)
		assert.Equal(t, "newalluser", updatedUser.Username)
		assert.Equal(t, "newall@example.com", updatedUser.Email)
	})

	t.Run("PUT /api/auth/profile - Username conflict", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create first user using helper
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create second user manually with different username
		passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
		pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
		_, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
			ID:           pgUserID,
			Email:        "user2@example.com",
			Username:     "user2",
			PasswordHash: passwordHash,
			Role:         "user",
		})
		require.NoError(t, err)

		// Try to update user1 to user2's username
		jsonData, _ := json.Marshal(map[string]interface{}{
			"username": "user2",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusConflict, rec.Code)
	})

	t.Run("PUT /api/auth/profile - Email conflict", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create first user using helper
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create second user manually with different email
		passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
		pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
		_, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
			ID:           pgUserID,
			Email:        "email2@example.com",
			Username:     "email2user",
			PasswordHash: passwordHash,
			Role:         "user",
		})
		require.NoError(t, err)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"email": "email2@example.com",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusConflict, rec.Code)
	})

	t.Run("PUT /api/auth/profile - No auth", func(t *testing.T) {
		setup := setupTestServer(t)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"username": "test",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
		// No Authorization header

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusUnauthorized, rec.Code)
	})
}

// TestUpdateProfileAdminMode tests admin updating another user (same handler, with URL param)
func TestUpdateProfileAdminMode(t *testing.T) {
	t.Run("PUT /api/auth/profile/:id - Admin update username", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user to update
		passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
		pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
		user, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
			ID:           pgUserID,
			Email:        "regularuser@example.com",
			Username:     "regularuser",
			PasswordHash: passwordHash,
			Role:         "user",
		})
		require.NoError(t, err)
		userUUID, _ := uuid.FromBytes(user.ID.Bytes[0:16])

		newUsername := "updateduser"
		jsonData, _ := json.Marshal(map[string]interface{}{
			"username": newUsername,
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile/"+userUUID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify username updated
		updatedUser, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
		assert.NoError(t, err)
		assert.Equal(t, newUsername, updatedUser.Username)
	})

	t.Run("PUT /api/auth/profile/:id - Admin promote user to admin", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user to promote
		user := createRegularUserOnce(t, setup.DB)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"role": "admin",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile/"+user.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify role changed
		updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
		assert.NoError(t, err)
		assert.Equal(t, "admin", updatedUser.Role)
	})

	t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create only one admin (createTestUserOnce creates admin)
		lastAdmin := createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Try to demote self
		jsonData, _ := json.Marshal(map[string]interface{}{
			"role": "user",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile/"+lastAdmin.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusBadRequest, rec.Code)
		assert.Contains(t, rec.Body.String(), "cannot demote the last admin")
	})

	t.Run("PUT /api/auth/profile/:id - Non-admin tries update", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create admin first
		targetAdmin := createTestUserOnce(t, setup.DB)
		// Create regular user (non-admin)
		regularUser := createRegularUserOnce(t, setup.DB)
		// Login as regular user
		token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"username": "hacked",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusForbidden, rec.Code)
	})

	t.Run("PUT /api/auth/profile/:id - Invalid role", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user to update
		targetUser := createRegularUserOnce(t, setup.DB)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"role": "superadmin",
		})

		req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusBadRequest, rec.Code)
		assert.Contains(t, rec.Body.String(), "invalid role")
	})
}

// TestUpdatePasswordAdminMode tests admin password reset (same handler, with URL param)
func TestUpdatePasswordAdminMode(t *testing.T) {
	t.Run("PUT /api/auth/password/:id - Admin reset password", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user to reset password for
		targetUser := createRegularUserOnce(t, setup.DB)

		newPassword := "NewPassword123!"
		jsonData, _ := json.Marshal(map[string]interface{}{
			"new_password":     newPassword,
			"confirm_password": newPassword,
		})

		req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify password changed by attempting login with new password
		loginData, _ := json.Marshal(map[string]interface{}{
			"login":    targetUser.Username,
			"password": newPassword,
		})

		loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData))
		loginRec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(loginRec, loginReq)

		assert.Equal(t, http.StatusOK, loginRec.Code)
	})

	t.Run("PUT /api/auth/password/:id - Non-admin tries reset", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create admin first
		targetAdmin := createTestUserOnce(t, setup.DB)
		// Create regular user (non-admin)
		regularUser := createRegularUserOnce(t, setup.DB)
		// Login as regular user
		token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"new_password":     "hacked",
			"confirm_password": "hacked",
		})

		req := httptest.NewRequest("PUT", "/api/auth/password/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusForbidden, rec.Code)
	})

	t.Run("PUT /api/auth/password/:id - Mismatched passwords", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user
		targetUser := createRegularUserOnce(t, setup.DB)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"new_password":     "password1",
			"confirm_password": "password2",
		})

		req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusBadRequest, rec.Code)
		assert.Contains(t, rec.Body.String(), "passwords do not match")
	})

	t.Run("PUT /api/auth/password/:id - Invalid password format", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user
		targetUser := createRegularUserOnce(t, setup.DB)

		jsonData, _ := json.Marshal(map[string]interface{}{
			"new_password":     "123",
			"confirm_password": "123",
		})

		req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusBadRequest, rec.Code)
	})
}

// TestDeleteUserConsolidated tests account deletion
func TestDeleteUserConsolidated(t *testing.T) {
	t.Run("DELETE /api/auth/profile - User deletes self successfully", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create admin first so we have at least 2 users (admin won't delete self)
		_ = createTestUserOnce(t, setup.DB)

		// Create regular user who will delete themselves
		selfDeletingUser := createRegularUserOnce(t, setup.DB)
		token := loginUserWithCredentials(t, setup.Server, selfDeletingUser.Email, selfDeletingUser.Password)

		req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify user deleted
		_, err := setup.DB.GetUser(context.Background(), uuidToPGType(selfDeletingUser.ID))
		assert.Error(t, err)
	})

	t.Run("DELETE /api/auth/profile - Delete without auth", func(t *testing.T) {
		setup := setupTestServer(t)

		req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
		// No auth

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusUnauthorized, rec.Code)
	})

	t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create only one admin (createTestUserOnce creates admin)
		lastAdmin := createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusBadRequest, rec.Code)
		assert.Contains(t, rec.Body.String(), "cannot delete the last admin")
	})

	t.Run("DELETE /api/auth/profile/:id - Admin deletes user", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		// Create regular user to delete
		targetUser := createRegularUserOnce(t, setup.DB)

		req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetUser.ID.String(), nil)
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusOK, rec.Code)

		// Verify user deleted
		_, err := setup.DB.GetUser(context.Background(), uuidToPGType(targetUser.ID))
		assert.Error(t, err)
	})

	t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create only one admin (createTestUserOnce creates admin)
		lastAdmin := createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		req := httptest.NewRequest("DELETE", "/api/auth/profile/"+lastAdmin.ID.String(), nil)
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusBadRequest, rec.Code)
		assert.Contains(t, rec.Body.String(), "cannot delete the last admin")
	})

	t.Run("DELETE /api/auth/profile/:id - Non-admin tries delete", func(t *testing.T) {
		setup := setupTestServer(t)

		// Create admin first
		targetAdmin := createTestUserOnce(t, setup.DB)
		// Create regular user (non-admin)
		regularUser := createRegularUserOnce(t, setup.DB)
		// Login as regular user
		token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)

		req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetAdmin.ID.String(), nil)
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusForbidden, rec.Code)
	})

	t.Run("DELETE /api/auth/profile/:id - Delete non-existent user", func(t *testing.T) {
		setup := setupTestServer(t)

		// Admin user (createTestUserOnce creates admin role)
		_ = createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, setup.DB)

		fakeUUID := uuid.New()
		req := httptest.NewRequest("DELETE", "/api/auth/profile/"+fakeUUID.String(), nil)
		req.Header.Set("Authorization", "Bearer "+token)

		rec := httptest.NewRecorder()
		setup.Server.Config.Handler.ServeHTTP(rec, req)

		assert.Equal(t, http.StatusNotFound, rec.Code)
	})
}

Step 6.7: Run Tests

# Run user tests
go test cmd/server/tests/user_test.go -v

Expected: All new tests pass

Fix any failures before proceeding.

Commit:

git add cmd/server/tests/test_helpers.go cmd/server/tests/user_test.go
git commit -m "test(auth): overhaul user profile tests for API consolidation

- Add createRegularUserOnce helper for non-admin test users
- Add uuidToPGType helper for UUID type conversion
- Remove 9 obsolete tests (username, email endpoints)
- Modify existing profile test for new fields
- Modify DELETE account tests for new endpoints
- Add TestUpdateProfileConsolidated (7 tests) - self-edit mode
- Add TestUpdateProfileAdminMode (5 tests) - admin mode via URL param
- Add TestUpdatePasswordAdminMode (4 tests) - admin reset via URL param
- Add TestDeleteUserConsolidated (7 tests) - combined self/admin delete
- Cover all scenarios: self-update, admin update, role changes
- Test last-admin protection thoroughly
- Test conflict detection (username, email)
- Test combined handlers work in both modes
"

PHASE 7: Bruno OpenCollection Tests

Goal: Update/create Bruno YAML tests Files: ~5 files (0 delete, 2 modify, 3 create) Risk: Low (contract testing)

Step 7.1: Check Current Bruno Structure

find bruno -name "*.yml" | head -20

Step 7.2: Delete Obsolete Bruno Tests

# Find username/email tests if they exist
find bruno -name "*username*" -o -name "*email*"

Delete if found.

Step 7.3: Update Existing Profile Test

File: bruno/user/profile/Update Profile.yml

Current content should be updated to:

info:
  name: Update Profile
  type: http
  seq: 4
http:
  method: PUT
  url: '{{base_url}}/api/auth/profile'
  auth: inherit
  body:
    type: json
    jsonBody: |-
      {
        "username": "updateduser",
        "email": "updated@example.com",
        "first_name": "Updated",
        "last_name": "User",
        "theme": "dracula"
      }

docs: |-
  ## Update User Profile
    
    Updates the authenticated user's profile information.
    
    **Method:** PUT
    
    **Endpoint:** /api/auth/profile
    
    **Authentication:** Required (Bearer token)
    
    **Request Body:**
    - `username` (string, optional): New username (must be unique)
    - `email` (string, optional): New email address (must be unique)
    - `first_name` (string, optional): First name
    - `last_name` (string, optional): Last name
    - `theme` (string, optional): Theme preference
    
    **Response:**
    - `message` (string): Success message
    
    **Status Codes:**
    - 200: Success
    - 400: Invalid request
    - 401: Unauthorized
    - 409: Username or email already taken

Step 7.4: Create Admin Update User Test

File: bruno/user/admin/Update User.yml

info:
  name: Update User
  type: http
  seq: 10
http:
  method: PUT
  url: '{{base_url}}/api/auth/profile/{{user_id}}'
  auth: inherit
  body:
    type: json
    jsonBody: |-
      {
        "username": "newusername",
        "email": "newemail@example.com",
        "role": "admin"
      }

docs: |-
  ## Admin Update User
    
    Admin-only endpoint to update another user's profile.
    
    **Method:** PUT
    
    **Endpoint:** /api/auth/profile/:id
    
    **Authentication:** Required (Admin Bearer token)
    
    **Request Body:**
    - `username` (string, optional): New username
    - `email` (string, optional): New email
    - `first_name` (string, optional): First name
    - `last_name` (string, optional): Last name
    - `theme` (string, optional): Theme preference
    - `role` (string, optional): Role ("user" or "admin") - admin only
    
    **Status Codes:**
    - 200: Success
    - 400: Invalid role or cannot demote last admin
    - 403: Not an admin
    - 404: User not found
    - 409: Username or email already taken

Step 7.5: Create Admin Reset Password Test

File: bruno/user/admin/Reset User Password.yml

info:
  name: Reset User Password
  type: http
  seq: 11
http:
  method: PUT
  url: '{{base_url}}/api/auth/password/{{user_id}}'
  auth: inherit
  body:
    type: json
    jsonBody: |-
      {
        "new_password": "NewSecurePassword123!",
        "confirm_password": "NewSecurePassword123!"
      }

docs: |-
  ## Admin Reset User Password
    
    Admin-only endpoint to reset another user's password.
    
    **Method:** PUT
    
    **Endpoint:** /api/auth/password/:id
    
    **Authentication:** Required (Admin Bearer token)
    
    **Request Body:**
    - `new_password` (string, required): New password
    - `confirm_password` (string, required): Must match new_password
    
    **Status Codes:**
    - 200: Success
    - 400: Passwords do not match or invalid format
    - 403: Not an admin
    - 404: User not found

Step 7.6: Create Delete Account Tests

File: bruno/user/admin/Delete User.yml

info:
  name: Delete User
  type: http
  seq: 12
http:
  method: DELETE
  url: '{{base_url}}/api/auth/profile/{{user_id}}'
  auth: inherit

docs: |-
  ## Delete User Account
    
    Delete a user account. Users can delete their own account, admins can delete any user.
    
    **Method:** DELETE
    
    **Endpoint:** /api/auth/profile (self) or /api/auth/profile/:id (admin)
    
    **Authentication:** Required (Bearer token)
    
    **Status Codes:**
    - 200: Success
    - 400: Cannot delete the last admin
    - 403: Not an admin (when trying to delete another user)
    - 404: User not found

Commit:

git add bruno/
git commit -m "test(bruno): update auth tests for API consolidation

- Delete obsolete tests: Update Username.yml, Update Email.yml
- Update Update Profile.yml with new consolidated endpoint
- Add Update User.yml for admin user management
- Add Reset User Password.yml for admin password resets
- Add Delete User.yml for account deletion (self and admin)
- Document all scenarios with proper request bodies
"

PHASE 8: Documentation

Goal: Update API and user documentation
Files: ~5 files (create/update)
Risk: Lowest

Step 8.1: Update API Documentation

Create: docs/developer/api/auth/profile.md

# Profile Management

## Get Profile

Retrieve the authenticated user's profile information.

\`\`\`
GET /api/auth/profile
Authorization: Bearer {token}
\`\`\`

### Response

\`\`\`json
{
  "id": "uuid",
  "email": "user@example.com",
  "username": "username",
  "first_name": "John",
  "last_name": "Doe",
  "role": "user"
}
\`\`\`

## Update Profile (Self)

Update your own profile information. All fields are optional.

\`\`\`
PUT /api/auth/profile
Authorization: Bearer {token}

{
  "username": "newusername",      // optional
  "email": "new@example.com",     // optional
  "first_name": "John",           // optional
  "last_name": "Doe",             // optional
  "theme": "dracula"              // optional
}
\`\`\`

### Response

\`\`\`json
{
  "message": "profile updated successfully"
}
\`\`\`

### Errors

- `409 Conflict` - Username or email already taken
- `401 Unauthorized` - No token provided
- `400 Bad Request` - Invalid request data

## Update Profile (Admin)

Update another user's profile information. All fields are optional.

\`\`\`
PUT /api/auth/profile/:id
Authorization: Bearer {admin_token}

{
  "username": "newusername",      // optional
  "email": "new@example.com",     // optional
  "first_name": "John",           // optional
  "last_name": "Doe",             // optional
  "theme": "dracula",             // optional
  "role": "admin"                 // optional, admin only
}
\`\`\`

### Response

\`\`\`json
{
  "message": "user updated successfully"
}
\`\`\`

### Errors

- `403 Forbidden` - Not an admin
- `404 Not Found` - User not found
- `400 Bad Request` - Invalid role or cannot demote last admin
- `409 Conflict` - Username or email already taken

## Delete Account (Self)

Delete your own account.

**⚠️ WARNING:** This action cannot be undone. All data will be permanently deleted.

\`\`\`
DELETE /api/auth/profile
Authorization: Bearer {token}
\`\`\`

### Response

\`\`\`json
{
  "message": "account deleted successfully"
}
\`\`\`

### Errors

- `400 Bad Request` - Cannot delete the last admin account
- `401 Unauthorized` - No token provided

## Delete Account (Admin)

Delete another user's account.

\`\`\`
DELETE /api/auth/profile/:id
Authorization: Bearer {admin_token}
\`\`\`

### Response

\`\`\`json
{
  "message": "user account deleted successfully"
}
\`\`\`

### Errors

- `403 Forbidden` - Not an admin
- `404 Not Found` - User not found
- `400 Bad Request` - Cannot delete the last admin account

**Security Note:** The system must always have at least one admin account. The last admin cannot be deleted (even by themselves).

Create: docs/developer/api/auth/password.md

# Password Management

## Change Password (Self)

Change your own password by providing the current password.

\`\`\`
PUT /api/auth/password
Authorization: Bearer {token}

{
  "current_password": "oldpass123",
  "new_password": "newpass123",
  "confirm_password": "newpass123"
}
\`\`\`

### Response

\`\`\`json
{
  "message": "password updated successfully"
}
\`\`\`

### Errors

- `401 Unauthorized` - Current password is incorrect
- `400 Bad Request` - Passwords do not match or too short

## Reset Password (Admin)

Reset another user's password without requiring the current password. Used for password recovery or account management.

\`\`\`
PUT /api/auth/password/:id
Authorization: Bearer {admin_token}

{
  "new_password": "newpass123",
  "confirm_password": "newpass123"
}
\`\`\`

### Response

\`\`\`json
{
  "message": "password reset successfully"
}
\`\`\`

### Errors

- `403 Forbidden` - Not an admin
- `404 Not Found` - User not found
- `400 Bad Request` - Passwords do not match or too short

**Security Note:** Admin password resets bypass the current password check. Use with caution and ensure proper authorization.

Step 8.2: Create User Guide

Create: docs/user/profile-guide.md

# Profile Management Guide

## Updating Your Profile

Your profile contains your account information and preferences.

### How to Update

1. Click on your **username** (top-right)
2. Select **Profile** from the dropdown
3. Edit any fields in the "Account Information" section
4. Click **Update Profile**
5. Changes take effect immediately

### Fields You Can Update

- **Username** - Your login name (must be unique)
- **Email** - Your email address (must be unique)
- **First Name** - Optional display name
- **Last Name** - Optional display name
- **Theme** - Your preferred color scheme

## Changing Your Password

Regular password changes are recommended for account security.

### How to Change

1. Go to **Profile** page
2. Scroll to "Change Password" section
3. Enter your **current password**
4. Enter your **new password**
5. **Confirm** your new password
6. Click **Update Password**

### Password Requirements

- Minimum 6 characters
- Must match confirmation field
- Current password must be correct

## Deleting Your Account

**⚠️ WARNING:** This action cannot be undone.

### What Gets Deleted

When you delete your account:
- Your profile information
- Reading progress and history
- Device connections
- Collection preferences
- All personal data

### What Stays

- Library books (media belongs to the library, not you)
- System settings
- Other users' accounts

### How to Delete

1. Go to **Profile** page
2. Scroll to "Danger Zone" (bottom of page)
3. Click **Remove My Account**
4. Confirm by clicking "OK" in the popup

**Note:** If you're the last admin, you cannot delete your account for security reasons.

## Theme Options

Personalize your reading experience with different color themes.

### Quick Theme Switch

1. Click the **paintbrush icon** (top-right, next to your username)
2. Select a theme from the dropdown
3. Changes apply instantly

### Available Themes

- **Tokyo Night** (default) - Blue/purple accents
- **Dracula** - Purple/pink tones
- **Nord** - Arctic, bluish-gray
- **Solarized Dark** - Warm, precise contrast
- **Monokai** - Classic vibrant colors
- **One Dark Pro** - Atom editor inspired
- **Material Dark** - Google Material Design
- **Wood Light** - Light wood texture
- **Wood Dark** - Dark wood texture  
- **Wood Mahogany** - Reddish-brown wood

### For Admin Users

If you're an administrator, you can also manage other users' profiles from the admin panel.

See [Admin User Management](../admin/users.md) for details.

Step 8.3: Update Navigation

Update: docs/developer/api/_navigation.md or equivalent

Add new endpoints to navigation.

Commit:

git add docs/
git commit -m "docs: document consolidated profile API endpoints

- Add profile.md with GET/PUT/DELETE /api/auth/profile
- Add password.md with PUT /api/auth/password and /:id variants
- Add user guide in docs/user/profile-guide.md
- Document self-service and admin operations
- Add security notes (last-admin protection)
- Include error scenarios and response codes
"

FINAL VERIFICATION

Step F.1: Run Verification Script

bash scripts/verify-guidelines.sh

Expected: 0 errors, warnings are OK

Step F.2: Run Full Test Suite

go test ./... -v

Expected: All tests pass

Step F.3: Build Project

go build ./...

Expected: No compilation errors

Step F.4: Review All Changes

git diff --stat
git diff

Verify:

  • Only intended files modified
  • No unintended deletions
  • Logic is correct

Step F.5: Check Documentation Renders

# Start server
podman compose up -d

# Visit http://localhost:8080/docs
# Verify new docs appear
# Test search finds new content

Step F.6: Manual Testing (Bruno)

  1. Test self-update:

    • GET /api/auth/profile → See profile
    • PUT /api/auth/profile (update username) → Success
    • Verify username changed
  2. Test admin-update:

    • PUT /api/auth/profile/:id (admin updates user) → Success
    • PUT /api/auth/profile/:id with role field → User promoted to admin
  3. Test password:

    • PUT /api/auth/password (self-change) → Success
    • PUT /api/auth/password/:id (admin reset) → Success
  4. Test deletion:

    • DELETE /api/auth/profile (self-delete test user) → Success
    • DELETE /api/auth/profile/:id (admin delete test user) → Success
    • Try DELETE /api/auth/profile (last admin) → 400 error
  5. Test theme:

    • PUT /api/auth/theme (quick toggle) → Success
    • Verify theme changed immediately

SUMMARY OF CHANGES

Phase Files Lines Added Lines Removed Risk
1. Database Query 1 5 0 Lowest
2. Extend UpdateProfile & Admin Handlers 1 230 20 Medium
3. Rename DeleteAccount 1 10 0 Low
4. Router Updates 1 8 3 Low
5. Frontend (Profile + Users + Modal) 7 650 160 Medium
6. Tests 1 400 120 High
7. Bruno Tests 5 100 0 Low
8. Documentation 5 200 0 Lowest
TOTAL 22 ~1603 ~303 Medium

SUCCESS CRITERIA

  • Database query added and code regenerated
  • UpdateUser handler created (reuses existing queries)
  • ResetUserPassword handler created (standalone)
  • UpdateProfile extended for all fields
  • DeleteUser renamed, uses URL param
  • Routes consolidated (3 removed, 4 added)
  • /profile page created for all users
  • /admin/users page created with user management table
  • Profile modal component created (reusable for self and admin editing)
  • GET /admin/users/:id/profile-modal route added (admin only, server validates)
  • HTMX Edit button loads modal (secure, server-validated)
  • Profile modal conditionally shows current password field
  • HTMX role toggles with page reload after success
  • HTMX delete buttons with page reload after success
  • Last-admin protection in UI (disabled states for role/delete)
  • Header links updated (/settings → /profile)
  • Admin navigation includes /admin/users link
  • Admin-only profile page removed
  • Login template shows account deleted message
  • All tests passing (40+ new tests)
  • Bruno tests updated
  • Documentation complete
  • Zero compilation errors
  • Zero guideline violations
  • NO OVER-ENGINEERING - Simple composition approach

End of Revised Implementation Plan

Follow this plan phase-by-phase. Commit after each phase. Stop and verify after each step. No cascading edits. Surgical precision. Simple composition. NO OVER-ENGINEERING.