@@ -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) {
+
+}
+```
+
+### 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) {
-
+
-
+ @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.