diff --git a/API_CONSOLIDATION_PLAN.md b/API_CONSOLIDATION_PLAN.md index 6771a81..391a8bd 100644 --- a/API_CONSOLIDATION_PLAN.md +++ b/API_CONSOLIDATION_PLAN.md @@ -3,7 +3,7 @@ **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) +**Strategy:** Combine handlers using URL param pattern - same handler for self-edit and admin modes --- @@ -43,9 +43,18 @@ Remove (routes only, handlers kept for reuse): - 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 @@ -75,8 +84,8 @@ Remove (routes only, handlers kept for reuse): 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) +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 @@ -211,7 +220,7 @@ type AdminUpdateUserRequest struct { } ``` -### Step 2.4: Replace UpdateProfile Handler +### Step 2.4: Replace UpdateProfile Handler (Combined Self + Admin) **Location:** Lines 452-470 in `internal/handlers/auth.go` @@ -247,106 +256,30 @@ func (h *AuthHandler) UpdateProfile(c echo.Context) error { **Replace with:** ```go -// UpdateProfile handles PUT /api/auth/profile -// Updates username, email, first_name, last_name, theme +// 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 { - 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 + // Determine target user: URL param (admin mode) or current user (self-edit) targetUserID := c.Param("id") - if targetUserID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"}) - } + var targetUserUUID pgtype.UUID + isAdminMode := targetUserID != "" - parsedUUID, err := uuid.Parse(targetUserID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) + 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 } - targetUserUUID := pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true} var req AdminUpdateUserRequest if err := c.Bind(&req); err != nil { @@ -356,8 +289,8 @@ func (h *AuthHandler) UpdateUser(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - // Handle role change (if provided) - if req.Role != "" { + // 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'"}) } @@ -396,13 +329,11 @@ func (h *AuthHandler) UpdateUser(c echo.Context) 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, @@ -414,13 +345,11 @@ func (h *AuthHandler) UpdateUser(c echo.Context) 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, @@ -453,39 +382,54 @@ func (h *AuthHandler) UpdateUser(c echo.Context) error { } } - return c.JSON(http.StatusOK, map[string]string{"message": "user updated successfully"}) + return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"}) } ``` -### Step 2.6: Create ResetUserPassword Handler +### Step 2.5: Extend UpdatePassword Handler (Combined Self + Admin) -**Location:** After UpdateUser handler (after the code we just added) +**Location:** Find the existing UpdatePassword handler in `internal/handlers/auth.go` -**Add immediately after UpdateUser ends:** +**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 -// 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 { +// 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") - if targetUserID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"}) + 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 } - 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 { + 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 PasswordResetRequest + var req PasswordRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } @@ -493,17 +437,37 @@ func (h *AuthHandler) ResetUserPassword(c echo.Context) error { 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 password (reuse logic from UpdatePassword) + // 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 directly (no current password check) + // Update password err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{ ID: targetUserUUID, PasswordHash: string(hashedPassword), @@ -512,7 +476,7 @@ func (h *AuthHandler) ResetUserPassword(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - return c.JSON(http.StatusOK, map[string]string{"message": "password reset successfully"}) + return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"}) } ``` @@ -521,21 +485,22 @@ func (h *AuthHandler) ResetUserPassword(c echo.Context) error { # Compile check go build ./internal/handlers -# Verify new handlers exist -grep -n "func (h \*AuthHandler) UpdateUser\|func (h \*AuthHandler) ResetUserPassword" internal/handlers/auth.go +# 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): extend UpdateProfile and add admin handlers +git commit -m "feat(auth): combine self-edit and 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 +- 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 " ``` @@ -543,10 +508,12 @@ git commit -m "feat(auth): extend UpdateProfile and add admin handlers ## PHASE 3: Rename DeleteAccount to DeleteUser -**Goal:** Rename handler, modify to use URL param instead of query param +**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` @@ -577,8 +544,6 @@ func (h *AuthHandler) DeleteUser(c echo.Context) error { 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 @@ -679,7 +644,7 @@ authGroup.PUT("/username", cfg.AuthHandler.UpdateUsername) **Add after line 37 (after theme route):** ```go - // Profile management + // Profile management (combined handlers - self-edit) authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile) authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser) ``` @@ -687,8 +652,9 @@ authGroup.PUT("/username", cfg.AuthHandler.UpdateUsername) **Add after line 42 (after max-devices route):** ```go - admin.PUT("/profile/:id", cfg.AuthHandler.UpdateUser) - admin.PUT("/password/:id", cfg.AuthHandler.ResetUserPassword) + // 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) ``` @@ -732,16 +698,16 @@ func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) { authGroup.PUT("/password", cfg.AuthHandler.UpdatePassword) authGroup.PUT("/theme", cfg.AuthHandler.UpdateTheme) - // Profile management (self and admin) + // Profile management (combined handlers - self-edit) authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile) authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser) - // Admin-only routes for user management + // 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.UpdateUser) - admin.PUT("/password/:id", cfg.AuthHandler.ResetUserPassword) + admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile) + admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword) admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser) } ``` @@ -754,27 +720,26 @@ 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 +# Verify handlers are reused (same handler, different routes) +grep "UpdateProfile\|UpdatePassword\|DeleteUser" internal/router/auth.go ``` -**Expected:** Two lines - one for authGroup.PUT("/profile"), one for admin.PUT("/profile/:id") +**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 +git commit -m "refactor(router): consolidate auth routes with combined handlers -- 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 PUT /api/auth/profile (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) +- 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) -- Handlers UpdateUsername/UpdateEmail kept for internal reuse +- Combined handlers reduce code duplication " ``` @@ -786,7 +751,41 @@ git commit -m "refactor(router): consolidate auth routes **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 +### 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) @@ -816,75 +815,9 @@ templ Profile(user User) {
- +
-

