# API Consolidation Implementation Plan (REVISED - UPDATED) **Goal:** Consolidate fragmented user profile endpoints using simple handler composition (copy/paste existing logic) **Principle:** Surgical, line-by-line changes with verification at each step **Rule #1:** NO OVER-ENGINEERING - Keep It Simple, Copy/Paste Existing Logic **Strategy:** Combine handlers using URL param pattern - same handler for self-edit and admin modes --- ## ๐ŸŽฏ FINAL API DESIGN (Reference) ``` Self-Service: GET /api/auth/profile - Get own profile (UNCHANGED) PUT /api/auth/profile - Update own profile {username, email, first_name, last_name, theme} PUT /api/auth/password - Change own password {current_password, new_password, confirm_password} PUT /api/auth/theme - Quick theme toggle {theme} (UNCHANGED) DELETE /api/auth/profile - Delete your own account Admin: GET /api/auth/users - List all users (UNCHANGED) PUT /api/auth/profile/:id - Update user {username, email, first_name, last_name, theme, role?} PUT /api/auth/password/:id - Reset user password {new_password, confirm_password} PUT /api/auth/users/:id/max-devices - Update device limit (UNCHANGED) DELETE /api/auth/profile/:id - Delete user (with last-admin check) Remove (routes only, handlers kept for reuse): PUT /api/auth/username - Route removed, handler kept for UpdateUser to call PUT /api/auth/email - Route removed, handler kept for UpdateUser to call ``` --- ## ๐Ÿงช Available Test Helpers (From cmd/server/tests/test_helpers.go) **Setup Helpers:** - `setupTestServer(t)` - Creates complete test server with automatic cleanup via `t.Cleanup()` - `setupDeviceTest(t)` - Creates test environment for device tests (includes server + user + token) **User Creation:** - `createTestUserOnce(t, db)` - Creates test user with deterministic credentials (returns `UserTestData`) - Email: "testuser@example.com" - Username: "testuser" - Password: "Test@Pass123!" - Role: "admin" - `createRegularUserOnce(t, db)` - Creates a regular (non-admin) test user with unique credentials (returns `UserTestData`) - Email: unique (e.g., "regularuser-{uuid}@example.com") - Username: unique (e.g., "regularuser-{uuid}") - Password: "Test@Pass123!" - Role: "user" - **Use this for testing admin operations on regular users** - `getTestUserID(t, db)` - Gets or creates test user UUID (admin role) - `getRegularUserID(t, db)` - Gets or creates regular user UUID ("user" role) **Type Conversion:** - `uuidToPGType(u uuid.UUID) pgtype.UUID` - Converts `uuid.UUID` to `pgtype.UUID` for database operations **Authentication:** - `loginTestUser(t, ts, db)` - Logs in test user, returns JWT token - `loginUserWithCredentials(t, ts, email, password)` - Login with custom credentials - `loginRegularUser(t, ts, db)` - Login as regular user (role="user") **Database Verification:** - `verifyUserField(t, db, userID, field, expected)` - Verify user fields in DB (supports: email, first_name, last_name, username, theme) - `verifyDeviceCreated(t, db, deviceID, name, type, identifier)` - Verify device exists - `verifyDeviceDeleted(t, db, deviceID)` - Verify device deleted - `verifyMediaItemInDB(t, db, mediaID)` - Verify media item exists - `verifyMediaItemDeleted(t, db, mediaID)` - Verify media item deleted **Library Creation:** - `createTestLibraryWithFolder(t, ts, token, name, withFolder)` - Creates library with optional folder **Media Item Creation:** - `createTestMediaItemID(t, ts)` - Creates test media item and returns its ID **Other:** - `runConcurrent(t, maxConcurrent, fns)` - Runs functions concurrently for testing --- ## ๐Ÿ“‹ PHASE OVERVIEW 1. **Phase 1:** Database Query Addition 2. **Phase 2:** Extend UpdateProfile, Create Admin Handlers (copy/paste logic) 3. **Phase 3:** Rename DeleteAccount to DeleteUser (use URL param) 4. **Phase 4:** Router Updates (remove/add routes) 5. **Phase 5:** Frontend (Profile page, Header updates, Login updates, Profile modal, Admin users list - 14 steps) 6. **Phase 6:** Test Overhaul (add helpers, remove obsolete, add new - 7 steps) 7. **Phase 7:** Bruno YAML Tests 8. **Phase 8:** Documentation --- ## PHASE 1: Database Query Addition **Goal:** Add UpdateUserRole query **Files:** 1 file, 5 lines added **Risk:** Lowest (no schema changes) ### Step 1.1: Add UpdateUserRole Query **File:** `internal/database/queries/queries.sql` **Location:** After line 334 (after UpdatePassword query) **Action:** Add the following lines: ```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 (Combined Self + Admin) **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 (self-edit) and PUT /api/auth/profile/:id (admin edit) // Combined handler for both self-service and admin profile updates func (h *AuthHandler) UpdateProfile(c echo.Context) error { currentUser := MustGetAuthenticatedUser(c) // Determine target user: URL param (admin mode) or current user (self-edit) targetUserID := c.Param("id") var targetUserUUID pgtype.UUID isAdminMode := targetUserID != "" if isAdminMode { // Admin editing another user - validate admin role if currentUser.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true} } else { // Self-edit mode targetUserUUID = currentUser.ID } var req AdminUpdateUserRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Handle role change (admin mode only) if req.Role != "" && isAdminMode { if req.Role != "admin" && req.Role != "user" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"}) } // Check if this is the last admin (preventing demotion) users, err := h.db.ListUsers(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"}) } adminCount := 0 currentTargetUserRole := "" for _, user := range users { if user.Role == "admin" { adminCount++ } if user.ID.Bytes == targetUserUUID.Bytes { currentTargetUserRole = user.Role } } // Prevent demoting the last admin if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"}) } // Update role _, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{ ID: targetUserUUID, Role: req.Role, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update username (if provided) if req.Username != "" { existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username) if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes { return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"}) } err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{ ID: targetUserUUID, Username: req.Username, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update email (if provided) if req.Email != "" { existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email) if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes { return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"}) } err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{ ID: targetUserUUID, Email: req.Email, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update first/last name (if provided) if req.FirstName != "" || req.LastName != "" { err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{ ID: targetUserUUID, FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""}, LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update theme (if provided) if req.Theme != "" { err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{ ID: targetUserUUID, Theme: pgtype.Text{String: req.Theme, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"}) } ``` ### Step 2.5: Extend UpdatePassword Handler (Combined Self + Admin) **Location:** Find the existing UpdatePassword handler in `internal/handlers/auth.go` **Find existing handler:** ```bash grep -n "func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go ``` **Modify to handle both self-change (requires current password) and admin reset (no current password):** ```go // UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset) // Combined handler for both self-service password change and admin password reset func (h *AuthHandler) UpdatePassword(c echo.Context) error { currentUser := MustGetAuthenticatedUser(c) // Determine target user: URL param (admin mode) or current user (self-change) targetUserID := c.Param("id") var targetUserUUID pgtype.UUID isAdminMode := targetUserID != "" if isAdminMode { // Admin resetting another user's password - validate admin role if currentUser.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true} } else { // Self-change mode targetUserUUID = currentUser.ID } type PasswordRequest struct { CurrentPassword string `json:"current_password,omitempty"` NewPassword string `json:"new_password" validate:"required,passwordcomplex"` ConfirmPassword string `json:"confirm_password" validate:"required"` } var req PasswordRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Self-change mode: require current password if !isAdminMode { if req.CurrentPassword == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"}) } // Get current password hash passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"}) } // Verify current password err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)) if err != nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"}) } } // Validate new password matches confirmation if req.NewPassword != req.ConfirmPassword { return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"}) } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) } // Update password err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{ ID: targetUserUUID, PasswordHash: string(hashedPassword), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"}) } ``` **Verification:** ```bash # Compile check go build ./internal/handlers # Verify handlers exist grep -n "func (h \*AuthHandler) UpdateProfile\|func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go ``` **Commit:** ```bash git add internal/handlers/auth.go git commit -m "feat(auth): combine self-edit and admin handlers - UpdateProfile now handles both self-edit and admin edit (via URL param) - UpdatePassword now handles both self-change and admin reset (via URL param) - Single source of truth for profile and password update logic - Role field only allowed in admin mode (URL param present) - Admin mode validates admin role before allowing operations - Last-admin protection for role demotion - Removes code duplication between self-service and admin handlers " ``` --- ## PHASE 3: Rename DeleteAccount to DeleteUser **Goal:** Rename handler, modify to use URL param instead of query param (already combined pattern) **Files:** 1 file, ~10 lines modified **Risk:** Low (simple rename and param change) **Note:** This handler already follows the combined pattern - no URL param = self-delete, with URL param = admin delete. ### Step 3.1: Rename DeleteAccount to DeleteUser **Location:** Lines 658-731 in `internal/handlers/auth.go` **Read current implementation:** ```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 ``` **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 (combined handlers - self-edit) authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile) authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser) ``` **Add after line 42 (after max-devices route):** ```go // Admin routes (same handlers, with URL param for target user) admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile) admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword) admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser) ``` ### Step 4.4: Verify Router File **Complete file should look like:** ```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 (combined handlers - self-edit) authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile) authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser) // Admin-only routes (same handlers with URL param) admin := protected.Group("/auth", handlers.AdminMiddleware) admin.GET("/users", cfg.AuthHandler.ListUsers) admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices) admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile) admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword) admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser) } ``` **Verification:** ```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 handlers are reused (same handler, different routes) grep "UpdateProfile\|UpdatePassword\|DeleteUser" internal/router/auth.go ``` **Expected:** UpdateProfile, UpdatePassword, DeleteUser each appear twice (self + admin routes) **Commit:** ```bash git add internal/router/auth.go git commit -m "refactor(router): consolidate auth routes with combined handlers - Remove PUT /api/auth/email (merged into /profile) - Remove PUT /api/auth/username (merged into /profile) - Add PUT /api/auth/profile (self-update) - Add DELETE /api/auth/profile (self-deletion) - Add PUT /api/auth/profile/:id (admin update - same handler) - Add PUT /api/auth/password/:id (admin reset - same handler) - Add DELETE /api/auth/profile/:id (admin delete - same handler) - Keep PUT /api/auth/theme unchanged (header quick-toggle) - Combined handlers reduce code duplication " ``` --- ## PHASE 5: Frontend Routes & Templates **Goal:** Create /profile page, create /admin/users page with modal editing, update header links **Files:** 7 files (3 new: profile.templ, profile_modal.templ, admin_users.templ; 4 modified) **Risk:** Medium (template changes, HTMX modal pattern) ### Step 5.1: Extend templates.User Struct **File:** `templates/types.go` **Current User struct:** ```go type User struct { ID string Username string Email string Role string Theme string } ``` **Replace with:** ```go type User struct { ID string Username string Email string Role string Theme string FirstName string LastName string CreatedAt time.Time } ``` **Add import at top of file (if not present):** ```go import "time" ``` ### Step 5.2: 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

