- Comprehensive 8-phase plan for consolidating profile endpoints - Self-service profile: PUT /api/auth/profile for users - Admin user management: profile updates, password resets, deletions - Frontend: universal /profile page, /admin/users with modal editing - Security: last-admin protection throughout - Follows KISS principle: copy/paste logic, no over-engineering
102 KiB
API Consolidation Implementation Plan (REVISED - UPDATED)
Goal: Consolidate fragmented user profile endpoints using simple handler composition (copy/paste existing logic)
Principle: Surgical, line-by-line changes with verification at each step
Rule #1: NO OVER-ENGINEERING - Keep It Simple, Copy/Paste Existing Logic
Strategy: Keep existing handlers, create new admin handlers that reuse their logic (no function calls, just copy/paste)
🎯 FINAL API DESIGN (Reference)
Self-Service:
GET /api/auth/profile - Get own profile (UNCHANGED)
PUT /api/auth/profile - Update own profile {username, email, first_name, last_name, theme}
PUT /api/auth/password - Change own password {current_password, new_password, confirm_password}
PUT /api/auth/theme - Quick theme toggle {theme} (UNCHANGED)
DELETE /api/auth/profile - Delete your own account
Admin:
GET /api/auth/users - List all users (UNCHANGED)
PUT /api/auth/profile/:id - Update user {username, email, first_name, last_name, theme, role?}
PUT /api/auth/password/:id - Reset user password {new_password, confirm_password}
PUT /api/auth/users/:id/max-devices - Update device limit (UNCHANGED)
DELETE /api/auth/profile/:id - Delete user (with last-admin check)
Remove (routes only, handlers kept for reuse):
PUT /api/auth/username - Route removed, handler kept for UpdateUser to call
PUT /api/auth/email - Route removed, handler kept for UpdateUser to call
🧪 Available Test Helpers (From cmd/server/tests/test_helpers.go)
Setup Helpers:
setupTestServer(t)- Creates complete test server with automatic cleanup viat.Cleanup()setupDeviceTest(t)- Creates test environment for device tests (includes server + user + token)
User Creation:
createTestUserOnce(t, db)- Creates test user with deterministic credentials (returnsUserTestData)- Email: "testuser@example.com"
- Username: "testuser"
- Password: "Test@Pass123!"
- Role: "admin"
getTestUserID(t, db)- Gets or creates test user UUID (admin role)getRegularUserID(t, db)- Gets or creates regular user UUID ("user" role)
Authentication:
loginTestUser(t, ts, db)- Logs in test user, returns JWT tokenloginUserWithCredentials(t, ts, email, password)- Login with custom credentialsloginRegularUser(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 existsverifyDeviceDeleted(t, db, deviceID)- Verify device deletedverifyMediaItemInDB(t, db, mediaID)- Verify media item existsverifyMediaItemDeleted(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
- Phase 1: Database Query Addition
- Phase 2: Extend UpdateProfile, Create Admin Handlers (copy/paste logic)
- Phase 3: Rename DeleteAccount to DeleteUser (use URL param)
- Phase 4: Router Updates (remove/add routes)
- Phase 5: Frontend (Profile page, Header updates, Login updates, Profile modal, Admin users list - 12 steps)
- Phase 6: Test Overhaul (remove obsolete, add new)
- Phase 7: Bruno YAML Tests
- 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:
-- name: UpdateUserRole :one
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;
Verification:
# 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:
make sqlc
Verification:
# 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:
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:
sed -n '538,565p' internal/handlers/auth.go
Read UpdateEmail to understand conflict checking pattern:
sed -n '572,599p' internal/handlers/auth.go
Read UpdatePassword to understand password hashing logic:
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:
type UpdateProfileRequest struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
}
Replace with:
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:
type AdminUpdateUserRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
}
Step 2.4: Replace UpdateProfile Handler
Location: Lines 452-470 in internal/handlers/auth.go
Read current implementation:
sed -n '452,470p' internal/handlers/auth.go
Current code:
// 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:
// UpdateProfile handles PUT /api/auth/profile
// Updates username, email, first_name, last_name, theme
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
var req UpdateProfileRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Update username (if provided) - COPY/PASTED from UpdateUsername handler
if req.Username != "" {
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: user.ID,
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update email (if provided) - COPY/PASTED from UpdateEmail handler
if req.Email != "" {
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: user.ID,
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update first/last name (if provided)
if req.FirstName != "" || req.LastName != "" {
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: user.ID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update theme (if provided)
if req.Theme != "" {
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: user.ID,
Theme: pgtype.Text{String: req.Theme, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
}
Step 2.5: Create UpdateUser Handler (Admin)
Location: After UpdateProfile handler in internal/handlers/auth.go
Find insertion point:
grep -n "func (h \*AuthHandler) UpdateProfile" internal/handlers/auth.go
Add after the UpdateProfile handler ends:
// UpdateUser handles PUT /api/auth/profile/:id (admin only)
// Updates another user's profile using copy/pasted conflict checking logic
func (h *AuthHandler) UpdateUser(c echo.Context) error {
currentUser := MustGetAuthenticatedUser(c)
// Get target user ID from URL param
targetUserID := c.Param("id")
if targetUserID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID := pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
var req AdminUpdateUserRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Handle role change (if provided)
if req.Role != "" {
if req.Role != "admin" && req.Role != "user" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"})
}
// Check if this is the last admin (preventing demotion)
users, err := h.db.ListUsers(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
}
adminCount := 0
currentTargetUserRole := ""
for _, user := range users {
if user.Role == "admin" {
adminCount++
}
if user.ID.Bytes == targetUserUUID.Bytes {
currentTargetUserRole = user.Role
}
}
// Prevent demoting the last admin
if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"})
}
// Update role
_, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{
ID: targetUserUUID,
Role: req.Role,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update username (if provided)
if req.Username != "" {
// Check if username is already taken
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
// Update username
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: targetUserUUID,
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update email (if provided)
if req.Email != "" {
// Check if email is already taken
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
// Update email
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: targetUserUUID,
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update first/last name (if provided)
if req.FirstName != "" || req.LastName != "" {
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: targetUserUUID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update theme (if provided)
if req.Theme != "" {
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: targetUserUUID,
Theme: pgtype.Text{String: req.Theme, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
return c.JSON(http.StatusOK, map[string]string{"message": "user updated successfully"})
}
Step 2.6: Create ResetUserPassword Handler
Location: After UpdateUser handler (after the code we just added)
Add immediately after UpdateUser ends:
// ResetUserPassword handles PUT /api/auth/password/:id (admin only)
// Resets another user's password without requiring current password
func (h *AuthHandler) ResetUserPassword(c echo.Context) error {
currentUser := MustGetAuthenticatedUser(c)
targetUserID := c.Param("id")
if targetUserID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID := pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
type PasswordResetRequest struct {
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
var req PasswordResetRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.NewPassword != req.ConfirmPassword {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
}
// Hash password (reuse logic from UpdatePassword)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
// Update password directly (no current password check)
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
ID: targetUserUUID,
PasswordHash: string(hashedPassword),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "password reset successfully"})
}
Verification:
# Compile check
go build ./internal/handlers
# Verify new handlers exist
grep -n "func (h \*AuthHandler) UpdateUser\|func (h \*AuthHandler) ResetUserPassword" internal/handlers/auth.go
Commit:
git add internal/handlers/auth.go
git commit -m "feat(auth): extend UpdateProfile and add admin handlers
- Extend UpdateProfile handler to support username, email, theme fields
- Copy/paste conflict checking logic from UpdateUsername and UpdateEmail handlers
- Add UpdateUser handler for admin profile updates
- Add ResetUserPassword handler for admin password resets
- All handlers call database queries directly (no handler-to-handler calls)
- Admin handlers support last-admin protection
"
PHASE 3: Rename DeleteAccount to DeleteUser
Goal: Rename handler, modify to use URL param instead of query param Files: 1 file, ~10 lines modified Risk: Low (simple rename and param change)
Step 3.1: Rename DeleteAccount to DeleteUser
Location: Lines 658-731 in internal/handlers/auth.go
Read current implementation:
sed -n '658,731p' internal/handlers/auth.go
Step 3.1.1: Change function signature (line 658)
From:
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
To:
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:
sed -n '658,731p' internal/handlers/auth.go
Step 3.2: Modify Query Param to URL Param (lines 661-678)
Current code:
// 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:
// 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:
# Compile check
go build ./internal/handlers
# Verify function was renamed
grep -n "func (h \*AuthHandler) DeleteUser" internal/handlers/auth.go
Commit:
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
cat internal/router/auth.go
Step 4.2: Remove Obsolete Routes
Delete line 24:
protected.PUT("/auth/profile", cfg.AuthHandler.UpdateProfile)
Delete lines 34-35:
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):
// Profile management
authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
Add after line 42 (after max-devices route):
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateUser)
admin.PUT("/password/:id", cfg.AuthHandler.ResetUserPassword)
admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
Step 4.4: Verify Router File
Complete file should look like:
package router
import (
"bookhoard/internal/handlers"
"github.com/labstack/echo/v4"
)
func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
e := cfg.Echo
// Auth routes (no auth required, but rate limited)
e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register))
e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login))
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
// Create protected route group
protected := e.Group("/api", jwtMiddleware)
// Protected auth routes
protected.GET("/auth/profile", cfg.AuthHandler.GetProfile)
// Refresh token endpoint (no authentication required - uses refresh token from body)
e.POST("/api/auth/refresh", cfg.AuthHandler.RefreshAccessToken)
// Logout endpoint (optional authentication - can revoke tokens if provided)
e.POST("/api/auth/logout", cfg.AuthHandler.Logout)
// Auth update routes
authGroup := e.Group("/api/auth", createJWTMiddleware(cfg))
authGroup.PUT("/password", cfg.AuthHandler.UpdatePassword)
authGroup.PUT("/theme", cfg.AuthHandler.UpdateTheme)
// Profile management (self and admin)
authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
// Admin-only routes for user management
admin := protected.Group("/auth", handlers.AdminMiddleware)
admin.GET("/users", cfg.AuthHandler.ListUsers)
admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateUser)
admin.PUT("/password/:id", cfg.AuthHandler.ResetUserPassword)
admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
}
Verification:
# Compile check
go build ./internal/router
# Count routes (should be 10)
grep -E "\.(GET|POST|PUT|DELETE)\(" internal/router/auth.go | wc -l
# Verify no duplicate routes
grep "PUT.*profile" internal/router/auth.go
Expected: Two lines - one for authGroup.PUT("/profile"), one for admin.PUT("/profile/:id")
Commit:
git add internal/router/auth.go
git commit -m "refactor(router): consolidate auth routes
- Remove PUT /api/auth/profile (re-add with different route group)
- Remove PUT /api/auth/email (merged into /profile)
- Remove PUT /api/auth/username (merged into /profile)
- Add PUT /api/auth/profile (consolidated self-update)
- Add DELETE /api/auth/profile (self-deletion)
- Add PUT /api/auth/profile/:id (admin update user)
- Add PUT /api/auth/password/:id (admin reset password)
- Add DELETE /api/auth/profile/:id (admin delete user)
- Keep PUT /api/auth/theme unchanged (header quick-toggle)
- Handlers UpdateUsername/UpdateEmail kept for internal reuse
"
PHASE 5: Frontend Routes & Templates
Goal: Create /profile page, create /admin/users page with modal editing, update header links Files: 7 files (3 new: profile.templ, profile_modal.templ, admin_users.templ; 4 modified) Risk: Medium (template changes, HTMX modal pattern)
Step 5.1: Create Profile Template
File: templates/profile.templ (NEW FILE)
Create with following content:
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 -->
<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-6" style="color: var(--text-primary)">Account Information</h3>
<form hx-put="/api/auth/profile" hx-target="#profile-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="space-y-4 max-w-md">
<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" 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" 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>
<button type="submit" class="btn-primary px-4 py-2 rounded">Update Profile</button>
</form>
<div id="profile-result" class="mt-2"></div>
</div>
<!-- Change Password -->
<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-6" style="color: var(--text-primary)">Change Password</h3>
<form hx-put="/api/auth/password" hx-target="#password-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' 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)" required 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)" required minlength="6">
</div>
<button type="submit" class="btn-primary px-4 py-2 rounded">Update Password</button>
</form>
<div id="password-result" class="mt-2"></div>
</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.2: Update Header Template
File: templates/header.templ
Find the settings link (around line 117-118):
Current:
<a href="/settings" class="...">Settings</a>
Change to:
<a href="/profile" class="...">Profile</a>
Regenerate template:
templ generate
Step 5.3: Update Admin Template
File: templates/admin.templ
Find the profile link (around line 39-40):
Current:
<a href="/admin/profile">Profile Settings</a>
Change to:
<a href="/profile">Profile Settings</a>
Regenerate template:
templ generate
Step 5.4: Delete Admin Profile Template
File: templates/admin_profile.templ
Delete the entire file:
rm templates/admin_profile.templ
Regenerate templates:
templ generate
Step 5.5: Add Frontend Route for Profile Page
File: internal/router/frontend.go
Add after line 385 (in the frontendProtected routes section):
// Profile page (all users)
frontendProtected.GET("/profile", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
var buf bytes.Buffer
err = templates.Profile(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Step 5.6: Update Login Template
File: templates/login.templ
Line 3: Change signature from:
templ Login(sessionExpired bool) {
To:
templ Login(sessionExpired bool, deleted bool) {
After the sessionExpired block (around line 40), add:
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:
templ generate
Step 5.7: Update Login Frontend Route
File: internal/router/frontend.go
Find the login route (around line 53):
grep -n "GET.*login" internal/router/frontend.go
Modify to read deleted query parameter:
Current:
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:
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:
# Compile check
go build ./internal/router
Commit (for Steps 5.1-5.7):
git add templates/ internal/router/frontend.go
git commit -m "feat(frontend): add universal /profile page, remove admin-only profile
- Create templates/profile.templ for all users
- Update header link: /settings → /profile
- Update admin template: /admin/profile → /profile
- Remove templates/admin_profile.templ (no longer needed)
- Add GET /profile route in frontend.go
- Add account deletion with confirmation
- Update Login template to show account deleted message
- Update login route to pass deleted parameter
"
Step 5.8: Create Profile Modal Component
Goal: Reusable modal component for editing user profiles (self and admin editing other users)
File: templates/profile_modal.templ (NEW FILE)
Create with following content:
package templates
templ ProfileModal(user User, isAdminEditingOther bool) {
<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)">
{ isAdminEditingOther ? "Edit User Profile" : "Edit Your 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);"
>
×
</button>
</div>
<!-- Modal Body -->
<div class="p-6">
<form hx-put="/api/auth/profile/{user.ID}"
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}
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}
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>
</div>
</div>
<!-- Change Password Section -->
<div>
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Change Password</h3>
@if !isAdminEditingOther {
<!-- Editing self - 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="submit"
hx-put="/api/auth/password"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target="#password-result"
hx-swap="innerHTML"
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 {
<!-- Editing other user as admin - 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="submit"
hx-put="/api/auth/password/{user.ID}"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target="#password-result"
hx-swap="innerHTML"
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);">
<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>
</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:
templ generate
Step 5.9: Add Profile Modal Route
File: internal/router/frontend.go
Add after the profile route (around line 400):
// Admin: Get profile modal for editing user
frontendAdmin.GET("/users/:id/profile-modal", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Verify admin access
if user.Role != "admin" {
return c.HTML(http.StatusForbidden, "<div>Access denied</div>")
}
// 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.DB.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, true).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Note: This adds a new admin-only endpoint that returns the profile modal HTML for any user. Server validates admin before returning modal.
Step 5.10: Create Admin Users List Page
Goal: Admin page to view and manage all users with role toggles, delete actions, and modal profile editing
File: templates/admin_users.templ (NEW FILE)
Create with following content:
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>
{ 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)">
{fmt.Sprintf("%s", 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:
templ generate
Step 5.11: Add Admin Users Route
File: internal/router/frontend.go
Add after the /profile route (around line 395):
// Admin users page
frontendAdmin.GET("/users", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Check if user is admin
if user.Role != "admin" {
return c.HTML(http.StatusForbidden, "<h1>Admin access required</h1>")
}
// Fetch all users
users, err := cfg.DB.ListUsers(c.Request().Context())
if err != nil {
return renderErrorPage(c, "Error loading users", "users_load_error")
}
// Count admins for UI protection
adminCount := 0
for _, u := range users {
if u.Role == "admin" {
adminCount++
}
}
// Get current user ID for "You" badge
currentUserID := user.ID
var buf bytes.Buffer
err = templates.AdminUsers(toTemplateUsers(users), adminCount, currentUserID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Note: You'll need helper functions to convert database users to template users. Add these in internal/router/frontend.go:
// Convert single database user to template user
func toTemplateUser(dbUser database.User) templates.User {
return templates.User{
ID: dbUser.ID,
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
FirstName: dbUser.FirstName,
LastName: dbUser.LastName,
Theme: dbUser.Theme,
CreatedAt: dbUser.CreatedAt,
}
}
// Convert slice of database users to template users
func toTemplateUsers(dbUsers []database.User) []templates.User {
users := make([]templates.User, len(dbUsers))
for i, u := range dbUsers {
users[i] = toTemplateUser(u)
}
return users
}
Step 5.12: Update Admin Navigation Link
File: templates/admin.templ
Add link to users page (around line 40):
Find this section:
<div>
<a href="/profile">Profile Settings</a>
</div>
Add after it:
<div>
<a href="/admin/users">Users</a>
</div>
Regenerate template:
templ generate
Verification:
# 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:
git add templates/ internal/router/frontend.go
git commit -m "feat(frontend): add admin users management with modal profile editing
- Create templates/profile_modal.templ (reusable component for editing profiles)
- Add GET /admin/users/:id/profile-modal route (admin only, server validates)
- Create templates/admin_users.templ with user management table
- Add GET /admin/users route with SSR initial data fetch
- HTMX Edit button loads modal (no page navigation, secure)
- HTMX role toggles with page reload after success
- HTMX delete buttons with page reload after success
- Last-admin protection in UI (disabled states for role/delete)
- Profile modal conditionally shows current password field
- Editing self: requires current password
- Editing other user (admin): no current password required
- Progressive enhancement - page reloads after successful actions
- Server-side security - admin-only access validated before modal sent
"
PHASE 6: Test Overhaul
Goal: Remove obsolete tests, add comprehensive tests
Files: 1 file, ~50 tests removed, ~40 tests added
Risk: Highest (test changes must be thorough)
Step 6.1: Backup Current Tests
git stash push -m "Backup tests before API consolidation overhaul"
Step 6.2: Read Current Test Structure
# Count tests in user_test.go
grep -c "t.Run(" cmd/server/tests/user_test.go
# List all test functions
grep "^func Test" cmd/server/tests/user_test.go
Step 6.3: Remove Obsolete Tests
File: cmd/server/tests/user_test.go
Find and delete these test blocks:
- PUT /api/auth/email tests - Find lines:
grep -n "PUT /api/auth/email" cmd/server/tests/user_test.go
Delete all tests under this category (approximately lines 82-166)
- PUT /api/auth/username tests - Find lines:
grep -n "PUT /api/auth/username" cmd/server/tests/user_test.go
Delete all tests under this category (approximately lines 167-215)
Keep: GET profile, PUT profile (will modify), PUT password, PUT theme, DELETE account (will modify)
Step 6.4: Modify Existing Tests
Modify PUT /api/auth/profile test:
Find the test:
grep -n "PUT /api/auth/profile - Update profile" cmd/server/tests/user_test.go
Update the test body to include new fields:
t.Run("PUT /api/auth/profile - Update profile with all fields", func(t *testing.T) {
username := "updateduser"
email := "updated@example.com"
firstName := "Updated"
lastName := "User"
theme := "dracula"
jsonData, _ := json.Marshal(map[string]interface{}{
"username": username,
"email": email,
"first_name": firstName,
"last_name": lastName,
"theme": theme,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock handler logic
w.Write([]byte(`{"message":"profile updated successfully"}`))
})
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
Modify DELETE /api/auth/account tests to use new endpoints:
Find all occurrences:
grep -n "DELETE /api/auth/account" cmd/server/tests/user_test.go
Replace all:
req := httptest.NewRequest("DELETE", "/api/auth/account", nil)
With:
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
Replace all:
req := httptest.NewRequest("DELETE", "/api/auth/account?user_id="+userID.String(), nil)
With:
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:
// Example: Create a second test user with different credentials
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" // "Test@Pass123!"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
user2, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "user2@example.com",
Username: "user2",
PasswordHash: passwordHash,
Role: "user",
})
NOTE: createTestUserOnce() creates an admin user (role="admin"). For admin tests:
- Use
createTestUserOnce(t, setup.DB)for the admin - Manually create a regular user with
Role: "user"for testing admin operations on regular users
// Example: Create a second test user with different credentials
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" // "Test@Pass123!"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
user2, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "user2@example.com",
Username: "user2",
PasswordHash: passwordHash,
Role: "user",
})
Add at end of file before final closing brace:
// TestUpdateProfileConsolidated tests the consolidated profile update endpoint
func TestUpdateProfileConsolidated(t *testing.T) {
t.Run("PUT /api/auth/profile - Update username only", func(t *testing.T) {
setup := setupTestServer(t)
// Create test user using helper
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
username := "newusername"
jsonData, _ := json.Marshal(map[string]interface{}{
"username": username,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify username was updated
updatedUser, err := setup.DB.GetUserByUsername(context.Background(), username)
assert.NoError(t, err)
assert.Equal(t, username, updatedUser.Username)
})
t.Run("PUT /api/auth/profile - Update email only", func(t *testing.T) {
setup := setupTestServer(t)
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
email := "newemail@example.com"
jsonData, _ := json.Marshal(map[string]interface{}{
"email": email,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify email was updated
updatedUser, err := setup.DB.GetUserByEmail(context.Background(), email)
assert.NoError(t, err)
assert.Equal(t, email, updatedUser.Email)
})
t.Run("PUT /api/auth/profile - Update theme only", func(t *testing.T) {
setup := setupTestServer(t)
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
theme := "dracula"
jsonData, _ := json.Marshal(map[string]interface{}{
"theme": theme,
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify theme was updated
updatedUser, err := setup.DB.GetUser(context.Background(), user.ID)
assert.NoError(t, err)
assert.Equal(t, theme, updatedUser.Theme.String)
})
t.Run("PUT /api/auth/profile - Update all fields", func(t *testing.T) {
setup := setupTestServer(t)
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "newalluser",
"email": "newall@example.com",
"first_name": "NewAll",
"last_name": "User",
"theme": "nord",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify all fields updated
updatedUser, err := setup.DB.GetUser(context.Background(), user.ID)
assert.NoError(t, err)
assert.Equal(t, "newalluser", updatedUser.Username)
assert.Equal(t, "newall@example.com", updatedUser.Email)
})
t.Run("PUT /api/auth/profile - Username conflict", func(t *testing.T) {
setup := setupTestServer(t)
// Create first user using helper
user1 := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create second user manually with different username
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
_, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "user2@example.com",
Username: "user2",
PasswordHash: passwordHash,
Role: "user",
})
require.NoError(t, err)
// Try to update user1 to user2's username
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "user2",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
})
t.Run("PUT /api/auth/profile - Email conflict", func(t *testing.T) {
setup := setupTestServer(t)
// Create first user using helper
user1 := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create second user manually with different email
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
_, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "email2@example.com",
Username: "email2user",
PasswordHash: passwordHash,
Role: "user",
})
require.NoError(t, err)
jsonData, _ := json.Marshal(map[string]interface{}{
"email": "email2@example.com",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
})
t.Run("PUT /api/auth/profile - No auth", func(t *testing.T) {
setup := setupTestServer(t)
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "test",
})
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
// No Authorization header
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
// TestUpdateUserAdmin tests admin updating another user
func TestUpdateUserAdmin(t *testing.T) {
t.Run("PUT /api/auth/profile/:id - Admin update username", func(t *testing.T) {
setup := setupTestServer(t)
// Admin user (createTestUserOnce creates admin role)
admin := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
// Create regular user to update
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
user, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
ID: pgUserID,
Email: "regularuser@example.com",
Username: "regularuser",
PasswordHash: passwordHash,
Role: "user",
})
require.NoError(t, err)
userUUID, _ := uuid.FromBytes(user.ID.Bytes[0:16])
newUsername := "updateduser"
jsonData, _ := json.Marshal(map[string]interface{}{
"username": newUsername,
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+userUUID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify username updated
updatedUser, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
assert.NoError(t, err)
assert.Equal(t, newUsername, updatedUser.Username)
})
t.Run("PUT /api/auth/profile/:id - Admin promote user to admin", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"role": "admin",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+user.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify role changed
updatedUser, err := setup.DB.GetUser(context.Background(), user.ID)
assert.NoError(t, err)
assert.Equal(t, "admin", updatedUser.Role)
})
t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
// Try to demote self
jsonData, _ := json.Marshal(map[string]interface{}{
"role": "user",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+admin.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "cannot demote the last admin")
})
t.Run("PUT /api/auth/profile/:id - Non-admin tries update", func(t *testing.T) {
setup := setupTestServer(t)
user1 := createTestUserOnce(t, setup.DB)
user2 := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"username": "hacked",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+user2.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("PUT /api/auth/profile/:id - Invalid role", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
user := createTestUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"role": "superadmin",
})
req := httptest.NewRequest("PUT", "/api/auth/profile/"+user.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid role")
})
}
// TestResetUserPassword tests admin password reset functionality
func TestResetUserPassword(t *testing.T) {
t.Run("PUT /api/auth/password/:id - Admin reset password", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
newPassword := "newpassword123"
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": newPassword,
"confirm_password": newPassword,
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+user.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify password changed by attempting login with new password
loginData, _ := json.Marshal(map[string]interface{}{
"login": "passuser",
"password": newPassword,
})
loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData))
loginW := httptest.NewRecorder()
setup.Router.ServeHTTP(loginW, loginReq)
assert.Equal(t, http.StatusOK, loginW.Code)
})
t.Run("PUT /api/auth/password/:id - Non-admin tries reset", func(t *testing.T) {
setup := setupTestServer(t)
user1 := createTestUserOnce(t, setup.DB)
user2 := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": "hacked",
"confirm_password": "hacked",
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+user2.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("PUT /api/auth/password/:id - Mismatched passwords", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
user := createTestUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": "password1",
"confirm_password": "password2",
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+user.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "passwords do not match")
})
t.Run("PUT /api/auth/password/:id - Invalid password format", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
user := createTestUserOnce(t, setup.DB)
jsonData, _ := json.Marshal(map[string]interface{}{
"new_password": "123",
"confirm_password": "123",
})
req := httptest.NewRequest("PUT", "/api/auth/password/"+user.ID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
// TestDeleteUserConsolidated tests account deletion
func TestDeleteUserConsolidated(t *testing.T) {
t.Run("DELETE /api/auth/profile - User deletes self successfully", func(t *testing.T) {
setup := setupTestServer(t)
// Create admin first so we have at least 2 users
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify user deleted
_, err := setup.DB.GetUser(context.Background(), user.ID)
assert.Error(t, err)
})
t.Run("DELETE /api/auth/profile - Delete without auth", func(t *testing.T) {
setup := setupTestServer(t)
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
// No auth
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
setup := setupTestServer(t)
// Create only one admin
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "cannot delete the last admin")
})
t.Run("DELETE /api/auth/profile/:id - Admin deletes user", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
user := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+user.ID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify user deleted
_, err := setup.DB.GetUser(context.Background(), user.ID)
assert.Error(t, err)
})
t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
setup := setupTestServer(t)
// Create only one admin
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+admin.ID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "cannot delete the last admin")
})
t.Run("DELETE /api/auth/profile/:id - Non-admin tries delete", func(t *testing.T) {
setup := setupTestServer(t)
user1 := createTestUserOnce(t, setup.DB)
user2 := createTestUserOnce(t, setup.DB)
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+user2.ID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("DELETE /api/auth/profile/:id - Delete non-existent user", func(t *testing.T) {
setup := setupTestServer(t)
admin := createTestUserOnce(t, setup.DB)
admin.Role = "admin"
setup.DB.UpdateUserRole(context.Background(), database.UpdateUserRoleParams{
ID: admin.ID,
Role: "admin",
})
token := loginTestUser(t, setup.Server, setup.DB)
fakeUUID := uuid.New()
req := httptest.NewRequest("DELETE", "/api/auth/profile/"+fakeUUID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
setup.Router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
Step 6.6: Run Tests
# Run user tests
go test cmd/server/tests/user_test.go -v
Expected: All new tests pass
Fix any failures before proceeding.
Commit:
git add cmd/server/tests/user_test.go
git commit -m "test(auth): overhaul user profile tests for API consolidation
- Remove 9 obsolete tests (username, email endpoints)
- Modify existing profile test for new fields
- Modify DELETE account tests for new endpoints
- Add TestUpdateProfileConsolidated (7 tests)
- Add TestUpdateUserAdmin (6 tests)
- Add TestResetUserPassword (4 tests)
- Add TestDeleteUserConsolidated (7 tests)
- Cover all scenarios: self-update, admin update, role changes
- Test last-admin protection thoroughly
- Test conflict detection (username, email)
"
PHASE 7: Bruno OpenCollection Tests
Goal: Update/create Bruno YAML tests Files: ~5 files (0 delete, 2 modify, 3 create) Risk: Low (contract testing)
Step 7.1: Check Current Bruno Structure
find bruno -name "*.yml" | head -20
Step 7.2: Delete Obsolete Bruno Tests
# Find username/email tests if they exist
find bruno -name "*username*" -o -name "*email*"
Delete if found.
Step 7.3: Update Existing Profile Test
File: bruno/auth/profile.yml (create if doesn't exist)
meta:
name: Profile Management
type: http
seq: 1
# Scenario: Update username
meta:
name: Update username
request:
method: PUT
url: {{baseUrl}}/api/auth/profile
headers:
Authorization: Bearer {{token}}
body:
username: updateduser
# Scenario: Update email
meta:
name: Update email
request:
method: PUT
url: {{baseUrl}}/api/auth/profile
headers:
Authorization: Bearer {{token}}
body:
email: updated@example.com
# Scenario: Update all fields
meta:
name: Update all profile fields
request:
method: PUT
url: {{baseUrl}}/api/auth/profile
headers:
Authorization: Bearer {{token}}
body:
username: updateduser
email: updated@example.com
first_name: Updated
last_name: User
theme: dracula
# Scenario: Update theme only
meta:
name: Update theme
request:
method: PUT
url: {{baseUrl}}/api/auth/profile
headers:
Authorization: Bearer {{token}}
body:
theme: nord
Step 7.4: Create Admin Update User Test
File: bruno/auth/admin-update-user.yml
meta:
name: Admin Update User
type: http
seq: 1
# Scenario: Admin update user username
meta:
name: Admin update username
request:
method: PUT
url: {{baseUrl}}/api/auth/profile/{{userId}}
headers:
Authorization: Bearer {{adminToken}}
body:
username: newusername
# Scenario: Admin update user email
meta:
name: Admin update email
request:
method: PUT
url: {{baseUrl}}/api/auth/profile/{{userId}}
headers:
Authorization: Bearer {{adminToken}}
body:
email: newemail@example.com
# Scenario: Admin promote user to admin
meta:
name: Admin promote user
request:
method: PUT
url: {{baseUrl}}/api/auth/profile/{{userId}}
headers:
Authorization: Bearer {{adminToken}}
body:
role: admin
# Scenario: Admin demote admin to user
meta:
name: Admin demote admin
request:
method: PUT
url: {{baseUrl}}/api/auth/profile/{{adminId}}
headers:
Authorization: Bearer {{superAdminToken}}
body:
role: user
Step 7.5: Create Admin Reset Password Test
File: bruno/auth/admin-reset-password.yml
meta:
name: Admin Reset Password
type: http
seq: 1
# Scenario: Admin reset user password
meta:
name: Admin reset password
request:
method: PUT
url: {{baseUrl}}/api/auth/password/{{userId}}
headers:
Authorization: Bearer {{adminToken}}
body:
new_password: newpassword123
confirm_password: newpassword123
Step 7.6: Create Delete Account Tests
File: bruno/auth/delete-account.yml
meta:
name: Delete Account
type: http
seq: 1
# Scenario: User delete own account
meta:
name: Delete own account
request:
method: DELETE
url: {{baseUrl}}/api/auth/profile
headers:
Authorization: Bearer {{token}}
# Scenario: Admin delete user
meta:
name: Admin delete user
request:
method: DELETE
url: {{baseUrl}}/api/auth/profile/{{userId}}
headers:
Authorization: Bearer {{adminToken}}
# Scenario: Try to delete last admin (should fail)
meta:
name: Try to delete last admin
request:
method: DELETE
url: {{baseUrl}}/api/auth/profile/{{lastAdminId}}
headers:
Authorization: Bearer {{lastAdminToken}}
expect: 400
Commit:
git add bruno/
git commit -m "test(bruno): update auth tests for API consolidation
- Delete obsolete tests: username.yml, email.yml
- Update profile.yml with new consolidated endpoint
- Add admin-update-user.yml for admin user management
- Add admin-reset-password.yml for admin password resets
- Add delete-account.yml for account deletion (self and admin)
- Document all scenarios with proper request bodies
"
PHASE 8: Documentation
Goal: Update API and user documentation
Files: ~5 files (create/update)
Risk: Lowest
Step 8.1: Update API Documentation
Create: docs/developer/api/auth/profile.md
# 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
# 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
# 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:
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 scripts/verify-guidelines.sh
Expected: 0 errors, warnings are OK
Step F.2: Run Full Test Suite
go test ./... -v
Expected: All tests pass
Step F.3: Build Project
go build ./...
Expected: No compilation errors
Step F.4: Review All Changes
git diff --stat
git diff
Verify:
- Only intended files modified
- No unintended deletions
- Logic is correct
Step F.5: Check Documentation Renders
# 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)
-
Test self-update:
- GET /api/auth/profile → See profile
- PUT /api/auth/profile (update username) → Success
- Verify username changed
-
Test admin-update:
- PUT /api/auth/profile/:id (admin updates user) → Success
- PUT /api/auth/profile/:id with role field → User promoted to admin
-
Test password:
- PUT /api/auth/password (self-change) → Success
- PUT /api/auth/password/:id (admin reset) → Success
-
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
-
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
- Database query added and code regenerated
- UpdateUser handler created (reuses existing queries)
- ResetUserPassword handler created (standalone)
- UpdateProfile extended for all fields
- DeleteUser renamed, uses URL param
- Routes consolidated (3 removed, 4 added)
- /profile page created for all users
- /admin/users page created with user management table
- Profile modal component created (reusable for self and admin editing)
- GET /admin/users/:id/profile-modal route added (admin only, server validates)
- HTMX Edit button loads modal (secure, server-validated)
- Profile modal conditionally shows current password field
- 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)
- Header links updated (/settings → /profile)
- Admin navigation includes /admin/users link
- Admin-only profile page removed
- Login template shows account deleted message
- All tests passing (40+ new tests)
- Bruno tests updated
- Documentation complete
- Zero compilation errors
- Zero guideline violations
- 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.