From d0a204234487db39f08505af99a22e70486d8f8c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 21 Feb 2026 00:58:50 -0500 Subject: [PATCH] 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 --- API_CONSOLIDATION_PLAN.md | 3143 +++++++++++++++++++++++++++++++++++++ 1 file changed, 3143 insertions(+) create mode 100644 API_CONSOLIDATION_PLAN.md 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 }) +

+
+ +
+ + +
+
+ + +
+

Account Information

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

Change Password

+ + @if !isAdminEditingOther { + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ } else { + +

+ As an admin, you can change this user's password without knowing their current password. +

+ +
+
+ + +
+ +
+ + +
+ + +
+
+ } +
+ + +
+ + +
+ +
+
+
+
+
+ + +} +``` + +**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

+
+ + +
+ + + + + + + + + + + + @for _, user := range users { + + + + + + + + + + + + + + + + + } + +
UsernameEmailRoleCreatedActions
+
+
+
{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 +
+ Profile Settings +
+``` + +**Add after it:** +```templ +
+ Users +
+``` + +**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.