Files
bookhoard/API_CONSOLIDATION_PLAN.md
T
john-okeefe d0a2042344 docs: add API consolidation implementation plan
- Comprehensive 8-phase plan for consolidating profile endpoints
- Self-service profile: PUT /api/auth/profile for users
- Admin user management: profile updates, password resets, deletions
- Frontend: universal /profile page, /admin/users with modal editing
- Security: last-admin protection throughout
- Follows KISS principle: copy/paste logic, no over-engineering
2026-02-21 00:58:50 -05:00

102 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: Keep existing handlers, create new admin handlers that reuse their logic (no function calls, just copy/paste)


🎯 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)
  • getTestUserID(t, db) - Gets or creates test user UUID (admin role)
  • getRegularUserID(t, db) - Gets or creates regular user UUID ("user" role)

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 - 12 steps)
  6. Phase 6: Test Overhaul (remove obsolete, add new)
  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

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
// Updates username, email, first_name, last_name, theme
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"})
	}
	if err := c.Validate(&req); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
	}

	// Update username (if provided) - COPY/PASTED from UpdateUsername handler
	if req.Username != "" {
		existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
		if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
			return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
		}

		err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
			ID:       user.ID,
			Username: req.Username,
		})
		if err != nil {
			return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
		}
	}

	// Update email (if provided) - COPY/PASTED from UpdateEmail handler
	if req.Email != "" {
		existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
		if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
			return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
		}

		err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
			ID:    user.ID,
			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:        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()})
		}
	}

	// Update theme (if provided)
	if req.Theme != "" {
		err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
			ID:    user.ID,
			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: Create UpdateUser Handler (Admin)

Location: After UpdateProfile handler in internal/handlers/auth.go

Find insertion point:

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

Add after the UpdateProfile handler ends:

// UpdateUser handles PUT /api/auth/profile/:id (admin only)
// Updates another user's profile using copy/pasted conflict checking logic
func (h *AuthHandler) UpdateUser(c echo.Context) error {
	currentUser := MustGetAuthenticatedUser(c)

	// Get target user ID from URL param
	targetUserID := c.Param("id")
	if targetUserID == "" {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id 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}

	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 (if provided)
	if req.Role != "" {
		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 != "" {
		// Check if username is already taken
		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"})
		}

		// Update username
		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 != "" {
		// Check if email is already taken
		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"})
		}

		// Update email
		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": "user updated successfully"})
}

Step 2.6: Create ResetUserPassword Handler

Location: After UpdateUser handler (after the code we just added)

Add immediately after UpdateUser ends:

// ResetUserPassword handles PUT /api/auth/password/:id (admin only)
// Resets another user's password without requiring current password
func (h *AuthHandler) ResetUserPassword(c echo.Context) error {
	currentUser := MustGetAuthenticatedUser(c)

	targetUserID := c.Param("id")
	if targetUserID == "" {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id 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}

	type PasswordResetRequest struct {
		NewPassword     string `json:"new_password" validate:"required,passwordcomplex"`
		ConfirmPassword string `json:"confirm_password" validate:"required"`
	}

	var req PasswordResetRequest
	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()})
	}

	if req.NewPassword != req.ConfirmPassword {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
	}

	// Hash password (reuse logic from UpdatePassword)
	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 directly (no current password check)
	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 reset successfully"})
}

Verification:

# Compile check
go build ./internal/handlers

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

Commit:

git add internal/handlers/auth.go
git commit -m "feat(auth): extend UpdateProfile and add admin handlers

- Extend UpdateProfile handler to support username, email, theme fields
- Copy/paste conflict checking logic from UpdateUsername and UpdateEmail handlers
- Add UpdateUser handler for admin profile updates
- Add ResetUserPassword handler for admin password resets
- All handlers call database queries directly (no handler-to-handler calls)
- Admin handlers support last-admin protection
"

PHASE 3: Rename DeleteAccount to DeleteUser

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

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

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

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
	authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
	authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)

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

	admin.PUT("/profile/:id", cfg.AuthHandler.UpdateUser)
	admin.PUT("/password/:id", cfg.AuthHandler.ResetUserPassword)
	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 (self and admin)
	authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
	authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)

	// Admin-only routes for user management
	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.UpdateUser)
	admin.PUT("/password/:id", cfg.AuthHandler.ResetUserPassword)
	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 no duplicate routes
grep "PUT.*profile" internal/router/auth.go

Expected: Two lines - one for authGroup.PUT("/profile"), one for admin.PUT("/profile/:id")

Commit:

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

