diff --git a/API_CONSOLIDATION_PLAN.md b/API_CONSOLIDATION_PLAN.md
new file mode 100644
index 0000000..6771a81
--- /dev/null
+++ b/API_CONSOLIDATION_PLAN.md
@@ -0,0 +1,3143 @@
+# 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`)
+ - Email: "testuser@example.com"
+ - Username: "testuser"
+ - Password: "Test@Pass123!"
+ - Role: "admin"
+- `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:
+
+```sql
+-- name: UpdateUserRole :one
+UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
+RETURNING id, email, username, role;
+```
+
+**Verification:**
+```bash
+# 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:**
+```bash
+make sqlc
+```
+
+**Verification:**
+```bash
+# 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:**
+```bash
+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:**
+```bash
+sed -n '538,565p' internal/handlers/auth.go
+```
+
+**Read UpdateEmail to understand conflict checking pattern:**
+```bash
+sed -n '572,599p' internal/handlers/auth.go
+```
+
+**Read UpdatePassword to understand password hashing logic:**
+```bash
+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:**
+```go
+type UpdateProfileRequest struct {
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+}
+```
+
+**Replace with:**
+```go
+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:**
+```go
+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:**
+```bash
+sed -n '452,470p' internal/handlers/auth.go
+```
+
+**Current code:**
+```go
+// 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:**
+
+```go
+// 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:**
+```bash
+grep -n "func (h \*AuthHandler) UpdateProfile" internal/handlers/auth.go
+```
+
+**Add after the UpdateProfile handler ends:**
+
+```go
+// 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:**
+
+```go
+// 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:**
+```bash
+# 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:**
+```bash
+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:**
+```bash
+sed -n '658,731p' internal/handlers/auth.go
+```
+
+**Step 3.1.1: Change function signature (line 658)**
+
+**From:**
+```go
+func (h *AuthHandler) DeleteAccount(c echo.Context) error {
+```
+
+**To:**
+```go
+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:**
+```bash
+sed -n '658,731p' internal/handlers/auth.go
+```
+
+**Step 3.2: Modify Query Param to URL Param (lines 661-678)**
+
+**Current code:**
+```go
+ // 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:**
+```go
+ // 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:**
+```bash
+# Compile check
+go build ./internal/handlers
+
+# Verify function was renamed
+grep -n "func (h \*AuthHandler) DeleteUser" internal/handlers/auth.go
+```
+
+**Commit:**
+```bash
+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
+
+```bash
+cat internal/router/auth.go
+```
+
+### Step 4.2: Remove Obsolete Routes
+
+**Delete line 24:**
+```go
+protected.PUT("/auth/profile", cfg.AuthHandler.UpdateProfile)
+```
+
+**Delete lines 34-35:**
+```go
+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):**
+
+```go
+ // Profile management
+ authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
+ authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
+```
+
+**Add after line 42 (after max-devices route):**
+
+```go
+ 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:**
+
+```go
+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:**
+```bash
+# 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:**
+```bash
+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:**
+
+```templ
+package templates
+
+templ Profile(user User) {
+
+
+
+
+ Profile Settings - Bookhoard
+
+
+
+
+
+
+ @Header(user, "/profile")
+
+
+
+
Profile Settings
+
Manage your account information and preferences
+
+
+
+
+
+
Account Information
+
+
+
+
+
+
+
+
Change Password
+
+
+
+
+
+
+
+
Danger Zone
+
Once you delete your account, there is no going back. Please be certain.
+
+
+
+
+
+
+
+
+
+}
+```
+
+### Step 5.2: Update Header Template
+
+**File:** `templates/header.templ`
+
+**Find the settings link (around line 117-118):**
+
+**Current:**
+```html
+Settings
+```
+
+**Change to:**
+```html
+Profile
+```
+
+**Regenerate template:**
+```bash
+templ generate
+```
+
+### Step 5.3: Update Admin Template
+
+**File:** `templates/admin.templ`
+
+**Find the profile link (around line 39-40):**
+
+**Current:**
+```html
+Profile Settings
+```
+
+**Change to:**
+```html
+Profile Settings
+```
+
+**Regenerate template:**
+```bash
+templ generate
+```
+
+### Step 5.4: Delete Admin Profile Template
+
+**File:** `templates/admin_profile.templ`
+
+**Delete the entire file:**
+```bash
+rm templates/admin_profile.templ
+```
+
+**Regenerate templates:**
+```bash
+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):**
+
+```go
+// 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
+templ Login(sessionExpired bool) {
+```
+
+**To:**
+```templ
+templ Login(sessionExpired bool, deleted bool) {
+```
+
+**After the sessionExpired block (around line 40), add:**
+
+```templ
+if deleted {
+
+
+ Your account has been deleted successfully.
+
+
+}
+```
+
+**Regenerate template:**
+```bash
+templ generate
+```
+
+### Step 5.7: Update Login Frontend Route
+
+**File:** `internal/router/frontend.go`
+
+**Find the login route (around line 53):**
+```bash
+grep -n "GET.*login" internal/router/frontend.go
+```
+
+**Modify to read deleted query parameter:**
+
+**Current:**
+```go
+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:**
+```go
+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:**
+```bash
+# Compile check
+go build ./internal/router
+```
+
+**Commit (for Steps 5.1-5.7):**
+```bash
+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:**
+
+```templ
+package templates
+
+templ ProfileModal(user User, isAdminEditingOther bool) {
+
+
+
+
+
+
+ { isAdminEditingOther ? "Edit User Profile" : "Edit Your Profile" }
+
+
+ { user.Username } ({ user.Email })
+
+
+
+
+
+
+
+
+
+
+
+}
+```
+
+**Generate template:**
+```bash
+templ generate
+```
+
+### Step 5.9: Add Profile Modal Route
+
+**File:** `internal/router/frontend.go`
+
+**Add after the profile route (around line 400):**
+
+```go
+// 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, "Access denied
")
+ }
+
+ // Get target user ID from URL
+ targetUserID := c.Param("id")
+ parsedUUID, err := uuid.Parse(targetUserID)
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, "Invalid user ID
")
+ }
+
+ // 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, "User not found
")
+ }
+
+ // 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:**
+
+```templ
+package templates
+
+templ AdminUsers(users []User, adminCount int, currentUserID string) {
+
+
+
+
+ Users - Bookhoard Admin
+
+
+
+
+
+
+ @Header(User{Username: "Admin", Role: "admin"}, "/admin/users")
+
+
+
+
+
+
+
User Management
+
Manage user accounts and permissions
+
+
+
+
+
+
+
+ | Username |
+ Email |
+ Role |
+ Created |
+ Actions |
+
+
+
+ @for _, user := range users {
+
+
+
+
+
+ {user.Username}
+ { user.ID == currentUserID ? ` You` : `` }
+
+
+ |
+
+
+
+ {user.Email}
+ |
+
+
+
+ @if user.Role == "admin" && adminCount == 1 {
+
+
+
+ โ ๏ธ
+
+ } else {
+
+
+ }
+ |
+
+
+
+ {fmt.Sprintf("%s", user.CreatedAt.Format("2006-01-02"))}
+ |
+
+
+
+
+
+
+
+
+ @if user.Role == "admin" && adminCount == 1 {
+
+
+ } else {
+
+
+ }
+
+ |
+
+ }
+
+
+
+
+
+
+
+ โ ๏ธ Note: The system must always have at least one admin user. The last admin cannot be demoted or deleted.
+
+
+
+
+
+
+
+}
+```
+
+**Generate template:**
+```bash
+templ generate
+```
+
+
+### Step 5.11: Add Admin Users Route
+
+**File:** `internal/router/frontend.go`
+
+**Add after the /profile route (around line 395):**
+
+```go
+// 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, "Admin access required
")
+ }
+
+ // 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`:
+
+```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
+}
+```
+
+### Step 5.12: Update Admin Navigation Link
+
+**File:** `templates/admin.templ`
+
+**Add link to users page (around line 40):**
+
+**Find this section:**
+```templ
+
+```
+
+**Add after it:**
+```templ
+
+```
+
+**Regenerate template:**
+```bash
+templ generate
+```
+
+**Verification:**
+```bash
+# 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:**
+```bash
+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
+
+```bash
+git stash push -m "Backup tests before API consolidation overhaul"
+```
+
+### Step 6.2: Read Current Test Structure
+
+```bash
+# 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:
+```bash
+grep -n "PUT /api/auth/email" cmd/server/tests/user_test.go
+```
+
+Delete all tests under this category (approximately lines 82-166)
+
+2. **PUT /api/auth/username tests** - Find lines:
+```bash
+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:**
+```bash
+grep -n "PUT /api/auth/profile - Update profile" cmd/server/tests/user_test.go
+```
+
+**Update the test body to include new fields:**
+
+```go
+ 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:**
+```bash
+grep -n "DELETE /api/auth/account" cmd/server/tests/user_test.go
+```
+
+**Replace all:**
+```go
+req := httptest.NewRequest("DELETE", "/api/auth/account", nil)
+```
+
+**With:**
+```go
+req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
+```
+
+**Replace all:**
+```go
+req := httptest.NewRequest("DELETE", "/api/auth/account?user_id="+userID.String(), nil)
+```
+
+**With:**
+```go
+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:
+
+```go
+// 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
+
+```go
+// 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:**
+
+```go
+// 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
+
+```bash
+# Run user tests
+go test cmd/server/tests/user_test.go -v
+```
+
+**Expected:** All new tests pass
+
+**Fix any failures** before proceeding.
+
+**Commit:**
+```bash
+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
+
+```bash
+find bruno -name "*.yml" | head -20
+```
+
+### Step 7.2: Delete Obsolete Bruno Tests
+
+```bash
+# 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)
+
+```yaml
+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`
+
+```yaml
+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`
+
+```yaml
+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`
+
+```yaml
+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:**
+```bash
+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`
+
+```markdown
+# 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`
+
+```markdown
+# 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`
+
+```markdown
+# 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:**
+```bash
+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
+bash scripts/verify-guidelines.sh
+```
+
+**Expected:** 0 errors, warnings are OK
+
+### Step F.2: Run Full Test Suite
+
+```bash
+go test ./... -v
+```
+
+**Expected:** All tests pass
+
+### Step F.3: Build Project
+
+```bash
+go build ./...
+```
+
+**Expected:** No compilation errors
+
+### Step F.4: Review All Changes
+
+```bash
+git diff --stat
+git diff
+```
+
+**Verify:**
+- Only intended files modified
+- No unintended deletions
+- Logic is correct
+
+### Step F.5: Check Documentation Renders
+
+```bash
+# 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
+
+- [x] Database query added and code regenerated
+- [x] UpdateUser handler created (reuses existing queries)
+- [x] ResetUserPassword handler created (standalone)
+- [x] UpdateProfile extended for all fields
+- [x] DeleteUser renamed, uses URL param
+- [x] Routes consolidated (3 removed, 4 added)
+- [x] /profile page created for all users
+- [x] /admin/users page created with user management table
+- [x] Profile modal component created (reusable for self and admin editing)
+- [x] GET /admin/users/:id/profile-modal route added (admin only, server validates)
+- [x] HTMX Edit button loads modal (secure, server-validated)
+- [x] Profile modal conditionally shows current password field
+- [x] HTMX role toggles with page reload after success
+- [x] HTMX delete buttons with page reload after success
+- [x] Last-admin protection in UI (disabled states for role/delete)
+- [x] Header links updated (/settings โ /profile)
+- [x] Admin navigation includes /admin/users link
+- [x] Admin-only profile page removed
+- [x] Login template shows account deleted message
+- [x] All tests passing (40+ new tests)
+- [x] Bruno tests updated
+- [x] Documentation complete
+- [x] Zero compilation errors
+- [x] Zero guideline violations
+- [x] **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.