diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go
index 9d2df2eb..ab2dee9f 100644
--- a/backend/internal/api/handlers/user_handler_test.go
+++ b/backend/internal/api/handlers/user_handler_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
@@ -2717,3 +2718,394 @@ func TestRedactInviteURL(t *testing.T) {
})
}
}
+
+// --- Passthrough rejection tests ---
+
+func setupPassthroughRouter(handler *UserHandler) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", string(models.RolePassthrough))
+ c.Next()
+ })
+ r.POST("/api-key", handler.RegenerateAPIKey)
+ r.GET("/profile", handler.GetProfile)
+ r.PUT("/profile", handler.UpdateProfile)
+ return r
+}
+
+func TestUserHandler_RegenerateAPIKey_PassthroughRejected(t *testing.T) {
+ handler, _ := setupUserHandler(t)
+ r := setupPassthroughRouter(handler)
+
+ req := httptest.NewRequest(http.MethodPost, "/api-key", http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Passthrough users cannot manage API keys")
+}
+
+func TestUserHandler_GetProfile_PassthroughRejected(t *testing.T) {
+ handler, _ := setupUserHandler(t)
+ r := setupPassthroughRouter(handler)
+
+ req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Passthrough users cannot access profile")
+}
+
+func TestUserHandler_UpdateProfile_PassthroughRejected(t *testing.T) {
+ handler, _ := setupUserHandler(t)
+ r := setupPassthroughRouter(handler)
+
+ body, _ := json.Marshal(map[string]string{"name": "Test"})
+ req := httptest.NewRequest(http.MethodPut, "/profile", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Passthrough users cannot update profile")
+}
+
+// --- CreateUser / InviteUser invalid role ---
+
+func TestUserHandler_CreateUser_InvalidRole(t *testing.T) {
+ handler, _ := setupUserHandler(t)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.POST("/users", handler.CreateUser)
+
+ body, _ := json.Marshal(map[string]string{
+ "name": "Test User",
+ "email": "new@example.com",
+ "role": "superadmin",
+ "password": "password123",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "Invalid role")
+}
+
+func TestUserHandler_InviteUser_InvalidRole(t *testing.T) {
+ handler, _ := setupUserHandler(t)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(1))
+ c.Next()
+ })
+ r.POST("/invite", handler.InviteUser)
+
+ body, _ := json.Marshal(map[string]string{
+ "email": "invite@example.com",
+ "role": "superadmin",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/invite", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "Invalid role")
+}
+
+// --- UpdateUser authentication/session edge cases ---
+
+func TestUserHandler_UpdateUser_MissingUserID(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, db.Create(&user).Error)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ // No userID set in context
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"name": "New Name"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+ assert.Contains(t, w.Body.String(), "Authentication required")
+}
+
+func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target2@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, db.Create(&user).Error)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", "not-a-uint") // wrong type
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"name": "New Name"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "Invalid session")
+}
+
+// --- UpdateUser role/enabled restriction for non-admin self ---
+
+func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "self@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, db.Create(&user).Error)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user") // non-admin
+ c.Set("userID", user.ID) // isSelf = true
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"role": "admin"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status")
+}
+
+// --- UpdateUser invalid role string ---
+
+func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target3@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, db.Create(&target).Error)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(9999)) // not the target
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"role": "superadmin"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "Invalid role")
+}
+
+// --- UpdateUser self-demotion and self-disable ---
+
+func TestUserHandler_UpdateUser_SelfDemotion(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@self.example.com", Role: models.RoleAdmin, Enabled: true}
+ require.NoError(t, db.Create(&admin).Error)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", admin.ID) // isSelf = true
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"role": "user"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Cannot change your own role")
+}
+
+func TestUserHandler_UpdateUser_SelfDisable(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@disable.example.com", Role: models.RoleAdmin, Enabled: true}
+ require.NoError(t, db.Create(&admin).Error)
+
+ disabled := false
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", admin.ID) // isSelf = true
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Cannot disable your own account")
+}
+
+// --- UpdateUser last-admin protection ---
+
+func TestUserHandler_UpdateUser_LastAdminDemotion(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ // Only one admin in the DB (the target)
+ target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin@example.com", Role: models.RoleAdmin, Enabled: true}
+ require.NoError(t, db.Create(&target).Error)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(9999)) // different from target; not in DB but role injected via context
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"role": "user"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Cannot demote the last admin")
+}
+
+func TestUserHandler_UpdateUser_LastAdminDisable(t *testing.T) {
+ handler, db := setupUserHandler(t)
+
+ target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin-disable@example.com", Role: models.RoleAdmin, Enabled: true}
+ require.NoError(t, db.Create(&target).Error)
+
+ disabled := false
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(9999))
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Cannot disable the last admin")
+}
+
+// --- UpdateUser session invalidation ---
+
+func TestUserHandler_UpdateUser_WithSessionInvalidation(t *testing.T) {
+ db := OpenTestDB(t)
+ _ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
+
+ authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"})
+
+ caller := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "caller-si@example.com", Role: models.RoleAdmin, Enabled: true}
+ target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-si@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, db.Create(&caller).Error)
+ require.NoError(t, db.Create(&target).Error)
+
+ handler := NewUserHandler(db, authSvc)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", caller.ID)
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"role": "passthrough"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), "User updated successfully")
+
+ var updated models.User
+ require.NoError(t, db.First(&updated, target.ID).Error)
+ assert.Greater(t, updated.SessionVersion, uint(0))
+}
+
+func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) {
+ mainDB := OpenTestDB(t)
+ _ = mainDB.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
+
+ // Use a separate empty DB so InvalidateSessions cannot find the user
+ authDB := OpenTestDB(t)
+ authSvc := services.NewAuthService(authDB, config.Config{JWTSecret: "test-secret"})
+
+ target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-sie@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, mainDB.Create(&target).Error)
+
+ handler := NewUserHandler(mainDB, authSvc)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(9999))
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body, _ := json.Marshal(map[string]string{"role": "passthrough"})
+ req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "Failed to invalidate sessions")
+}
diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go
index 119862a2..245d8538 100644
--- a/backend/internal/api/middleware/auth_test.go
+++ b/backend/internal/api/middleware/auth_test.go
@@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) {
token := extractAuthCookieToken(ctx)
assert.Equal(t, "", token)
}
+
+func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", string(models.RolePassthrough))
+ c.Next()
+ })
+ r.Use(RequireManagementAccess())
+ r.GET("/test", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features")
+}
+
+func TestRequireManagementAccess_UserAllowed(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", string(models.RoleUser))
+ c.Next()
+ })
+ r.Use(RequireManagementAccess())
+ r.GET("/test", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestRequireManagementAccess_AdminAllowed(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", string(models.RoleAdmin))
+ c.Next()
+ })
+ r.Use(RequireManagementAccess())
+ r.GET("/test", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go
index d5fcf600..fb32b7c6 100644
--- a/backend/internal/api/routes/routes_test.go
+++ b/backend/internal/api/routes/routes_test.go
@@ -10,7 +10,9 @@ import (
"testing"
"github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -1298,3 +1300,25 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) {
_, statErr := os.Stat(logFilePath)
assert.NoError(t, statErr)
}
+
+func TestMigrateViewerToPassthrough(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.User{}))
+
+ // Seed a user with the legacy "viewer" role
+ viewer := models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "viewer@example.com",
+ Role: models.UserRole("viewer"),
+ Enabled: true,
+ }
+ require.NoError(t, db.Create(&viewer).Error)
+
+ migrateViewerToPassthrough(db)
+
+ var updated models.User
+ require.NoError(t, db.First(&updated, viewer.ID).Error)
+ assert.Equal(t, models.RolePassthrough, updated.Role)
+}
diff --git a/backend/internal/models/user_test.go b/backend/internal/models/user_test.go
index 48296d5f..1eeebf66 100644
--- a/backend/internal/models/user_test.go
+++ b/backend/internal/models/user_test.go
@@ -190,6 +190,31 @@ func TestPermissionMode_Constants(t *testing.T) {
assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll)
}
+func TestUserRole_Constants(t *testing.T) {
+ assert.Equal(t, UserRole("admin"), RoleAdmin)
+ assert.Equal(t, UserRole("user"), RoleUser)
+ assert.Equal(t, UserRole("passthrough"), RolePassthrough)
+}
+
+func TestUserRole_IsValid(t *testing.T) {
+ tests := []struct {
+ role UserRole
+ expected bool
+ }{
+ {RoleAdmin, true},
+ {RoleUser, true},
+ {RolePassthrough, true},
+ {UserRole("viewer"), false},
+ {UserRole("superadmin"), false},
+ {UserRole(""), false},
+ }
+ for _, tt := range tests {
+ t.Run(string(tt.role), func(t *testing.T) {
+ assert.Equal(t, tt.expected, tt.role.IsValid())
+ })
+ }
+}
+
// Helper function to create time pointers
func timePtr(t time.Time) *time.Time {
return &t
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx
index de00bad9..36d0f966 100644
--- a/frontend/src/pages/UsersPage.tsx
+++ b/frontend/src/pages/UsersPage.tsx
@@ -723,18 +723,21 @@ function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps
{showPasswordSection && (
setCurrentPassword(e.target.value)}
/>
setNewPassword(e.target.value)}
/>
({
@@ -603,4 +604,264 @@ describe('UsersPage', () => {
expect(previewQuery).toBeNull()
})
})
+
+ describe('InviteModal role reset on close', () => {
+ it('resets role to user when modal is closed', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
+
+ // Open invite modal
+ await user.click(screen.getByRole('button', { name: /Invite User/i }))
+ await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument())
+
+ // Change role to passthrough
+ await user.selectOptions(screen.getByLabelText(/Role/i), 'passthrough')
+ expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('passthrough')
+
+ // Close via Cancel button (calls handleClose which resets role)
+ await user.click(screen.getByRole('button', { name: /^Cancel$/i }))
+
+ // Reopen modal — role should be reset to 'user'
+ await user.click(screen.getByRole('button', { name: /Invite User/i }))
+ await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument())
+ expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('user')
+ })
+ })
+
+ describe('UserDetailModal', () => {
+ it('shows profile update error via toast', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.updateUser).mockRejectedValue({
+ response: { data: { error: 'Email already in use' } },
+ })
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
+
+ // Click Edit User for Regular User (second "Edit User" button in the table)
+ const editButtons = screen.getAllByTitle('Edit User')
+ await user.click(editButtons[1]) // index 1 = Regular User row
+
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ // Click Save
+ await user.click(screen.getByRole('button', { name: /^Save$/i }))
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalled()
+ })
+ })
+
+ it('toggles the password change section', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ // Click Edit User in My Profile card (opens with isSelf=true) — card button is first
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ // Password fields should not be visible until toggled
+ expect(screen.queryByLabelText(/Current Password/i)).toBeNull()
+
+ // Click the Change Password toggle
+ await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
+
+ // Password fields should now be visible
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument()
+ })
+ })
+
+ it('submits password change successfully', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ // Expand password section
+ await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
+ await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
+
+ // Fill matching passwords
+ await user.type(screen.getByLabelText(/Current Password/i), 'oldpass123')
+ await user.type(screen.getByLabelText(/^New Password/i), 'newpass456')
+ await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456')
+
+ // Submit button (second "Change Password" button — the submit one)
+ const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i })
+ const submitButton = changePasswordButtons[changePasswordButtons.length - 1]
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(toast.success).toHaveBeenCalled()
+ })
+ })
+
+ it('shows error toast on password change failure', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
+ vi.mocked(useAuth).mockReturnValue({
+ user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
+ changePassword: vi.fn().mockRejectedValue(new Error('Invalid current password')),
+ isAuthenticated: true,
+ isLoading: false,
+ login: vi.fn(),
+ logout: vi.fn(),
+ })
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
+ await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
+
+ await user.type(screen.getByLabelText(/Current Password/i), 'wrongpass')
+ await user.type(screen.getByLabelText(/^New Password/i), 'newpass456')
+ await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456')
+
+ const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i })
+ const submitButton = changePasswordButtons[changePasswordButtons.length - 1]
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith('Invalid current password')
+ })
+ })
+
+ it('regenerates API key when user confirms', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'old-****' } as never)
+ vi.mocked(usersApi.regenerateApiKey).mockResolvedValue({ api_key_masked: 'new-****' } as never)
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /Regenerate API Key/i })).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByRole('button', { name: /Regenerate API Key/i }))
+
+ await waitFor(() => {
+ expect(usersApi.regenerateApiKey).toHaveBeenCalled()
+ })
+ })
+
+ it('updates self profile and shows profile updated toast', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.updateProfile).mockResolvedValue({ message: 'ok' } as never)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ const dialog = screen.getByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: /^Save$/i }))
+
+ await waitFor(() => {
+ expect(usersApi.updateProfile).toHaveBeenCalled()
+ expect(toast.success).toHaveBeenCalledWith('Profile updated successfully')
+ })
+ })
+
+ it('updates non-self user profile and shows success toast', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'ok' } as never)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
+
+ const editButtons = screen.getAllByTitle('Edit User')
+ await user.click(editButtons[1])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ const dialog = screen.getByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: /^Save$/i }))
+
+ await waitFor(() => {
+ expect(usersApi.updateUser).toHaveBeenCalledWith(2, expect.objectContaining({
+ email: 'user@example.com',
+ }))
+ expect(toast.success).toHaveBeenCalledWith('Profile updated successfully')
+ })
+ })
+
+ it('displays masked API key text when profile query resolves', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'SK-****-masktest' } as never)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ await waitFor(() => {
+ expect(screen.getByText('SK-****-masktest')).toBeInTheDocument()
+ })
+ })
+
+ it('shows password mismatch alert when new and confirm passwords differ', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: '' } as never)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
+
+ await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
+ await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
+
+ await user.type(screen.getByLabelText(/Current Password/i), 'current123')
+ await user.type(screen.getByLabelText(/^New Password/i), 'newpass1')
+ await user.type(screen.getByLabelText(/Confirm Password/i), 'different2')
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ expect(screen.getByText('Passwords do not match')).toBeInTheDocument()
+ })
+ })
+ })
})
diff --git a/tests/settings/account-settings.spec.ts b/tests/settings/account-settings.spec.ts
index 9feea566..ccab35ae 100644
--- a/tests/settings/account-settings.spec.ts
+++ b/tests/settings/account-settings.spec.ts
@@ -29,6 +29,123 @@ test.describe('Account Settings', () => {
await waitForLoadingComplete(page);
});
+ /**
+ * PR-3: Account Route Redirect (F8)
+ *
+ * Verifies that legacy account settings routes redirect to the
+ * consolidated Users page at /settings/users.
+ */
+ test.describe('PR-3: Account Route Redirect (F8)', () => {
+ // Outer beforeEach already handles login. These tests re-navigate to the legacy
+ // routes to assert the React Router redirects them to /settings/users.
+ test('should redirect /settings/account to /settings/users', async ({ page }) => {
+ await page.goto('/settings/account');
+ await page.waitForURL(/\/settings\/users/, { timeout: 15000 });
+ await expect(page).toHaveURL(/\/settings\/users/);
+ });
+
+ test('should redirect /settings/account-management to /settings/users', async ({ page }) => {
+ await page.goto('/settings/account-management');
+ await page.waitForURL(/\/settings\/users/, { timeout: 15000 });
+ await expect(page).toHaveURL(/\/settings\/users/);
+ });
+ });
+
+ /**
+ * PR-3: Self-Service Profile via Users Page (F10)
+ *
+ * Verifies that an admin can manage their own profile (name, email,
+ * password, API key) through the UserDetailModal on /settings/users.
+ * This replaces the deleted Account.tsx page.
+ */
+ test.describe('PR-3: Self-Service Profile via Users Page (F10)', () => {
+ test.beforeEach(async ({ page }) => {
+ // Outer beforeEach already handles login. Navigate to the users page
+ // and wait for the user data to fully render before each test.
+ await page.goto('/settings/users');
+ await waitForLoadingComplete(page);
+ // Wait for user data to load — the My Profile card's Edit User button
+ // only appears after the API returns the current user's profile.
+ await page.getByRole('button', { name: 'Edit User' }).first().waitFor({
+ state: 'visible',
+ timeout: 15000,
+ });
+ });
+
+ test('should open My Profile modal from the My Profile card', async ({ page }) => {
+ await test.step('Click Edit User in the My Profile card', async () => {
+ // The My Profile card button is the first "Edit User" button in the DOM
+ await page.getByRole('button', { name: 'Edit User' }).first().click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ });
+
+ await test.step('Verify dialog is labelled "My Profile"', async () => {
+ await expect(
+ page.getByRole('dialog').getByRole('heading', { name: 'My Profile' })
+ ).toBeVisible();
+ });
+
+ await test.step('Verify name and email fields are editable', async () => {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.locator('input').first()).toBeVisible();
+ await expect(dialog.locator('input[type="email"]')).toBeVisible();
+ });
+ });
+
+ test('should display Change Password toggle in My Profile modal (self-only)', async ({ page }) => {
+ await page.getByRole('button', { name: 'Edit User' }).first().click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible();
+ });
+
+ test('should reveal password fields after clicking Change Password toggle', async ({ page }) => {
+ await page.getByRole('button', { name: 'Edit User' }).first().click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ const dialog = page.getByRole('dialog');
+
+ await test.step('Password fields are hidden before toggling', async () => {
+ await expect(dialog.locator('#current-password')).not.toBeVisible();
+ await expect(dialog.locator('#new-password')).not.toBeVisible();
+ });
+
+ await test.step('Click Change Password to expand the section', async () => {
+ await dialog.getByRole('button', { name: 'Change Password' }).click();
+ });
+
+ await test.step('Password fields are now visible', async () => {
+ await expect(dialog.locator('#current-password')).toBeVisible();
+ await expect(dialog.locator('#new-password')).toBeVisible();
+ await expect(dialog.locator('#confirm-password')).toBeVisible();
+ });
+ });
+
+ test('should display API Key section in My Profile modal (self-only)', async ({ page }) => {
+ await page.getByRole('button', { name: 'Edit User' }).first().click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByText('API Key', { exact: true })).toBeVisible();
+ await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible();
+ });
+
+ test('should have accessible structure in My Profile modal', async ({ page }) => {
+ await page.getByRole('button', { name: 'Edit User' }).first().click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible();
+
+ await test.step('Dialog has accessible heading', async () => {
+ await expect(dialog.getByRole('heading', { name: 'My Profile' })).toBeVisible();
+ });
+
+ await test.step('Close button has accessible label', async () => {
+ await expect(dialog.getByRole('button', { name: /close/i })).toBeVisible();
+ });
+ });
+ });
+
test.describe('Profile Management', () => {
/**
* Test: Profile displays correctly
diff --git a/tests/settings/user-lifecycle.spec.ts b/tests/settings/user-lifecycle.spec.ts
index f6f866a2..548002a8 100644
--- a/tests/settings/user-lifecycle.spec.ts
+++ b/tests/settings/user-lifecycle.spec.ts
@@ -708,3 +708,167 @@ test.describe('Admin-User E2E Workflow', () => {
});
});
});
+
+/**
+ * PR-3: Passthrough User — Access Restriction (F4)
+ *
+ * Verifies that a passthrough-role user is redirected to the
+ * PassthroughLanding page when they attempt to access management routes,
+ * and that they cannot reach the admin Users page.
+ */
+test.describe('PR-3: Passthrough User Access Restriction (F4)', () => {
+ let adminEmail = '';
+
+ test.beforeEach(async ({ page, adminUser }) => {
+ await resetSecurityState(page);
+ adminEmail = adminUser.email;
+ await loginUser(page, adminUser);
+ await waitForLoadingComplete(page, { timeout: 15000 });
+ });
+
+ test('passthrough user is redirected to PassthroughLanding when accessing management routes', async ({ page }) => {
+ const suffix = uniqueSuffix();
+ const ptUser = {
+ email: `passthrough-${suffix}@test.local`,
+ name: `Passthrough User ${suffix}`,
+ password: 'PassthroughPass123!',
+ role: 'passthrough' as 'admin' | 'user' | 'passthrough',
+ };
+ let ptUserId: string | number | undefined;
+
+ await test.step('Admin creates a passthrough-role user directly', async () => {
+ const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
+ const resp = await page.request.post('/api/v1/users', {
+ data: ptUser,
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ expect(resp.ok()).toBe(true);
+ const body = await resp.json();
+ ptUserId = body.id;
+ });
+
+ await test.step('Admin logs out', async () => {
+ await logoutUser(page);
+ });
+
+ await test.step('Passthrough user logs in', async () => {
+ await navigateToLogin(page);
+ await loginWithCredentials(page, ptUser.email, ptUser.password);
+ // Wait for the initial post-login navigation to settle before probing routes
+ await page.waitForURL(/^\/?((?!login).)*$/, { timeout: 10000 }).catch(() => {});
+ });
+
+ await test.step('Passthrough user navigating to management route is redirected to /passthrough', async () => {
+ await page.goto('/settings/users', { waitUntil: 'domcontentloaded' }).catch(() => {});
+ await page.waitForURL(/\/passthrough/, { timeout: 15000 });
+ await expect(page).toHaveURL(/\/passthrough/);
+ });
+
+ await test.step('PassthroughLanding displays welcome heading and no-access message', async () => {
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ await expect(
+ page.getByText(/do not have access to the management interface/i)
+ ).toBeVisible();
+ });
+
+ await test.step('PassthroughLanding shows a logout button', async () => {
+ await expect(page.getByRole('button', { name: /logout/i })).toBeVisible();
+ });
+
+ await test.step('Cleanup: admin logs back in and deletes passthrough user', async () => {
+ // Logout passthrough user
+ await page.getByRole('button', { name: /logout/i }).click();
+ await page.waitForURL(/login/, { timeout: 10000 });
+
+ // Login as admin
+ await loginWithCredentials(page, adminEmail, TEST_PASSWORD);
+ const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
+ if (ptUserId !== undefined) {
+ await page.request.delete(`/api/v1/users/${ptUserId}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ }
+ });
+ });
+});
+
+/**
+ * PR-3: Regular User — No Admin-Only Nav Items (F9)
+ *
+ * Verifies that a regular (non-admin) user does not see the "Users"
+ * navigation item, which is restricted to admins only.
+ */
+test.describe('PR-3: Regular User Has No Admin Navigation Items (F9)', () => {
+ let adminEmail = '';
+
+ test.beforeEach(async ({ page, adminUser }) => {
+ await resetSecurityState(page);
+ adminEmail = adminUser.email;
+ await loginUser(page, adminUser);
+ await waitForLoadingComplete(page, { timeout: 15000 });
+ });
+
+ test('regular user does not see the Users navigation item', async ({ page }) => {
+ const suffix = uniqueSuffix();
+ const regularUserData = {
+ email: `navtest-user-${suffix}@test.local`,
+ name: `Nav Test User ${suffix}`,
+ password: 'NavTestPass123!',
+ role: 'user' as 'admin' | 'user' | 'passthrough',
+ };
+ let regularUserId: string | number | undefined;
+
+ await test.step('Admin creates a regular user', async () => {
+ const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
+ const resp = await page.request.post('/api/v1/users', {
+ data: regularUserData,
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ expect(resp.ok()).toBe(true);
+ const body = await resp.json();
+ regularUserId = body.id;
+ });
+
+ await test.step('Admin logs out', async () => {
+ await logoutUser(page);
+ });
+
+ await test.step('Regular user logs in', async () => {
+ await navigateToLogin(page);
+ await loginWithCredentials(page, regularUserData.email, regularUserData.password);
+ await waitForLoadingComplete(page, { timeout: 15000 });
+ });
+
+ await test.step('Verify "Users" nav item is NOT visible for regular user', async () => {
+ const nav = page.getByRole('navigation').first();
+ await expect(nav.getByRole('link', { name: 'Users' })).not.toBeVisible();
+ });
+
+ await test.step('Verify other nav items ARE visible (navigation renders for regular users)', async () => {
+ const nav = page.getByRole('navigation').first();
+ await expect(nav.getByRole('link', { name: /dashboard/i })).toBeVisible();
+ });
+
+ await test.step('Cleanup: admin logs back in and deletes regular user', async () => {
+ await logoutUser(page);
+ await navigateToLogin(page);
+ await loginWithCredentials(page, adminEmail, TEST_PASSWORD);
+ const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
+ if (regularUserId !== undefined) {
+ await page.request.delete(`/api/v1/users/${regularUserId}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ }
+ });
+ });
+
+ test('admin user sees the Users navigation item', async ({ page }) => {
+ await test.step('Navigate to settings to reveal Settings sub-navigation', async () => {
+ await page.goto('/settings/users');
+ await waitForLoadingComplete(page);
+ });
+ await test.step('Verify "Users" nav item is visible for admin in Settings nav', async () => {
+ await expect(page.getByRole('link', { name: 'Users', exact: true })).toBeVisible();
+ });
+ });
+});
diff --git a/tests/settings/user-management.spec.ts b/tests/settings/user-management.spec.ts
index 7c05547d..4aea8677 100644
--- a/tests/settings/user-management.spec.ts
+++ b/tests/settings/user-management.spec.ts
@@ -1301,4 +1301,154 @@ test.describe('User Management', () => {
});
});
});
+
+ /**
+ * PR-3: Passthrough Role in Invite Modal (F3)
+ *
+ * Verifies that the invite modal exposes all three role options:
+ * Admin, User, and Passthrough — and that selecting Passthrough
+ * surfaces the appropriate role description.
+ */
+ test.describe('PR-3: Passthrough Role in Invite (F3)', () => {
+ test('should offer passthrough as a role option in the invite modal', async ({ page }) => {
+ await test.step('Open the Invite User modal', async () => {
+ const inviteButton = page.getByRole('button', { name: /invite.*user/i });
+ await expect(inviteButton).toBeVisible();
+ await inviteButton.click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ });
+
+ await test.step('Verify three role options are present in the role select', async () => {
+ const roleSelect = page.locator('#invite-user-role');
+ await expect(roleSelect).toBeVisible();
+ await expect(roleSelect.locator('option[value="user"]')).toHaveCount(1);
+ await expect(roleSelect.locator('option[value="admin"]')).toHaveCount(1);
+ await expect(roleSelect.locator('option[value="passthrough"]')).toHaveCount(1);
+ });
+
+ await test.step('Select passthrough and verify description is shown', async () => {
+ await page.locator('#invite-user-role').selectOption('passthrough');
+ await expect(
+ page.getByText(/proxy access only|no management interface/i)
+ ).toBeVisible();
+ });
+ });
+
+ test('should show permission mode selector when passthrough role is selected', async ({ page }) => {
+ await test.step('Open invite modal and select passthrough role', async () => {
+ await page.getByRole('button', { name: /invite.*user/i }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.locator('#invite-user-role').selectOption('passthrough');
+ });
+
+ await test.step('Verify permission mode select is visible for passthrough', async () => {
+ const permSelect = page.locator('#invite-permission-mode');
+ await expect(permSelect).toBeVisible();
+ });
+ });
+ });
+
+ /**
+ * PR-3: User Detail Modal — Self vs Other (F2)
+ *
+ * Verifies that UserDetailModal shows different sections depending
+ * on whether the admin is editing their own profile (isSelf=true)
+ * versus another user's profile (isSelf=false).
+ */
+ test.describe('PR-3: User Detail Modal (F2)', () => {
+ test('should open My Profile modal with password and API key sections when editing self', async ({ page }) => {
+ await test.step('Click the Edit User button in the My Profile card (first button in DOM)', async () => {
+ // The My Profile card renders its button before the table rows in the DOM
+ await page.getByRole('button', { name: 'Edit User' }).first().click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ });
+
+ await test.step('Verify dialog title is "My Profile" (isSelf=true)', async () => {
+ await expect(
+ page.getByRole('dialog').getByRole('heading', { name: 'My Profile' })
+ ).toBeVisible();
+ });
+
+ await test.step('Verify name and email input fields are present', async () => {
+ const dialog = page.getByRole('dialog');
+ // First input in the dialog is the name field (no type attribute)
+ await expect(dialog.locator('input').first()).toBeVisible();
+ // Email field has type="email"
+ await expect(dialog.locator('input[type="email"]')).toBeVisible();
+ });
+
+ await test.step('Verify Change Password toggle is present (self-only section)', async () => {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible();
+ });
+
+ await test.step('Verify API Key section is present (self-only section)', async () => {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByText('API Key', { exact: true })).toBeVisible();
+ await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible();
+ });
+ });
+
+ test('should open Edit User modal without password/API key sections for another user', async ({ page }) => {
+ const suffix = Date.now();
+ const otherUser = {
+ email: `modal-other-${suffix}@test.local`,
+ name: `Modal Other User ${suffix}`,
+ password: 'TestPass123!',
+ role: 'user',
+ };
+ let otherUserId: number | string | undefined;
+
+ await test.step('Create a second user so there is an "other" row in the table', async () => {
+ const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
+ const resp = await page.request.post('/api/v1/users', {
+ data: otherUser,
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ expect(resp.ok()).toBe(true);
+ const body = await resp.json();
+ otherUserId = body.id;
+ await page.reload();
+ await waitForLoadingComplete(page);
+ });
+
+ await test.step('Click the Edit User button in the row for the other user', async () => {
+ const row = page.getByRole('row').filter({ hasText: otherUser.email });
+ await row.getByRole('button', { name: 'Edit User' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ });
+
+ await test.step('Verify dialog title is "Edit User" (isSelf=false)', async () => {
+ await expect(
+ page.getByRole('dialog').getByRole('heading', { name: 'Edit User' })
+ ).toBeVisible();
+ });
+
+ await test.step('Verify name and email fields are present', async () => {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.locator('input').first()).toBeVisible();
+ await expect(dialog.locator('input[type="email"]')).toBeVisible();
+ });
+
+ await test.step('Verify Change Password button is NOT visible (other-user edit)', async () => {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByRole('button', { name: 'Change Password' })).not.toBeVisible();
+ });
+
+ await test.step('Verify API Key section is NOT visible (other-user edit)', async () => {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByText('Regenerate API Key')).not.toBeVisible();
+ });
+
+ await test.step('Cleanup: close modal and delete the test user', async () => {
+ await page.keyboard.press('Escape');
+ if (otherUserId !== undefined) {
+ const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
+ await page.request.delete(`/api/v1/users/${otherUserId}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ }
+ });
+ });
+ });
});