- Remove PUT /api/auth/profile (re-add with different route group)
- Remove PUT /api/auth/email (merged into /profile)
- Remove PUT /api/auth/username (merged into /profile)
- Add PUT /api/auth/profile (consolidated self-update)
- Add DELETE /api/auth/profile (self-deletion)
- Add PUT /api/auth/profile/:id (admin update user)
- Add PUT /api/auth/password/:id (admin reset password)
- Add DELETE /api/auth/profile/:id (admin delete user)
- Keep PUT /api/auth/theme unchanged (header quick-toggle)
- Handlers UpdateUsername/UpdateEmail kept for internal reuse
"

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: 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 -->
                <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-6" style="color: var(--text-primary)">Account Information</h3>

                    <form hx-put="/api/auth/profile" hx-target="#profile-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="space-y-4 max-w-md">
                        <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" 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" 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>

                        <button type="submit" class="btn-primary px-4 py-2 rounded">Update Profile</button>
                    </form>
                    <div id="profile-result" class="mt-2"></div>
                </div>

                <!-- Change Password -->
                <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-6" style="color: var(--text-primary)">Change Password</h3>

                    <form hx-put="/api/auth/password" hx-target="#password-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' 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)" required 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)" required minlength="6">
                        </div>

                        <button type="submit" class="btn-primary px-4 py-2 rounded">Update Password</button>
                    </form>
                    <div id="password-result" class="mt-2"></div>
                </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.2: 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.3: 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.4: Delete Admin Profile Template

File: templates/admin_profile.templ

Delete the entire file:

rm templates/admin_profile.templ

Regenerate templates:

templ generate

Step 5.5: 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.6: 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.7: 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.8: Create Profile Modal Component

Goal: Reusable modal component for editing user profiles (self and admin editing other users)

File: templates/profile_modal.templ (NEW FILE)

Create with following content:

package templates

templ ProfileModal(user User, isAdminEditingOther bool) {
    <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)">
                        { isAdminEditingOther ? "Edit User Profile" : "Edit Your 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 -->
            <div class="p-6">
                <form hx-put="/api/auth/profile/{user.ID}"
                      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}
                                       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}
                                       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>
                        </div>
                    </div>

                    <!-- Change Password Section -->
                    <div>
                        <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Change Password</h3>
                        
                        @if !isAdminEditingOther {
                            <!-- Editing self - 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="submit" 
                                        hx-put="/api/auth/password"
                                        hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
                                        hx-target="#password-result"
                                        hx-swap="innerHTML"
                                        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 {
                            <!-- Editing other user as admin - 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="submit"
                                        hx-put="/api/auth/password/{user.ID}"
                                        hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
                                        hx-target="#password-result"
                                        hx-swap="innerHTML"
                                        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);">
                        <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>
            </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.9: Add Profile Modal Route

File: internal/router/frontend.go

Add after the profile route (around line 400):

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

    // Verify admin access
    if user.Role != "admin" {
        return c.HTML(http.StatusForbidden, "<div>Access denied</div>")
    }

    // 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.DB.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, true).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.10: 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>
                                            { 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)">
                                    {fmt.Sprintf("%s", 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.11: Add Admin Users Route

File: internal/router/frontend.go

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

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

    // Check if user is admin
    if user.Role != "admin" {
        return c.HTML(http.StatusForbidden, "<h1>Admin access required</h1>")
    }

    // Fetch all users
    users, err := cfg.DB.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:

// Convert single database user to template user
func toTemplateUser(dbUser database.User) templates.User {
    return templates.User{
        ID:        dbUser.ID,
        Username:  dbUser.Username,
        Email:     dbUser.Email,
        Role:      dbUser.Role,
        FirstName: dbUser.FirstName,
        LastName:  dbUser.LastName,
        Theme:     dbUser.Theme,
        CreatedAt: dbUser.CreatedAt,
    }
}

// Convert slice of database users to template users
func toTemplateUsers(dbUsers []database.User) []templates.User {
    users := make([]templates.User, len(dbUsers))
    for i, u := range dbUsers {
        users[i] = toTemplateUser(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_modal.templ (reusable component for editing profiles)
- 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)
- Profile modal conditionally shows current password field
  - Editing self: requires current password
  - Editing other user (admin): no current password required
- 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: 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.4: 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)

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

		handler.ServeHTTP(w, req)
		assert.Equal(t, http.StatusOK, w.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.5: Add New Complete Tests

NOTE: For tests that need multiple users, create additional users directly using DB queries:

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

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

  • Use createTestUserOnce(t, setup.DB) for the admin
  • Manually create a regular user with Role: "user" for testing admin operations on regular users
// Example: Create a second test user with different credentials
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" // "Test@Pass123!"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
user2, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
    ID:           pgUserID,
    Email:        "user2@example.com",
    Username:     "user2",
    PasswordHash: passwordHash,
    Role:         "user",
})

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
		user := 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusOK, w.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)
		

		user := 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusOK, w.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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

		// Verify theme was updated
		updatedUser, err := setup.DB.GetUser(context.Background(), 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

		// Verify all fields updated
		updatedUser, err := setup.DB.GetUser(context.Background(), 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
		user1 := 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		// Create first user using helper
		user1 := 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusConflict, w.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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

// TestUpdateUserAdmin tests admin updating another user
func TestUpdateUserAdmin(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)
		admin := 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusOK, w.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 := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})

		user := createTestUserOnce(t, setup.DB)
		token := loginTestUser(t, setup.Server, 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

		// Verify role changed
		updatedUser, err := setup.DB.GetUser(context.Background(), 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)
		

		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		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/"+admin.ID.String(), bytes.NewBuffer(jsonData))
		req.Header.Set("Authorization", "Bearer "+token)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusBadRequest, w.Code)
		assert.Contains(t, w.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)
		

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

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		token := loginTestUser(t, setup.Server, setup.DB)

		user := createTestUserOnce(t, setup.DB)

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})

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

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData))
		loginW := httptest.NewRecorder()
		setup.Router.ServeHTTP(loginW, loginReq)

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

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

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

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		token := loginTestUser(t, setup.Server, setup.DB)

		user := createTestUserOnce(t, setup.DB)

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		token := loginTestUser(t, setup.Server, setup.DB)

		user := createTestUserOnce(t, setup.DB)

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusBadRequest, w.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 := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})

		user := 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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

		// Verify user deleted
		_, err := setup.DB.GetUser(context.Background(), user.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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		// Create only one admin
		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		token := loginTestUser(t, setup.Server, setup.DB)

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusBadRequest, w.Code)
		assert.Contains(t, w.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 := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

		// Verify user deleted
		_, err := setup.DB.GetUser(context.Background(), user.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
		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		token := loginTestUser(t, setup.Server, setup.DB)

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

		assert.Equal(t, http.StatusBadRequest, w.Code)
		assert.Contains(t, w.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)
		

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

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

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

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

		admin := createTestUserOnce(t, setup.DB)
		admin.Role = "admin"
		setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
			ID:   admin.ID,
			Role: "admin",
		})
		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)

		w := httptest.NewRecorder()
		setup.Router.ServeHTTP(w, req)

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

