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.
3094 lines
99 KiB
Markdown
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);"
|
|
>
|
|
×
|
|
</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.
|