diff --git a/backend/internal/handler/api/v1/coverage26_test.go b/backend/internal/handler/api/v1/coverage26_test.go index 4dac61bb..3e1d3289 100644 --- a/backend/internal/handler/api/v1/coverage26_test.go +++ b/backend/internal/handler/api/v1/coverage26_test.go @@ -2291,6 +2291,7 @@ func TestInboxHandler_List_WithDB_Cov26(t *testing.T) { svc := service.NewInboxService(repo, agentBotInboxRepo, agentBotRepo, campaignRepo, webhookSubRepo, nil, nil) h := NewInboxHandler(svc) c, w := ctxCov26("GET", "/api/v1/accounts/"+uitoaCov26(acc.ID)+"/inboxes", map[string]string{"account_id": uitoaCov26(acc.ID)}) + c.Set("role", "administrator") safeCallCov26(t, "InboxList_WithDB", func() { h.List(c) }) assert.Equal(t, http.StatusOK, w.Code) } diff --git a/backend/internal/handler/api/v1/inbox_handler.go b/backend/internal/handler/api/v1/inbox_handler.go index 7793b393..defc904e 100644 --- a/backend/internal/handler/api/v1/inbox_handler.go +++ b/backend/internal/handler/api/v1/inbox_handler.go @@ -63,7 +63,20 @@ func (h *InboxHandler) List(c *gin.Context) { perPage := getPageSize(c) offset := (page - 1) * perPage - inboxes, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage) + var ( + inboxes []model.Inbox + svcErr error + ) + if getRole(c) == "administrator" { + inboxes, _, svcErr = h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage) + } else { + userID := getUserID(c) + if userID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "current user is required") + return + } + inboxes, _, svcErr = h.svc.ListByAccountAndUser(c.Request.Context(), accountID, userID, offset, perPage) + } if svcErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list inboxes"}) return diff --git a/backend/internal/handler/api/v1/inbox_handler_test.go b/backend/internal/handler/api/v1/inbox_handler_test.go index fbbb5241..4a7ae9da 100644 --- a/backend/internal/handler/api/v1/inbox_handler_test.go +++ b/backend/internal/handler/api/v1/inbox_handler_test.go @@ -42,6 +42,48 @@ func newNilInboxHandler() *InboxHandler { return NewInboxHandler(&service.InboxService{}) } +func setupInboxListAccessRouter(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.User) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:inbox_list_access?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.InboxMember{}, &model.WorkingHour{})) + account := &model.Account{Name: "Inbox List Access", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + user := &model.User{Name: "Scoped Agent", Email: "handler-scoped-agent@example.com"} + require.NoError(t, db.Create(user).Error) + + handler := NewInboxHandler(service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil)) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user_id", user.ID) + c.Set("role", "agent") + c.Next() + }) + router.GET("/api/v1/accounts/:account_id/inboxes", handler.List) + return router, db, account, user +} + +func TestInboxHandler_ListScopesAgentToAssignedInboxes(t *testing.T) { + router, db, account, user := setupInboxListAccessRouter(t) + assigned := &model.Inbox{AccountID: account.ID, Name: "Assigned", ChannelType: "web_widget", ChannelID: 1} + hidden := &model.Inbox{AccountID: account.ID, Name: "Hidden", ChannelType: "api", ChannelID: 2} + require.NoError(t, db.Create(assigned).Error) + require.NoError(t, db.Create(hidden).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/inboxes", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body struct { + Payload []map[string]any `json:"payload"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Len(t, body.Payload, 1) + assert.Equal(t, "Assigned", body.Payload[0]["name"]) +} + type fakeInboxHandlerWhatsAppService struct{} func (f *fakeInboxHandlerWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) { diff --git a/backend/internal/repository/inbox_repo.go b/backend/internal/repository/inbox_repo.go index b427e060..eaebc271 100644 --- a/backend/internal/repository/inbox_repo.go +++ b/backend/internal/repository/inbox_repo.go @@ -50,6 +50,25 @@ func (r *InboxRepo) FindByAccount(ctx context.Context, accountID uint, offset, l return inboxes, total, err } +// FindByAccountAndUser retrieves inboxes assigned to a user within an account. +func (r *InboxRepo) FindByAccountAndUser(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.Inbox, int64, error) { + var inboxes []model.Inbox + var total int64 + + base := r.db.WithContext(ctx).Model(&model.Inbox{}). + Joins("JOIN inbox_members ON inbox_members.inbox_id = inboxes.id"). + Where("inboxes.account_id = ? AND inbox_members.user_id = ?", accountID, userID) + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := r.withWorkingHours(base.Session(&gorm.Session{})). + Select("inboxes.*"). + Offset(offset).Limit(limit).Order("inboxes.id DESC"). + Find(&inboxes).Error + return inboxes, total, err +} + // Create inserts a new inbox. func (r *InboxRepo) Create(ctx context.Context, inbox *model.Inbox) error { return r.db.WithContext(ctx).Create(inbox).Error diff --git a/backend/internal/repository/inbox_repo_test.go b/backend/internal/repository/inbox_repo_test.go index 7036d5d9..7383760a 100644 --- a/backend/internal/repository/inbox_repo_test.go +++ b/backend/internal/repository/inbox_repo_test.go @@ -119,6 +119,43 @@ func TestInboxRepo_FindByAccount_Pagination(t *testing.T) { assert.Len(t, inboxes, 3) } +func TestInboxRepo_FindByAccountAndUser_OnlyAssignedInboxes(t *testing.T) { + db := setupTestDB(t, &model.InboxMember{}) + repo := NewInboxRepo(db) + + account := &model.Account{Name: "InboxMemberScopedOrg", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + user := &model.User{Name: "Scoped Agent", Email: "scoped-agent@example.com"} + require.NoError(t, db.Create(user).Error) + assigned := createTestInbox(t, db, account.ID, "AssignedInbox") + _ = createTestInbox(t, db, account.ID, "HiddenInbox") + require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error) + + inboxes, total, err := repo.FindByAccountAndUser(context.Background(), account.ID, user.ID, 0, 10) + + require.NoError(t, err) + assert.Equal(t, int64(1), total) + require.Len(t, inboxes, 1) + assert.Equal(t, assigned.ID, inboxes[0].ID) +} + +func TestInboxRepo_FindByAccountAndUser_EmptyWhenUnassigned(t *testing.T) { + db := setupTestDB(t, &model.InboxMember{}) + repo := NewInboxRepo(db) + + account := &model.Account{Name: "UnassignedInboxOrg", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + user := &model.User{Name: "Unassigned Agent", Email: "unassigned-agent@example.com"} + require.NoError(t, db.Create(user).Error) + _ = createTestInbox(t, db, account.ID, "HiddenInbox") + + inboxes, total, err := repo.FindByAccountAndUser(context.Background(), account.ID, user.ID, 0, 10) + + require.NoError(t, err) + assert.Zero(t, total) + assert.Empty(t, inboxes) +} + func TestInboxRepo_Create(t *testing.T) { db := setupTestDB(t) repo := NewInboxRepo(db) @@ -254,7 +291,7 @@ func TestInboxRepo_UpdateFields_BooleanFields(t *testing.T) { assert.False(t, inbox.GreetingEnabled) err := repo.UpdateFields(context.Background(), inbox.ID, map[string]interface{}{ - "greeting_enabled": true, + "greeting_enabled": true, "csat_survey_enabled": true, }) assert.NoError(t, err) @@ -293,4 +330,4 @@ func TestInboxRepo_UpdateFields_EmptyMap(t *testing.T) { found, err := repo.FindByID(context.Background(), inbox.ID) assert.NoError(t, err) assert.Equal(t, "EmptyMapInbox", found.Name) -} \ No newline at end of file +} diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index 74f1d65c..30643e6b 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -104,6 +104,11 @@ func (s *InboxService) ListByAccount(ctx context.Context, accountID uint, offset return s.repo.FindByAccount(ctx, accountID, offset, limit) } +// ListByAccountAndUser retrieves inboxes assigned to a user within an account. +func (s *InboxService) ListByAccountAndUser(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.Inbox, int64, error) { + return s.repo.FindByAccountAndUser(ctx, accountID, userID, offset, limit) +} + // GetByID retrieves a single inbox. func (s *InboxService) GetByID(ctx context.Context, id uint) (*model.Inbox, error) { return s.repo.FindByID(ctx, id) diff --git a/backend/internal/service/inbox_service_test.go b/backend/internal/service/inbox_service_test.go index a22e8cce..d1331f7e 100644 --- a/backend/internal/service/inbox_service_test.go +++ b/backend/internal/service/inbox_service_test.go @@ -120,6 +120,24 @@ func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*mod func ptrUint(v uint) *uint { return &v } +func TestInboxService_ListByAccountAndUser_OnlyAssignedInboxes(t *testing.T) { + svc, db := setupInboxServiceTest(t) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.InboxMember{})) + account, assigned := createInboxTestPrereqs(t, db, "web_widget") + hidden := &model.Inbox{AccountID: account.ID, Name: "Hidden", ChannelType: "api", ChannelID: 2} + require.NoError(t, db.Create(hidden).Error) + user := &model.User{Name: "Scoped Agent", Email: "service-scoped-agent@example.com"} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error) + + inboxes, total, err := svc.ListByAccountAndUser(context.Background(), account.ID, user.ID, 0, 25) + + require.NoError(t, err) + assert.Equal(t, int64(1), total) + require.Len(t, inboxes, 1) + assert.Equal(t, assigned.ID, inboxes[0].ID) +} + func createWhatsAppInboxTestPrereqs(t *testing.T, db *gorm.DB, provider string) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp) { t.Helper() account, inbox := createInboxTestPrereqs(t, db, "whatsapp") diff --git a/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js b/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js index 30cd51ae..6370a9a9 100644 --- a/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js +++ b/frontend/app/javascript/dashboard/helper/specs/routeHelpers.spec.js @@ -13,6 +13,17 @@ describe('#routeIsAccessibleFor', () => { expect(routeIsAccessibleFor(route, ['agent'])).toEqual(false); expect(routeIsAccessibleFor(route, ['administrator'])).toEqual(true); }); + + it('allows agents to open the help center entry route', () => { + const route = { + name: 'portals_index', + meta: { + permissions: ['administrator', 'agent', 'knowledge_base_manage'], + }, + }; + + expect(routeIsAccessibleFor(route, ['agent'])).toEqual(true); + }); }); describe('#defaultRedirectPage', () => { diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/agentMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/agentMgmt.json index 954ec0e3..665c1319 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/agentMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/agentMgmt.json @@ -104,6 +104,7 @@ "RESET_DESC": "Copy this new password now and share it with the agent securely. The old password no longer works, and this password cannot be shown again after closing.", "COPY": "Copy password", "COPY_SUCCESS": "Password copied to clipboard", + "COPY_FAILED": "Unable to copy the password. Please select and copy it manually.", "DONE": "I have saved it" }, "SEARCH_PLACEHOLDER": "Search agents...", diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index a07b6a96..087e43f5 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -393,6 +393,12 @@ "USERNAME": "Login username", "PASSWORD": "Login password", "DESIRED_PRESENCE": "Initial presence", + "PRESENCE": { + "ONLINE": "Online", + "BUSY": "Busy", + "AWAY": "Away", + "OFFLINE": "Offline" + }, "WEBHOOK_URL": "Connector webhook URL", "SUBMIT_BUTTON": "Create Shangwutong channel", "API": { @@ -1180,6 +1186,45 @@ "REFRESH": "Refresh status", "DESIRED_PRESENCE": "Desired presence", "DESIRED_PRESENCE_HELP": "Changes are applied online. Offline logs out only this Shangwutong account.", + "PRESENCE": { + "ONLINE": "Online", + "BUSY": "Busy", + "AWAY": "Away", + "OFFLINE": "Offline" + }, + "STATUS_FIELDS": { + "CONNECTION_STATUS": "Connection status", + "ACTUAL_PRESENCE": "Current presence", + "CREDENTIAL_STATUS": "Credential status", + "LAST_HEARTBEAT_AT": "Last heartbeat" + }, + "STATUS_VALUES": { + "UNKNOWN": "Unknown", + "CONNECTION": { + "PENDING": "Pending", + "LOGGING_IN": "Logging in", + "CONNECTED": "Connected", + "DEGRADED": "Degraded", + "RELOGIN_REQUIRED": "Relogin required", + "VERIFICATION_REQUIRED": "Verification required", + "AUTH_FAILED": "Authentication failed", + "DISABLED": "Disabled", + "OFFLINE": "Offline" + }, + "PRESENCE": { + "ONLINE": "Online", + "BUSY": "Busy", + "AWAY": "Away", + "OFFLINE": "Offline" + }, + "CREDENTIAL": { + "PENDING": "Pending verification", + "VERIFYING": "Verifying", + "APPLIED": "Applied", + "REJECTED": "Rejected", + "VERIFICATION_REQUIRED": "Verification required" + } + }, "PASSWORD": "Replace password", "PASSWORD_HELP": "Leave blank to keep the current password. A new password is verified online before replacing the working credential.", "WEBHOOK_URL": "Connector webhook URL", diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/agentMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/agentMgmt.json index 03c000f2..9bc74e4c 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/agentMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/agentMgmt.json @@ -104,6 +104,7 @@ "RESET_DESC": "请立即复制并安全地将此新密码交给客服。旧密码已失效,关闭后将无法再次查看。", "COPY": "复制密码", "COPY_SUCCESS": "密码已复制到剪贴板", + "COPY_FAILED": "无法复制密码,请手动选择并复制。", "DONE": "我已保存" }, "SEARCH_PLACEHOLDER": "搜索客服代表...", diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index 6738c97f..e97e2a7f 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -393,6 +393,12 @@ "USERNAME": "登录账号", "PASSWORD": "登录密码", "DESIRED_PRESENCE": "初始在线状态", + "PRESENCE": { + "ONLINE": "在线", + "BUSY": "忙碌", + "AWAY": "离开", + "OFFLINE": "离线" + }, "WEBHOOK_URL": "Connector Webhook 地址", "SUBMIT_BUTTON": "创建商务通渠道", "API": { @@ -838,50 +844,50 @@ }, "ASSIGNMENT": { "TITLE": "对话分配", - "DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies", - "ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment", - "DEFAULT_RULES_TITLE": "Default assignment rules", - "DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations", - "DEFAULT_RULE_1": "Earliest created conversations first", - "DEFAULT_RULE_2": "Round robin distribution", - "CUSTOMIZE_WITH_POLICY": "Customize with assignment policy", - "USING_POLICY": "Using custom assignment policy for this inbox", - "CUSTOMIZE_POLICY": "Customize with assignment policy", - "DELETE_POLICY": "Delete policy", - "POLICY_LABEL": "Assignment policy", - "ASSIGNMENT_ORDER_LABEL": "Assignment Order", - "ASSIGNMENT_METHOD_LABEL": "Assignment Method", + "DESCRIPTION": "根据分配策略将新对话自动分配给可用客服", + "ENABLE_AUTO_ASSIGNMENT": "启用自动分配对话", + "DEFAULT_RULES_TITLE": "默认分配规则", + "DEFAULT_RULES_DESCRIPTION": "所有对话使用默认分配方式", + "DEFAULT_RULE_1": "优先分配最早创建的对话", + "DEFAULT_RULE_2": "轮询分配", + "CUSTOMIZE_WITH_POLICY": "使用分配策略自定义", + "USING_POLICY": "此收件箱正在使用自定义分配策略", + "CUSTOMIZE_POLICY": "自定义分配策略", + "DELETE_POLICY": "删除策略", + "POLICY_LABEL": "分配策略", + "ASSIGNMENT_ORDER_LABEL": "分配顺序", + "ASSIGNMENT_METHOD_LABEL": "分配方式", "POLICY_STATUS": { - "ACTIVE": "状态", - "INACTIVE": "Inactive" + "ACTIVE": "启用", + "INACTIVE": "停用" }, "PRIORITY": { - "EARLIEST_CREATED": "Earliest created", - "LONGEST_WAITING": "Longest waiting" + "EARLIEST_CREATED": "最早创建", + "LONGEST_WAITING": "等待时间最长" }, "METHOD": { - "ROUND_ROBIN": "Round robin", - "BALANCED": "Balanced assignment" + "ROUND_ROBIN": "轮询分配", + "BALANCED": "均衡分配" }, - "UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan", - "UPGRADE_TO_BUSINESS": "Upgrade to Business", - "DEFAULT_POLICY_LINKED": "Default policy linked", - "DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.", - "LINK_EXISTING_POLICY": "Link existing policy", - "CREATE_NEW_POLICY": "Create new policy", - "NO_POLICIES": "No assignment policies found", - "VIEW_ALL_POLICIES": "View all policies", - "CURRENT_BEHAVIOR": "Currently using default assignment behavior:", - "LINK_SUCCESS": "Assignment policy linked successfully", - "LINK_ERROR": "Failed to link assignment policy" + "UPGRADE_PROMPT": "自定义分配策略适用于商业版套餐", + "UPGRADE_TO_BUSINESS": "升级到商业版", + "DEFAULT_POLICY_LINKED": "已关联默认策略", + "DEFAULT_POLICY_DESCRIPTION": "关联自定义分配策略,以配置此收件箱如何将对话分配给客服。", + "LINK_EXISTING_POLICY": "关联现有策略", + "CREATE_NEW_POLICY": "创建新策略", + "NO_POLICIES": "未找到分配策略", + "VIEW_ALL_POLICIES": "查看全部策略", + "CURRENT_BEHAVIOR": "当前使用默认分配方式:", + "LINK_SUCCESS": "分配策略关联成功", + "LINK_ERROR": "分配策略关联失败" }, "ASSIGNMENT_POLICY": { - "DELETE_CONFIRM_TITLE": "Delete assignment policy?", - "DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.", + "DELETE_CONFIRM_TITLE": "删除分配策略?", + "DELETE_CONFIRM_MESSAGE": "确定要从此收件箱移除该分配策略吗?移除后,收件箱将恢复使用默认分配规则。", "CANCEL": "取消", "CONFIRM_DELETE": "删除", - "DELETE_SUCCESS": "Assignment policy removed successfully", - "DELETE_ERROR": "Failed to remove assignment policy" + "DELETE_SUCCESS": "分配策略已移除", + "DELETE_ERROR": "移除分配策略失败" }, "FACEBOOK_REAUTHORIZE": { "TITLE": "重新授权", @@ -1175,15 +1181,54 @@ } }, "SHANGWUTONG_SETTINGS": { - "RUNTIME_STATUS": "Connector 运行状态", - "RUNTIME_STATUS_HELP": "显示 Connector 最近回报的连接、在线与凭据状态。", + "RUNTIME_STATUS": "连接器运行状态", + "RUNTIME_STATUS_HELP": "显示连接器最近回报的连接、在线与凭据状态。", "REFRESH": "刷新状态", "DESIRED_PRESENCE": "期望在线状态", - "DESIRED_PRESENCE_HELP": "状态在线切换;选择 offline 只会退出当前商务通账号。", + "DESIRED_PRESENCE_HELP": "状态在线切换;选择离线只会退出当前商务通账号。", + "PRESENCE": { + "ONLINE": "在线", + "BUSY": "忙碌", + "AWAY": "离开", + "OFFLINE": "离线" + }, + "STATUS_FIELDS": { + "CONNECTION_STATUS": "连接状态", + "ACTUAL_PRESENCE": "当前在线状态", + "CREDENTIAL_STATUS": "凭据状态", + "LAST_HEARTBEAT_AT": "最后心跳时间" + }, + "STATUS_VALUES": { + "UNKNOWN": "未知", + "CONNECTION": { + "PENDING": "等待连接", + "LOGGING_IN": "正在登录", + "CONNECTED": "已连接", + "DEGRADED": "连接异常", + "RELOGIN_REQUIRED": "需要重新登录", + "VERIFICATION_REQUIRED": "需要验证", + "AUTH_FAILED": "认证失败", + "DISABLED": "已禁用", + "OFFLINE": "已离线" + }, + "PRESENCE": { + "ONLINE": "在线", + "BUSY": "忙碌", + "AWAY": "离开", + "OFFLINE": "离线" + }, + "CREDENTIAL": { + "PENDING": "等待验证", + "VERIFYING": "正在验证", + "APPLIED": "已生效", + "REJECTED": "验证失败", + "VERIFICATION_REQUIRED": "需要验证" + } + }, "PASSWORD": "修改密码", "PASSWORD_HELP": "留空表示保持原密码。新密码在线验证成功后才替换当前可用凭据。", - "WEBHOOK_URL": "Connector Webhook 地址", - "WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个 Connector 地址。" + "WEBHOOK_URL": "连接器 Webhook 地址", + "WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个连接器地址。" }, "CHANNELS": { "MESSENGER": "Messenger", diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json index 035753e8..1a94f8bb 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json @@ -322,7 +322,7 @@ "CAPTAIN_INBOXES": "收件箱", "CAPTAIN_SETTINGS": "设置", "HOME": "首页", - "AGENTS": "客服代理", + "AGENTS": "客服", "AGENT_BOTS": "机器人", "AUDIT_LOGS": "审计日志", "INBOXES": "收件箱", diff --git a/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js b/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js index 8face00d..c4a82a89 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js +++ b/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js @@ -102,10 +102,7 @@ const portalRoutes = [ { path: getPortalRoute(':navigationPath'), name: 'portals_index', - meta: { - featureFlag: FEATURE_FLAGS.HELP_CENTER, - permissions: ['administrator', 'knowledge_base_manage'], - }, + meta, component: PortalsIndex, }, ]; diff --git a/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.spec.js new file mode 100644 index 00000000..fbb5a913 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.spec.js @@ -0,0 +1,22 @@ +import helpcenterRoutes from './helpcenter.routes'; + +const findRoute = name => { + const [rootRoute] = helpcenterRoutes.routes; + return rootRoute.children.find(route => route.name === name); +}; + +describe('Help center routes', () => { + it('allows agents to enter the help center router', () => { + expect(findRoute('portals_index').meta.permissions).toContain('agent'); + }); + + it('keeps article routes accessible to agents', () => { + expect(findRoute('portals_articles_index').meta.permissions).toContain( + 'agent' + ); + }); + + it('keeps portal creation restricted to administrators or custom roles', () => { + expect(findRoute('portals_new').meta.permissions).not.toContain('agent'); + }); +}); diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/agents/TemporaryPasswordDialog.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/agents/TemporaryPasswordDialog.vue index d25a5922..8ad24872 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/agents/TemporaryPasswordDialog.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/agents/TemporaryPasswordDialog.vue @@ -29,8 +29,12 @@ const open = () => dialogRef.value?.open(); const close = () => dialogRef.value?.close(); const copyPassword = async password => { - await copyTextToClipboard(password); - useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS')); + try { + await copyTextToClipboard(password); + useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS')); + } catch { + useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_FAILED')); + } }; defineExpose({ open, close }); diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue index 904764f5..4e2c92f6 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue @@ -143,10 +143,18 @@ export default { diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue index 2915ceb7..426b1b84 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue @@ -32,6 +32,13 @@ export default { this.fetchHealth(); }, methods: { + statusLabel(type, value, fallback) { + const status = value || fallback; + const key = `INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_VALUES.${type}.${status.toUpperCase()}`; + return this.$te(key) + ? this.$t(key) + : this.$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_VALUES.UNKNOWN'); + }, async fetchHealth() { this.isLoadingHealth = true; try { @@ -88,43 +95,65 @@ export default {
- connection_status + {{ + $t( + 'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CONNECTION_STATUS' + ) + }}
{{ - health?.connection_status || - inbox.connection_status || - 'pending' + statusLabel( + 'CONNECTION', + health?.connection_status || inbox.connection_status, + 'pending' + ) }}
- actual_presence + {{ + $t( + 'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.ACTUAL_PRESENCE' + ) + }}
{{ - health?.actual_presence || - inbox.actual_presence || - 'offline' + statusLabel( + 'PRESENCE', + health?.actual_presence || inbox.actual_presence, + 'offline' + ) }}
- credential_status + {{ + $t( + 'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CREDENTIAL_STATUS' + ) + }}
{{ - health?.credential_status || - inbox.credential_status || - 'pending' + statusLabel( + 'CREDENTIAL', + health?.credential_status || inbox.credential_status, + 'pending' + ) }}
- last_heartbeat_at + {{ + $t( + 'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.LAST_HEARTBEAT_AT' + ) + }}
{{ @@ -156,10 +185,18 @@ export default { " > diff --git a/frontend/app/javascript/shared/helpers/clipboard.js b/frontend/app/javascript/shared/helpers/clipboard.js index ca9166a8..096428ec 100644 --- a/frontend/app/javascript/shared/helpers/clipboard.js +++ b/frontend/app/javascript/shared/helpers/clipboard.js @@ -33,6 +33,7 @@ export const copyTextToClipboard = async data => { document.body.appendChild(textarea); try { + textarea.focus({ preventScroll: true }); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); diff --git a/frontend/app/javascript/shared/helpers/specs/clipboard.spec.js b/frontend/app/javascript/shared/helpers/specs/clipboard.spec.js index 412245bc..2c86df86 100644 --- a/frontend/app/javascript/shared/helpers/specs/clipboard.spec.js +++ b/frontend/app/javascript/shared/helpers/specs/clipboard.spec.js @@ -2,6 +2,7 @@ import { copyTextToClipboard, handleOtpPaste } from '../clipboard'; const mockWriteText = vi.fn(); const mockExecCommand = vi.fn(); +const originalCreateElement = document.createElement.bind(document); const setClipboard = clipboard => { Object.defineProperty(navigator, 'clipboard', { @@ -148,9 +149,16 @@ describe('copyTextToClipboard', () => { it('falls back when clipboard API is not available', async () => { setClipboard(undefined); + const focus = vi.fn(); + vi.spyOn(document, 'createElement').mockImplementation(tagName => { + const element = originalCreateElement(tagName); + if (tagName === 'textarea') element.focus = focus; + return element; + }); await copyTextToClipboard('test'); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(mockExecCommand).toHaveBeenCalledWith('copy'); expect(document.querySelector('textarea')).toBeNull(); });