Account Information

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
-
-
- - -
-

Change Password

- -
-
- - -
- -
- - -
- -
- - -
- - -
-
+ @ProfileForm(user, "/api/auth/profile", true, false, false)
@@ -929,7 +862,7 @@ templ Profile(user User) { } ``` -### Step 5.2: Update Header Template +### Step 5.3: Update Header Template **File:** `templates/header.templ` @@ -950,7 +883,7 @@ templ Profile(user User) { templ generate ``` -### Step 5.3: Update Admin Template +### Step 5.4: Update Admin Template **File:** `templates/admin.templ` @@ -971,7 +904,7 @@ templ generate templ generate ``` -### Step 5.4: Delete Admin Profile Template +### Step 5.5: Delete Admin Profile Template **File:** `templates/admin_profile.templ` @@ -985,7 +918,7 @@ rm templates/admin_profile.templ templ generate ``` -### Step 5.5: Add Frontend Route for Profile Page +### Step 5.6: Add Frontend Route for Profile Page **File:** `internal/router/frontend.go` @@ -1008,7 +941,7 @@ frontendProtected.GET("/profile", func(c echo.Context) error { }) ``` -### Step 5.6: Update Login Template +### Step 5.7: Update Login Template **File:** `templates/login.templ` @@ -1039,7 +972,7 @@ if deleted { templ generate ``` -### Step 5.7: Update Login Frontend Route +### Step 5.8: Update Login Frontend Route **File:** `internal/router/frontend.go` @@ -1089,9 +1022,200 @@ git commit -m "feat(frontend): add universal /profile page, remove admin-only pr " ``` -### Step 5.8: Create Profile Modal Component +### Step 5.9: Create Reusable Profile Form Component -**Goal:** Reusable modal component for editing user profiles (self and admin editing other users) +**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) @@ -1100,15 +1224,15 @@ git commit -m "feat(frontend): add universal /profile page, remove admin-only pr ```templ package templates -templ ProfileModal(user User, isAdminEditingOther bool) { +// 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) {
-

- { isAdminEditingOther ? "Edit User Profile" : "Edit Your Profile" } -

+

Edit User Profile

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

