diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 46b147d3..dde96634 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -905,7 +905,7 @@ func Bootstrap(env string) (*App, error) { AssignmentPolicyV2: v1.NewAssignmentPolicyV2Handler(assignmentPolicyV2Service), // Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics handlers Audit: v1.NewAuditHandler(auditService), - CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService), + CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService).WithEventPublisher(eventPublisher), AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService).WithAuditService(auditService), CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService), Search: v1.NewSearchHandler(searchService, db), @@ -953,7 +953,7 @@ func Bootstrap(env string) (*App, error) { Upload: uploadHandler, // Lane B: AssignableAgent handler (find agents available for assignment) AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService), - Agent: v1.NewAgentHandler(agentService).WithAuditService(auditService), + Agent: v1.NewAgentHandler(agentService).WithAuditService(auditService).WithEventPublisher(eventPublisher), AgentBulk: v1.NewAgentBulkHandler(conversationService), BulkAction: v1.NewBulkActionHandler(conversationService, contactService).WithWorkerPool(workerPool), // Lane C: CSAT template (singular per inbox) + Inbox limits diff --git a/backend/internal/auth/coverage3_test.go b/backend/internal/auth/coverage3_test.go index 4eefe7cc..e30b3ee0 100644 --- a/backend/internal/auth/coverage3_test.go +++ b/backend/internal/auth/coverage3_test.go @@ -579,7 +579,7 @@ func TestNewPolicyContext_Agent_Cov3(t *testing.T) { func TestNewPolicyContext_CustomRole_NilPerms_Cov3(t *testing.T) { pc := NewPolicyContext(1, 1, "custom_role", 0, nil) - assert.Equal(t, AgentDefaultPermissions, pc.Permissions) + assert.Equal(t, PermissionMatrixMap{}, pc.Permissions) } // --- RefreshTokenStore tests --- diff --git a/backend/internal/auth/coverage_test.go b/backend/internal/auth/coverage_test.go index 24fd7216..2bda4b05 100644 --- a/backend/internal/auth/coverage_test.go +++ b/backend/internal/auth/coverage_test.go @@ -327,7 +327,7 @@ func TestNewPolicyContextCustomRole(t *testing.T) { func TestNewPolicyContextCustomRoleNilPerms(t *testing.T) { pc := NewPolicyContext(1, 10, "custom_role", 5, nil) assert.NotNil(t, pc) - assert.Equal(t, AgentDefaultPermissions, pc.Permissions) + assert.Equal(t, PermissionMatrixMap{}, pc.Permissions) } func TestPolicyContextCanAdministrator(t *testing.T) { diff --git a/backend/internal/auth/policy.go b/backend/internal/auth/policy.go index 300b8606..c8a4413a 100644 --- a/backend/internal/auth/policy.go +++ b/backend/internal/auth/policy.go @@ -155,9 +155,10 @@ func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, pe case "agent": pc.Permissions = AgentDefaultPermissions case "custom_role": - // Use the provided permissions matrix (from CustomRole) + // Use only the provided permissions matrix (from CustomRole). A missing + // matrix means no permissions; callers must not widen it to agent access. if pc.Permissions == nil { - pc.Permissions = AgentDefaultPermissions // fallback + pc.Permissions = PermissionMatrixMap{} } } diff --git a/backend/internal/handler/api/v1/agent_handler.go b/backend/internal/handler/api/v1/agent_handler.go index 7f730f31..94641f3f 100644 --- a/backend/internal/handler/api/v1/agent_handler.go +++ b/backend/internal/handler/api/v1/agent_handler.go @@ -10,6 +10,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) @@ -18,8 +19,9 @@ import ( // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb // An "agent" in Chatwoot is a User with an AccountUser membership in a specific account. type AgentHandler struct { - svc *service.AgentService - audit *service.AuditService + svc *service.AgentService + audit *service.AuditService + events *ws.EventPublisher } func (h *AgentHandler) WithAuditService(audit *service.AuditService) *AgentHandler { @@ -27,6 +29,11 @@ func (h *AgentHandler) WithAuditService(audit *service.AuditService) *AgentHandl return h } +func (h *AgentHandler) WithEventPublisher(events *ws.EventPublisher) *AgentHandler { + h.events = events + return h +} + // NewAgentHandler creates a new AgentHandler. func NewAgentHandler(svc *service.AgentService) *AgentHandler { return &AgentHandler{svc: svc} @@ -127,6 +134,7 @@ func (h *AgentHandler) Create(c *gin.Context) { c.Header("Cache-Control", "no-store") recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: agent.ID, Action: "create", AuditedChanges: gin.H{"role": agent.Role, "active": agent.Active}}) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } @@ -171,6 +179,7 @@ func (h *AgentHandler) Update(c *gin.Context) { } recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: uint(id), Action: "update", AuditedChanges: agentUpdateAuditChanges(req)}) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } @@ -198,6 +207,7 @@ func (h *AgentHandler) Delete(c *gin.Context) { } recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: uint(id), Action: "destroy", AuditedChanges: gin.H{"account_id": accountID}}) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.Status(http.StatusOK) } @@ -267,6 +277,7 @@ func (h *AgentHandler) BulkCreate(c *gin.Context) { } c.Header("Cache-Control", "no-store") + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeAgentDetails(agents, accountID)) } diff --git a/backend/internal/handler/api/v1/agent_handler_test.go b/backend/internal/handler/api/v1/agent_handler_test.go index a1ef088b..5ecf0603 100644 --- a/backend/internal/handler/api/v1/agent_handler_test.go +++ b/backend/internal/handler/api/v1/agent_handler_test.go @@ -169,6 +169,7 @@ func (s *AgentHandlerTestSuite) TestListIgnoresPerPageLikeChatwoot() { func (s *AgentHandlerTestSuite) TestCreateAgent() { customRoleID := uint(7) + s.Require().NoError(s.db.Create(&model.CustomRole{ID: customRoleID, AccountID: s.account.ID, Name: "Custom", Permissions: "[]"}).Error) req := service.CreateAgentRequest{ Email: "agent1@test.com", Name: "Agent One", diff --git a/backend/internal/handler/api/v1/custom_role_handler.go b/backend/internal/handler/api/v1/custom_role_handler.go index 951ae700..9a7f70d0 100644 --- a/backend/internal/handler/api/v1/custom_role_handler.go +++ b/backend/internal/handler/api/v1/custom_role_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) @@ -16,6 +17,7 @@ import ( type CustomRoleHandler struct { svc *service.CustomRoleService auditSvc *service.AuditService + events *ws.EventPublisher } // NewCustomRoleHandler creates a new CustomRole handler. @@ -28,6 +30,11 @@ func (h *CustomRoleHandler) WithAuditService(auditSvc *service.AuditService) *Cu return h } +func (h *CustomRoleHandler) WithEventPublisher(events *ws.EventPublisher) *CustomRoleHandler { + h.events = events + return h +} + // List returns all custom roles for an account. // GET /api/v1/accounts/:account_id/custom_roles func (h *CustomRoleHandler) List(c *gin.Context) { @@ -66,14 +73,18 @@ func (h *CustomRoleHandler) Create(c *gin.Context) { } var wrapper struct { - CustomRole service.CreateCustomRoleRequest `json:"custom_role"` + CustomRole *service.CreateCustomRoleRequest `json:"custom_role"` } if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if wrapper.CustomRole == nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "custom_role envelope is required") + return + } - role, err := h.svc.Create(c.Request.Context(), accountID, wrapper.CustomRole) + role, err := h.svc.Create(c.Request.Context(), accountID, *wrapper.CustomRole) if err != nil { applogger.L().Errorf("Create custom role for account %d: %v", accountID, err) handleServiceError(c, err) @@ -86,6 +97,7 @@ func (h *CustomRoleHandler) Create(c *gin.Context) { Action: "create", AuditedChanges: role, }) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeCustomRole(role)) } @@ -140,14 +152,18 @@ func (h *CustomRoleHandler) Update(c *gin.Context) { } var wrapper struct { - CustomRole service.UpdateCustomRoleRequest `json:"custom_role"` + CustomRole *service.UpdateCustomRoleRequest `json:"custom_role"` } if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if wrapper.CustomRole == nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "custom_role envelope is required") + return + } - role, svcErr := h.svc.Update(c.Request.Context(), id, accountID, wrapper.CustomRole) + role, svcErr := h.svc.Update(c.Request.Context(), id, accountID, *wrapper.CustomRole) if svcErr != nil { applogger.L().Errorf("Update custom role %d for account %d: %v", id, accountID, svcErr) handleServiceError(c, svcErr) @@ -160,6 +176,7 @@ func (h *CustomRoleHandler) Update(c *gin.Context) { Action: "update", AuditedChanges: role, }) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeCustomRole(role)) } @@ -195,6 +212,7 @@ func (h *CustomRoleHandler) Delete(c *gin.Context) { Action: "destroy", AuditedChanges: gin.H{"id": id}, }) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.Status(http.StatusOK) } diff --git a/backend/internal/handler/api/v1/custom_role_handler_test.go b/backend/internal/handler/api/v1/custom_role_handler_test.go index d4131f0d..1f60cf25 100644 --- a/backend/internal/handler/api/v1/custom_role_handler_test.go +++ b/backend/internal/handler/api/v1/custom_role_handler_test.go @@ -92,6 +92,18 @@ func (s *CustomRoleHandlerTestSuite) TestCreate_BadRequest_EmptyBody() { assert.Equal(s.T(), http.StatusBadRequest, w.Code) } +func (s *CustomRoleHandlerTestSuite) TestCreate_BadRequest_RequiresEnvelope() { + r := gin.New() + r.POST("/api/v1/accounts/:account_id/custom_roles", withCustomRoleAdminContext(s.account.ID, s.handler.Create)) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/custom_roles", s.account.ID), bytes.NewBufferString(`{"name":"raw-role","permissions":[]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusBadRequest, w.Code) +} + func (s *CustomRoleHandlerTestSuite) TestGet_BadRequest_InvalidID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Get)) @@ -115,6 +127,20 @@ func (s *CustomRoleHandlerTestSuite) TestUpdate_BadRequest_InvalidID() { assert.Equal(s.T(), http.StatusBadRequest, w.Code) } +func (s *CustomRoleHandlerTestSuite) TestUpdate_BadRequest_RequiresEnvelope() { + role := &model.CustomRole{AccountID: s.account.ID, Name: "raw-update", Permissions: `[]`} + s.Require().NoError(s.db.Create(role).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Update)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, role.ID), bytes.NewBufferString(`{"name":"raw-update-edited","permissions":[]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusBadRequest, w.Code) +} + func (s *CustomRoleHandlerTestSuite) TestDelete_BadRequest_InvalidID() { r := gin.New() r.DELETE("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Delete)) diff --git a/backend/internal/middleware/account_scope.go b/backend/internal/middleware/account_scope.go index 09912e6c..a64197bd 100644 --- a/backend/internal/middleware/account_scope.go +++ b/backend/internal/middleware/account_scope.go @@ -95,10 +95,15 @@ func AccountScope() gin.HandlerFunc { } } } + if roleStr == "administrator" && customRoleID > 0 { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "invalid administrator custom role assignment") + return + } // Step 6: Build PolicyContext from JWT claims data // For custom_role, permissions will be nil (loaded by service layer) - var permissions auth.PermissionMatrixMap + permissions := auth.PermissionMatrixMap{} policyCtx := auth.NewPolicyContext( userID.(uint), accountID, @@ -168,17 +173,23 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { "User does not belong to this account") return } + if role == "administrator" && customRoleID > 0 { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "invalid administrator custom role assignment") + return + } - // Build permissions matrix based on role + // Build permissions matrix based on role. A custom-role lookup failure must + // fail closed; Agent defaults would grant permissions to a broken assignment. permissions := auth.PermissionMatrixMap{} - if customRoleID > 0 && role != "administrator" { - pm, err := lookup.GetCustomRolePermissions(customRoleID) - if err != nil { - // Fallback to agent defaults if custom role not found - permissions = auth.AgentDefaultPermissions - } else { - permissions = pm + if customRoleID > 0 { + pm, err := lookup.GetCustomRolePermissionsForAccount(customRoleID, accountID) + if err != nil || pm == nil { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "custom role permissions unavailable") + return } + permissions = pm } effectiveRole := role @@ -285,5 +296,5 @@ func isSuperAdminContext(c *gin.Context) bool { // for use with AccountScopeWithService middleware. type RBACLookup interface { GetAccountUserRole(userID, accountID uint) (role string, customRoleID uint, err error) - GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) + GetCustomRolePermissionsForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) } diff --git a/backend/internal/middleware/account_scope_test.go b/backend/internal/middleware/account_scope_test.go index 67664ef6..e539ff80 100644 --- a/backend/internal/middleware/account_scope_test.go +++ b/backend/internal/middleware/account_scope_test.go @@ -23,7 +23,7 @@ func (l accountScopeLookup) GetAccountUserRole(_ uint, accountID uint) (string, return "administrator", 0, nil } -func (accountScopeLookup) GetCustomRolePermissions(uint) (auth.PermissionMatrixMap, error) { +func (accountScopeLookup) GetCustomRolePermissionsForAccount(uint, uint) (auth.PermissionMatrixMap, error) { return nil, nil } @@ -185,6 +185,39 @@ func TestAccountScope_UsesAuthMiddlewareRoleForAdministratorRoute(t *testing.T) assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) } +func TestAccountScope_RejectsAdministratorCustomRoleClaims(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", uint(1)) + c.Set("role", "administrator") + c.Set("claims", &auth.Claims{Role: "administrator", CustomRoleID: 9}) + c.Next() + }) + r.Use(AccountScope()) + r.GET("/api/v1/accounts/:account_id", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1", nil)) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestAccountScope_RejectsCustomRoleClaimsWithoutLoadedPermissions(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", uint(1)) + c.Set("role", "agent") + c.Set("claims", &auth.Claims{Role: "agent", CustomRoleID: 9}) + c.Next() + }) + r.Use(AccountScope()) + r.Use(PolicyMiddleware("conversation", "read")) + r.GET("/api/v1/accounts/:account_id", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1", nil)) + assert.Equal(t, http.StatusForbidden, w.Code) +} + func TestAccountScopeWithService_AllowsVerifiedAccountSwitch(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() diff --git a/backend/internal/middleware/cors_logger.go b/backend/internal/middleware/cors_logger.go index 8c975a54..5d2b663e 100644 --- a/backend/internal/middleware/cors_logger.go +++ b/backend/internal/middleware/cors_logger.go @@ -7,14 +7,23 @@ import ( "github.com/gin-gonic/gin" ) -// CORSMiddleware adds permissive CORS headers for development. +var legacyCORSAllowedOrigins = []string{ + "http://localhost:3000", + "http://localhost:5000", + "http://127.0.0.1:3000", + "http://127.0.0.1:5000", +} + +// CORSMiddleware adds CORS headers for the local development origins. func CORSMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + if origin := c.Request.Header.Get("Origin"); origin != "" && isOriginAllowed(origin, legacyCORSAllowedOrigins) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") c.Header("Access-Control-Max-Age", "86400") - c.Header("Access-Control-Allow-Credentials", "true") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) @@ -39,4 +48,4 @@ func RequestLoggerMiddleware() gin.HandlerFunc { fmt.Fprintf(gin.DefaultWriter, "%s %s %d %v\n", method, path, status, latency) } -} \ No newline at end of file +} diff --git a/backend/internal/middleware/cors_logger_test.go b/backend/internal/middleware/cors_logger_test.go index 6b242bde..d879bdde 100644 --- a/backend/internal/middleware/cors_logger_test.go +++ b/backend/internal/middleware/cors_logger_test.go @@ -16,14 +16,15 @@ func TestCORSMiddleware_Headers(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:5000") r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "http://localhost:5000", w.Header().Get("Access-Control-Allow-Origin")) assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "GET") assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Authorization") assert.Equal(t, "86400", w.Header().Get("Access-Control-Max-Age")) - assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) } func TestCORSMiddleware_Options(t *testing.T) { diff --git a/backend/internal/middleware/coverage6_test.go b/backend/internal/middleware/coverage6_test.go index d13cc5dd..2e412080 100644 --- a/backend/internal/middleware/coverage6_test.go +++ b/backend/internal/middleware/coverage6_test.go @@ -99,7 +99,7 @@ func TestCORSMiddleware_Options_Cov6(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("OPTIONS", "/api/test", nil) - c.Request.Header.Set("Origin", "https://example.com") + c.Request.Header.Set("Origin", "http://localhost:3000") handler(c) } @@ -109,7 +109,7 @@ func TestCORSMiddleware_Get_Cov6(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/test", nil) - c.Request.Header.Set("Origin", "https://example.com") + c.Request.Header.Set("Origin", "http://localhost:3000") handler(c) } diff --git a/backend/internal/middleware/coverage7_test.go b/backend/internal/middleware/coverage7_test.go index 9cf0221e..3cb112fc 100644 --- a/backend/internal/middleware/coverage7_test.go +++ b/backend/internal/middleware/coverage7_test.go @@ -1429,7 +1429,7 @@ func TestCORS_DevMode_Cov7(t *testing.T) { c.Request = httptest.NewRequest("GET", "/", nil) c.Request.Header.Set("Origin", "http://localhost:3000") CORS(CORSConfig{DevMode: true})(c) - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Origin")) } func TestCORS_Options_Cov7(t *testing.T) { @@ -1460,22 +1460,11 @@ func TestCORS_NotAllowedOrigin_Cov7(t *testing.T) { } func TestCORS_WildcardSubdomain_Cov7(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Origin", "https://foo.example.com") - CORS(CORSConfig{AllowedOrigins: []string{"*.example.com"}, AllowCredentials: true})(c) - assert.Equal(t, "https://foo.example.com", w.Header().Get("Access-Control-Allow-Origin")) - assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.True(t, isOriginAllowed("https://foo.example.com", []string{"*.example.com"})) } func TestCORS_WildcardNoSubdomain_Cov7(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Origin", "https://example.com") - CORS(CORSConfig{AllowedOrigins: []string{"*.example.com"}})(c) - assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) + assert.False(t, isOriginAllowed("https://example.com", []string{"*.example.com"})) } func TestCORS_CustomMethods_Cov7(t *testing.T) { @@ -1790,8 +1779,9 @@ func TestCORSMiddleware_Legacy_Cov7(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("Origin", "http://localhost:5000") CORSMiddleware()(c) - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "http://localhost:5000", w.Header().Get("Access-Control-Allow-Origin")) } func TestCORSMiddleware_LegacyOptions_Cov7(t *testing.T) { @@ -2102,7 +2092,7 @@ func (m *mockRBACLookup_Cov7) GetAccountUserRole(userID, accountID uint) (string return m.role, m.customRole, m.roleErr } -func (m *mockRBACLookup_Cov7) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) { +func (m *mockRBACLookup_Cov7) GetCustomRolePermissionsForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) { return m.perms, m.permsErr } @@ -2185,7 +2175,8 @@ func TestAccountScopeWithService_CustomRolePermsError_Cov7(t *testing.T) { c.Request.Header.Set("X-Account-ID", "1") c.Set("user_id", uint(1)) AccountScopeWithService(lookup)(c) - assert.False(t, c.IsAborted()) + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusForbidden, w.Code) } func TestAccountScopeWithService_RouteAccountID_Cov7(t *testing.T) { diff --git a/backend/internal/model/custom_role.go b/backend/internal/model/custom_role.go index afb61465..72ab363a 100644 --- a/backend/internal/model/custom_role.go +++ b/backend/internal/model/custom_role.go @@ -114,10 +114,9 @@ func (cr *CustomRole) SetPermissionKeys(keys []PermissionDimension) error { return nil } -// GetPermissionMap deserializes the JSONB permissions field into a map of -// PermissionDimension → PermissionLevel. This is the raw deserialization; -// the auth.PolicyContext layer converts this into auth.PermissionMatrix for -// policy evaluation. +// GetPermissionMap deserializes the JSONB permissions field into a binary map +// of PermissionDimension → PermissionLevel. Legacy non-none levels normalize +// to full; unsupported dimensions normalize to none. func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLevel, error) { var keys []PermissionDimension if err := json.Unmarshal([]byte(cr.Permissions), &keys); err == nil { @@ -132,7 +131,7 @@ func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLeve return pm, nil } - var m map[PermissionDimension]PermissionLevel + m := make(map[PermissionDimension]PermissionLevel) if cr.Permissions == "" || cr.Permissions == "{}" { return map[PermissionDimension]PermissionLevel{}, nil } @@ -143,6 +142,13 @@ func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLeve if level != PermissionLevelFull && level != PermissionLevelRead && level != PermissionLevelNone { return nil, fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim) } + if !IsValidCustomRolePermission(dim) { + m[dim] = PermissionLevelNone + continue + } + if level != PermissionLevelNone { + m[dim] = PermissionLevelFull + } } return m, nil } diff --git a/backend/internal/model/methods_test.go b/backend/internal/model/methods_test.go index 55c30687..4ce35263 100644 --- a/backend/internal/model/methods_test.go +++ b/backend/internal/model/methods_test.go @@ -124,6 +124,18 @@ func TestCustomRoleGetPermissionMap(t *testing.T) { assert.Empty(t, pm[model.DimensionAutomationManage]) } +func TestCustomRoleLegacyPermissionMapUsesBinaryLevels(t *testing.T) { + cr := &model.CustomRole{ + Permissions: `{"conversation_manage":"read","conversation_delete":"full","contact_manage":"none"}`, + } + + pm, err := cr.GetPermissionMap() + assert.NoError(t, err) + assert.Equal(t, model.PermissionLevelFull, pm[model.DimensionConversationManage]) + assert.Equal(t, model.PermissionLevelNone, pm[model.DimensionConversationDelete]) + assert.Equal(t, model.PermissionLevelNone, pm[model.DimensionContactManage]) +} + func TestCustomRoleGetPermissionMapEmpty(t *testing.T) { cr := &model.CustomRole{Permissions: "{}"} pm, err := cr.GetPermissionMap() diff --git a/backend/internal/repository/account_user_agent_test.go b/backend/internal/repository/account_user_agent_test.go index f87a8dcb..9ecc9fe4 100644 --- a/backend/internal/repository/account_user_agent_test.go +++ b/backend/internal/repository/account_user_agent_test.go @@ -418,6 +418,7 @@ func TestAgentRepo_UpdateAgent_CustomRole(t *testing.T) { account := &model.Account{Name: "A"} require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(&model.CustomRole{ID: 5, AccountID: account.ID, Name: "Custom", Permissions: "[]"}).Error) user := &model.User{Name: "John", Email: "john@e.com", Password: "p", AccountID: account.ID} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error) diff --git a/backend/internal/repository/agent_repo.go b/backend/internal/repository/agent_repo.go index 4f6575d9..94ffb130 100644 --- a/backend/internal/repository/agent_repo.go +++ b/backend/internal/repository/agent_repo.go @@ -3,9 +3,11 @@ package repository import ( "context" "errors" + "fmt" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/model" ) @@ -26,6 +28,34 @@ func NewAgentRepo(db *gorm.DB) *AgentRepo { return &AgentRepo{db: db} } +func normalizeAgentRole(role string, customRoleID uint) (string, error) { + if customRoleID == 0 { + return role, nil + } + if role == "administrator" { + return "", fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + return "agent", nil +} + +func validateCustomRoleAssignment(tx *gorm.DB, accountID, customRoleID uint) error { + if customRoleID == 0 { + return nil + } + + var role model.CustomRole + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND account_id = ? AND deleted_at IS NULL", customRoleID, accountID). + First(&role).Error + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("custom role %d not found for account %d", customRoleID, accountID) + } + return fmt.Errorf("validate custom role %d: %w", customRoleID, err) +} + // AgentDetail represents an agent as returned by the API — User with their // AccountUser role/availability for the specific account. // Reference: Chatwoot renders User objects with included account_users. @@ -146,72 +176,84 @@ func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) ( // Reference: Chatwoot agents_controller.rb#create → AgentBuilder.new.perform // If the user does not exist, creates the user first, then creates the AccountUser. func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint, passwordDigest string, confirmedAt time.Time) (*AgentDetail, error) { - // Find or create the user - var user model.User - isNewUser := false - err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error - if err == gorm.ErrRecordNotFound { - // Create new user - if name == "" { - name = email - if atIdx := indexOfAt(email); atIdx > 0 { - name = email[:atIdx] + role, err := normalizeAgentRole(role, customRoleID) + if err != nil { + return nil, err + } + + var detail AgentDetail + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := validateCustomRoleAssignment(tx, accountID, customRoleID); err != nil { + return err + } + + // Find or create the user. + var user model.User + isNewUser := false + err := tx.Where("email = ?", email).First(&user).Error + if err == gorm.ErrRecordNotFound { + if name == "" { + name = email + if atIdx := indexOfAt(email); atIdx > 0 { + name = email[:atIdx] + } } + user = model.User{ + AccountID: accountID, + Name: name, + Email: email, + Password: passwordDigest, + PasswordDigest: passwordDigest, + Provider: "email", + Active: true, + ConfirmedAt: &confirmedAt, + } + if err := tx.Create(&user).Error; err != nil { + return err + } + isNewUser = true + } else if err != nil { + return err } - user = model.User{ - AccountID: accountID, - Name: name, - Email: email, - Password: passwordDigest, - PasswordDigest: passwordDigest, - Provider: "email", - Active: true, - ConfirmedAt: &confirmedAt, + + // Check if AccountUser already exists. + var existingAU model.AccountUser + err = tx.Where("account_id = ? AND user_id = ?", accountID, user.ID).First(&existingAU).Error + if err == nil { + return ErrAlreadyMember + } else if err != gorm.ErrRecordNotFound { + return err } - if err := r.db.WithContext(ctx).Create(&user).Error; err != nil { - return nil, err + + au := model.AccountUser{ + UserID: user.ID, + AccountID: accountID, + Role: role, + CustomRoleID: customRoleID, + Availability: availability, + AutoOffline: autoOffline, + InvitedBy: inviterID, } - isNewUser = true - } else if err != nil { + if err := tx.Select("UserID", "AccountID", "Role", "CustomRoleID", "Availability", "AutoOffline", "InvitedBy").Create(&au).Error; err != nil { + return err + } + + detail = AgentDetail{ + User: user, + Role: au.Role, + Availability: au.Availability, + AutoOffline: au.AutoOffline, + InvitedBy: au.InvitedBy, + AccountUserID: au.ID, + CustomRoleID: au.CustomRoleID, + IsNewUser: isNewUser, + } + return nil + }) + if err != nil { return nil, err } - - // Check if AccountUser already exists - var existingAU model.AccountUser - err = r.db.WithContext(ctx). - Where("account_id = ? AND user_id = ?", accountID, user.ID). - First(&existingAU).Error - if err == nil { - // Already a member — return conflict error - return nil, ErrAlreadyMember - } else if err != gorm.ErrRecordNotFound { - return nil, err - } - - // Create AccountUser - au := model.AccountUser{ - UserID: user.ID, - AccountID: accountID, - Role: role, - CustomRoleID: customRoleID, - Availability: availability, - AutoOffline: autoOffline, - InvitedBy: inviterID, - } - if err := r.db.WithContext(ctx).Select("UserID", "AccountID", "Role", "CustomRoleID", "Availability", "AutoOffline", "InvitedBy").Create(&au).Error; err != nil { - return nil, err - } - - return &AgentDetail{ - User: user, - Role: au.Role, - Availability: au.Availability, - AutoOffline: au.AutoOffline, - InvitedBy: au.InvitedBy, - AccountUserID: au.ID, - CustomRoleID: au.CustomRoleID, - IsNewUser: isNewUser, - }, nil + return &detail, nil } // UpdateAgent updates both the User (name) and AccountUser (role, availability, auto_offline). @@ -222,6 +264,32 @@ func (r *AgentRepo) UpdateAgent(ctx context.Context, userID, accountID uint, nam func (r *AgentRepo) UpdateAgentWithActive(ctx context.Context, userID, accountID uint, name, role, availability string, autoOffline bool, autoOfflineSet bool, customRoleID *uint, customRoleIDSet bool, active *bool, beforeCommit func() error) (*AgentDetail, error) { if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var currentAU model.AccountUser + if err := tx.Where("account_id = ? AND user_id = ?", accountID, userID).First(¤tAU).Error; err != nil { + return err + } + + assignedCustomRoleID := currentAU.CustomRoleID + if customRoleIDSet { + assignedCustomRoleID = 0 + if customRoleID != nil { + assignedCustomRoleID = *customRoleID + } + } + if role == "administrator" && assignedCustomRoleID > 0 { + return fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + if assignedCustomRoleID > 0 { + normalizedRole, err := normalizeAgentRole(role, assignedCustomRoleID) + if err != nil { + return err + } + role = normalizedRole + if err := validateCustomRoleAssignment(tx, accountID, assignedCustomRoleID); err != nil { + return err + } + } + userUpdates := map[string]interface{}{} if name != "" { userUpdates["name"] = name diff --git a/backend/internal/repository/custom_role_repo.go b/backend/internal/repository/custom_role_repo.go index ee19ff4f..5b5979d6 100644 --- a/backend/internal/repository/custom_role_repo.go +++ b/backend/internal/repository/custom_role_repo.go @@ -2,8 +2,10 @@ package repository import ( "context" + "errors" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/model" ) @@ -74,12 +76,24 @@ func (r *CustomRoleRepo) Update(ctx context.Context, role *model.CustomRole) err // Delete soft-deletes a custom role (GORM DeletedAt field). func (r *CustomRoleRepo) Delete(ctx context.Context, id, accountID uint) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var role model.CustomRole + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Unscoped().First(&role, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if role.AccountID != accountID { + return gorm.ErrRecordNotFound + } + if role.DeletedAt.Valid { + return nil + } if err := tx.Model(&model.AccountUser{}). Where("account_id = ? AND custom_role_id = ?", accountID, id). Updates(map[string]interface{}{"role": "agent", "custom_role_id": 0}).Error; err != nil { return err } - return tx.Where("id = ? AND account_id = ?", id, accountID). - Delete(&model.CustomRole{}).Error + return tx.Delete(&role).Error }) } diff --git a/backend/internal/repository/custom_role_repo_test.go b/backend/internal/repository/custom_role_repo_test.go index c9b385d1..95b36926 100644 --- a/backend/internal/repository/custom_role_repo_test.go +++ b/backend/internal/repository/custom_role_repo_test.go @@ -188,6 +188,20 @@ func TestCustomRoleRepo_Delete(t *testing.T) { assert.NotNil(t, found.DeletedAt) } +func TestCustomRoleRepo_Delete_RejectsOtherAccount(t *testing.T) { + db := setupTestDB(t, &model.CustomRole{}) + repo := NewCustomRoleRepo(db) + + account := createCustomRoleTestAccount(t, db) + otherAccount := createCustomRoleTestAccount(t, db) + role := createTestCustomRole(t, db, account.ID, "Supervisor") + + err := repo.Delete(context.Background(), role.ID, otherAccount.ID) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + _, err = repo.FindByIDAndAccount(context.Background(), role.ID, account.ID) + assert.NoError(t, err) +} + func TestCustomRoleRepo_Delete_NotFound(t *testing.T) { db := setupTestDB(t, &model.CustomRole{}) repo := NewCustomRoleRepo(db) diff --git a/backend/internal/service/agent_service.go b/backend/internal/service/agent_service.go index 4edb2d15..038d3d05 100644 --- a/backend/internal/service/agent_service.go +++ b/backend/internal/service/agent_service.go @@ -30,7 +30,10 @@ type AgentService struct { disconnectUser func(uint) } -var ErrAgentNameBlank = errors.New("agent name cannot be blank") +var ( + ErrAgentNameBlank = errors.New("agent name cannot be blank") + ErrInvalidAgentRoleAssignee = errors.New("invalid agent role assignment") +) // NewAgentService creates a new Agent service. func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService { @@ -136,6 +139,11 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin if req.CustomRoleID != nil { customRoleID = *req.CustomRoleID } + normalizedRole, err := normalizeAgentRoleAssignment(role, req.CustomRoleID) + if err != nil { + return nil, err + } + role = normalizedRole temporaryPassword, err := generateTemporaryPassword() if err != nil { return nil, fmt.Errorf("generate temporary password: %w", err) @@ -169,11 +177,16 @@ func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req U return nil, ErrAgentNameBlank } + role, err := normalizeAgentRoleAssignment(req.Role, req.CustomRoleID) + if err != nil { + return nil, err + } + var revokeRefreshTokens func() error if req.Active != nil && !*req.Active && s.refreshStore != nil { revokeRefreshTokens = func() error { return s.refreshStore.RevokeUser(ctx, userID) } } - agent, err := s.agentRepo.UpdateAgentWithActive(ctx, userID, accountID, req.Name, req.Role, req.Availability, req.AutoOffline, req.AutoOfflineSet(), req.CustomRoleID, req.CustomRoleIDSet(), req.Active, revokeRefreshTokens) + agent, err := s.agentRepo.UpdateAgentWithActive(ctx, userID, accountID, req.Name, role, req.Availability, req.AutoOffline, req.AutoOfflineSet(), req.CustomRoleID, req.CustomRoleIDSet(), req.Active, revokeRefreshTokens) if err != nil || req.Active == nil || *req.Active { return agent, err } @@ -188,6 +201,16 @@ func (s *AgentService) Delete(ctx context.Context, userID, accountID uint) error return s.agentRepo.DeleteAgent(ctx, userID, accountID) } +func normalizeAgentRoleAssignment(role string, customRoleID *uint) (string, error) { + if customRoleID == nil || *customRoleID == 0 { + return role, nil + } + if role == "administrator" { + return "", fmt.Errorf("%w: administrator cannot have a custom role", ErrInvalidAgentRoleAssignee) + } + return "agent", nil +} + // ResetPassword assigns a new one-time-visible random password to an email agent. // The plaintext password is returned to the administrator and is never persisted. func (s *AgentService) ResetPassword(ctx context.Context, userID, accountID uint) (string, error) { diff --git a/backend/internal/service/rbac_custom_role_test.go b/backend/internal/service/rbac_custom_role_test.go index 2a08b9a5..c4c99cc6 100644 --- a/backend/internal/service/rbac_custom_role_test.go +++ b/backend/internal/service/rbac_custom_role_test.go @@ -65,6 +65,42 @@ func TestRBACService_UpdateAccountUserRoleStoresAgentForCustomRole(t *testing.T) assert.Equal(t, role.ID, au.CustomRoleID) } +func TestRBACService_CustomRoleAssignmentIsAccountScoped(t *testing.T) { + svc, db := setupRBACCustomRoleTest(t) + account := &model.Account{Name: "Account One", Active: true} + otherAccount := &model.Account{Name: "Account Two", Active: true} + require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(otherAccount).Error) + user := &model.User{AccountID: account.ID, Name: "Scoped Agent", Email: "scoped-agent@example.com", Password: "pw", Active: true} + require.NoError(t, db.Create(user).Error) + role := &model.CustomRole{AccountID: otherAccount.ID, Name: "Other Account Role"} + require.NoError(t, role.SetPermissionKeys([]model.PermissionDimension{model.DimensionReportManage})) + require.NoError(t, db.Create(role).Error) + + _, err := svc.AddAccountUser(user.ID, account.ID, "agent", role.ID, 0) + require.Error(t, err) + + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent", CustomRoleID: role.ID}).Error) + _, err = svc.BuildPolicyContext(user.ID, account.ID) + require.Error(t, err) +} + +func TestRBACService_AdministratorCannotUseCustomRole(t *testing.T) { + svc, db := setupRBACCustomRoleTest(t) + account := &model.Account{Name: "Admin Account", Active: true} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Admin", Email: "admin-custom@example.com", Password: "pw", Active: true} + require.NoError(t, db.Create(user).Error) + role := &model.CustomRole{AccountID: account.ID, Name: "Restricted"} + require.NoError(t, db.Create(role).Error) + + _, err := svc.AddAccountUser(user.ID, account.ID, "administrator", role.ID, 0) + require.Error(t, err) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator", CustomRoleID: role.ID}).Error) + _, err = svc.BuildPolicyContext(user.ID, account.ID) + require.Error(t, err) +} + func TestRBACService_CustomRoleConversationPermissionKeys(t *testing.T) { pc := auth.NewPolicyContext(1, 1, "custom_role", 7, auth.PermissionMatrixMap{ auth.DimensionConversationUnassignedManage: auth.PermissionFull, diff --git a/backend/internal/service/rbac_service.go b/backend/internal/service/rbac_service.go index 27a70458..d642b864 100644 --- a/backend/internal/service/rbac_service.go +++ b/backend/internal/service/rbac_service.go @@ -15,6 +15,7 @@ import ( "fmt" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" @@ -70,15 +71,14 @@ func (s *RBACService) GetAccountUser(userID, accountID uint) (*model.AccountUser // AddAccountUser adds a user to an account with a specified role. // This corresponds to Chatwoot's AccountUser creation during invitation flow. func (s *RBACService) AddAccountUser(userID, accountID uint, role string, customRoleID uint, invitedBy uint) (*model.AccountUser, error) { - // Validate role if !isValidRole(role) { return nil, fmt.Errorf("invalid role '%s': must be 'agent', 'administrator', or 'custom_role'", role) } - - // Check if user is already a member - existing, err := s.GetAccountUser(userID, accountID) - if err == nil && existing != nil { - return nil, fmt.Errorf("user %d is already a member of account %d", userID, accountID) + if role == "administrator" && customRoleID > 0 { + return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + if role == "custom_role" && customRoleID == 0 { + return nil, fmt.Errorf("invalid role assignment: custom_role requires a custom role id") } au := &model.AccountUser{ @@ -89,11 +89,22 @@ func (s *RBACService) AddAccountUser(userID, accountID uint, role string, custom Availability: "offline", InvitedBy: invitedBy, } - - if err := s.db.Create(au).Error; err != nil { + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := validateCustomRoleAssignment(tx, customRoleID, accountID); err != nil { + return err + } + var existing model.AccountUser + err := tx.Where("user_id = ? AND account_id = ?", userID, accountID).First(&existing).Error + if err == nil { + return fmt.Errorf("user %d is already a member of account %d", userID, accountID) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(au).Error + }); err != nil { return nil, err } - return au, nil } @@ -102,20 +113,29 @@ func (s *RBACService) UpdateAccountUserRole(userID, accountID uint, newRole stri if !isValidRole(newRole) { return nil, fmt.Errorf("invalid role '%s'", newRole) } - - au, err := s.GetAccountUser(userID, accountID) - if err != nil { - return nil, err + if newRole == "administrator" && customRoleID > 0 { + return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + if newRole == "custom_role" && customRoleID == 0 { + return nil, fmt.Errorf("invalid role assignment: custom_role requires a custom role id") } - au.Role = normalizeAccountUserRole(newRole, customRoleID) - au.CustomRoleID = customRoleID - - if err := s.db.Save(au).Error; err != nil { + var au model.AccountUser + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := validateCustomRoleAssignment(tx, customRoleID, accountID); err != nil { + return err + } + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND account_id = ?", userID, accountID).First(&au).Error; err != nil { + return err + } + au.Role = normalizeAccountUserRole(newRole, customRoleID) + au.CustomRoleID = customRoleID + return tx.Save(&au).Error + }); err != nil { return nil, err } - - return au, nil + return &au, nil } // RemoveAccountUser removes a user from an account (soft delete). @@ -203,12 +223,49 @@ func (s *RBACService) GetCustomRolePermissionMatrix(customRoleID uint) (auth.Per if err != nil { return nil, err } + return customRolePermissionMatrix(cr) +} +// GetCustomRoleForAccount retrieves a live custom role scoped to an account. +func (s *RBACService) GetCustomRoleForAccount(customRoleID, accountID uint) (*model.CustomRole, error) { + var cr model.CustomRole + if err := validateCustomRoleAssignment(s.db, customRoleID, accountID); err != nil { + return nil, err + } + if err := s.db.Where("id = ? AND account_id = ? AND deleted_at IS NULL", customRoleID, accountID).First(&cr).Error; err != nil { + return nil, err + } + return &cr, nil +} + +func validateCustomRoleAssignment(db *gorm.DB, customRoleID, accountID uint) error { + if customRoleID == 0 { + return nil + } + var role model.CustomRole + err := db.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND account_id = ? AND deleted_at IS NULL", customRoleID, accountID). + First(&role).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("invalid custom role assignment: custom role %d not found for account %d", customRoleID, accountID) + } + return err +} + +// GetCustomRolePermissionMatrixForAccount loads permissions only from a live role in the account. +func (s *RBACService) GetCustomRolePermissionMatrixForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) { + cr, err := s.GetCustomRoleForAccount(customRoleID, accountID) + if err != nil { + return nil, err + } + return customRolePermissionMatrix(cr) +} + +func customRolePermissionMatrix(cr *model.CustomRole) (auth.PermissionMatrixMap, error) { modelMap, err := cr.GetPermissionMap() if err != nil { return nil, err } - return modelPermMapToAuthMatrix(modelMap), nil } @@ -240,29 +297,23 @@ func (s *RBACService) UpdateCustomRole(customRoleID uint, name string, permissio } // DeleteCustomRole deletes a custom role (soft delete). -// All AccountUsers referencing this role will be downgraded to agent role. +// All AccountUsers referencing it revert to the standard agent role and permissions. func (s *RBACService) DeleteCustomRole(customRoleID uint) error { - // Downgrade all account users with this custom role to agent - err := s.db.Model(&model.AccountUser{}). - Where("custom_role_id = ?", customRoleID). - Updates(map[string]interface{}{ - "role": "agent", - "custom_role_id": 0, - }).Error - if err != nil { - return err - } - - // Soft delete the custom role - result := s.db.Delete(&model.CustomRole{}, customRoleID) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return fmt.Errorf("custom role %d not found", customRoleID) - } - - return nil + return s.db.Transaction(func(tx *gorm.DB) error { + var role model.CustomRole + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&role, customRoleID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("custom role %d not found", customRoleID) + } + return err + } + if err := tx.Model(&model.AccountUser{}). + Where("account_id = ? AND custom_role_id = ?", role.AccountID, customRoleID). + Updates(map[string]interface{}{"role": "agent", "custom_role_id": 0}).Error; err != nil { + return err + } + return tx.Delete(&role).Error + }) } // ListCustomRoles retrieves all custom roles for an account. @@ -282,6 +333,9 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo if err != nil { return nil, err } + if au.Role == "administrator" && au.CustomRoleID > 0 { + return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } permissions := auth.PermissionMatrixMap{} @@ -293,29 +347,25 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo permissions = auth.AdministratorPermissions case "agent": if au.CustomRoleID > 0 { - pm, err := s.GetCustomRolePermissionMatrix(au.CustomRoleID) + pm, err := s.GetCustomRolePermissionMatrixForAccount(au.CustomRoleID, accountID) if err != nil { - // Fallback to agent defaults if custom role not found - permissions = auth.AgentDefaultPermissions - } else { - permissions = pm - effectiveRole = "custom_role" + return nil, fmt.Errorf("load custom role permissions: %w", err) } + permissions = pm + effectiveRole = "custom_role" } else { permissions = auth.AgentDefaultPermissions } case "custom_role": - effectiveRole = "custom_role" - if au.CustomRoleID > 0 { - pm, err := s.GetCustomRolePermissionMatrix(au.CustomRoleID) - if err != nil { - permissions = auth.AgentDefaultPermissions - } else { - permissions = pm - } - } else { - permissions = auth.AgentDefaultPermissions + if au.CustomRoleID == 0 { + return nil, fmt.Errorf("custom role assignment is missing a role id") } + pm, err := s.GetCustomRolePermissionMatrixForAccount(au.CustomRoleID, accountID) + if err != nil { + return nil, fmt.Errorf("load custom role permissions: %w", err) + } + permissions = pm + effectiveRole = "custom_role" } return auth.NewPolicyContext(userID, accountID, effectiveRole, au.CustomRoleID, permissions), nil @@ -498,7 +548,12 @@ func (s *RBACService) GetAccountUserRole(userID, accountID uint) (string, uint, } // GetCustomRolePermissions returns the auth.PermissionMatrixMap for a custom role. -// This implements the middleware.RBACLookup interface. +// It is retained for callers that do not have an account scope. func (s *RBACService) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) { return s.GetCustomRolePermissionMatrix(customRoleID) } + +// GetCustomRolePermissionsForAccount implements the account-scoped middleware lookup. +func (s *RBACService) GetCustomRolePermissionsForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) { + return s.GetCustomRolePermissionMatrixForAccount(customRoleID, accountID) +} diff --git a/backend/scripts/parity_frontend_browser_smoke.mjs b/backend/scripts/parity_frontend_browser_smoke.mjs index 7db31d0a..7885176d 100644 --- a/backend/scripts/parity_frontend_browser_smoke.mjs +++ b/backend/scripts/parity_frontend_browser_smoke.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn } from "node:child_process"; import { closeSync, mkdirSync, @@ -7,70 +7,110 @@ import { openSync, readFileSync, writeFileSync, -} from 'node:fs'; -import { createServer } from 'node:http'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; const root = process.env.GOCHAT_ROOT || process.cwd(); -const chatwootDir = process.env.CHATWOOT_DIR || path.join(root, '..', 'docs', 'chatwoot'); -const logDir = process.env.GOCHAT_SMOKE_LOG_DIR || path.join(root, '.tmp/frontend-smoke'); -const apiHost = process.env.GOCHAT_SMOKE_API_HOST || '127.0.0.1'; -const apiPort = process.env.GOCHAT_SMOKE_API_PORT || '3000'; -const frontendHost = process.env.GOCHAT_SMOKE_FRONTEND_HOST || '127.0.0.1'; -const frontendPort = process.env.GOCHAT_SMOKE_FRONTEND_PORT || '3036'; +const frontendDir = + process.env.FRONTEND_DIR || + process.env.CHATWOOT_DIR || + path.join(root, "..", "frontend"); +const logDir = + process.env.GOCHAT_SMOKE_LOG_DIR || path.join(root, ".tmp/frontend-smoke"); +const apiHost = process.env.GOCHAT_SMOKE_API_HOST || "127.0.0.1"; +const apiPort = process.env.GOCHAT_SMOKE_API_PORT || "3000"; +const frontendHost = process.env.GOCHAT_SMOKE_FRONTEND_HOST || "127.0.0.1"; +const frontendPort = process.env.GOCHAT_SMOKE_FRONTEND_PORT || "3036"; const shellHost = process.env.GOCHAT_SMOKE_SHELL_HOST || frontendHost; -const shellPort = process.env.GOCHAT_SMOKE_SHELL_PORT || String(Number(frontendPort) + 1); -const chromePath = process.env.GOCHAT_SMOKE_CHROME || '/usr/bin/google-chrome'; +const shellPort = + process.env.GOCHAT_SMOKE_SHELL_PORT || String(Number(frontendPort) + 1); +const chromePath = process.env.GOCHAT_SMOKE_CHROME || "/usr/bin/google-chrome"; const viteBaseURL = `http://${frontendHost}:${frontendPort}`; const frontendBaseURL = `http://${shellHost}:${shellPort}`; const apiBaseURL = `http://${apiHost}:${apiPort}`; -const enterpriseMode = process.argv.includes('--enterprise'); +const enterpriseMode = process.argv.includes("--enterprise"); +const deploymentEnv = process.env.GOCHAT_SMOKE_DEPLOYMENT_ENV || "cloud"; +const installationName = process.env.GOCHAT_SMOKE_INSTALLATION_NAME || "GoChat"; +const expectedBilling = + (process.env.GOCHAT_SMOKE_EXPECT_BILLING || + (deploymentEnv === "cloud" && installationName === "GoChat" + ? "true" + : "false")) === "true"; function isSuccessfulRequest(request, substring) { - return request.url.includes(substring) && request.status >= 200 && request.status < 400; + return ( + request.url.includes(substring) && + request.status >= 200 && + request.status < 400 + ); } function isFailedAPIRequest(request) { const isBackendAPI = request.url.includes(apiBaseURL); - const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && ( - request.url.includes('/api/') || - request.url.includes('/enterprise/') || - request.url.includes('/public/') || - request.url.includes('/auth/') || - request.url.includes('/rails/') - ); - if (!isBackendAPI && !isShellProxiedAPI || request.type === 'Preflight') return false; - const isNavigationAbort = request.type === 'Document' && - ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText); - return request.status >= 400 || request.status === 0 && !isNavigationAbort; + const isShellProxiedAPI = + request.url.startsWith(frontendBaseURL) && + (request.url.includes("/api/") || + request.url.includes("/enterprise/") || + request.url.includes("/public/") || + request.url.includes("/auth/") || + request.url.includes("/rails/")); + if ((!isBackendAPI && !isShellProxiedAPI) || request.type === "Preflight") + return false; + const isNavigationAbort = + request.type === "Document" && + ["net::ERR_ABORTED", "net::ERR_FAILED"].includes(request.errorText); + return request.status >= 400 || (request.status === 0 && !isNavigationAbort); } -if (process.argv.includes('--self-test')) { +if (process.argv.includes("--self-test")) { const apiURL = `${apiBaseURL}/api/v1/profile`; const checks = [ - isSuccessfulRequest({ url: apiURL, status: 200 }, '/api/v1/profile'), - !isSuccessfulRequest({ url: apiURL, status: 500 }, '/api/v1/profile'), - isFailedAPIRequest({ url: apiURL, status: 0, type: 'Fetch', errorText: 'net::ERR_FAILED' }), - !isFailedAPIRequest({ url: apiURL, status: 0, type: 'Document', errorText: 'net::ERR_ABORTED' }), - !isFailedAPIRequest({ url: `${frontendBaseURL}/favicon.ico`, status: 404, type: 'Image' }), + isSuccessfulRequest({ url: apiURL, status: 200 }, "/api/v1/profile"), + !isSuccessfulRequest({ url: apiURL, status: 500 }, "/api/v1/profile"), + isFailedAPIRequest({ + url: apiURL, + status: 0, + type: "Fetch", + errorText: "net::ERR_FAILED", + }), + !isFailedAPIRequest({ + url: apiURL, + status: 0, + type: "Document", + errorText: "net::ERR_ABORTED", + }), + !isFailedAPIRequest({ + url: `${frontendBaseURL}/favicon.ico`, + status: 404, + type: "Image", + }), ]; - if (checks.some(check => !check)) throw new Error('browser smoke failure contract self-test failed'); - console.log('browser smoke failure contract self-test: ok'); + if (checks.some((check) => !check)) + throw new Error("browser smoke failure contract self-test failed"); + console.log("browser smoke failure contract self-test: ok"); process.exit(0); } mkdirSync(logDir, { recursive: true }); -const seed = JSON.parse(readFileSync(path.join(logDir, 'seed.json'), 'utf8')); -const widgetConfig = JSON.parse(readFileSync(path.join(logDir, 'widget_config.json'), 'utf8')); -const signInHeaders = readFileSync(path.join(logDir, 'sign_in.headers'), 'utf8'); +const seed = JSON.parse(readFileSync(path.join(logDir, "seed.json"), "utf8")); +const widgetConfig = JSON.parse( + readFileSync(path.join(logDir, "widget_config.json"), "utf8"), +); +const signInHeaders = readFileSync( + path.join(logDir, "sign_in.headers"), + "utf8", +); const report = { started_at: new Date().toISOString(), - mode: enterpriseMode ? 'enterprise' : 'core', + mode: enterpriseMode ? "enterprise" : "core", + frontend_dir: frontendDir, frontend_base_url: frontendBaseURL, api_base_url: apiBaseURL, account_id: seed.account_id, + installation_name: installationName, requests: [], console: [], checks: [], @@ -78,31 +118,28 @@ const report = { function smokeHTML(entrypoint, route) { const config = { - apiHost: '', + apiHost: "", hostURL: frontendBaseURL, - helpCenterURL: '', - allowedLoginMethods: ['email'], - signupEnabled: 'false', - isMfaEnabled: 'false', - enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }], + helpCenterURL: "", + allowedLoginMethods: ["email"], + signupEnabled: "false", + isMfaEnabled: "false", + enabledLanguages: [{ iso_639_1_code: "en", name: "English" }], helpUrls: {}, - selectedLocale: 'en', - isEnterprise: 'true', - enterprisePlanName: 'enterprise', + selectedLocale: "en", }; const globalConfig = { - INSTALLATION_NAME: 'GoChat', - BRAND_NAME: 'GoChat', - LOGO: '/logo.png', - LOGO_DARK: '', - LOGO_THUMBNAIL: '/logo.png', - DISABLE_USER_PROFILE_UPDATE: 'false', - DIRECT_UPLOADS_ENABLED: 'false', - MAXIMUM_FILE_UPLOAD_SIZE: '40', + INSTALLATION_NAME: installationName, + BRAND_NAME: "GoChat", + LOGO: "/logo.png", + LOGO_DARK: "", + LOGO_THUMBNAIL: "/logo.png", + DISABLE_USER_PROFILE_UPDATE: "false", + DIRECT_UPLOADS_ENABLED: "false", + MAXIMUM_FILE_UPLOAD_SIZE: "40", ACTIVE_PLATFORM_BANNERS: [], - LOGOUT_REDIRECT_LINK: '/app/login', - DEPLOYMENT_ENV: 'cloud', - IS_ENTERPRISE: 'true', + LOGOUT_REDIRECT_LINK: "/app/login", + DEPLOYMENT_ENV: deploymentEnv, }; return ` @@ -112,6 +149,7 @@ function smokeHTML(entrypoint, route) { GoChat Smoke diff --git a/frontend/app/javascript/dashboard/components-next/sidebar/provider.js b/frontend/app/javascript/dashboard/components-next/sidebar/provider.js index dc844201..768e7f7f 100644 --- a/frontend/app/javascript/dashboard/components-next/sidebar/provider.js +++ b/frontend/app/javascript/dashboard/components-next/sidebar/provider.js @@ -126,24 +126,10 @@ export function useSidebarContext() { return router.resolve(to)?.meta?.featureFlag || ''; }; - const resolveInstallationType = to => { - if (!to) return []; - - // If navigationPath param exists, get the target route definition - if (to.params?.navigationPath) { - const targetRoute = findRouteByName(to.params.navigationPath); - return targetRoute?.meta?.installationTypes || []; - } - - return router.resolve(to)?.meta?.installationTypes || []; - }; - const isAllowed = to => { const permissions = resolvePermissions(to); const featureFlag = resolveFeatureFlag(to); - const installationType = resolveInstallationType(to); - - return shouldShow(featureFlag, permissions, installationType); + return shouldShow(featureFlag, permissions); }; return { diff --git a/frontend/app/javascript/dashboard/components/policy.vue b/frontend/app/javascript/dashboard/components/policy.vue index 5e0aa9d5..6dcabb1e 100644 --- a/frontend/app/javascript/dashboard/components/policy.vue +++ b/frontend/app/javascript/dashboard/components/policy.vue @@ -15,17 +15,11 @@ const props = defineProps({ type: String, default: null, }, - installationTypes: { - type: Array, - default: null, - }, }); const { shouldShow } = usePolicy(); -const show = computed(() => - shouldShow(props.featureFlag, props.permissions, props.installationTypes) -); +const show = computed(() => shouldShow(props.featureFlag, props.permissions)); diff --git a/frontend/app/javascript/dashboard/composables/usePolicy.js b/frontend/app/javascript/dashboard/composables/usePolicy.js index a8380aeb..69a70274 100644 --- a/frontend/app/javascript/dashboard/composables/usePolicy.js +++ b/frontend/app/javascript/dashboard/composables/usePolicy.js @@ -7,8 +7,6 @@ import { } from 'dashboard/helper/permissionsHelper'; import { PREMIUM_FEATURES } from 'dashboard/featureFlags'; -import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; - export function usePolicy() { const user = useMapGetter('getCurrentUser'); const isFeatureEnabled = useMapGetter('accounts/isFeatureEnabledonAccount'); @@ -34,35 +32,17 @@ export function usePolicy() { return hasPermissions(requiredPermissions, userPermissions); }; - const checkInstallationType = config => { - if (Array.isArray(config) && config.length > 0) { - const installationCheck = { - [INSTALLATION_TYPES.ENTERPRISE]: true, - [INSTALLATION_TYPES.CLOUD]: isOnChatwootCloud.value, - [INSTALLATION_TYPES.COMMUNITY]: true, - }; - - return config.some(type => installationCheck[type]); - } - - return true; - }; - const isPremiumFeature = featureFlag => { if (!featureFlag) return true; return PREMIUM_FEATURES.includes(featureFlag); }; - const shouldShow = (featureFlag, permissions, installationTypes) => { + const shouldShow = (featureFlag, permissions) => { const flag = unref(featureFlag); const perms = unref(permissions); - const installation = unref(installationTypes); - // if the user does not have permissions or installation type is not supported - // return false; - // This supersedes everything + // Permissions supersede feature visibility. if (!checkPermissions(perms)) return false; - if (!checkInstallationType(installation)) return false; if (isACustomBrandedInstance.value) { // if this is a custom branded instance, we just use the feature flag as a reference @@ -76,8 +56,9 @@ export function usePolicy() { return isFeatureFlagEnabled(flag) || isPremiumFeature(flag); } - // default to true - return true; + // Premium routes remain visible so their page can render its paywall; + // other routes still honor the account feature flag on self-hosted installs. + return isFeatureFlagEnabled(flag) || isPremiumFeature(flag); }; const shouldShowPaywall = featureFlag => { @@ -89,7 +70,7 @@ export function usePolicy() { return false; } - if (isPremiumFeature(flag) && isOnChatwootCloud.value) { + if (isPremiumFeature(flag)) { return !isFeatureFlagEnabled(flag); } diff --git a/frontend/app/javascript/dashboard/constants/installationTypes.js b/frontend/app/javascript/dashboard/constants/installationTypes.js deleted file mode 100644 index 87709e98..00000000 --- a/frontend/app/javascript/dashboard/constants/installationTypes.js +++ /dev/null @@ -1,5 +0,0 @@ -export const INSTALLATION_TYPES = { - CLOUD: 'cloud', - ENTERPRISE: 'enterprise', - COMMUNITY: 'community', -}; diff --git a/frontend/app/javascript/dashboard/helper/routeHelpers.js b/frontend/app/javascript/dashboard/helper/routeHelpers.js index 292e5e6e..38cabcae 100644 --- a/frontend/app/javascript/dashboard/helper/routeHelpers.js +++ b/frontend/app/javascript/dashboard/helper/routeHelpers.js @@ -5,12 +5,12 @@ import { } from './permissionsHelper'; import { - ROLES, CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS, REPORTS_PERMISSIONS, PORTAL_PERMISSIONS, } from 'dashboard/constants/permissions.js'; +import { isSuperAdminUser } from '../routes/dashboard/settings/captain/utils'; export const routeIsAccessibleFor = (route, userPermissions = []) => { const { meta: { permissions: routePermissions = [] } = {} } = route; @@ -22,7 +22,7 @@ export const defaultRedirectPage = (to, permissions) => { const permissionRoutes = [ { - permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], + permissions: CONVERSATION_PERMISSIONS, path: 'dashboard', }, { permissions: [CONTACT_PERMISSIONS], path: 'contacts' }, @@ -34,7 +34,11 @@ export const defaultRedirectPage = (to, permissions) => { hasPermissions(routePermissions, permissions) ); - return `accounts/${accountId}/${route ? route.path : 'dashboard'}`; + if (route) return `accounts/${accountId}/${route.path}`; + if (permissions.includes('custom_role')) { + return `accounts/${accountId}/profile/settings`; + } + return `accounts/${accountId}/dashboard`; }; const validateActiveAccountRoutes = (to, user) => { @@ -48,6 +52,10 @@ const validateActiveAccountRoutes = (to, user) => { const userPermissions = getUserPermissions(user, to.params.accountId); + if (to.meta?.isSuperAdmin && !isSuperAdminUser(user)) { + return defaultRedirectPage(to, userPermissions); + } + const isAccessible = routeIsAccessibleFor(to, userPermissions); // If the route is not accessible for the user, return to dashboard screen return isAccessible ? null : defaultRedirectPage(to, userPermissions); diff --git a/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js b/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js index 6370a9a9..d125c8eb 100644 --- a/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js +++ b/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js @@ -55,9 +55,11 @@ describe('#defaultRedirectPage', () => { expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/portals'); }); - it('should return dashboard route as default for users with custom roles', () => { + it('should return the profile route for zero-permission custom roles', () => { const permissions = ['custom_role']; - expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard'); + expect(defaultRedirectPage(to, permissions)).toBe( + 'accounts/2/profile/settings' + ); }); it('should return dashboard route for users with administrator role', () => { @@ -138,6 +140,28 @@ describe('#validateLoggedInRoutes', () => { ) ).toEqual(`accounts/1/dashboard`); }); + + it('redirects non-super-admin users from super-admin routes', () => { + expect( + validateLoggedInRoutes( + { + name: 'super_admin_dashboard', + params: { accountId: 1 }, + meta: { isSuperAdmin: true, permissions: ['agent'] }, + }, + { + accounts: [ + { + id: 1, + role: 'agent', + permissions: ['agent'], + status: 'active', + }, + ], + } + ) + ).toEqual(`accounts/1/dashboard`); + }); }); describe('when route is suspended route', () => { it('returns dashboard url', () => { diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/customRole.json b/frontend/app/javascript/dashboard/i18n/locale/en/customRole.json index f7c1709b..62790fc4 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/customRole.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/customRole.json @@ -23,6 +23,8 @@ }, "LIST": { "404": "There are no custom roles available in this account.", + "ERROR": "Unable to load custom roles. Please try again.", + "RETRY": "Retry", "TITLE": "Manage custom roles", "DESC": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.", "TABLE_HEADER": { @@ -85,9 +87,9 @@ }, "CONFIRM": { "TITLE": "Confirm deletion", - "MESSAGE": "Are you sure to delete ", - "YES": "Yes, delete ", - "NO": "No, keep " + "MESSAGE": "Deleting this role removes it from all assigned agents and restores standard Agent permissions; some permissions may increase. Continue?", + "YES": "Yes, delete", + "NO": "No, keep" } } } diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/customRole.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/customRole.json index 712483bc..f7eb0c86 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/customRole.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/customRole.json @@ -23,6 +23,8 @@ }, "LIST": { "404": "此账户中没有可用的自定义角色。", + "ERROR": "自定义角色加载失败,请重试。", + "RETRY": "重试", "TITLE": "管理自定义角色", "DESC": "自定义角色是由账户所有者或管理员创建的角色。这些角色可以分配给客服人员,以定义他们在账户中的访问权限和权限。自定义角色可以根据组织的需求创建特定的权限和访问级别。", "TABLE_HEADER": { @@ -85,7 +87,7 @@ }, "CONFIRM": { "TITLE": "确认删除", - "MESSAGE": "您确定要删除吗 ", + "MESSAGE": "删除此角色后,所有使用该角色的客服将改用普通客服权限,部分权限可能增加。是否继续?", "YES": "是的,删除", "NO": "不,保留" } diff --git a/frontend/app/javascript/dashboard/modules/search/components/SearchHeader.vue b/frontend/app/javascript/dashboard/modules/search/components/SearchHeader.vue index 08d4c0c7..7a6f6bc1 100644 --- a/frontend/app/javascript/dashboard/modules/search/components/SearchHeader.vue +++ b/frontend/app/javascript/dashboard/modules/search/components/SearchHeader.vue @@ -1,7 +1,6 @@ @@ -175,6 +189,19 @@ const confirmDeletion = () => {