Files
bookhoard/API_CONSOLIDATION_PLAN.md
T
john-okeefe ff25454901 docs: update API documentation for consolidated user management
Add documentation for delete_user and reset_user_password endpoints.
Update update_profile to reflect consolidated endpoint. Remove obsolete
documentation for individual update operations. Add profile guide for
end-users. Update API_CONSOLIDATION_PLAN.md with implementation status.
2026-02-22 01:59:52 -05:00

3094 lines
99 KiB
Markdown

# API Consolidation Implementation Plan (REVISED - UPDATED)
**Goal:** Consolidate fragmented user profile endpoints using simple handler composition (copy/paste existing logic)
**Principle:** Surgical, line-by-line changes with verification at each step
**Rule #1:** NO OVER-ENGINEERING - Keep It Simple, Copy/Paste Existing Logic
**Strategy:** Combine handlers using URL param pattern - same handler for self-edit and admin modes
---
## 🎯 FINAL API DESIGN (Reference)
```
Self-Service:
GET /api/auth/profile - Get own profile (UNCHANGED)
PUT /api/auth/profile - Update own profile {username, email, first_name, last_name, theme}
PUT /api/auth/password - Change own password {current_password, new_password, confirm_password}
PUT /api/auth/theme - Quick theme toggle {theme} (UNCHANGED)
DELETE /api/auth/profile - Delete your own account
Admin:
GET /api/auth/users - List all users (UNCHANGED)
PUT /api/auth/profile/:id - Update user {username, email, first_name, last_name, theme, role?}
PUT /api/auth/password/:id - Reset user password {new_password, confirm_password}
PUT /api/auth/users/:id/max-devices - Update device limit (UNCHANGED)
DELETE /api/auth/profile/:id - Delete user (with last-admin check)
Remove (routes only, handlers kept for reuse):
PUT /api/auth/username - Route removed, handler kept for UpdateUser to call
PUT /api/auth/email - Route removed, handler kept for UpdateUser to call
```
---
## 🧪 Available Test Helpers (From cmd/server/tests/test_helpers.go)
**Setup Helpers:**
- `setupTestServer(t)` - Creates complete test server with automatic cleanup via `t.Cleanup()`
- `setupDeviceTest(t)` - Creates test environment for device tests (includes server + user + token)
**User Creation:**
- `createTestUserOnce(t, db)` - Creates test user with deterministic credentials (returns `UserTestData`)
- Email: "testuser@example.com"
- Username: "testuser"
- Password: "Test@Pass123!"
- Role: "admin"
- `createRegularUserOnce(t, db)` - Creates a regular (non-admin) test user with unique credentials (returns `UserTestData`)
- Email: unique (e.g., "regularuser-{uuid}@example.com")
- Username: unique (e.g., "regularuser-{uuid}")
- Password: "Test@Pass123!"
- Role: "user"
- **Use this for testing admin operations on regular users**
- `getTestUserID(t, db)` - Gets or creates test user UUID (admin role)
- `getRegularUserID(t, db)` - Gets or creates regular user UUID ("user" role)
**Type Conversion:**
- `uuidToPGType(u uuid.UUID) pgtype.UUID` - Converts `uuid.UUID` to `pgtype.UUID` for database operations
**Authentication:**
- `loginTestUser(t, ts, db)` - Logs in test user, returns JWT token
- `loginUserWithCredentials(t, ts, email, password)` - Login with custom credentials
- `loginRegularUser(t, ts, db)` - Login as regular user (role="user")
**Database Verification:**
- `verifyUserField(t, db, userID, field, expected)` - Verify user fields in DB (supports: email, first_name, last_name, username, theme)
- `verifyDeviceCreated(t, db, deviceID, name, type, identifier)` - Verify device exists
- `verifyDeviceDeleted(t, db, deviceID)` - Verify device deleted
- `verifyMediaItemInDB(t, db, mediaID)` - Verify media item exists
- `verifyMediaItemDeleted(t, db, mediaID)` - Verify media item deleted
**Library Creation:**
- `createTestLibraryWithFolder(t, ts, token, name, withFolder)` - Creates library with optional folder
**Media Item Creation:**
- `createTestMediaItemID(t, ts)` - Creates test media item and returns its ID
**Other:**
- `runConcurrent(t, maxConcurrent, fns)` - Runs functions concurrently for testing
---
## 📋 PHASE OVERVIEW
1. **Phase 1:** Database Query Addition
2. **Phase 2:** Extend UpdateProfile, Create Admin Handlers (copy/paste logic)
3. **Phase 3:** Rename DeleteAccount to DeleteUser (use URL param)
4. **Phase 4:** Router Updates (remove/add routes)
5. **Phase 5:** Frontend (Profile page, Header updates, Login updates, Profile modal, Admin users list - 14 steps)
6. **Phase 6:** Test Overhaul (add helpers, remove obsolete, add new - 7 steps)
7. **Phase 7:** Bruno YAML Tests
8. **Phase 8:** Documentation
---
## PHASE 1: Database Query Addition
**Goal:** Add UpdateUserRole query
**Files:** 1 file, 5 lines added
**Risk:** Lowest (no schema changes)
### Step 1.1: Add UpdateUserRole Query
**File:** `internal/database/queries/queries.sql`
**Location:** After line 334 (after UpdatePassword query)
**Action:** Add the following lines:
```sql
-- name: UpdateUserRole :one
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;
```
**Verification:**
```bash
# Read back the added lines
sed -n '335,340p' internal/database/queries/queries.sql
```
**Expected output:**
```
-- name: UpdateUserRole :one
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;
```
### Step 1.2: Regenerate Database Code
**Command:**
```bash
make sqlc
```
**Verification:**
```bash
# Check that UpdateUserRole was added to queries.sql.go
grep -n "UpdateUserRole" internal/database/queries.sql.go
# Check that UpdateUserRole was added to querier.go interface
grep -n "UpdateUserRole" internal/database/querier.go
# Verify the query function signature
grep -A 5 "func (q \*Queries) UpdateUserRole" internal/database/queries.sql.go | head -1
```
**Expected:**
- Line in `queries.sql.go`: `func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) {`
- Line in `querier.go`: `UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)`
**Commit:**
```bash
git add internal/database/
git commit -m "feat(db): add UpdateUserRole query for admin user management"
```
---
## PHASE 2: Extend UpdateProfile and Create Admin Handlers
**Goal:** Extend UpdateProfile for all fields, create UpdateUser and ResetUserPassword handlers
**Files:** 1 file, ~230 lines modified
**Risk:** Medium (modifying existing UpdateProfile handler, adding new handlers)
**Approach:** Copy/paste conflict checking logic (NO function calls, keep it simple)
### Step 2.1: Read Existing Handlers to Copy/Paste Logic
**Read UpdateUsername to understand conflict checking pattern:**
```bash
sed -n '538,565p' internal/handlers/auth.go
```
**Read UpdateEmail to understand conflict checking pattern:**
```bash
sed -n '572,599p' internal/handlers/auth.go
```
**Read UpdatePassword to understand password hashing logic:**
```bash
sed -n '608,654p' internal/handlers/auth.go
```
**NOTE:** We will copy/paste the logic from these handlers, NOT call them as functions. This keeps handlers simple and avoids over-engineering.
### Step 2.2: Extend UpdateProfileRequest Struct
**Location:** Lines 76-79 in `internal/handlers/auth.go`
**Current code:**
```go
type UpdateProfileRequest struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
}
```
**Replace with:**
```go
type UpdateProfileRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
}
```
### Step 2.3: Create AdminUpdateUserRequest Struct
**Location:** After UpdateProfileRequest struct in `internal/handlers/auth.go`
**Add after line 79:**
```go
type AdminUpdateUserRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
}
```
### Step 2.4: Replace UpdateProfile Handler (Combined Self + Admin)
**Location:** Lines 452-470 in `internal/handlers/auth.go`
**Read current implementation:**
```bash
sed -n '452,470p' internal/handlers/auth.go
```
**Current code:**
```go
// UpdateProfile handles PUT /api/auth/profile
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
var req UpdateProfileRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: user.ID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
}
```
**Replace with:**
```go
// UpdateProfile handles PUT /api/auth/profile (self-edit) and PUT /api/auth/profile/:id (admin edit)
// Combined handler for both self-service and admin profile updates
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
currentUser := MustGetAuthenticatedUser(c)
// Determine target user: URL param (admin mode) or current user (self-edit)
targetUserID := c.Param("id")
var targetUserUUID pgtype.UUID
isAdminMode := targetUserID != ""
if isAdminMode {
// Admin editing another user - validate admin role
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
// Self-edit mode
targetUserUUID = currentUser.ID
}
var req AdminUpdateUserRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Handle role change (admin mode only)
if req.Role != "" && isAdminMode {
if req.Role != "admin" && req.Role != "user" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"})
}
// Check if this is the last admin (preventing demotion)
users, err := h.db.ListUsers(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
}
adminCount := 0
currentTargetUserRole := ""
for _, user := range users {
if user.Role == "admin" {
adminCount++
}
if user.ID.Bytes == targetUserUUID.Bytes {
currentTargetUserRole = user.Role
}
}
// Prevent demoting the last admin
if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"})
}
// Update role
_, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{
ID: targetUserUUID,
Role: req.Role,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update username (if provided)
if req.Username != "" {
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: targetUserUUID,
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update email (if provided)
if req.Email != "" {
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: targetUserUUID,
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update first/last name (if provided)
if req.FirstName != "" || req.LastName != "" {
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: targetUserUUID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update theme (if provided)
if req.Theme != "" {
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: targetUserUUID,
Theme: pgtype.Text{String: req.Theme, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
}
```
### Step 2.5: Extend UpdatePassword Handler (Combined Self + Admin)
**Location:** Find the existing UpdatePassword handler in `internal/handlers/auth.go`
**Find existing handler:**
```bash
grep -n "func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go
```
**Modify to handle both self-change (requires current password) and admin reset (no current password):**
```go
// UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset)
// Combined handler for both self-service password change and admin password reset
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
currentUser := MustGetAuthenticatedUser(c)
// Determine target user: URL param (admin mode) or current user (self-change)
targetUserID := c.Param("id")
var targetUserUUID pgtype.UUID
isAdminMode := targetUserID != ""
if isAdminMode {
// Admin resetting another user's password - validate admin role
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
// Self-change mode
targetUserUUID = currentUser.ID
}
type PasswordRequest struct {
CurrentPassword string `json:"current_password,omitempty"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
var req PasswordRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Self-change mode: require current password
if !isAdminMode {
if req.CurrentPassword == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"})
}
// Get current password hash
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"})
}
// Verify current password
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword))
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
}
}
// Validate new password matches confirmation
if req.NewPassword != req.ConfirmPassword {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
// Update password
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
ID: targetUserUUID,
PasswordHash: string(hashedPassword),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
}
```
**Verification:**
```bash
# Compile check
go build ./internal/handlers
# Verify handlers exist
grep -n "func (h \*AuthHandler) UpdateProfile\|func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go
```
**Commit:**
```bash
git add internal/handlers/auth.go
git commit -m "feat(auth): combine self-edit and admin handlers
- UpdateProfile now handles both self-edit and admin edit (via URL param)
- UpdatePassword now handles both self-change and admin reset (via URL param)
- Single source of truth for profile and password update logic
- Role field only allowed in admin mode (URL param present)
- Admin mode validates admin role before allowing operations
- Last-admin protection for role demotion
- Removes code duplication between self-service and admin handlers
"
```
---
## PHASE 3: Rename DeleteAccount to DeleteUser
**Goal:** Rename handler, modify to use URL param instead of query param (already combined pattern)
**Files:** 1 file, ~10 lines modified
**Risk:** Low (simple rename and param change)
**Note:** This handler already follows the combined pattern - no URL param = self-delete, with URL param = admin delete.
### Step 3.1: Rename DeleteAccount to DeleteUser
**Location:** Lines 658-731 in `internal/handlers/auth.go`
**Read current implementation:**
```bash
sed -n '658,731p' internal/handlers/auth.go
```
**Step 3.1.1: Change function signature (line 658)**
**From:**
```go
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
```
**To:**
```go
func (h *AuthHandler) DeleteUser(c echo.Context) error {
```
### Step 3.2: Modify Query Param to URL Param (lines 661-678)
**Location:** Lines 658-731 in `internal/handlers/auth.go`
**Read current implementation:**
```bash
sed -n '658,731p' internal/handlers/auth.go
```
**Current code:**
```go
// Get target user ID from query parameter (for admin override) or use current user
targetUserID := c.QueryParam("user_id")
var targetUserUUID pgtype.UUID
// If admin override is used, validate admin and use target
if targetUserID != "" {
// Admin override mode - check if current user is admin
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
targetUserUUID = currentUser.ID
}
```
**Replace with:**
```go
// Get target user ID from URL param (admin mode) or use current user (self-deletion)
targetUserID := c.Param("id")
var targetUserUUID pgtype.UUID
if targetUserID != "" {
// Admin deletion mode
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
// Self-deletion mode
targetUserUUID = currentUser.ID
}
```
**Rest of function (lines 680-730) remains unchanged**
**Verification:**
```bash
# Compile check
go build ./internal/handlers
# Verify function was renamed
grep -n "func (h \*AuthHandler) DeleteUser" internal/handlers/auth.go
```
**Commit:**
```bash
git add internal/handlers/auth.go
git commit -m "refactor(auth): rename DeleteAccount to DeleteUser
- Rename DeleteAccount handler to DeleteUser
- Modify DeleteUser to use URL param (:id) instead of query param (?user_id=)
- Keep all last-admin protection logic
- Self-deletion: DELETE /api/auth/profile (no ID)
- Admin deletion: DELETE /api/auth/profile/:id (with user ID)
"
```
---
## PHASE 4: Router Updates
**Goal:** Register new routes, remove obsolete routes
**Files:** 1 file, ~8 lines removed, ~8 lines added
**Risk:** Low (route registration only)
### Step 4.1: Read Current Router
```bash
cat internal/router/auth.go
```
### Step 4.2: Remove Obsolete Routes
**Delete line 24:**
```go
protected.PUT("/auth/profile", cfg.AuthHandler.UpdateProfile)
```
**Delete lines 34-35:**
```go
authGroup.PUT("/email", cfg.AuthHandler.UpdateEmail)
authGroup.PUT("/username", cfg.AuthHandler.UpdateUsername)
```
### Step 4.3: Add Consolidated Routes
**Add after line 37 (after theme route):**
```go
// Profile management (combined handlers - self-edit)
authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
```
**Add after line 42 (after max-devices route):**
```go
// Admin routes (same handlers, with URL param for target user)
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
```
### Step 4.4: Verify Router File
**Complete file should look like:**
```go
package router
import (
"bookhoard/internal/handlers"
"github.com/labstack/echo/v4"
)
func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
e := cfg.Echo
// Auth routes (no auth required, but rate limited)
e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register))
e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login))
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
// Create protected route group
protected := e.Group("/api", jwtMiddleware)
// Protected auth routes
protected.GET("/auth/profile", cfg.AuthHandler.GetProfile)
// Refresh token endpoint (no authentication required - uses refresh token from body)
e.POST("/api/auth/refresh", cfg.AuthHandler.RefreshAccessToken)
// Logout endpoint (optional authentication - can revoke tokens if provided)
e.POST("/api/auth/logout", cfg.AuthHandler.Logout)
// Auth update routes
authGroup := e.Group("/api/auth", createJWTMiddleware(cfg))
authGroup.PUT("/password", cfg.AuthHandler.UpdatePassword)
authGroup.PUT("/theme", cfg.AuthHandler.UpdateTheme)
// Profile management (combined handlers - self-edit)
authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
// Admin-only routes (same handlers with URL param)
admin := protected.Group("/auth", handlers.AdminMiddleware)
admin.GET("/users", cfg.AuthHandler.ListUsers)
admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
}
```
**Verification:**
```bash
# Compile check
go build ./internal/router
# Count routes (should be 10)
grep -E "\.(GET|POST|PUT|DELETE)\(" internal/router/auth.go | wc -l
# Verify handlers are reused (same handler, different routes)
grep "UpdateProfile\|UpdatePassword\|DeleteUser" internal/router/auth.go
```
**Expected:** UpdateProfile, UpdatePassword, DeleteUser each appear twice (self + admin routes)
**Commit:**
```bash
git add internal/router/auth.go
git commit -m "refactor(router): consolidate auth routes with combined handlers
- Remove PUT /api/auth/email (merged into /profile)
- Remove PUT /api/auth/username (merged into /profile)
- Add PUT /api/auth/profile (self-update)
- Add DELETE /api/auth/profile (self-deletion)
- Add PUT /api/auth/profile/:id (admin update - same handler)
- Add PUT /api/auth/password/:id (admin reset - same handler)
- Add DELETE /api/auth/profile/:id (admin delete - same handler)
- Keep PUT /api/auth/theme unchanged (header quick-toggle)
- Combined handlers reduce code duplication
"
```
---
## PHASE 5: Frontend Routes & Templates
**Goal:** Create /profile page, create /admin/users page with modal editing, update header links
**Files:** 7 files (3 new: profile.templ, profile_modal.templ, admin_users.templ; 4 modified)
**Risk:** Medium (template changes, HTMX modal pattern)
### Step 5.1: Extend templates.User Struct
**File:** `templates/types.go`
**Current User struct:**
```go
type User struct {
ID string
Username string
Email string
Role string
Theme string
}
```
**Replace with:**
```go
type User struct {
ID string
Username string
Email string
Role string
Theme string
FirstName string
LastName string
CreatedAt time.Time
}
```
**Add import at top of file (if not present):**
```go
import "time"
```
### Step 5.2: Create Profile Template
**File:** `templates/profile.templ` (NEW FILE)
**Create with following content:**
```templ
package templates
templ Profile(user User) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Profile Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/header.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/profile")
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1>
<p style="color: var(--text-secondary)">Manage your account information and preferences</p>
</div>
<div class="space-y-8">
<!-- Account Information & Password - uses reusable ProfileForm -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
@ProfileForm(user, "/api/auth/profile", true, false, false)
</div>
<!-- Danger Zone -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Danger Zone</h3>
<p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p>
<button onclick="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
Remove My Account
</button>
</div>
</div>
</main>
<script>
function confirmDeleteAccount() {
if (confirm("Are you sure? All preferences and devices will be deleted. This action cannot be undone.")) {
fetch('/api/auth/profile', {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(data => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login?deleted=true';
})
.catch(error => {
alert('Failed to delete account: ' + error.message);
});
}
}
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
});
</script>
</body>
</html>
}
```
### Step 5.3: Update Header Template
**File:** `templates/header.templ`
**Find the settings link (around line 117-118):**
**Current:**
```html
<a href="/settings" class="...">Settings</a>
```
**Change to:**
```html
<a href="/profile" class="...">Profile</a>
```
**Regenerate template:**
```bash
templ generate
```
### Step 5.4: Update Admin Template
**File:** `templates/admin.templ`
**Find the profile link (around line 39-40):**
**Current:**
```html
<a href="/admin/profile">Profile Settings</a>
```
**Change to:**
```html
<a href="/profile">Profile Settings</a>
```
**Regenerate template:**
```bash
templ generate
```
### Step 5.5: Delete Admin Profile Template
**File:** `templates/admin_profile.templ`
**Delete the entire file:**
```bash
rm templates/admin_profile.templ
```
**Regenerate templates:**
```bash
templ generate
```
### Step 5.6: Add Frontend Route for Profile Page
**File:** `internal/router/frontend.go`
**Add after line 385 (in the frontendProtected routes section):**
```go
// Profile page (all users)
frontendProtected.GET("/profile", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
var buf bytes.Buffer
err = templates.Profile(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
```
### Step 5.7: Update Login Template
**File:** `templates/login.templ`
**Line 3: Change signature from:**
```templ
templ Login(sessionExpired bool) {
```
**To:**
```templ
templ Login(sessionExpired bool, deleted bool) {
```
**After the sessionExpired block (around line 40), add:**
```templ
if deleted {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
<p style="color: var(--text-primary);">
Your account has been deleted successfully.
</p>
</div>
}
```
**Regenerate template:**
```bash
templ generate
```
### Step 5.8: Update Login Frontend Route
**File:** `internal/router/frontend.go`
**Find the login route (around line 53):**
```bash
grep -n "GET.*login" internal/router/frontend.go
```
**Modify to read deleted query parameter:**
**Current:**
```go
e.GET("/login", func(c echo.Context) error {
var buf bytes.Buffer
sessionExpired := c.QueryParam("session") == "expired"
err := templates.Login(sessionExpired).Render(c.Request().Context(), &buf)
```
**Change to:**
```go
e.GET("/login", func(c echo.Context) error {
var buf bytes.Buffer
sessionExpired := c.QueryParam("session") == "expired"
deleted := c.QueryParam("deleted") == "true"
err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)
```
**Verification:**
```bash
# Compile check
go build ./internal/router
```
**Commit (for Steps 5.1-5.7):**
```bash
git add templates/ internal/router/frontend.go
git commit -m "feat(frontend): add universal /profile page, remove admin-only profile
- Create templates/profile.templ for all users
- Update header link: /settings → /profile
- Update admin template: /admin/profile → /profile
- Remove templates/admin_profile.templ (no longer needed)
- Add GET /profile route in frontend.go
- Add account deletion with confirmation
- Update Login template to show account deleted message
- Update login route to pass deleted parameter
"
```
### Step 5.9: Create Reusable Profile Form Component
**Goal:** Create a reusable profile form component that can be used both for self-editing (Profile page) and admin editing other users (ProfileModal)
**File:** `templates/profile_form.templ` (NEW FILE)
**Create with following content:**
```templ
package templates
// ProfileForm is a reusable form component for editing user profiles
// - actionURL: The endpoint to submit the form to (e.g., "/api/auth/profile" or "/api/auth/profile/{id}")
// - requireCurrentPassword: If true, show current password field (self-edit); if false, admin reset mode
// - showRoleField: If true, show role dropdown (admin editing other users)
// - showCancelButton: If true, show cancel button that closes modal (modal context); if false, no cancel button (page context)
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
<form hx-put={ actionURL }
hx-target="#profile-result"
hx-swap="innerHTML"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
class="space-y-6">
<!-- Account Information -->
<div>
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Account Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" value={user.Username}
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email Address</label>
<input type="email" name="email" value={user.Email}
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" value={user.FirstName} placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" value={user.LastName} placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Theme</label>
<select name="theme"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="tokyo-night" selected?={user.Theme == "tokyo-night"}>Tokyo Night</option>
<option value="dracula" selected?={user.Theme == "dracula"}>Dracula</option>
<option value="nord" selected?={user.Theme == "nord"}>Nord</option>
<option value="solarized-dark" selected?={user.Theme == "solarized-dark"}>Solarized Dark</option>
<option value="monokai" selected?={user.Theme == "monokai"}>Monokai</option>
<option value="one-dark-pro" selected?={user.Theme == "one-dark-pro"}>One Dark Pro</option>
<option value="material-dark" selected?={user.Theme == "material-dark"}>Material Dark</option>
<option value="wood-light" selected?={user.Theme == "wood-light"}>Wood Light</option>
<option value="wood-dark" selected?={user.Theme == "wood-dark"}>Wood Dark</option>
<option value="wood-mahogany" selected?={user.Theme == "wood-mahogany"}>Wood Mahogany</option>
</select>
</div>
if showRoleField {
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Role</label>
<select name="role"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="user" selected?={user.Role == "user"}>User</option>
<option value="admin" selected?={user.Role == "admin"}>Admin</option>
</select>
</div>
}
</div>
</div>
<!-- Change Password Section -->
<div>
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Change Password</h3>
if requireCurrentPassword {
<!-- Self-edit: require current password -->
<div class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
<input type="password" name="current_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
<input type="password" name="new_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
minlength="6">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
<input type="password" name="confirm_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
minlength="6">
</div>
<button type="button"
hx-put="/api/auth/password"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target="#password-result"
hx-swap="innerHTML"
hx-include="closest form"
class="px-4 py-2 rounded"
style="background-color: var(--accent); color: white;">
Update Password
</button>
<div id="password-result" class="mt-2"></div>
</div>
} else {
<!-- Admin edit: no current password required -->
<p class="text-sm italic mb-4" style="color: var(--text-secondary);">
As an admin, you can change this user's password without knowing their current password.
</p>
<div class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
<input type="password" name="new_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
minlength="6">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
<input type="password" name="confirm_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
minlength="6">
</div>
<button type="button"
hx-put={ "/api/auth/password/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target="#password-result"
hx-swap="innerHTML"
hx-include="closest form"
class="px-4 py-2 rounded"
style="background-color: var(--accent); color: white;">
Reset Password
</button>
<div id="password-result" class="mt-2"></div>
</div>
}
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);">
if showCancelButton {
<button type="button"
onclick="closeProfileModal()"
class="px-4 py-2 rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);">
Cancel
</button>
}
<button type="submit"
class="px-4 py-2 rounded"
style="background-color: var(--accent); color: white;">
Save Changes
</button>
</div>
<div id="profile-result" class="mt-2"></div>
</form>
}
```
### Step 5.10: Create Profile Modal Component
**Goal:** Modal wrapper for admin editing other users, using the reusable ProfileForm
**File:** `templates/profile_modal.templ` (NEW FILE)
**Create with following content:**
```templ
package templates
// ProfileModal is used by admins to edit other users
// Always uses: actionURL = "/api/auth/profile/{user.ID}", requireCurrentPassword = false, showRoleField = true, showCancelButton = true
templ ProfileModal(user User) {
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" style="background-color: rgba(0,0,0,0.5);">
<div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);">
<!-- Modal Header -->
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
<div>
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary);">
{ user.Username } ({ user.Email })
</p>
</div>
<button
onclick="closeProfileModal()"
class="text-2xl"
style="color: var(--text-secondary);"
>
&times;
</button>
</div>
<!-- Modal Body - uses reusable form -->
<div class="p-6">
@ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
</div>
</div>
</div>
<script>
function closeProfileModal() {
const modal = document.getElementById('profile-modal');
if (modal) {
modal.remove();
}
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeProfileModal();
}
});
</script>
}
```
**Generate template:**
```bash
templ generate
```
### Step 5.11: Add Profile Modal Route
**File:** `internal/router/frontend.go`
**Add after the profile route (around line 400):**
```go
// Admin: Get profile modal for editing user
frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Get target user ID from URL
targetUserID := c.Param("id")
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid user ID</div>")
}
// Fetch target user
targetUser, err := cfg.Queries.GetUser(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true})
if err != nil {
return c.HTML(http.StatusNotFound, "<div>User not found</div>")
}
// Convert to template user
templateUser := toTemplateUser(targetUser)
// Render modal (admin editing other user)
var buf bytes.Buffer
err = templates.ProfileModal(templateUser).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
```
**Note:** This adds a new admin-only endpoint that returns the profile modal HTML for any user. Server validates admin before returning modal.
### Step 5.12: Create Admin Users List Page
**Goal:** Admin page to view and manage all users with role toggles, delete actions, and modal profile editing
**File:** `templates/admin_users.templ` (NEW FILE)
**Create with following content:**
```templ
package templates
templ AdminUsers(users []User, adminCount int, currentUserID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users - Bookhoard Admin</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/header.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-tokyo-night">
@Header(User{Username: "Admin", Role: "admin"}, "/admin/users")
<!-- Modal Container (populated by HTMX) -->
<div id="modal-container"></div>
<main class="max-w-7xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
</div>
<!-- Users Table -->
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
<table class="w-full">
<thead style="background-color: var(--bg-primary)">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
</tr>
</thead>
<tbody class="divide-y" style="divide-color: var(--border)">
@for _, user := range users {
<tr id="user-{user.ID}" class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
<!-- Username -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div>
<div class="text-sm font-medium" style="color: var(--text-primary)">{user.Username}</div>
if user.ID == currentUserID {
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
}
</div>
</div>
</td>
<!-- Email -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-primary)">{user.Email}</div>
</td>
<!-- Role Toggle (with last-admin protection) -->
<td class="px-6 py-4 whitespace-nowrap">
@if user.Role == "admin" && adminCount == 1 {
<!-- Last admin - disabled -->
<div class="relative">
<select
disabled
class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50"
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
title="Cannot demote the last admin"
>
<option value="user">User</option>
<option value="admin" selected>Admin</option>
</select>
<span class="ml-2" title="Cannot demote the last admin">⚠️</span>
</div>
} else {
<!-- Normal role toggle - HTMX with page reload -->
<form hx-put="/api/auth/profile/{user.ID}"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-on::after-request="if(event.detail.successful) window.location.reload()">
<select
name="role"
onchange="this.form.requestSubmit()"
class="text-sm rounded px-2 py-1 cursor-pointer"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
>
<option value="user" selected?={user.Role == "user"}>User</option>
<option value="admin" selected?={user.Role == "admin"}>Admin</option>
</select>
</form>
}
</td>
<!-- Created Date -->
<td class="px-6 py-4 whitespace-nowrap text-sm" style="color: var(--text-secondary)">
{user.CreatedAt.Format("2006-01-02")}
</td>
<!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2">
<!-- Edit Profile Button - HTMX loads modal -->
<button
hx-get="/admin/users/{user.ID}/profile-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="px-3 py-1 rounded text-xs"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
>
Edit
</button>
<!-- Delete Button (with last-admin protection) -->
@if user.Role == "admin" && adminCount == 1 {
<!-- Last admin - disabled -->
<button
disabled
class="px-3 py-1 rounded text-xs cursor-not-allowed opacity-50"
style="background-color: #dc2626; color: white;"
title="Cannot delete the last admin"
>
Delete
</button>
} else {
<!-- Normal delete button - HTMX with page reload -->
<button
hx-delete="/api/auth/profile/{user.ID}"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
hx-on::after-request="if(event.detail.successful) window.location.reload()"
class="px-3 py-1 rounded text-xs"
style="background-color: #dc2626; color: white;"
>
Delete
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Info Note -->
<div class="mt-4 p-4 rounded border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<p class="text-sm" style="color: var(--text-secondary);">
<strong>⚠️ Note:</strong> The system must always have at least one admin user. The last admin cannot be demoted or deleted.
</p>
</div>
</main>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
});
</script>
</body>
</html>
}
```
**Generate template:**
```bash
templ generate
```
### Step 5.13: Add Admin Users Route
**File:** `internal/router/frontend.go`
**Add after the /profile route (around line 395):**
```go
// Admin users page
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Fetch all users
users, err := cfg.Queries.ListUsers(c.Request().Context())
if err != nil {
return renderErrorPage(c, "Error loading users", "users_load_error")
}
// Count admins for UI protection
adminCount := 0
for _, u := range users {
if u.Role == "admin" {
adminCount++
}
}
// Get current user ID for "You" badge
currentUserID := user.ID
var buf bytes.Buffer
err = templates.AdminUsers(toTemplateUsers(users), adminCount, currentUserID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
```
**Note:** You'll need helper functions to convert database users to template users. Add these in `internal/router/frontend.go`:
```go
import "github.com/google/uuid"
// Convert single database user (from GetUser) to template user
func toTemplateUser(dbUser database.GetUserRow) templates.User {
return templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
FirstName: dbUser.FirstName.String,
LastName: dbUser.LastName.String,
Theme: dbUser.Theme.String,
CreatedAt: dbUser.CreatedAt.Time,
}
}
// Convert single database user (from ListUsers) to template user
func toTemplateUserFromList(dbUser database.ListUsersRow) templates.User {
return templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
FirstName: dbUser.FirstName.String,
LastName: dbUser.LastName.String,
Theme: dbUser.Theme.String,
CreatedAt: dbUser.CreatedAt.Time,
}
}
// Convert slice of database users (from ListUsers) to template users
func toTemplateUsers(dbUsers []database.ListUsersRow) []templates.User {
users := make([]templates.User, len(dbUsers))
for i, u := range dbUsers {
users[i] = toTemplateUserFromList(u)
}
return users
}
```
### Step 5.14: Update Admin Navigation Link
**File:** `templates/admin.templ`
**Add link to users page (around line 40):**
**Find this section:**
```templ
<div>
<a href="/profile">Profile Settings</a>
</div>
```
**Add after it:**
```templ
<div>
<a href="/admin/users">Users</a>
</div>
```
**Regenerate template:**
```bash
templ generate
```
**Verification:**
```bash
# Compile check
go build ./internal/router
# Verify template exists
ls templates/admin_users.templ
# Verify route exists
grep "admin.GET.*users" internal/router/frontend.go
```
**Commit:**
```bash
git add templates/ internal/router/frontend.go
git commit -m "feat(frontend): add admin users management with modal profile editing
- Create templates/profile_form.templ (reusable form component)
- Create templates/profile_modal.templ (admin-only modal using ProfileForm)
- Add GET /admin/users/:id/profile-modal route (admin only, server validates)
- Create templates/admin_users.templ with user management table
- Add GET /admin/users route with SSR initial data fetch
- HTMX Edit button loads modal (no page navigation, secure)
- HTMX role toggles with page reload after success
- HTMX delete buttons with page reload after success
- Last-admin protection in UI (disabled states for role/delete)
- ProfileForm supports both self-edit and admin-edit modes via parameters
- Progressive enhancement - page reloads after successful actions
- Server-side security - admin-only access validated before modal sent
"
```
---
## PHASE 6: Test Overhaul
**Goal:** Remove obsolete tests, add comprehensive tests
**Files:** 1 file, ~50 tests removed, ~40 tests added
**Risk:** Highest (test changes must be thorough)
### Step 6.1: Backup Current Tests
```bash
git stash push -m "Backup tests before API consolidation overhaul"
```
### Step 6.2: Read Current Test Structure
```bash
# Count tests in user_test.go
grep -c "t.Run(" cmd/server/tests/user_test.go
# List all test functions
grep "^func Test" cmd/server/tests/user_test.go
```
### Step 6.3: Add New Test Helper Functions
**File:** `cmd/server/tests/test_helpers.go`
**Add these helper functions to the file:**
```go
// uuidToPGType converts uuid.UUID to pgtype.UUID for database operations
func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
}
// createRegularUserOnce creates a regular (non-admin) test user with unique credentials
// Use this for testing admin operations on regular users
func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
ctx := context.Background()
uniqueID := uuid.New().String()[:8]
email := "regular-" + uniqueID + "@example.com"
username := "regular-" + uniqueID
password := "Test@Pass123!"
// Hash the password
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
require.NoError(t, err, "Failed to hash password")
// Create user with regular role
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
ID: pgUserID,
Email: email,
Username: username,
PasswordHash: string(passwordHash),
Role: "user",
})
require.NoError(t, err, "Failed to create regular user")
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
require.NoError(t, err, "Failed to convert user ID to UUID")
return UserTestData{
ID: userUUID,
Email: email,
Username: username,
Password: password,
}
}
```
### Step 6.4: Remove Obsolete Tests
**File:** `cmd/server/tests/user_test.go`
**Find and delete these test blocks:**
1. **PUT /api/auth/email tests** - Find lines:
```bash
grep -n "PUT /api/auth/email" cmd/server/tests/user_test.go
```
Delete all tests under this category (approximately lines 82-166)
2. **PUT /api/auth/username tests** - Find lines:
```bash
grep -n "PUT /api/auth/username" cmd/server/tests/user_test.go
```
Delete all tests under this category (approximately lines 167-215)
**Keep:** GET profile, PUT profile (will modify), PUT password, PUT theme, DELETE account (will modify)
### Step 6.5: Modify Existing Tests
**Modify PUT /api/auth/profile test:**
**Find the test:**
```bash
grep -n "PUT /api/auth/profile - Update profile" cmd/server/tests/user_test.go
```
**Update the test body to include new fields:**
```go
t.Run("PUT /api/auth/profile - Update profile with all fields", func(t *testing.T) {
username := "updateduser"
email := "updated@example.com"
firstName := "Updated"
lastName := "User"
theme := "dracula"
jsonData, _ := json.Marshal(map[string]interface{}{
"username": username,
"email": email,
"first_name": firstName,
"last_name": lastName,
"theme": theme,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock handler logic
w.Write([]byte(`{"message":"profile updated successfully"}`))
})
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
```
**Modify DELETE /api/auth/account tests to use new endpoints:**
**Find all occurrences:**
```bash
grep -n "DELETE /api/auth/account" cmd/server/tests/user_test.go
```
**Replace all:**
```go
req := httptest.NewRequest("DELETE", "/api/auth/account", nil)
```
**With:**
```go
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
```
**Replace all:**
```go
req := httptest.NewRequest("DELETE", "/api/auth/account?user_id="+userID.String(), nil)
```
**With:**
```go
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+userID.String(), nil)
```
### Step 6.6: Add New Complete Tests
**NOTE:** `createTestUserOnce()` creates an admin user (role="admin"). For admin tests:
- Use `createTestUserOnce(t, setup.DB)` for the admin
- Use `createRegularUserOnce(t, setup.DB)` for a regular user (role="user") to test admin operations on regular users
- Use `uuidToPGType(user.ID)` to convert `uuid.UUID` to `pgtype.UUID` for database operations
**Add at end of file before final closing brace:**
```go
// TestUpdateProfileConsolidated tests the consolidated profile update endpoint
func TestUpdateProfileConsolidated(t *testing.T) {
t.Run("PUT /api/auth/profile - Update username only", func(t *testing.T) {
setup := setupTestServer(t)
// Create test user using helper
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
username := "newusername"
jsonData, _ := json.Marshal(map[string]interface{}{
"username": username,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify username was updated
updatedUser, err := setup.DB.GetUserByUsername(context.Background(), username)
assert.NoError(t, err)
assert.Equal(t, username, updatedUser.Username)
})
t.Run("PUT /api/auth/profile - Update email only", func(t *testing.T) {
setup := setupTestServer(t)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
email := "newemail@example.com"
jsonData, _ := json.Marshal(map[string]interface{}{
"email": email,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify email was updated
updatedUser, err := setup.DB.GetUserByEmail(context.Background(), email)
assert.NoError(t, err)
assert.Equal(t, email, updatedUser.Email)
})
t.Run("PUT /api/auth/profile - Update theme only", func(t *testing.T) {
setup := setupTestServer(t)
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
theme := "dracula"
jsonData, _ := json.Marshal(map[string]interface{}{
"theme": theme,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify theme was updated
updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
assert.NoError(t, err)
assert.Equal(t, theme, updatedUser.Theme.String)
})
t.Run("PUT /api/auth/profile - Update all fields", func(t *testing.T) {
setup := setupTestServer(t)
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "newalluser",
"email": "newall@example.com",
"first_name": "NewAll",
"last_name": "User",
"theme": "nord",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify all fields updated
updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
assert.NoError(t, err)
assert.Equal(t, "newalluser", updatedUser.Username)
assert.Equal(t, "newall@example.com", updatedUser.Email)
})
t.Run("PUT /api/auth/profile - Username conflict", func(t *testing.T) {
setup := setupTestServer(t)
// Create first user using helper
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create second user manually with different username
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
_, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "user2@example.com",
Username: "user2",
PasswordHash: passwordHash,
Role: "user",
})
require.NoError(t, err)
// Try to update user1 to user2's username
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "user2",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusConflict, rec.Code)
})
t.Run("PUT /api/auth/profile - Email conflict", func(t *testing.T) {
setup := setupTestServer(t)
// Create first user using helper
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create second user manually with different email
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
_, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "email2@example.com",
Username: "email2user",
PasswordHash: passwordHash,
Role: "user",
})
require.NoError(t, err)
jsonData, _ := json.Marshal(map[string]interface{}{
"email": "email2@example.com",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusConflict, rec.Code)
})
t.Run("PUT /api/auth/profile - No auth", func(t *testing.T) {
setup := setupTestServer(t)
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "test",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
// No Authorization header
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}
// TestUpdateProfileAdminMode tests admin updating another user (same handler, with URL param)
func TestUpdateProfileAdminMode(t *testing.T) {
t.Run("PUT /api/auth/profile/:id - Admin update username", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user to update
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
user, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "regularuser@example.com",
Username: "regularuser",
PasswordHash: passwordHash,
Role: "user",
})
require.NoError(t, err)
userUUID, _ := uuid.FromBytes(user.ID.Bytes[0:16])
newUsername := "updateduser"
jsonData, _ := json.Marshal(map[string]interface{}{
"username": newUsername,
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+userUUID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify username updated
updatedUser, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
assert.NoError(t, err)
assert.Equal(t, newUsername, updatedUser.Username)
})
t.Run("PUT /api/auth/profile/:id - Admin promote user to admin", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user to promote
user := createRegularUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"role": "admin",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+user.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify role changed
updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
assert.NoError(t, err)
assert.Equal(t, "admin", updatedUser.Role)
})
t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
setup := setupTestServer(t)
// Create only one admin (createTestUserOnce creates admin)
lastAdmin := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Try to demote self
jsonData, _ := json.Marshal(map[string]interface{}{
"role": "user",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+lastAdmin.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "cannot demote the last admin")
})
t.Run("PUT /api/auth/profile/:id - Non-admin tries update", func(t *testing.T) {
setup := setupTestServer(t)
// Create admin first
targetAdmin := createTestUserOnce(t, setup.DB)
// Create regular user (non-admin)
regularUser := createRegularUserOnce(t, setup.DB)
// Login as regular user
token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "hacked",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("PUT /api/auth/profile/:id - Invalid role", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user to update
targetUser := createRegularUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"role": "superadmin",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "invalid role")
})
}
// TestUpdatePasswordAdminMode tests admin password reset (same handler, with URL param)
func TestUpdatePasswordAdminMode(t *testing.T) {
t.Run("PUT /api/auth/password/:id - Admin reset password", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user to reset password for
targetUser := createRegularUserOnce(t, setup.DB)
newPassword := "NewPassword123!"
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": newPassword,
"confirm_password": newPassword,
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify password changed by attempting login with new password
loginData, _ := json.Marshal(map[string]interface{}{
"login": targetUser.Username,
"password": newPassword,
})
loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData))
loginRec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(loginRec, loginReq)
assert.Equal(t, http.StatusOK, loginRec.Code)
})
t.Run("PUT /api/auth/password/:id - Non-admin tries reset", func(t *testing.T) {
setup := setupTestServer(t)
// Create admin first
targetAdmin := createTestUserOnce(t, setup.DB)
// Create regular user (non-admin)
regularUser := createRegularUserOnce(t, setup.DB)
// Login as regular user
token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": "hacked",
"confirm_password": "hacked",
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("PUT /api/auth/password/:id - Mismatched passwords", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user
targetUser := createRegularUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": "password1",
"confirm_password": "password2",
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "passwords do not match")
})
t.Run("PUT /api/auth/password/:id - Invalid password format", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user
targetUser := createRegularUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": "123",
"confirm_password": "123",
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
// TestDeleteUserConsolidated tests account deletion
func TestDeleteUserConsolidated(t *testing.T) {
t.Run("DELETE /api/auth/profile - User deletes self successfully", func(t *testing.T) {
setup := setupTestServer(t)
// Create admin first so we have at least 2 users (admin won't delete self)
_ = createTestUserOnce(t, setup.DB)
// Create regular user who will delete themselves
selfDeletingUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfDeletingUser.Email, selfDeletingUser.Password)
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify user deleted
_, err := setup.DB.GetUser(context.Background(), uuidToPGType(selfDeletingUser.ID))
assert.Error(t, err)
})
t.Run("DELETE /api/auth/profile - Delete without auth", func(t *testing.T) {
setup := setupTestServer(t)
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
// No auth
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
setup := setupTestServer(t)
// Create only one admin (createTestUserOnce creates admin)
lastAdmin := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "cannot delete the last admin")
})
t.Run("DELETE /api/auth/profile/:id - Admin deletes user", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user to delete
targetUser := createRegularUserOnce(t, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetUser.ID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify user deleted
_, err := setup.DB.GetUser(context.Background(), uuidToPGType(targetUser.ID))
assert.Error(t, err)
})
t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
setup := setupTestServer(t)
// Create only one admin (createTestUserOnce creates admin)
lastAdmin := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+lastAdmin.ID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "cannot delete the last admin")
})
t.Run("DELETE /api/auth/profile/:id - Non-admin tries delete", func(t *testing.T) {
setup := setupTestServer(t)
// Create admin first
targetAdmin := createTestUserOnce(t, setup.DB)
// Create regular user (non-admin)
regularUser := createRegularUserOnce(t, setup.DB)
// Login as regular user
token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetAdmin.ID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("DELETE /api/auth/profile/:id - Delete non-existent user", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
_ = createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
fakeUUID := uuid.New()
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+fakeUUID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
```
### Step 6.7: Run Tests
```bash
# Run user tests
go test cmd/server/tests/user_test.go -v
```
**Expected:** All new tests pass
**Fix any failures** before proceeding.
**Commit:**
```bash
git add cmd/server/tests/test_helpers.go cmd/server/tests/user_test.go
git commit -m "test(auth): overhaul user profile tests for API consolidation
- Add createRegularUserOnce helper for non-admin test users
- Add uuidToPGType helper for UUID type conversion
- Remove 9 obsolete tests (username, email endpoints)
- Modify existing profile test for new fields
- Modify DELETE account tests for new endpoints
- Add TestUpdateProfileConsolidated (7 tests) - self-edit mode
- Add TestUpdateProfileAdminMode (5 tests) - admin mode via URL param
- Add TestUpdatePasswordAdminMode (4 tests) - admin reset via URL param
- Add TestDeleteUserConsolidated (7 tests) - combined self/admin delete
- Cover all scenarios: self-update, admin update, role changes
- Test last-admin protection thoroughly
- Test conflict detection (username, email)
- Test combined handlers work in both modes
"
```
---
## PHASE 7: Bruno OpenCollection Tests
**Goal:** Update/create Bruno YAML tests
**Files:** ~5 files (0 delete, 2 modify, 3 create)
**Risk:** Low (contract testing)
### Step 7.1: Check Current Bruno Structure
```bash
find bruno -name "*.yml" | head -20
```
### Step 7.2: Delete Obsolete Bruno Tests
```bash
# Find username/email tests if they exist
find bruno -name "*username*" -o -name "*email*"
```
**Delete if found.**
### Step 7.3: Update Existing Profile Test
**File:** `bruno/user/profile/Update Profile.yml`
**Current content should be updated to:**
```yaml
info:
name: Update Profile
type: http
seq: 4
http:
method: PUT
url: '{{base_url}}/api/auth/profile'
auth: inherit
body:
type: json
jsonBody: |-
{
"username": "updateduser",
"email": "updated@example.com",
"first_name": "Updated",
"last_name": "User",
"theme": "dracula"
}
docs: |-
## Update User Profile
Updates the authenticated user's profile information.
**Method:** PUT
**Endpoint:** /api/auth/profile
**Authentication:** Required (Bearer token)
**Request Body:**
- `username` (string, optional): New username (must be unique)
- `email` (string, optional): New email address (must be unique)
- `first_name` (string, optional): First name
- `last_name` (string, optional): Last name
- `theme` (string, optional): Theme preference
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 409: Username or email already taken
```
### Step 7.4: Create Admin Update User Test
**File:** `bruno/user/admin/Update User.yml`
```yaml
info:
name: Update User
type: http
seq: 10
http:
method: PUT
url: '{{base_url}}/api/auth/profile/{{user_id}}'
auth: inherit
body:
type: json
jsonBody: |-
{
"username": "newusername",
"email": "newemail@example.com",
"role": "admin"
}
docs: |-
## Admin Update User
Admin-only endpoint to update another user's profile.
**Method:** PUT
**Endpoint:** /api/auth/profile/:id
**Authentication:** Required (Admin Bearer token)
**Request Body:**
- `username` (string, optional): New username
- `email` (string, optional): New email
- `first_name` (string, optional): First name
- `last_name` (string, optional): Last name
- `theme` (string, optional): Theme preference
- `role` (string, optional): Role ("user" or "admin") - admin only
**Status Codes:**
- 200: Success
- 400: Invalid role or cannot demote last admin
- 403: Not an admin
- 404: User not found
- 409: Username or email already taken
```
### Step 7.5: Create Admin Reset Password Test
**File:** `bruno/user/admin/Reset User Password.yml`
```yaml
info:
name: Reset User Password
type: http
seq: 11
http:
method: PUT
url: '{{base_url}}/api/auth/password/{{user_id}}'
auth: inherit
body:
type: json
jsonBody: |-
{
"new_password": "NewSecurePassword123!",
"confirm_password": "NewSecurePassword123!"
}
docs: |-
## Admin Reset User Password
Admin-only endpoint to reset another user's password.
**Method:** PUT
**Endpoint:** /api/auth/password/:id
**Authentication:** Required (Admin Bearer token)
**Request Body:**
- `new_password` (string, required): New password
- `confirm_password` (string, required): Must match new_password
**Status Codes:**
- 200: Success
- 400: Passwords do not match or invalid format
- 403: Not an admin
- 404: User not found
```
### Step 7.6: Create Delete Account Tests
**File:** `bruno/user/admin/Delete User.yml`
```yaml
info:
name: Delete User
type: http
seq: 12
http:
method: DELETE
url: '{{base_url}}/api/auth/profile/{{user_id}}'
auth: inherit
docs: |-
## Delete User Account
Delete a user account. Users can delete their own account, admins can delete any user.
**Method:** DELETE
**Endpoint:** /api/auth/profile (self) or /api/auth/profile/:id (admin)
**Authentication:** Required (Bearer token)
**Status Codes:**
- 200: Success
- 400: Cannot delete the last admin
- 403: Not an admin (when trying to delete another user)
- 404: User not found
```
**Commit:**
```bash
git add bruno/
git commit -m "test(bruno): update auth tests for API consolidation
- Delete obsolete tests: Update Username.yml, Update Email.yml
- Update Update Profile.yml with new consolidated endpoint
- Add Update User.yml for admin user management
- Add Reset User Password.yml for admin password resets
- Add Delete User.yml for account deletion (self and admin)
- Document all scenarios with proper request bodies
"
```
---
## PHASE 8: Documentation
**Goal:** Update API and user documentation
**Files:** ~5 files (create/update)
**Risk:** Lowest
### Step 8.1: Update API Documentation
**Create:** `docs/developer/api/auth/profile.md`
```markdown
# Profile Management
## Get Profile
Retrieve the authenticated user's profile information.
\`\`\`
GET /api/auth/profile
Authorization: Bearer {token}
\`\`\`
### Response
\`\`\`json
{
"id": "uuid",
"email": "user@example.com",
"username": "username",
"first_name": "John",
"last_name": "Doe",
"role": "user"
}
\`\`\`
## Update Profile (Self)
Update your own profile information. All fields are optional.
\`\`\`
PUT /api/auth/profile
Authorization: Bearer {token}
{
"username": "newusername", // optional
"email": "new@example.com", // optional
"first_name": "John", // optional
"last_name": "Doe", // optional
"theme": "dracula" // optional
}
\`\`\`
### Response
\`\`\`json
{
"message": "profile updated successfully"
}
\`\`\`
### Errors
- `409 Conflict` - Username or email already taken
- `401 Unauthorized` - No token provided
- `400 Bad Request` - Invalid request data
## Update Profile (Admin)
Update another user's profile information. All fields are optional.
\`\`\`
PUT /api/auth/profile/:id
Authorization: Bearer {admin_token}
{
"username": "newusername", // optional
"email": "new@example.com", // optional
"first_name": "John", // optional
"last_name": "Doe", // optional
"theme": "dracula", // optional
"role": "admin" // optional, admin only
}
\`\`\`
### Response
\`\`\`json
{
"message": "user updated successfully"
}
\`\`\`
### Errors
- `403 Forbidden` - Not an admin
- `404 Not Found` - User not found
- `400 Bad Request` - Invalid role or cannot demote last admin
- `409 Conflict` - Username or email already taken
## Delete Account (Self)
Delete your own account.
**⚠️ WARNING:** This action cannot be undone. All data will be permanently deleted.
\`\`\`
DELETE /api/auth/profile
Authorization: Bearer {token}
\`\`\`
### Response
\`\`\`json
{
"message": "account deleted successfully"
}
\`\`\`
### Errors
- `400 Bad Request` - Cannot delete the last admin account
- `401 Unauthorized` - No token provided
## Delete Account (Admin)
Delete another user's account.
\`\`\`
DELETE /api/auth/profile/:id
Authorization: Bearer {admin_token}
\`\`\`
### Response
\`\`\`json
{
"message": "user account deleted successfully"
}
\`\`\`
### Errors
- `403 Forbidden` - Not an admin
- `404 Not Found` - User not found
- `400 Bad Request` - Cannot delete the last admin account
**Security Note:** The system must always have at least one admin account. The last admin cannot be deleted (even by themselves).
```
**Create:** `docs/developer/api/auth/password.md`
```markdown
# Password Management
## Change Password (Self)
Change your own password by providing the current password.
\`\`\`
PUT /api/auth/password
Authorization: Bearer {token}
{
"current_password": "oldpass123",
"new_password": "newpass123",
"confirm_password": "newpass123"
}
\`\`\`
### Response
\`\`\`json
{
"message": "password updated successfully"
}
\`\`\`
### Errors
- `401 Unauthorized` - Current password is incorrect
- `400 Bad Request` - Passwords do not match or too short
## Reset Password (Admin)
Reset another user's password without requiring the current password. Used for password recovery or account management.
\`\`\`
PUT /api/auth/password/:id
Authorization: Bearer {admin_token}
{
"new_password": "newpass123",
"confirm_password": "newpass123"
}
\`\`\`
### Response
\`\`\`json
{
"message": "password reset successfully"
}
\`\`\`
### Errors
- `403 Forbidden` - Not an admin
- `404 Not Found` - User not found
- `400 Bad Request` - Passwords do not match or too short
**Security Note:** Admin password resets bypass the current password check. Use with caution and ensure proper authorization.
```
### Step 8.2: Create User Guide
**Create:** `docs/user/profile-guide.md`
```markdown
# Profile Management Guide
## Updating Your Profile
Your profile contains your account information and preferences.
### How to Update
1. Click on your **username** (top-right)
2. Select **Profile** from the dropdown
3. Edit any fields in the "Account Information" section
4. Click **Update Profile**
5. Changes take effect immediately
### Fields You Can Update
- **Username** - Your login name (must be unique)
- **Email** - Your email address (must be unique)
- **First Name** - Optional display name
- **Last Name** - Optional display name
- **Theme** - Your preferred color scheme
## Changing Your Password
Regular password changes are recommended for account security.
### How to Change
1. Go to **Profile** page
2. Scroll to "Change Password" section
3. Enter your **current password**
4. Enter your **new password**
5. **Confirm** your new password
6. Click **Update Password**
### Password Requirements
- Minimum 6 characters
- Must match confirmation field
- Current password must be correct
## Deleting Your Account
**⚠️ WARNING:** This action cannot be undone.
### What Gets Deleted
When you delete your account:
- Your profile information
- Reading progress and history
- Device connections
- Collection preferences
- All personal data
### What Stays
- Library books (media belongs to the library, not you)
- System settings
- Other users' accounts
### How to Delete
1. Go to **Profile** page
2. Scroll to "Danger Zone" (bottom of page)
3. Click **Remove My Account**
4. Confirm by clicking "OK" in the popup
**Note:** If you're the last admin, you cannot delete your account for security reasons.
## Theme Options
Personalize your reading experience with different color themes.
### Quick Theme Switch
1. Click the **paintbrush icon** (top-right, next to your username)
2. Select a theme from the dropdown
3. Changes apply instantly
### Available Themes
- **Tokyo Night** (default) - Blue/purple accents
- **Dracula** - Purple/pink tones
- **Nord** - Arctic, bluish-gray
- **Solarized Dark** - Warm, precise contrast
- **Monokai** - Classic vibrant colors
- **One Dark Pro** - Atom editor inspired
- **Material Dark** - Google Material Design
- **Wood Light** - Light wood texture
- **Wood Dark** - Dark wood texture
- **Wood Mahogany** - Reddish-brown wood
### For Admin Users
If you're an administrator, you can also manage other users' profiles from the admin panel.
See [Admin User Management](../admin/users.md) for details.
```
### Step 8.3: Update Navigation
**Update:** `docs/developer/api/_navigation.md` or equivalent
Add new endpoints to navigation.
**Commit:**
```bash
git add docs/
git commit -m "docs: document consolidated profile API endpoints
- Add profile.md with GET/PUT/DELETE /api/auth/profile
- Add password.md with PUT /api/auth/password and /:id variants
- Add user guide in docs/user/profile-guide.md
- Document self-service and admin operations
- Add security notes (last-admin protection)
- Include error scenarios and response codes
"
```
---
## FINAL VERIFICATION
### Step F.1: Run Verification Script
```bash
bash scripts/verify-guidelines.sh
```
**Expected:** 0 errors, warnings are OK
### Step F.2: Run Full Test Suite
```bash
go test ./... -v
```
**Expected:** All tests pass
### Step F.3: Build Project
```bash
go build ./...
```
**Expected:** No compilation errors
### Step F.4: Review All Changes
```bash
git diff --stat
git diff
```
**Verify:**
- Only intended files modified
- No unintended deletions
- Logic is correct
### Step F.5: Check Documentation Renders
```bash
# Start server
podman compose up -d
# Visit http://localhost:8080/docs
# Verify new docs appear
# Test search finds new content
```
### Step F.6: Manual Testing (Bruno)
1. **Test self-update:**
- GET /api/auth/profile → See profile
- PUT /api/auth/profile (update username) → Success
- Verify username changed
2. **Test admin-update:**
- PUT /api/auth/profile/:id (admin updates user) → Success
- PUT /api/auth/profile/:id with role field → User promoted to admin
3. **Test password:**
- PUT /api/auth/password (self-change) → Success
- PUT /api/auth/password/:id (admin reset) → Success
4. **Test deletion:**
- DELETE /api/auth/profile (self-delete test user) → Success
- DELETE /api/auth/profile/:id (admin delete test user) → Success
- Try DELETE /api/auth/profile (last admin) → 400 error
5. **Test theme:**
- PUT /api/auth/theme (quick toggle) → Success
- Verify theme changed immediately
---
## SUMMARY OF CHANGES
| Phase | Files | Lines Added | Lines Removed | Risk |
|-------|-------|-------------|---------------|------|
| 1. Database Query | 1 | 5 | 0 | Lowest |
| 2. Extend UpdateProfile & Admin Handlers | 1 | 230 | 20 | Medium |
| 3. Rename DeleteAccount | 1 | 10 | 0 | Low |
| 4. Router Updates | 1 | 8 | 3 | Low |
| 5. Frontend (Profile + Users + Modal) | 7 | 650 | 160 | Medium |
| 6. Tests | 1 | 400 | 120 | High |
| 7. Bruno Tests | 5 | 100 | 0 | Low |
| 8. Documentation | 5 | 200 | 0 | Lowest |
| **TOTAL** | **22** | **~1603** | **~303** | **Medium** |
---
## SUCCESS CRITERIA
- [x] Database query added and code regenerated
- [x] UpdateUser handler created (reuses existing queries)
- [x] ResetUserPassword handler created (standalone)
- [x] UpdateProfile extended for all fields
- [x] DeleteUser renamed, uses URL param
- [x] Routes consolidated (3 removed, 4 added)
- [x] /profile page created for all users
- [x] /admin/users page created with user management table
- [x] Profile modal component created (reusable for self and admin editing)
- [x] GET /admin/users/:id/profile-modal route added (admin only, server validates)
- [x] HTMX Edit button loads modal (secure, server-validated)
- [x] Profile modal conditionally shows current password field
- [x] HTMX role toggles with page reload after success
- [x] HTMX delete buttons with page reload after success
- [x] Last-admin protection in UI (disabled states for role/delete)
- [x] Header links updated (/settings → /profile)
- [x] Admin navigation includes /admin/users link
- [x] Admin-only profile page removed
- [x] Login template shows account deleted message
- [x] All tests passing (40+ new tests)
- [x] Bruno tests updated
- [x] Documentation complete
- [x] Zero compilation errors
- [x] Zero guideline violations
- [x] **NO OVER-ENGINEERING** - Simple composition approach
---
**End of Revised Implementation Plan**
Follow this plan phase-by-phase. Commit after each phase. Stop and verify after each step. No cascading edits. Surgical precision. Simple composition. NO OVER-ENGINEERING.