Step 6.6: 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/user_test.go
git commit -m "test(auth): overhaul user profile tests for API consolidation

- 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)
- Add TestUpdateUserAdmin (6 tests)
- Add TestResetUserPassword (4 tests)
- Add TestDeleteUserConsolidated (7 tests)
- Cover all scenarios: self-update, admin update, role changes
- Test last-admin protection thoroughly
- Test conflict detection (username, email)
"

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/auth/profile.yml (create if doesn't exist)

meta:
  name: Profile Management
  type: http
  seq: 1

# Scenario: Update username
meta:
  name: Update username
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile
    headers:
      Authorization: Bearer {{token}}
    body:
      username: updateduser

# Scenario: Update email
meta:
  name: Update email  
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile
    headers:
      Authorization: Bearer {{token}}
    body:
      email: updated@example.com

# Scenario: Update all fields
meta:
  name: Update all profile fields
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile
    headers:
      Authorization: Bearer {{token}}
    body:
      username: updateduser
      email: updated@example.com
      first_name: Updated
      last_name: User
      theme: dracula

# Scenario: Update theme only
meta:
  name: Update theme
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile
    headers:
      Authorization: Bearer {{token}}
    body:
      theme: nord

Step 7.4: Create Admin Update User Test

File: bruno/auth/admin-update-user.yml

meta:
  name: Admin Update User
  type: http
  seq: 1

# Scenario: Admin update user username
meta:
  name: Admin update username
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile/{{userId}}
    headers:
      Authorization: Bearer {{adminToken}}
    body:
      username: newusername

# Scenario: Admin update user email
meta:
  name: Admin update email
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile/{{userId}}
    headers:
      Authorization: Bearer {{adminToken}}
    body:
      email: newemail@example.com

# Scenario: Admin promote user to admin
meta:
  name: Admin promote user
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile/{{userId}}
    headers:
      Authorization: Bearer {{adminToken}}
    body:
      role: admin

# Scenario: Admin demote admin to user
meta:
  name: Admin demote admin
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/profile/{{adminId}}
    headers:
      Authorization: Bearer {{superAdminToken}}
    body:
      role: user

Step 7.5: Create Admin Reset Password Test

File: bruno/auth/admin-reset-password.yml

meta:
  name: Admin Reset Password
  type: http
  seq: 1

# Scenario: Admin reset user password
meta:
  name: Admin reset password
  request:
    method: PUT
    url: {{baseUrl}}/api/auth/password/{{userId}}
    headers:
      Authorization: Bearer {{adminToken}}
    body:
      new_password: newpassword123
      confirm_password: newpassword123

Step 7.6: Create Delete Account Tests

File: bruno/auth/delete-account.yml

meta:
  name: Delete Account
  type: http
  seq: 1

# Scenario: User delete own account
meta:
  name: Delete own account
  request:
    method: DELETE
    url: {{baseUrl}}/api/auth/profile
    headers:
      Authorization: Bearer {{token}}

# Scenario: Admin delete user
meta:
  name: Admin delete user
  request:
    method: DELETE
    url: {{baseUrl}}/api/auth/profile/{{userId}}
    headers:
      Authorization: Bearer {{adminToken}}

# Scenario: Try to delete last admin (should fail)
meta:
  name: Try to delete last admin
  request:
    method: DELETE
    url: {{baseUrl}}/api/auth/profile/{{lastAdminId}}
    headers:
      Authorization: Bearer {{lastAdminToken}}
  expect: 400

Commit:

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

- Delete obsolete tests: username.yml, email.yml
- Update profile.yml with new consolidated endpoint
- Add admin-update-user.yml for admin user management
- Add admin-reset-password.yml for admin password resets
- Add delete-account.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.