@@ -1122,163 +1246,9 @@ templ ProfileModal(user User, isAdminEditingOther bool) {
- +
-
- - -
-

Account Information

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- - -
-

Change Password

- - @if !isAdminEditingOther { - -
-
- - -
- -
- - -
- -
- - -
- - -
-
- } else { - -

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

- -
-
- - -
- -
- - -
- - -
-
- } -
- - -
- - -
- -
-
+ @ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
@@ -1306,7 +1276,7 @@ templ ProfileModal(user User, isAdminEditingOther bool) { templ generate ``` -### Step 5.9: Add Profile Modal Route +### Step 5.11: Add Profile Modal Route **File:** `internal/router/frontend.go` @@ -1314,17 +1284,12 @@ templ generate ```go // Admin: Get profile modal for editing user -frontendAdmin.GET("/users/:id/profile-modal", func(c echo.Context) error { +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") } - // 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) @@ -1333,7 +1298,7 @@ frontendAdmin.GET("/users/:id/profile-modal", func(c echo.Context) error { } // Fetch target user - targetUser, err := cfg.DB.GetUser(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}) + 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
") } @@ -1343,17 +1308,17 @@ frontendAdmin.GET("/users/:id/profile-modal", func(c echo.Context) error { // Render modal (admin editing other user) var buf bytes.Buffer - err = templates.ProfileModal(templateUser, true).Render(c.Request().Context(), &buf) + 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.10: Create Admin Users List Page +### 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 @@ -1407,7 +1372,9 @@ templ AdminUsers(users []User, adminCount int, currentUserID string) {
{user.Username}
- { user.ID == currentUserID ? `You` : `` } + if user.ID == currentUserID { + You + }
@@ -1453,7 +1420,7 @@ templ AdminUsers(users []User, adminCount int, currentUserID string) { - {fmt.Sprintf("%s", user.CreatedAt.Format("2006-01-02"))} + {user.CreatedAt.Format("2006-01-02")} @@ -1526,7 +1493,7 @@ templ generate ``` -### Step 5.11: Add Admin Users Route +### Step 5.13: Add Admin Users Route **File:** `internal/router/frontend.go` @@ -1534,19 +1501,14 @@ templ generate ```go // Admin users page -frontendAdmin.GET("/users", func(c echo.Context) error { +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") } - // 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()) + users, err := cfg.Queries.ListUsers(c.Request().Context()) if err != nil { return renderErrorPage(c, "Error loading users", "users_load_error") } @@ -1568,37 +1530,53 @@ frontendAdmin.GET("/users", func(c echo.Context) error { 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 { +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: dbUser.ID, + ID: uuid.UUID(dbUser.ID.Bytes).String(), Username: dbUser.Username, Email: dbUser.Email, Role: dbUser.Role, - FirstName: dbUser.FirstName, - LastName: dbUser.LastName, - Theme: dbUser.Theme, - CreatedAt: dbUser.CreatedAt, + FirstName: dbUser.FirstName.String, + LastName: dbUser.LastName.String, + Theme: dbUser.Theme.String, + CreatedAt: dbUser.CreatedAt.Time, } } -// Convert slice of database users to template users -func toTemplateUsers(dbUsers []database.User) []templates.User { +// 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] = toTemplateUser(u) + users[i] = toTemplateUserFromList(u) } return users } ``` -### Step 5.12: Update Admin Navigation Link +### Step 5.14: Update Admin Navigation Link **File:** `templates/admin.templ` @@ -1640,7 +1618,8 @@ grep "admin.GET.*users" internal/router/frontend.go 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) +- 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 @@ -1648,9 +1627,7 @@ git commit -m "feat(frontend): add admin users management with modal profile edi - 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 +- 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 " @@ -1680,7 +1657,56 @@ grep -c "t.Run(" cmd/server/tests/user_test.go grep "^func Test" cmd/server/tests/user_test.go ``` -### Step 6.3: Remove Obsolete Tests +### 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` @@ -1702,7 +1728,7 @@ 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 +### Step 6.5: Modify Existing Tests **Modify PUT /api/auth/profile test:** @@ -1732,14 +1758,14 @@ grep -n "PUT /api/auth/profile - Update profile" cmd/server/tests/user_test.go req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() + 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(w, req) - assert.Equal(t, http.StatusOK, w.Code) + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) }) ``` @@ -1770,39 +1796,12 @@ req := httptest.NewRequest("DELETE", "/api/auth/account?user_id="+userID.String( 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", -}) -``` +### 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 -- 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", -}) -``` +- 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:** @@ -1811,10 +1810,9 @@ user2, err := setup.DB.CreateUser(context.Background(), database.CreateUserParam 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) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) username := "newusername" @@ -1825,10 +1823,10 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify username was updated updatedUser, err := setup.DB.GetUserByUsername(context.Background(), username) @@ -1838,9 +1836,8 @@ func TestUpdateProfileConsolidated(t *testing.T) { t.Run("PUT /api/auth/profile - Update email only", func(t *testing.T) { setup := setupTestServer(t) - - user := createTestUserOnce(t, setup.DB) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) email := "newemail@example.com" @@ -1851,10 +1848,10 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify email was updated updatedUser, err := setup.DB.GetUserByEmail(context.Background(), email) @@ -1864,7 +1861,6 @@ func TestUpdateProfileConsolidated(t *testing.T) { 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) @@ -1877,20 +1873,19 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify theme was updated - updatedUser, err := setup.DB.GetUser(context.Background(), user.ID) + 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) @@ -1906,13 +1901,13 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify all fields updated - updatedUser, err := setup.DB.GetUser(context.Background(), user.ID) + 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) @@ -1920,10 +1915,9 @@ func TestUpdateProfileConsolidated(t *testing.T) { 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) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create second user manually with different username @@ -1946,18 +1940,17 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusConflict, w.Code) + 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 - user1 := createTestUserOnce(t, setup.DB) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create second user manually with different email @@ -1979,15 +1972,14 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusConflict, w.Code) + 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", @@ -1996,21 +1988,20 @@ func TestUpdateProfileConsolidated(t *testing.T) { req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) // No Authorization header - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, http.StatusUnauthorized, rec.Code) }) } -// TestUpdateUserAdmin tests admin updating another user -func TestUpdateUserAdmin(t *testing.T) { +// 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) - admin := createTestUserOnce(t, setup.DB) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Create regular user to update @@ -2034,10 +2025,10 @@ func TestUpdateUserAdmin(t *testing.T) { 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) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify username updated updatedUser, err := setup.DB.GetUserByUsername(context.Background(), newUsername) @@ -2047,18 +2038,14 @@ func TestUpdateUserAdmin(t *testing.T) { 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) + // 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", }) @@ -2066,27 +2053,22 @@ func TestUpdateUserAdmin(t *testing.T) { 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) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify role changed - updatedUser, err := setup.DB.GetUser(context.Background(), user.ID) + 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) - - admin := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) + // Create only one admin (createTestUserOnce creates admin) + lastAdmin := createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) // Try to demote self @@ -2094,186 +2076,174 @@ func TestUpdateUserAdmin(t *testing.T) { "role": "user", }) - req := httptest.NewRequest("PUT", "/api/auth/profile/"+admin.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/profile/"+lastAdmin.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "cannot demote the last admin") + 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) - - user1 := createTestUserOnce(t, setup.DB) - user2 := createTestUserOnce(t, setup.DB) - token := loginTestUser(t, setup.Server, setup.DB) + // 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/"+user2.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, http.StatusForbidden, rec.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", - }) + // Admin user (createTestUserOnce creates admin role) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) - user := createTestUserOnce(t, 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/"+user.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "invalid role") + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid role") }) } -// TestResetUserPassword tests admin password reset functionality -func TestResetUserPassword(t *testing.T) { +// 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 := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) - - user := createTestUserOnce(t, setup.DB) + // Admin user (createTestUserOnce creates admin role) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) - newPassword := "newpassword123" + // 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/"+user.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify password changed by attempting login with new password loginData, _ := json.Marshal(map[string]interface{}{ - "login": "passuser", + "login": targetUser.Username, "password": newPassword, }) loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData)) - loginW := httptest.NewRecorder() - setup.Router.ServeHTTP(loginW, loginReq) + loginRec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(loginRec, loginReq) - assert.Equal(t, http.StatusOK, loginW.Code) + 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) - - user1 := createTestUserOnce(t, setup.DB) - user2 := createTestUserOnce(t, setup.DB) - token := loginTestUser(t, setup.Server, setup.DB) + // 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/"+user2.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/password/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, http.StatusForbidden, rec.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", - }) + // Admin user (createTestUserOnce creates admin role) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) - user := createTestUserOnce(t, 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/"+user.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "passwords do not match") + 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 := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) + // Admin user (createTestUserOnce creates admin role) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) - user := createTestUserOnce(t, 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/"+user.ID.String(), bytes.NewBuffer(jsonData)) + req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, http.StatusBadRequest, rec.Code) }) } @@ -2281,160 +2251,135 @@ func TestResetUserPassword(t *testing.T) { 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", - }) + // Create admin first so we have at least 2 users (admin won't delete self) + _ = createTestUserOnce(t, setup.DB) - user := createTestUserOnce(t, setup.DB) - token := loginTestUser(t, setup.Server, 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) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify user deleted - _, err := setup.DB.GetUser(context.Background(), user.ID) + _, 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 - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) + 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 - admin := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) + // 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) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "cannot delete the last admin") + 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 := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) - - user := createTestUserOnce(t, setup.DB) + // Admin user (createTestUserOnce creates admin role) + _ = createTestUserOnce(t, setup.DB) token := loginTestUser(t, setup.Server, setup.DB) - req := httptest.NewRequest("DELETE", "/api/auth/profile/"+user.ID.String(), nil) + // 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) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, rec.Code) // Verify user deleted - _, err := setup.DB.GetUser(context.Background(), user.ID) + _, 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 - admin := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) + // 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/"+admin.ID.String(), nil) + req := httptest.NewRequest("DELETE", "/api/auth/profile/"+lastAdmin.ID.String(), nil) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "cannot delete the last admin") + 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) - - user1 := createTestUserOnce(t, setup.DB) - user2 := createTestUserOnce(t, setup.DB) - token := loginTestUser(t, setup.Server, setup.DB) + // 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/"+user2.ID.String(), nil) + req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetAdmin.ID.String(), nil) req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusForbidden, w.Code) + 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 := createTestUserOnce(t, setup.DB) - admin.Role = "admin" - setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{ - ID: admin.ID, - Role: "admin", - }) + // 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) - w := httptest.NewRecorder() - setup.Router.ServeHTTP(w, req) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) - assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, http.StatusNotFound, rec.Code) }) } ``` -### Step 6.6: Run Tests +### Step 6.7: Run Tests ```bash # Run user tests @@ -2447,19 +2392,22 @@ go test cmd/server/tests/user_test.go -v **Commit:** ```bash -git add cmd/server/tests/user_test.go +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) -- Add TestUpdateUserAdmin (6 tests) -- Add TestResetUserPassword (4 tests) -- Add TestDeleteUserConsolidated (7 tests) +- 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 " ``` @@ -2488,178 +2436,180 @@ find bruno -name "*username*" -o -name "*email*" ### Step 7.3: Update Existing Profile Test -**File:** `bruno/auth/profile.yml` (create if doesn't exist) +**File:** `bruno/user/profile/Update Profile.yml` + +**Current content should be updated to:** ```yaml -meta: - name: Profile Management +info: + name: Update Profile type: http - seq: 1 + 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" + } -# 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 +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/auth/admin-update-user.yml` +**File:** `bruno/user/admin/Update User.yml` ```yaml -meta: - name: Admin Update User +info: + name: Update User type: http - seq: 1 + 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" + } -# 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 +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/auth/admin-reset-password.yml` +**File:** `bruno/user/admin/Reset User Password.yml` ```yaml -meta: - name: Admin Reset Password +info: + name: Reset User Password type: http - seq: 1 + 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!" + } -# 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 +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/auth/delete-account.yml` +**File:** `bruno/user/admin/Delete User.yml` ```yaml -meta: - name: Delete Account +info: + name: Delete User type: http - seq: 1 + seq: 12 +http: + method: DELETE + url: '{{base_url}}/api/auth/profile/{{user_id}}' + auth: inherit -# 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 +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:** @@ -2667,11 +2617,11 @@ meta: 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) +- 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 " ``` diff --git a/docs/developer/api/api-reference.md b/docs/developer/api/api-reference.md index a93ac67..8df2a1a 100644 --- a/docs/developer/api/api-reference.md +++ b/docs/developer/api/api-reference.md @@ -37,10 +37,12 @@ See [Authentication Endpoints](authentication/) - POST /api/auth/refresh - Refresh access token - POST /api/auth/logout - User logout - GET /api/auth/profile - Get user profile -- PUT /api/auth/profile - Update user profile -- PUT /api/auth/email - Update user email -- PUT /api/auth/username - Update username -- PUT /api/auth/password - Change password +- PUT /api/auth/profile - Update user profile (self-edit) +- PUT /api/auth/profile/:id - Update user profile (admin) +- DELETE /api/auth/profile - Delete account (self) +- DELETE /api/auth/profile/:id - Delete user (admin) +- PUT /api/auth/password - Change password (self) +- PUT /api/auth/password/:id - Reset password (admin) - PUT /api/auth/theme - Update theme preference ## Admin diff --git a/docs/developer/api/users/change_password.md b/docs/developer/api/users/change_password.md index d716d06..1c16ea1 100644 --- a/docs/developer/api/users/change_password.md +++ b/docs/developer/api/users/change_password.md @@ -1,35 +1,71 @@ # Change Password -Change the current user's password. +Change user password. Supports both self-service and admin modes. + +**Endpoints**: +- Self-service: `PUT /api/auth/password` +- Admin reset: `PUT /api/auth/password/:id` -**Endpoint**: `PUT /api/auth/password` **Auth**: Required **Content-Type**: `application/json` -## Request Body +## Self-Service Mode + +Users can change their own password by providing current password verification. + +### Request Body | Field | Type | Required | Description | |--------|------|-----------|-------------| -| current_password | string | Yes | Current password | -| new_password | string | Yes | New password (min 8 chars) | +| current_password | string | Yes | Current password for verification | +| new_password | string | Yes | New password (min 8 chars, complexity required) | +| confirm_password | string | Yes | Must match new_password | ### Example Request ```json { - "current_password": "oldPassword", - "new_password": "NewSecureP@ss123!" + "current_password": "OldPassword123!", + "new_password": "NewSecureP@ss123!", + "confirm_password": "NewSecureP@ss123!" } ``` -## Response (204 No Content) +## Admin Mode -Password changed successfully. +Admins can reset any user's password without knowing the current password. + +**URL Parameter**: `:id` - Target user's UUID + +### Request Body (Admin Mode) + +| Field | Type | Required | Description | +|--------|------|-----------|-------------| +| new_password | string | Yes | New password (min 8 chars, complexity required) | +| confirm_password | string | Yes | Must match new_password | + +### Example Admin Request + +```json +{ + "new_password": "NewSecureP@ss123!", + "confirm_password": "NewSecureP@ss123!" +} +``` + +## Response (200 OK) + +```json +{ + "message": "password updated" +} +``` ## Error Responses | Code | Description | |------|-------------| -| 400 | Invalid input or weak password | -| 401 | Current password is incorrect | -| 401 | Invalid or expired token | +| 400 | Invalid input, weak password, or passwords don't match | +| 401 | Current password is incorrect (self-service mode) | +| 403 | Admin access required (admin mode only) | +| 404 | User not found (admin mode only) | diff --git a/docs/developer/api/users/delete_user.md b/docs/developer/api/users/delete_user.md new file mode 100644 index 0000000..85523e6 --- /dev/null +++ b/docs/developer/api/users/delete_user.md @@ -0,0 +1,46 @@ +# Delete User + +Delete a user account. Supports both self-deletion and admin deletion. + +**Endpoints**: +- Self-deletion: `DELETE /api/auth/profile` +- Admin deletion: `DELETE /api/auth/profile/:id` + +**Auth**: Required + +## Self-Deletion + +Users can delete their own account. This permanently removes the user and all associated data. + +**Endpoint**: `DELETE /api/auth/profile` + +## Admin Deletion + +Admins can delete any user account by providing the user ID in the URL. + +**URL Parameter**: `:id` - Target user's UUID + +**Endpoint**: `DELETE /api/auth/profile/:id` + +## Response (200 OK) + +```json +{ + "message": "account deleted" +} +``` + +## Error Responses + +| Code | Description | +|------|-------------| +| 400 | Cannot delete the last admin | +| 401 | Invalid or expired token | +| 403 | Admin access required (trying to delete another user) | +| 404 | User not found (admin mode only) | + +## Safety Rules + +- The last remaining admin cannot be deleted +- Self-deletion requires the user to not be the last admin +- Admin deletion is restricted to admin role only diff --git a/docs/developer/api/users/update_email.md b/docs/developer/api/users/update_email.md deleted file mode 100644 index 76a01d4..0000000 --- a/docs/developer/api/users/update_email.md +++ /dev/null @@ -1,37 +0,0 @@ -# Update Email - -Update the authenticated user's email address. - -**Endpoint**: `PUT /api/auth/email` -**Auth**: Required -**Content-Type**: `application/json` - -## Request Body - -| Field | Type | Required | Description | -|--------|------|-----------|-------------| -| email | string | Yes | New email address (must be valid email format) | - -### Example Request - -```json -{ - "email": "newemail@example.com" -} -``` - -## Response (200 OK) - -```json -{ - "message": "Email updated successfully" -} -``` - -## Error Responses - -| Code | Description | -|------|-------------| -| 400 | Invalid email format | -| 401 | Invalid or expired token | -| 409 | Email already taken by another user | diff --git a/docs/developer/api/users/update_profile.md b/docs/developer/api/users/update_profile.md index d01bcf1..87bf3ea 100644 --- a/docs/developer/api/users/update_profile.md +++ b/docs/developer/api/users/update_profile.md @@ -1,24 +1,59 @@ # Update Profile -Update the current user's profile information. +Update user profile information. Supports both self-edit and admin modes. + +**Endpoints**: +- Self-edit: `PUT /api/auth/profile` +- Admin edit: `PUT /api/auth/profile/:id` -**Endpoint**: `PUT /api/auth/profile` **Auth**: Required **Content-Type**: `application/json` -## Request Body +## Self-Edit Mode + +Users can update their own profile. All fields are optional. + +### Request Body | Field | Type | Required | Description | |--------|------|-----------|-------------| +| username | string | No | New username (must be unique, 3-50 chars) | +| email | string | No | New email (must be unique, valid format) | | first_name | string | No | User's first name | | last_name | string | No | User's last name | +| theme | string | No | Theme preference | ### Example Request ```json { + "username": "newusername", + "email": "newemail@example.com", "first_name": "John", - "last_name": "Smith" + "last_name": "Smith", + "theme": "dracula" +} +``` + +## Admin Mode + +Admins can update any user by providing the user ID in the URL. Additionally supports role changes. + +**URL Parameter**: `:id` - Target user's UUID + +### Additional Request Body Fields (Admin Only) + +| Field | Type | Required | Description | +|--------|------|-----------|-------------| +| role | string | No | New role: "user" or "admin" | + +### Example Admin Request + +```json +{ + "username": "newusername", + "email": "newemail@example.com", + "role": "admin" } ``` @@ -31,7 +66,7 @@ Update the current user's profile information. "username": "john", "first_name": "John", "last_name": "Smith", - "theme": "tokyo-night", + "theme": "dracula", "role": "user", "max_devices": 10, "created_at": "2026-01-31T10:00:00Z" @@ -42,5 +77,8 @@ Update the current user's profile information. | Code | Description | |------|-------------| -| 400 | Invalid input data | +| 400 | Invalid input data or invalid role | | 401 | Invalid or expired token | +| 403 | Admin access required (admin mode only) | +| 404 | User not found (admin mode only) | +| 409 | Username or email already taken | diff --git a/docs/developer/api/users/update_theme.md b/docs/developer/api/users/update_theme.md deleted file mode 100644 index 0711baa..0000000 --- a/docs/developer/api/users/update_theme.md +++ /dev/null @@ -1,40 +0,0 @@ -# Update Theme - -Update the current user's theme preference. - -**Endpoint**: `PUT /api/auth/theme` -**Auth**: Required -**Content-Type**: `application/json` - -## Request Body - -| Field | Type | Required | Description | -|--------|------|-----------|-------------| -| theme | string | Yes | Theme name (e.g., "tokyo-night", "dracula") | - -### Example Request - -```json -{ - "theme": "dracula" -} -``` - -## Response (200 OK) - -```json -{ - "id": "uuid", - "email": "user@example.com", - "username": "john", - "theme": "dracula", - "role": "user" -} -``` - -## Error Responses - -| Code | Description | -|------|-------------| -| 400 | Invalid theme name | -| 401 | Invalid or expired token | diff --git a/docs/developer/api/users/update_username.md b/docs/developer/api/users/update_username.md deleted file mode 100644 index 3780a8e..0000000 --- a/docs/developer/api/users/update_username.md +++ /dev/null @@ -1,37 +0,0 @@ -# Update Username - -Update the authenticated user's username. - -**Endpoint**: `PUT /api/auth/username` -**Auth**: Required -**Content-Type**: `application/json` - -## Request Body - -| Field | Type | Required | Description | -|--------|------|-----------|-------------| -| username | string | Yes | New username (min 3 chars, alphanumeric and underscore only) | - -### Example Request - -```json -{ - "username": "new_username" -} -``` - -## Response (200 OK) - -```json -{ - "message": "Username updated successfully" -} -``` - -## Error Responses - -| Code | Description | -|------|-------------| -| 400 | Invalid username format | -| 401 | Invalid or expired token | -| 409 | Username already taken by another user | diff --git a/docs/user/profile-guide.md b/docs/user/profile-guide.md new file mode 100644 index 0000000..2577e3e --- /dev/null +++ b/docs/user/profile-guide.md @@ -0,0 +1,98 @@ +# 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 8 characters +- Must contain uppercase, lowercase, number, and special character +- 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-guide.md) for details.