@ProfileForm(user, "/api/auth/profile", true, false, false)

Danger Zone

Once you delete your account, there is no going back. Please be certain.

} ``` ### Step 5.3: 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.4: 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.5: 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.6: 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.7: 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.8: 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.9: Create Reusable Profile Form Component **Goal:** Create a reusable profile form component that can be used both for self-editing (Profile page) and admin editing other users (ProfileModal) **File:** `templates/profile_form.templ` (NEW FILE) **Create with following content:** ```templ package templates // ProfileForm is a reusable form component for editing user profiles // - actionURL: The endpoint to submit the form to (e.g., "/api/auth/profile" or "/api/auth/profile/{id}") // - requireCurrentPassword: If true, show current password field (self-edit); if false, admin reset mode // - showRoleField: If true, show role dropdown (admin editing other users) // - showCancelButton: If true, show cancel button that closes modal (modal context); if false, no cancel button (page context) templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {

Account Information

if showRoleField {
}

Change Password

if requireCurrentPassword {
} else {

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

}
if showCancelButton { }
} ``` ### Step 5.10: Create Profile Modal Component **Goal:** Modal wrapper for admin editing other users, using the reusable ProfileForm **File:** `templates/profile_modal.templ` (NEW FILE) **Create with following content:** ```templ package templates // ProfileModal is used by admins to edit other users // Always uses: actionURL = "/api/auth/profile/{user.ID}", requireCurrentPassword = false, showRoleField = true, showCancelButton = true templ ProfileModal(user User) {

