# User Management Consolidation & Privilege Tier System
Date: 2026-06-25
Owner: Planning Agent
Status: Proposed
Severity: Medium (UX consolidation + feature enhancement)
---
## 1. Introduction
### 1.1 Overview
Charon currently manages users through **two separate interfaces**:
1. **Admin Account** page at `/settings/account` — self-service profile management (name, email, password, API key, certificate email)
2. **Account Management** page at `/settings/account-management` — admin-only user CRUD (list, invite, delete, update roles/permissions)
This split is confusing and redundant. A logged-in admin must navigate to two different places to manage their own account versus managing other users. The system also lacks a formal privilege tier model — the `Role` field on the `User` model is a raw string defaulting to `"user"`, with only `"admin"` and `"user"` exposed in the frontend selector (the model comment mentions `"viewer"` but it is entirely unused).
### 1.2 Objectives
1. **Consolidate** user management into a single **"Users"** page that lists ALL users, including the admin.
2. **Introduce a formal privilege tier system** with three tiers: **Admin**, **User**, and **Pass-through**.
3. **Self-service profile editing** occurs inline or via a detail drawer/modal on the consolidated page — no separate "Admin Account" page.
4. **Remove** the `/settings/account` route and its sidebar/tab navigation entry entirely.
5. **Update navigation** to a single "Users" entry under Settings (or as a top-level item).
6. **Write E2E Playwright tests** for the consolidated page before any implementation changes.
---
## 2. Research Findings
### 2.1 Current Architecture: Backend
#### 2.1.1 User Model — `backend/internal/models/user.go`
```go
type User struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Email string `json:"email" gorm:"uniqueIndex"`
APIKey string `json:"-" gorm:"uniqueIndex"`
PasswordHash string `json:"-"`
Name string `json:"name"`
Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer"
Enabled bool `json:"enabled" gorm:"default:true"`
FailedLoginAttempts int `json:"-" gorm:"default:0"`
LockedUntil *time.Time `json:"-"`
LastLogin *time.Time `json:"last_login,omitempty"`
SessionVersion uint `json:"-" gorm:"default:0"`
// Invite system fields ...
PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"`
PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
**Key observations:**
| Aspect | Current State | Issue |
|--------|--------------|-------|
| `Role` field | Raw string, default `"user"` | No Go-level enum/const; `"viewer"` mentioned in comment but unused anywhere |
| Permission logic | `CanAccessHost()` — admins bypass all checks | Sound; needs extension for pass-through tier |
| JSON serialization | `ID` is `json:"-"`, `APIKey` is `json:"-"` | Correctly hidden from API responses |
| Password hashing | bcrypt via `SetPassword()` / `CheckPassword()` | Solid |
#### 2.1.2 Auth Service — `backend/internal/services/auth_service.go`
- `Register()`: First user becomes admin (`role = "admin"`)
- `Login()`: Account lockout after 5 failed attempts; sets `LastLogin`
- `GenerateToken()`: JWT with claims `{UserID, Role, SessionVersion}`, 24h expiry
- `AuthenticateToken()`: Validates JWT + checks `Enabled` + matches `SessionVersion`
- `InvalidateSessions()`: Increments `SessionVersion` to revoke all existing tokens
**Impact of role changes:** The `Role` string is embedded in the JWT. When a user's role is changed, their existing JWT still carries the old role until it expires or `SessionVersion` is bumped. The `InvalidateSessions()` method exists for this purpose, **but it is NOT currently called in `UpdateUser()`**. This is a security gap — a user demoted from admin to passthrough retains their admin JWT for up to 24 hours. Task 2.6 addresses this.
#### 2.1.3 Auth Middleware — `backend/internal/api/middleware/auth.go`
```go
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc
func RequireRole(role string) gin.HandlerFunc
```
- `AuthMiddleware`: Extracts token from header/cookie/query, sets `userID` and `role` in Gin context.
- `RequireRole`: Checks `role` against a **single** required role string, always allows `"admin"` as fallback. Currently used only for SMTP settings.
**Gap:** Most admin-only endpoints in `user_handler.go` do inline `role != "admin"` checks via `requireAdmin(c)` in `permission_helpers.go` rather than using middleware. This works but is inconsistent.
#### 2.1.4 User Handler — `backend/internal/api/handlers/user_handler.go`
Two disjoint groups of endpoints:
| Group | Endpoints | Purpose | Auth |
|-------|-----------|---------|------|
| **Self-service** | `GET /user/profile`, `POST /user/profile`, `POST /user/api-key` | Current user edits own profile/API key | Any authenticated user |
| **Admin CRUD** | `GET /users`, `POST /users`, `POST /users/invite`, `GET /users/:id`, `PUT /users/:id`, `DELETE /users/:id`, `PUT /users/:id/permissions`, `POST /users/:id/resend-invite`, `POST /users/preview-invite-url` | Admin manages all users | Admin only (inline check) |
**Key handler behaviors:**
- `ListUsers()`: Returns all users with safe fields (excludes password hash, API key). Admin only.
- `UpdateUser()`: Can change `name`, `email`, `password`, `role`, `enabled`. **Does NOT call `InvalidateSessions()` on role change** — a demoted user retains their old-role JWT for up to 24 hours. This is a security gap that must be fixed in this spec (see Task 2.6).
- `DeleteUser()`: Prevents self-deletion.
- `GetProfile()`: Returns `{id, email, name, role, has_api_key, api_key_masked}` for the calling user.
- `UpdateProfile()`: Requires `current_password` verification when email changes.
- `RegenerateAPIKey()`: Generates new UUID-based API key for the calling user.
#### 2.1.5 Route Registration — `backend/internal/api/routes/routes.go`
```
Public:
POST /api/v1/auth/login
POST /api/v1/auth/register
GET /api/v1/auth/verify (forward auth for Caddy)
GET /api/v1/auth/status
GET /api/v1/setup/status
POST /api/v1/setup
GET /api/v1/invite/validate
POST /api/v1/invite/accept
Protected (authMiddleware):
POST /api/v1/auth/logout
POST /api/v1/auth/refresh
GET /api/v1/auth/me
POST /api/v1/auth/change-password
GET /api/v1/user/profile ← Self-service group
POST /api/v1/user/profile ←
POST /api/v1/user/api-key ←
GET /api/v1/users ← Admin CRUD group
POST /api/v1/users ←
POST /api/v1/users/invite ←
POST /api/v1/users/preview-invite-url ←
GET /api/v1/users/:id ←
PUT /api/v1/users/:id ←
DELETE /api/v1/users/:id ←
PUT /api/v1/users/:id/permissions ←
POST /api/v1/users/:id/resend-invite ←
```
#### 2.1.6 Forward Auth — `backend/internal/api/handlers/auth_handler.go`
The `Verify()` handler at `GET /api/v1/auth/verify` is the critical integration point for the pass-through tier:
1. Extracts token from cookie/header/query
2. Authenticates user via `AuthenticateToken()`
3. Reads `X-Forwarded-Host` from Caddy
4. Checks `user.CanAccessHost(hostID)` against the permission model
5. Returns `200 OK` with `X-Auth-User` headers or `401`/`403`
**Pass-through users will use this exact path** — they log in to Charon only to establish a session, then Caddy's `forward_auth` directive validates their access to proxied services. They never need to see the Charon management UI.
### 2.2 Current Architecture: Frontend
#### 2.2.1 Router — `frontend/src/App.tsx`
```tsx
// Three routes serve user management:
} /> // top-level
}>
} /> // self-service
} /> // admin CRUD
```
The `UsersPage` component is mounted at **two** routes: `/users` and `/settings/account-management`. The `Account` component is only at `/settings/account`.
#### 2.2.2 Settings Page — `frontend/src/pages/Settings.tsx`
Tab bar with 4 items: `System`, `Notifications`, `SMTP`, `Account`. Note that **Account Management is NOT in the tab bar** — it is only reachable via the sidebar. The `Account` tab links to `/settings/account` (the self-service profile page).
#### 2.2.3 Sidebar Navigation — `frontend/src/components/Layout.tsx`
Under the Settings section, two entries:
```tsx
{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' },
{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' },
```
#### 2.2.4 Account Page — `frontend/src/pages/Account.tsx`
Four cards:
1. **Profile** — name, email (with password verification for email changes)
2. **Certificate Email** — toggle between account email and custom email for ACME
3. **Password Change** — old/new/confirm password form
4. **API Key** — masked display + regenerate button
Uses `api/user.ts` (singular) — endpoints: `GET /user/profile`, `POST /user/profile`, `POST /user/api-key`.
#### 2.2.5 Users Page — `frontend/src/pages/UsersPage.tsx`
~900 lines. Contains:
- `InviteModal` — email, role select (`user`/`admin`), permission mode, host selection, URL preview
- `PermissionsModal` — permission mode + host checkboxes per user
- `UsersPage` (default export) — table listing all users with columns: User (name/email), Role (badge), Status (active/pending/expired), Permissions (whitelist/blacklist), Enabled (toggle), Actions (resend/permissions/delete)
**Current limitations:**
- Admin users cannot be disabled (switch is `disabled` when `role === 'admin'`)
- Admin users cannot be deleted (delete button is `disabled` when `role === 'admin'`)
- No permission editing for admins (gear icon hidden when `role !== 'admin'`)
- Role selector in `InviteModal` only has `user` and `admin` options
- No inline profile editing — admins can't edit their own name/email/password from this page
- The admin's own row appears in the list but has limited interactivity
#### 2.2.6 API Client Files
**`frontend/src/api/user.ts`** (singular — self-service):
```tsx
interface UserProfile { id, email, name, role, has_api_key, api_key_masked }
getProfile() → GET /user/profile
updateProfile(data) → POST /user/profile
regenerateApiKey() → POST /user/api-key
```
**`frontend/src/api/users.ts`** (plural — admin CRUD):
```tsx
interface User { id, uuid, email, name, role, enabled, last_login, invite_status, ... }
type PermissionMode = 'allow_all' | 'deny_all'
listUsers() → GET /users
getUser(id) → GET /users/:id
createUser(data) → POST /users
inviteUser(data) → POST /users/invite
updateUser(id, data) → PUT /users/:id
deleteUser(id) → DELETE /users/:id
updateUserPermissions(id, d) → PUT /users/:id/permissions
validateInvite(token) → GET /invite/validate
acceptInvite(data) → POST /invite/accept
previewInviteURL(email) → POST /users/preview-invite-url
resendInvite(id) → POST /users/:id/resend-invite
```
#### 2.2.7 Auth Context — `frontend/src/context/AuthContextValue.ts`
```tsx
interface User { user_id: number; role: string; name?: string; email?: string; }
interface AuthContextType { user, login, logout, changePassword, isAuthenticated, isLoading }
```
The `AuthProvider` in `AuthContext.tsx` fetches `/api/v1/auth/me` on mount and stores the user. Auto-logout after 15 minutes of inactivity.
#### 2.2.8 Route Protection — `frontend/src/components/RequireAuth.tsx`
Checks `isAuthenticated`, localStorage token, and `user !== null`. Redirects to `/login` if any check fails. **No role-based route protection exists** — all authenticated users see the same navigation and routes.
#### 2.2.9 Translation Keys — `frontend/src/locales/en/translation.json`
Relevant keys:
```json
"navigation.adminAccount": "Admin Account",
"navigation.accountManagement": "Account Management",
"users.roleUser": "User",
"users.roleAdmin": "Admin"
```
No keys exist for `"roleViewer"` or `"rolePassthrough"`.
### 2.3 Current Architecture: E2E Tests
**No Playwright E2E tests exist** for user management or account pages. The `tests/` directory contains specs for DNS providers, modal dropdowns, and debug utilities only.
### 2.4 Configuration Files Review
| File | Relevance | Action Needed |
|------|-----------|---------------|
| `.gitignore` | May need entries for new test artifacts | Review during implementation |
| `codecov.yml` | Coverage thresholds may need adjustment | Review if new files change coverage ratios |
| `.dockerignore` | No impact expected | No changes |
| `Dockerfile` | No impact expected | No changes |
---
## 3. Technical Specifications
### 3.1 Privilege Tier System
#### 3.1.1 Tier Definitions
| Tier | Value | Charon UI Access | Forward Auth (Proxy) Access | Can Manage Others |
|------|-------|------------------|---------------------------|-------------------|
| **Admin** | `"admin"` | Full access to all pages and settings | All hosts (bypasses permission checks) | Yes — full CRUD on all users |
| **User** | `"user"` | Access to Charon UI except admin-only pages (user management, system settings) | Based on `permission_mode` + `permitted_hosts` | No |
| **Pass-through** | `"passthrough"` | Login page only — redirected away from all management pages after login | Based on `permission_mode` + `permitted_hosts` | No |
#### 3.1.2 Behavioral Details
**Admin:**
- Unchanged from current behavior.
- Can edit any user's role, permissions, name, email, enabled state.
- Can edit their own profile inline on the consolidated Users page.
- Cannot delete themselves. Cannot disable themselves.
**User:**
- Can log in to the Charon management UI.
- Can view proxy hosts, certificates, DNS, uptime, and other read-oriented pages.
- Cannot access: User management, System settings, SMTP settings, Encryption, Plugins.
- Can edit their own profile (name, email, password, API key) via self-service section on the Users page or a dedicated "My Account" modal.
- Forward auth access is governed by `permission_mode` + `permitted_hosts`.
**Pass-through:**
- Can log in to Charon (to obtain a session cookie/JWT).
- Immediately after login, is redirected to `/passthrough` landing page — **no access to the management UI**.
- The session cookie is used by Caddy's `forward_auth` to grant access to proxied services based on `permission_mode` + `permitted_hosts`.
- Can access **only**: `/auth/logout`, `/auth/refresh`, `/auth/me`, `/auth/change-password`.
- **Cannot access**: `/user/profile`, `/user/api-key`, or any management API endpoints.
- Cannot access any Charon management UI pages.
#### 3.1.3 Backend Role Constants
Add typed constants to `backend/internal/models/user.go`:
```go
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleUser UserRole = "user"
RolePassthrough UserRole = "passthrough"
)
func (r UserRole) IsValid() bool {
switch r {
case RoleAdmin, RoleUser, RolePassthrough:
return true
}
return false
}
```
Change `User.Role` from `string` to `UserRole` (which is still a `string` alias, so GORM and JSON work identically — zero migration friction).
### 3.2 Database Schema Changes
#### 3.2.1 User Table
**No schema migration needed.** The `Role` column is already a `TEXT` field storing string values. Changing the Go type from `string` to `UserRole` (a `string` alias) requires no ALTER TABLE. New role values (`"passthrough"`) are immediately storable.
#### 3.2.2 Data Migration
A one-time migration function should:
1. Scan for any users with `role = "viewer"` and update them to `role = "passthrough"` (in case any were manually created via the unused viewer path).
2. Validate all existing roles are in the allowed set.
This runs in the `AutoMigrate` block in `routes.go`.
The migration must be in a clearly named function (e.g., `migrateViewerToPassthrough()`) and must log when it runs and how many records it affected:
```go
func migrateViewerToPassthrough(db *gorm.DB) {
result := db.Model(&User{}).Where("role = ?", "viewer").Update("role", string(RolePassthrough))
if result.RowsAffected > 0 {
log.Printf("[migration] Updated %d users from 'viewer' to 'passthrough' role", result.RowsAffected)
}
}
```
### 3.3 API Changes
#### 3.3.1 Consolidated Self-Service Endpoints
Merge the current `/user/profile` and `/user/api-key` endpoints into the existing `/users/:id` group by allowing users to edit their own record:
| Current Endpoint | Action | Proposed |
|-----------------|--------|----------|
| `GET /user/profile` | Get own profile | **Keep** (convenience alias) |
| `POST /user/profile` | Update own profile | **Keep** (convenience alias) |
| `POST /user/api-key` | Regenerate API key | **Keep** (convenience alias) |
| `PUT /users/:id` | Admin updates any user | **Extend**: allow authenticated user to update their own record (name, email with password verification) |
The self-service endpoints (`/user/*`) remain as convenient aliases that internally resolve to the caller's own user ID and delegate to the same service logic. No breaking API changes.
#### 3.3.2 Role Validation
The `UpdateUser()` and `CreateUser()` handlers must validate the `role` field against `UserRole.IsValid()`. The invite endpoint `InviteUser()` must accept `"passthrough"` as a valid role.
#### 3.3.3 Pass-through Route Protection
Add middleware to block pass-through users from accessing management endpoints:
```go
func RequireManagementAccess() gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("role")
if role == string(models.RolePassthrough) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "pass-through users cannot access management features",
})
return
}
c.Next()
}
}
```
Apply this middleware by restructuring the protected routes into two sub-groups:
**Exempt routes** (authMiddleware only — no management check):
```
POST /auth/logout
POST /auth/refresh
GET /auth/me
POST /auth/change-password
GET /auth/accessible-hosts
GET /auth/check-host/:hostId
GET /user/profile
POST /user/profile
POST /user/api-key
```
**Management routes** (authMiddleware + `RequireManagementAccess`):
```
GET /users
POST /users
POST /users/invite
POST /users/preview-invite-url
GET /users/:id
PUT /users/:id
DELETE /users/:id
PUT /users/:id/permissions
POST /users/:id/resend-invite
All /backup/* routes
All /logs/* routes
All /settings/* routes
All /system/* routes
All /security/* routes
All /features/* routes
All /proxy/* routes
All /certificates/* routes
All /dns/* routes
All /uptime/* routes
All /plugins/* routes
```
The route split in `routes.go` should look like:
```go
// Self-service + auth routes — accessible to all authenticated users (including passthrough)
exempt := protected.Group("")
{
exempt.POST("/auth/logout", ...)
exempt.POST("/auth/refresh", ...)
exempt.GET("/auth/me", ...)
exempt.POST("/auth/change-password", ...)
exempt.GET("/auth/accessible-hosts", ...)
exempt.GET("/auth/check-host/:hostId", ...)
exempt.GET("/user/profile", ...)
exempt.POST("/user/profile", ...)
exempt.POST("/user/api-key", ...)
}
// Management routes — blocked for passthrough users
management := protected.Group("", middleware.RequireManagementAccess())
{
// All other routes registered here
}
```
#### 3.3.4 New Endpoint: Pass-through Landing
No new backend endpoint needed. The forward auth flow (`GET /auth/verify`) already works for pass-through users. The minimal landing page is purely a frontend concern.
### 3.4 Frontend Changes
#### 3.4.1 Consolidated Users Page
Transform `UsersPage.tsx` into the single source of truth for all user management:
**New capabilities:**
1. **Admin's own row is fully interactive** — edit name/email/password, regenerate API key, view cert email settings.
2. **"My Profile" section** at the top (or accessible via a button) for the currently logged-in user to manage their own account, replacing the `Account.tsx` page entirely.
3. **Role selector** includes three options: `Admin`, `User`, `Pass-through`.
4. **Permission controls** are shown for `User` and `Pass-through` roles (hidden for `Admin`).
5. **Inline editing** or a detail drawer for editing individual users.
**Components to modify:**
| Component | File | Change |
|-----------|------|--------|
| `InviteModal` | `UsersPage.tsx` | Add `"passthrough"` to role `