fix: 修复客服权限与收件箱配置问题

This commit is contained in:
Rogee
2026-08-08 19:52:18 +08:00
parent dc2a6577bf
commit 518f9c5d6b
20 changed files with 385 additions and 70 deletions
@@ -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)
}
@@ -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
@@ -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) {
+19
View File
@@ -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
+39 -2
View File
@@ -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)
}
}
@@ -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)
@@ -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")
@@ -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', () => {
@@ -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...",
@@ -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",
@@ -104,6 +104,7 @@
"RESET_DESC": "请立即复制并安全地将此新密码交给客服。旧密码已失效,关闭后将无法再次查看。",
"COPY": "复制密码",
"COPY_SUCCESS": "密码已复制到剪贴板",
"COPY_FAILED": "无法复制密码,请手动选择并复制。",
"DONE": "我已保存"
},
"SEARCH_PLACEHOLDER": "搜索客服代表...",
@@ -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",
@@ -322,7 +322,7 @@
"CAPTAIN_INBOXES": "收件箱",
"CAPTAIN_SETTINGS": "设置",
"HOME": "首页",
"AGENTS": "客服代理",
"AGENTS": "客服",
"AGENT_BOTS": "机器人",
"AUDIT_LOGS": "审计日志",
"INBOXES": "收件箱",
@@ -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,
},
];
@@ -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');
});
});
@@ -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 });
@@ -143,10 +143,18 @@ export default {
<label>
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.DESIRED_PRESENCE') }}
<select v-model="desiredPresence">
<option value="online">online</option>
<option value="busy">busy</option>
<option value="away">away</option>
<option value="offline">offline</option>
<option value="online">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.ONLINE') }}
</option>
<option value="busy">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.BUSY') }}
</option>
<option value="away">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.AWAY') }}
</option>
<option value="offline">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.OFFLINE') }}
</option>
</select>
</label>
@@ -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 {
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
connection_status
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CONNECTION_STATUS'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.connection_status ||
inbox.connection_status ||
'pending'
statusLabel(
'CONNECTION',
health?.connection_status || inbox.connection_status,
'pending'
)
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
actual_presence
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.ACTUAL_PRESENCE'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.actual_presence ||
inbox.actual_presence ||
'offline'
statusLabel(
'PRESENCE',
health?.actual_presence || inbox.actual_presence,
'offline'
)
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
credential_status
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CREDENTIAL_STATUS'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.credential_status ||
inbox.credential_status ||
'pending'
statusLabel(
'CREDENTIAL',
health?.credential_status || inbox.credential_status,
'pending'
)
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
last_heartbeat_at
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.LAST_HEARTBEAT_AT'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
@@ -156,10 +185,18 @@ export default {
"
>
<select v-model="desiredPresence" class="w-full">
<option value="online">online</option>
<option value="busy">busy</option>
<option value="away">away</option>
<option value="offline">offline</option>
<option value="online">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.ONLINE') }}
</option>
<option value="busy">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.BUSY') }}
</option>
<option value="away">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.AWAY') }}
</option>
<option value="offline">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.OFFLINE') }}
</option>
</select>
</SettingsFieldSection>
@@ -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);
@@ -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();
});