Edit User Profile

{ user.Username } ({ user.Email })

@ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
} ``` **Generate template:** ```bash templ generate ``` ### Step 5.11: 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 frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } // Get target user ID from URL targetUserID := c.Param("id") parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.HTML(http.StatusBadRequest, "
Invalid user ID
") } // Fetch target user targetUser, err := cfg.Queries.GetUser(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}) if err != nil { return c.HTML(http.StatusNotFound, "
User not found
") } // Convert to template user templateUser := toTemplateUser(targetUser) // Render modal (admin editing other user) var buf bytes.Buffer err = templates.ProfileModal(templateUser).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) })) ``` **Note:** This adds a new admin-only endpoint that returns the profile modal HTML for any user. Server validates admin before returning modal. ### Step 5.12: Create Admin Users List Page **Goal:** Admin page to view and manage all users with role toggles, delete actions, and modal profile editing **File:** `templates/admin_users.templ` (NEW FILE) **Create with following content:** ```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 { }
Username Email Role Created Actions
{user.Username}
if user.ID == currentUserID { You }
{user.Email}
@if user.Role == "admin" && adminCount == 1 {
โš ๏ธ
} else {
}
{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.13: Add Admin Users Route **File:** `internal/router/frontend.go` **Add after the /profile route (around line 395):** ```go // Admin users page frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } // Fetch all users users, err := cfg.Queries.ListUsers(c.Request().Context()) if err != nil { return renderErrorPage(c, "Error loading users", "users_load_error") } // Count admins for UI protection adminCount := 0 for _, u := range users { if u.Role == "admin" { adminCount++ } } // Get current user ID for "You" badge currentUserID := user.ID var buf bytes.Buffer err = templates.AdminUsers(toTemplateUsers(users), adminCount, currentUserID).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) })) ``` **Note:** You'll need helper functions to convert database users to template users. Add these in `internal/router/frontend.go`: ```go import "github.com/google/uuid" // Convert single database user (from GetUser) to template user func toTemplateUser(dbUser database.GetUserRow) templates.User { return templates.User{ ID: uuid.UUID(dbUser.ID.Bytes).String(), Username: dbUser.Username, Email: dbUser.Email, Role: dbUser.Role, FirstName: dbUser.FirstName.String, LastName: dbUser.LastName.String, Theme: dbUser.Theme.String, CreatedAt: dbUser.CreatedAt.Time, } } // Convert single database user (from ListUsers) to template user func toTemplateUserFromList(dbUser database.ListUsersRow) templates.User { return templates.User{ ID: uuid.UUID(dbUser.ID.Bytes).String(), Username: dbUser.Username, Email: dbUser.Email, Role: dbUser.Role, FirstName: dbUser.FirstName.String, LastName: dbUser.LastName.String, Theme: dbUser.Theme.String, CreatedAt: dbUser.CreatedAt.Time, } } // Convert slice of database users (from ListUsers) to template users func toTemplateUsers(dbUsers []database.ListUsersRow) []templates.User { users := make([]templates.User, len(dbUsers)) for i, u := range dbUsers { users[i] = toTemplateUserFromList(u) } return users } ``` ### Step 5.14: 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_form.templ (reusable form component) - Create templates/profile_modal.templ (admin-only modal using ProfileForm) - Add GET /admin/users/:id/profile-modal route (admin only, server validates) - Create templates/admin_users.templ with user management table - Add GET /admin/users route with SSR initial data fetch - HTMX Edit button loads modal (no page navigation, secure) - HTMX role toggles with page reload after success - HTMX delete buttons with page reload after success - Last-admin protection in UI (disabled states for role/delete) - ProfileForm supports both self-edit and admin-edit modes via parameters - Progressive enhancement - page reloads after successful actions - Server-side security - admin-only access validated before modal sent " ``` --- ## PHASE 6: Test Overhaul **Goal:** Remove obsolete tests, add comprehensive tests **Files:** 1 file, ~50 tests removed, ~40 tests added **Risk:** Highest (test changes must be thorough) ### Step 6.1: Backup Current Tests ```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: Add New Test Helper Functions **File:** `cmd/server/tests/test_helpers.go` **Add these helper functions to the file:** ```go // uuidToPGType converts uuid.UUID to pgtype.UUID for database operations func uuidToPGType(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(u), Valid: true} } // createRegularUserOnce creates a regular (non-admin) test user with unique credentials // Use this for testing admin operations on regular users func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData { ctx := context.Background() uniqueID := uuid.New().String()[:8] email := "regular-" + uniqueID + "@example.com" username := "regular-" + uniqueID password := "Test@Pass123!" // Hash the password passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) require.NoError(t, err, "Failed to hash password") // Create user with regular role pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true} newUser, err := db.CreateUser(ctx, database.CreateUserParams{ ID: pgUserID, Email: email, Username: username, PasswordHash: string(passwordHash), Role: "user", }) require.NoError(t, err, "Failed to create regular user") userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16]) require.NoError(t, err, "Failed to convert user ID to UUID") return UserTestData{ ID: userUUID, Email: email, Username: username, Password: password, } } ``` ### Step 6.4: Remove Obsolete Tests **File:** `cmd/server/tests/user_test.go` **Find and delete these test blocks:** 1. **PUT /api/auth/email tests** - Find lines: ```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.5: 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) rec := httptest.NewRecorder() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Mock handler logic w.Write([]byte(`{"message":"profile updated successfully"}`)) }) handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) }) ``` **Modify DELETE /api/auth/account tests to use new endpoints:** **Find all occurrences:** ```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.6: Add New Complete Tests **NOTE:** `createTestUserOnce()` creates an admin user (role="admin"). For admin tests: - Use `createTestUserOnce(t, setup.DB)` for the admin - Use `createRegularUserOnce(t, setup.DB)` for a regular user (role="user") to test admin operations on regular users - Use `uuidToPGType(user.ID)` to convert `uuid.UUID` to `pgtype.UUID` for database operations **Add at end of file before final closing brace:** ```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 _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) username := "newusername" jsonData, _ := json.Marshal(map[string]interface{}{ "username": username, }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify username was updated updatedUser, err := setup.DB.GetUserByUsername(context.Background(), username) assert.NoError(t, err) assert.Equal(t, username, updatedUser.Username) }) t.Run("PUT /api/auth/profile - Update email only", func(t *testing.T) { setup := setupTestServer(t) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) email := "newemail@example.com" jsonData, _ := json.Marshal(map[string]interface{}{ "email": email, }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify email was updated updatedUser, err := setup.DB.GetUserByEmail(context.Background(), email) assert.NoError(t, err) assert.Equal(t, email, updatedUser.Email) }) t.Run("PUT /api/auth/profile - Update theme only", func(t *testing.T) { setup := setupTestServer(t) user := createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) theme := "dracula" jsonData, _ := json.Marshal(map[string]interface{}{ "theme": theme, }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify theme was updated updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID)) assert.NoError(t, err) assert.Equal(t, theme, updatedUser.Theme.String) }) t.Run("PUT /api/auth/profile - Update all fields", func(t *testing.T) { setup := setupTestServer(t) user := createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) jsonData, _ := json.Marshal(map[string]interface{}{ "username": "newalluser", "email": "newall@example.com", "first_name": "NewAll", "last_name": "User", "theme": "nord", }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify all fields updated updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID)) assert.NoError(t, err) assert.Equal(t, "newalluser", updatedUser.Username) assert.Equal(t, "newall@example.com", updatedUser.Email) }) t.Run("PUT /api/auth/profile - Username conflict", func(t *testing.T) { setup := setupTestServer(t) // Create first user using helper _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create second user manually with different username passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true} _, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{ ID: pgUserID, Email: "user2@example.com", Username: "user2", PasswordHash: passwordHash, Role: "user", }) require.NoError(t, err) // Try to update user1 to user2's username jsonData, _ := json.Marshal(map[string]interface{}{ "username": "user2", }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusConflict, rec.Code) }) t.Run("PUT /api/auth/profile - Email conflict", func(t *testing.T) { setup := setupTestServer(t) // Create first user using helper _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create second user manually with different email passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true} _, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{ ID: pgUserID, Email: "email2@example.com", Username: "email2user", PasswordHash: passwordHash, Role: "user", }) require.NoError(t, err) jsonData, _ := json.Marshal(map[string]interface{}{ "email": "email2@example.com", }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusConflict, rec.Code) }) t.Run("PUT /api/auth/profile - No auth", func(t *testing.T) { setup := setupTestServer(t) jsonData, _ := json.Marshal(map[string]interface{}{ "username": "test", }) req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) // No Authorization header rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) }) } // TestUpdateProfileAdminMode tests admin updating another user (same handler, with URL param) func TestUpdateProfileAdminMode(t *testing.T) { t.Run("PUT /api/auth/profile/:id - Admin update username", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user to update passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true} user, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{ ID: pgUserID, Email: "regularuser@example.com", Username: "regularuser", PasswordHash: passwordHash, Role: "user", }) require.NoError(t, err) userUUID, _ := uuid.FromBytes(user.ID.Bytes[0:16]) newUsername := "updateduser" jsonData, _ := json.Marshal(map[string]interface{}{ "username": newUsername, }) req := httptest.NewRequest("PUT", "/api/auth/profile/"+userUUID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify username updated updatedUser, err := setup.DB.GetUserByUsername(context.Background(), newUsername) assert.NoError(t, err) assert.Equal(t, newUsername, updatedUser.Username) }) t.Run("PUT /api/auth/profile/:id - Admin promote user to admin", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user to promote user := createRegularUserOnce(t, setup.DB) jsonData, _ := json.Marshal(map[string]interface{}{ "role": "admin", }) req := httptest.NewRequest("PUT", "/api/auth/profile/"+user.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify role changed updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID)) assert.NoError(t, err) assert.Equal(t, "admin", updatedUser.Role) }) t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) { setup := setupTestServer(t) // Create only one admin (createTestUserOnce creates admin) lastAdmin := createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Try to demote self jsonData, _ := json.Marshal(map[string]interface{}{ "role": "user", }) req := httptest.NewRequest("PUT", "/api/auth/profile/"+lastAdmin.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "cannot demote the last admin") }) t.Run("PUT /api/auth/profile/:id - Non-admin tries update", func(t *testing.T) { setup := setupTestServer(t) // Create admin first targetAdmin := createTestUserOnce(t, setup.DB) // Create regular user (non-admin) regularUser := createRegularUserOnce(t, setup.DB) // Login as regular user token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password) jsonData, _ := json.Marshal(map[string]interface{}{ "username": "hacked", }) req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) }) t.Run("PUT /api/auth/profile/:id - Invalid role", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user to update targetUser := createRegularUserOnce(t, setup.DB) jsonData, _ := json.Marshal(map[string]interface{}{ "role": "superadmin", }) req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "invalid role") }) } // TestUpdatePasswordAdminMode tests admin password reset (same handler, with URL param) func TestUpdatePasswordAdminMode(t *testing.T) { t.Run("PUT /api/auth/password/:id - Admin reset password", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user to reset password for targetUser := createRegularUserOnce(t, setup.DB) newPassword := "NewPassword123!" jsonData, _ := json.Marshal(map[string]interface{}{ "new_password": newPassword, "confirm_password": newPassword, }) req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify password changed by attempting login with new password loginData, _ := json.Marshal(map[string]interface{}{ "login": targetUser.Username, "password": newPassword, }) loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData)) loginRec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(loginRec, loginReq) assert.Equal(t, http.StatusOK, loginRec.Code) }) t.Run("PUT /api/auth/password/:id - Non-admin tries reset", func(t *testing.T) { setup := setupTestServer(t) // Create admin first targetAdmin := createTestUserOnce(t, setup.DB) // Create regular user (non-admin) regularUser := createRegularUserOnce(t, setup.DB) // Login as regular user token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password) jsonData, _ := json.Marshal(map[string]interface{}{ "new_password": "hacked", "confirm_password": "hacked", }) req := httptest.NewRequest("PUT", "/api/auth/password/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) }) t.Run("PUT /api/auth/password/:id - Mismatched passwords", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user targetUser := createRegularUserOnce(t, setup.DB) jsonData, _ := json.Marshal(map[string]interface{}{ "new_password": "password1", "confirm_password": "password2", }) req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "passwords do not match") }) t.Run("PUT /api/auth/password/:id - Invalid password format", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user targetUser := createRegularUserOnce(t, setup.DB) jsonData, _ := json.Marshal(map[string]interface{}{ "new_password": "123", "confirm_password": "123", }) req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) }) } // TestDeleteUserConsolidated tests account deletion func TestDeleteUserConsolidated(t *testing.T) { t.Run("DELETE /api/auth/profile - User deletes self successfully", func(t *testing.T) { setup := setupTestServer(t) // Create admin first so we have at least 2 users (admin won't delete self) _ = createTestUserOnce(t, setup.DB) // Create regular user who will delete themselves selfDeletingUser := createRegularUserOnce(t, setup.DB) token := loginUserWithCredentials(t, setup.Server, selfDeletingUser.Email, selfDeletingUser.Password) req := httptest.NewRequest("DELETE", "/api/auth/profile", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify user deleted _, err := setup.DB.GetUser(context.Background(), uuidToPGType(selfDeletingUser.ID)) assert.Error(t, err) }) t.Run("DELETE /api/auth/profile - Delete without auth", func(t *testing.T) { setup := setupTestServer(t) req := httptest.NewRequest("DELETE", "/api/auth/profile", nil) // No auth rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) }) t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) { setup := setupTestServer(t) // Create only one admin (createTestUserOnce creates admin) lastAdmin := createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) req := httptest.NewRequest("DELETE", "/api/auth/profile", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "cannot delete the last admin") }) t.Run("DELETE /api/auth/profile/:id - Admin deletes user", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user to delete targetUser := createRegularUserOnce(t, setup.DB) req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetUser.ID.String(), nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // Verify user deleted _, err := setup.DB.GetUser(context.Background(), uuidToPGType(targetUser.ID)) assert.Error(t, err) }) t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) { setup := setupTestServer(t) // Create only one admin (createTestUserOnce creates admin) lastAdmin := createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) req := httptest.NewRequest("DELETE", "/api/auth/profile/"+lastAdmin.ID.String(), nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "cannot delete the last admin") }) t.Run("DELETE /api/auth/profile/:id - Non-admin tries delete", func(t *testing.T) { setup := setupTestServer(t) // Create admin first targetAdmin := createTestUserOnce(t, setup.DB) // Create regular user (non-admin) regularUser := createRegularUserOnce(t, setup.DB) // Login as regular user token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password) req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetAdmin.ID.String(), nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) }) t.Run("DELETE /api/auth/profile/:id - Delete non-existent user", func(t *testing.T) { setup := setupTestServer(t) // Admin user (createTestUserOnce creates admin role) _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) fakeUUID := uuid.New() req := httptest.NewRequest("DELETE", "/api/auth/profile/"+fakeUUID.String(), nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) }) } ``` ### Step 6.7: Run Tests ```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/test_helpers.go cmd/server/tests/user_test.go git commit -m "test(auth): overhaul user profile tests for API consolidation - Add createRegularUserOnce helper for non-admin test users - Add uuidToPGType helper for UUID type conversion - Remove 9 obsolete tests (username, email endpoints) - Modify existing profile test for new fields - Modify DELETE account tests for new endpoints - Add TestUpdateProfileConsolidated (7 tests) - self-edit mode - Add TestUpdateProfileAdminMode (5 tests) - admin mode via URL param - Add TestUpdatePasswordAdminMode (4 tests) - admin reset via URL param - Add TestDeleteUserConsolidated (7 tests) - combined self/admin delete - Cover all scenarios: self-update, admin update, role changes - Test last-admin protection thoroughly - Test conflict detection (username, email) - Test combined handlers work in both modes " ``` --- ## PHASE 7: Bruno OpenCollection Tests **Goal:** Update/create Bruno YAML tests **Files:** ~5 files (0 delete, 2 modify, 3 create) **Risk:** Low (contract testing) ### Step 7.1: Check Current Bruno Structure ```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/user/profile/Update Profile.yml` **Current content should be updated to:** ```yaml info: name: Update Profile type: http seq: 4 http: method: PUT url: '{{base_url}}/api/auth/profile' auth: inherit body: type: json jsonBody: |- { "username": "updateduser", "email": "updated@example.com", "first_name": "Updated", "last_name": "User", "theme": "dracula" } docs: |- ## Update User Profile Updates the authenticated user's profile information. **Method:** PUT **Endpoint:** /api/auth/profile **Authentication:** Required (Bearer token) **Request Body:** - `username` (string, optional): New username (must be unique) - `email` (string, optional): New email address (must be unique) - `first_name` (string, optional): First name - `last_name` (string, optional): Last name - `theme` (string, optional): Theme preference **Response:** - `message` (string): Success message **Status Codes:** - 200: Success - 400: Invalid request - 401: Unauthorized - 409: Username or email already taken ``` ### Step 7.4: Create Admin Update User Test **File:** `bruno/user/admin/Update User.yml` ```yaml info: name: Update User type: http seq: 10 http: method: PUT url: '{{base_url}}/api/auth/profile/{{user_id}}' auth: inherit body: type: json jsonBody: |- { "username": "newusername", "email": "newemail@example.com", "role": "admin" } docs: |- ## Admin Update User Admin-only endpoint to update another user's profile. **Method:** PUT **Endpoint:** /api/auth/profile/:id **Authentication:** Required (Admin Bearer token) **Request Body:** - `username` (string, optional): New username - `email` (string, optional): New email - `first_name` (string, optional): First name - `last_name` (string, optional): Last name - `theme` (string, optional): Theme preference - `role` (string, optional): Role ("user" or "admin") - admin only **Status Codes:** - 200: Success - 400: Invalid role or cannot demote last admin - 403: Not an admin - 404: User not found - 409: Username or email already taken ``` ### Step 7.5: Create Admin Reset Password Test **File:** `bruno/user/admin/Reset User Password.yml` ```yaml info: name: Reset User Password type: http seq: 11 http: method: PUT url: '{{base_url}}/api/auth/password/{{user_id}}' auth: inherit body: type: json jsonBody: |- { "new_password": "NewSecurePassword123!", "confirm_password": "NewSecurePassword123!" } docs: |- ## Admin Reset User Password Admin-only endpoint to reset another user's password. **Method:** PUT **Endpoint:** /api/auth/password/:id **Authentication:** Required (Admin Bearer token) **Request Body:** - `new_password` (string, required): New password - `confirm_password` (string, required): Must match new_password **Status Codes:** - 200: Success - 400: Passwords do not match or invalid format - 403: Not an admin - 404: User not found ``` ### Step 7.6: Create Delete Account Tests **File:** `bruno/user/admin/Delete User.yml` ```yaml info: name: Delete User type: http seq: 12 http: method: DELETE url: '{{base_url}}/api/auth/profile/{{user_id}}' auth: inherit docs: |- ## Delete User Account Delete a user account. Users can delete their own account, admins can delete any user. **Method:** DELETE **Endpoint:** /api/auth/profile (self) or /api/auth/profile/:id (admin) **Authentication:** Required (Bearer token) **Status Codes:** - 200: Success - 400: Cannot delete the last admin - 403: Not an admin (when trying to delete another user) - 404: User not found ``` **Commit:** ```bash git add bruno/ git commit -m "test(bruno): update auth tests for API consolidation - Delete obsolete tests: Update Username.yml, Update Email.yml - Update Update Profile.yml with new consolidated endpoint - Add Update User.yml for admin user management - Add Reset User Password.yml for admin password resets - Add Delete User.yml for account deletion (self and admin) - Document all scenarios with proper request bodies " ``` --- ## PHASE 8: Documentation **Goal:** Update API and user documentation **Files:** ~5 files (create/update) **Risk:** Lowest ### Step 8.1: Update API Documentation **Create:** `docs/developer/api/auth/profile.md` ```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.