fix(ws): 发送 WebSocket 协议级 Ping 控制帧防止 60s 后连接断开

writePump 只发 ActionCable 文本 JSON ping,从不发 websocket.PingMessage,
导致 readPump 的 PongHandler 永远不触发,ReadDeadline(60s) 到期后连接
被强制关闭。前端每 60 秒弹出"离线""重连"通知。

修复:在 ticker 中先发 PingMessage 控制帧(浏览器自动回 Pong 重置
ReadDeadline),再发 ActionCable 文本 ping(保活 ConnectionMonitor)。

同时包含此前会话中的其他变更:
- 移除安全设置(SAML)页面及路由
- 新增超级管理员控制台前端页面
- platform agent_bots 路由使用 Platform* handler
- 超级管理员入口改为 SPA 内路由跳转
This commit is contained in:
Rogee
2026-07-30 16:55:44 +08:00
parent 1fb95e4151
commit cd82d079e3
26 changed files with 2421 additions and 199 deletions
+14 -2
View File
@@ -194,7 +194,19 @@ func (h *Handler) writePump(client *Client) {
}
case <-ticker.C:
// Send ActionCable-level ping message (JSON text frame).
// Send WebSocket protocol-level ping control frame.
// The browser automatically responds with a Pong, which triggers
// the PongHandler in readPump and resets the ReadDeadline.
// Without this, the ReadDeadline (PongWait=60s) expires and the
// connection is forcibly closed, causing the client to show
// "offline" / "reconnecting" notifications every ~60 seconds.
client.Conn.SetWriteDeadline(time.Now().Add(WriteWait))
if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
logger.L().Errorf("ws: ws-ping control frame failed for user=%d: %v", client.UserID, err)
return
}
// Also send ActionCable-level ping message (JSON text frame).
// The JS ConnectionMonitor expects periodic ping messages to
// keep the connection alive (staleThreshold = 6s by default).
pingMsg, _ := json.Marshal(PingFrame{
@@ -203,7 +215,7 @@ func (h *Handler) writePump(client *Client) {
})
client.Conn.SetWriteDeadline(time.Now().Add(WriteWait))
if err := client.Conn.WriteMessage(websocket.TextMessage, pingMsg); err != nil {
logger.L().Errorf("ws: ping write failed for user=%d: %v", client.UserID, err)
logger.L().Errorf("ws: actioncable ping write failed for user=%d: %v", client.UserID, err)
return
}
}
+6 -5
View File
@@ -2040,11 +2040,12 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
// Agent Bot CRUD (super-admin, global bots without account_id)
// Reference: Chatwoot namespace :agent_bots under :platform_app
g.GET("/agent_bots", h.AgentBot.List)
g.POST("/agent_bots", h.AgentBot.Create)
g.GET("/agent_bots/:id", h.AgentBot.Get)
g.PUT("/agent_bots/:id", h.AgentBot.Update)
g.DELETE("/agent_bots/:id", h.AgentBot.Delete)
// Use Platform* handlers — these don't require account_id from URL params.
g.GET("/agent_bots", h.AgentBot.PlatformList)
g.POST("/agent_bots", h.AgentBot.PlatformCreate)
g.GET("/agent_bots/:id", h.AgentBot.PlatformGet)
g.PUT("/agent_bots/:id", h.AgentBot.PlatformUpdate)
g.DELETE("/agent_bots/:id", h.AgentBot.PlatformDelete)
g.POST("/agent_bots/:id/reset_token", h.AgentBot.ResetToken)
g.POST("/agent_bots/:id/reset_secret", h.AgentBot.ResetSecret)
g.POST("/agent_bots/:id/delete_avatar", h.AgentBot.DeleteAvatar)
@@ -0,0 +1,110 @@
/* global axios */
/**
* Platform API client for Super Admin endpoints.
* All endpoints are under /platform/api/v1/ and require super_admin auth.
*/
class PlatformAPI {
constructor() {
this.baseURL = '/platform/api/v1';
}
// ── Accounts ──────────────────────────────────────────
getAccounts(params = {}) {
return axios.get(`${this.baseURL}/accounts`, { params });
}
getAccount(id) {
return axios.get(`${this.baseURL}/accounts/${id}`);
}
createAccount(data) {
return axios.post(`${this.baseURL}/accounts`, data);
}
updateAccount(id, data) {
return axios.patch(`${this.baseURL}/accounts/${id}`, data);
}
deleteAccount(id) {
return axios.delete(`${this.baseURL}/accounts/${id}`);
}
// ── Users ─────────────────────────────────────────────
getUsers(params = {}) {
return axios.get(`${this.baseURL}/users`, { params });
}
getUser(id) {
return axios.get(`${this.baseURL}/users/${id}`);
}
createUser(data) {
return axios.post(`${this.baseURL}/users`, data);
}
updateUser(id, data) {
return axios.patch(`${this.baseURL}/users/${id}`, data);
}
deleteUser(id) {
return axios.delete(`${this.baseURL}/users/${id}`);
}
// ── Account Users ─────────────────────────────────────
getAccountUsers(accountId) {
return axios.get(
`${this.baseURL}/accounts/${accountId}/account_users`
);
}
createAccountUser(accountId, data) {
return axios.post(
`${this.baseURL}/accounts/${accountId}/account_users`,
data
);
}
deleteAccountUser(accountId, userId) {
return axios.delete(
`${this.baseURL}/accounts/${userId}/account_users`,
{ data: { user_id: userId } }
);
}
// ── Agent Bots (platform-level) ───────────────────────
getAgentBots() {
return axios.get(`${this.baseURL}/agent_bots`);
}
createAgentBot(data) {
return axios.post(`${this.baseURL}/agent_bots`, data);
}
updateAgentBot(id, data) {
return axios.put(`${this.baseURL}/agent_bots/${id}`, data);
}
deleteAgentBot(id) {
return axios.delete(`${this.baseURL}/agent_bots/${id}`);
}
// ── Installation Config ───────────────────────────────
getInstallationConfigs(params = {}) {
return axios.get(`${this.baseURL}/installation_configs`, { params });
}
createInstallationConfig(data) {
return axios.post(`${this.baseURL}/installation_configs`, data);
}
updateInstallationConfig(id, data) {
return axios.put(`${this.baseURL}/installation_configs/${id}`, data);
}
deleteInstallationConfig(id) {
return axios.delete(`${this.baseURL}/installation_configs/${id}`);
}
}
export default new PlatformAPI();
@@ -783,12 +783,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-workflow',
to: accountScopedRoute('conversation_workflow_index'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
icon: 'i-lucide-shield',
to: accountScopedRoute('security_settings_index'),
},
{
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
@@ -104,9 +104,7 @@ const menuItems = computed(() => {
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
icon: 'i-lucide-castle',
link: '/super_admin',
nativeLink: true,
target: '_blank',
link: { name: 'super_admin_dashboard' },
},
{
show: true,
@@ -35,6 +35,7 @@ import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import superAdmin from './superAdmin.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
@@ -79,6 +80,7 @@ export default {
...signup,
...sla,
...snooze,
...superAdmin,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
@@ -381,7 +381,6 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security",
"CAPTAIN_AI": "Copilot Configuration",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
@@ -568,78 +567,6 @@
}
}
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML_DISABLED_MESSAGE": "SAML SSO is currently disabled. Please contact your administrator to enable this feature.",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Copied to clipboard",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CONVERSATION_WORKFLOW": {
"INDEX": {
"HEADER": {
@@ -0,0 +1,164 @@
{
"SUPER_ADMIN": {
"SIDEBAR": {
"TITLE": "Super Admin",
"DASHBOARD": "Dashboard",
"ACCOUNTS": "Accounts",
"USERS": "Users",
"AGENT_BOTS": "Agent Bots",
"CONFIGS": "Installation Config",
"BACK_TO_DASHBOARD": "Back to Dashboard"
},
"NO_ACCESS": {
"TITLE": "Access Denied",
"MESSAGE": "This page is only accessible to super admins."
},
"COMMON": {
"EDIT": "Edit",
"DELETE": "Delete",
"CONFIRM_DELETE": "Confirm Delete",
"CANCEL": "Cancel"
},
"DASHBOARD": {
"TITLE": "Platform Dashboard",
"DESCRIPTION": "Platform-wide statistics and overview",
"STATS": {
"ACCOUNTS": "Total Accounts",
"USERS": "Total Users"
},
"RECENT_ACCOUNTS": "Recent Accounts"
},
"ACCOUNTS": {
"TITLE": "Account Management",
"DESCRIPTION": "Manage all accounts on the platform",
"SEARCH": "Search accounts...",
"EMPTY": "No accounts found",
"TABLE": {
"NAME": "Name",
"STATUS": "Status",
"ACTIONS": "Actions"
},
"CREATE": {
"TITLE": "Create Account",
"SUCCESS": "Account created successfully"
},
"EDIT": {
"TITLE": "Edit Account",
"SUCCESS": "Account updated successfully"
},
"DELETE": {
"TITLE": "Delete Account",
"MESSAGE": "Are you sure you want to delete account \"{name}\"? This action cannot be undone.",
"SUCCESS": "Account deleted successfully"
},
"FIELDS": {
"NAME": "Account Name",
"NAME_PLACEHOLDER": "Enter account name"
},
"ERROR": "Operation failed, please try again"
},
"USERS": {
"TITLE": "User Management",
"DESCRIPTION": "Manage all users on the platform",
"SEARCH": "Search users...",
"EMPTY": "No users found",
"TABLE": {
"NAME": "User",
"TYPE": "Type",
"ACTIONS": "Actions"
},
"CREATE": {
"TITLE": "Create User",
"SUCCESS": "User created successfully"
},
"EDIT": {
"TITLE": "Edit User",
"SUCCESS": "User updated successfully"
},
"DELETE": {
"TITLE": "Delete User",
"MESSAGE": "Are you sure you want to delete user \"{name}\"? This action cannot be undone.",
"SUCCESS": "User deleted successfully"
},
"FIELDS": {
"NAME": "Name",
"NAME_PLACEHOLDER": "Enter user name",
"EMAIL": "Email",
"PASSWORD": "Password",
"PASSWORD_HINT": "Leave empty to keep current password",
"TYPE": "User Type",
"TYPE_USER": "Regular User",
"TYPE_SUPER_ADMIN": "Super Admin",
"TYPE_HINT": "Set to SuperAdmin to grant super admin privileges"
},
"ERROR": "Operation failed, please try again"
},
"AGENT_BOTS": {
"TITLE": "Agent Bot Management",
"DESCRIPTION": "Manage platform-level agent bots",
"SEARCH": "Search agent bots...",
"EMPTY": "No agent bots found",
"GLOBAL_BADGE": "System Bot",
"TABLE": {
"NAME": "Bot",
"URL": "Webhook URL",
"ACTIONS": "Actions"
},
"CREATE": {
"TITLE": "Create Agent Bot",
"SUCCESS": "Agent bot created successfully"
},
"EDIT": {
"TITLE": "Edit Agent Bot",
"SUCCESS": "Agent bot updated successfully"
},
"DELETE": {
"TITLE": "Delete Agent Bot",
"MESSAGE": "Are you sure you want to delete agent bot \"{name}\"?",
"SUCCESS": "Agent bot deleted successfully"
},
"FIELDS": {
"NAME": "Name",
"DESCRIPTION": "Description",
"DESCRIPTION_PLACEHOLDER": "Enter bot description",
"URL": "Webhook URL",
"TYPE": "Type",
"TYPE_HINT": "Defaults to webhook"
},
"ERROR": "Operation failed, please try again"
},
"CONFIGS": {
"TITLE": "Installation Config",
"DESCRIPTION": "Manage platform-wide key-value configuration",
"SEARCH": "Search configs...",
"EMPTY": "No configuration items found",
"TABLE": {
"NAME": "Name",
"VALUE": "Value",
"ACTIONS": "Actions"
},
"CREATE": {
"TITLE": "Create Config",
"SUCCESS": "Configuration created successfully"
},
"EDIT": {
"TITLE": "Edit Config",
"SUCCESS": "Configuration updated successfully"
},
"DELETE": {
"TITLE": "Delete Config",
"MESSAGE": "Are you sure you want to delete config \"{name}\"?",
"SUCCESS": "Configuration deleted successfully"
},
"FIELDS": {
"NAME": "Config Name",
"NAME_HINT": "Use uppercase snake_case, e.g. INSTALLATION_NAME",
"VALUE": "Config Value",
"VALUE_PLACEHOLDER": "Enter config value",
"LOCKED": "Locked",
"LOCKED_HINT": "When locked, non-super-admins cannot modify"
},
"ERROR": "Operation failed, please try again"
}
}
}
@@ -35,6 +35,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import superAdmin from './superAdmin.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
@@ -76,6 +77,7 @@ export default {
...settings,
...signup,
...sla,
...superAdmin,
...teamsSettings,
...whatsappTemplates,
};
@@ -381,7 +381,6 @@
"INFO_SHORT": "当您不使用应用程序时自动标记离线。"
},
"DOCS": "阅读文档",
"SECURITY": "安全",
"CAPTAIN_AI": "Copilot 配置",
"CONVERSATION_WORKFLOW": "会话工作流"
},
@@ -568,78 +567,6 @@
}
}
},
"SECURITY_SETTINGS": {
"TITLE": "安全",
"DESCRIPTION": "管理您的账户安全设置。",
"LINK_TEXT": "了解更多关于 SAML SSO 的信息",
"SAML_DISABLED_MESSAGE": "SAML SSO is currently disabled. Please contact your administrator to enable this feature.",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "已复制到剪贴板",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "更新 SAML 设置",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "请联系您的管理员进行升级。"
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "立即升级",
"CANCEL_ANYTIME": "您可以随时更改或取消您的计划"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CONVERSATION_WORKFLOW": {
"INDEX": {
"HEADER": {
@@ -0,0 +1,164 @@
{
"SUPER_ADMIN": {
"SIDEBAR": {
"TITLE": "超级管理控制台",
"DASHBOARD": "仪表盘",
"ACCOUNTS": "账户管理",
"USERS": "用户管理",
"AGENT_BOTS": "机器人管理",
"CONFIGS": "安装配置",
"BACK_TO_DASHBOARD": "返回工作台"
},
"NO_ACCESS": {
"TITLE": "无权访问",
"MESSAGE": "此页面仅限超级管理员访问。"
},
"COMMON": {
"EDIT": "编辑",
"DELETE": "删除",
"CONFIRM_DELETE": "确认删除",
"CANCEL": "取消"
},
"DASHBOARD": {
"TITLE": "平台仪表盘",
"DESCRIPTION": "平台全局统计与概览",
"STATS": {
"ACCOUNTS": "账户总数",
"USERS": "用户总数"
},
"RECENT_ACCOUNTS": "最近账户"
},
"ACCOUNTS": {
"TITLE": "账户管理",
"DESCRIPTION": "管理平台上的所有账户",
"SEARCH": "搜索账户...",
"EMPTY": "暂无账户",
"TABLE": {
"NAME": "账户名称",
"STATUS": "状态",
"ACTIONS": "操作"
},
"CREATE": {
"TITLE": "创建账户",
"SUCCESS": "账户创建成功"
},
"EDIT": {
"TITLE": "编辑账户",
"SUCCESS": "账户更新成功"
},
"DELETE": {
"TITLE": "删除账户",
"MESSAGE": "确定要删除账户「{name}」吗?此操作不可撤销。",
"SUCCESS": "账户删除成功"
},
"FIELDS": {
"NAME": "账户名称",
"NAME_PLACEHOLDER": "输入账户名称"
},
"ERROR": "操作失败,请重试"
},
"USERS": {
"TITLE": "用户管理",
"DESCRIPTION": "管理平台上的所有用户",
"SEARCH": "搜索用户...",
"EMPTY": "暂无用户",
"TABLE": {
"NAME": "用户",
"TYPE": "类型",
"ACTIONS": "操作"
},
"CREATE": {
"TITLE": "创建用户",
"SUCCESS": "用户创建成功"
},
"EDIT": {
"TITLE": "编辑用户",
"SUCCESS": "用户更新成功"
},
"DELETE": {
"TITLE": "删除用户",
"MESSAGE": "确定要删除用户「{name}」吗?此操作不可撤销。",
"SUCCESS": "用户删除成功"
},
"FIELDS": {
"NAME": "姓名",
"NAME_PLACEHOLDER": "输入用户姓名",
"EMAIL": "邮箱",
"PASSWORD": "密码",
"PASSWORD_HINT": "留空则不修改密码",
"TYPE": "用户类型",
"TYPE_USER": "普通用户",
"TYPE_SUPER_ADMIN": "超级管理员",
"TYPE_HINT": "设置为 SuperAdmin 可授予超级管理员权限"
},
"ERROR": "操作失败,请重试"
},
"AGENT_BOTS": {
"TITLE": "机器人管理",
"DESCRIPTION": "管理平台级 Agent Bot",
"SEARCH": "搜索机器人...",
"EMPTY": "暂无机器人",
"GLOBAL_BADGE": "系统机器人",
"TABLE": {
"NAME": "机器人",
"URL": "Webhook URL",
"ACTIONS": "操作"
},
"CREATE": {
"TITLE": "创建机器人",
"SUCCESS": "机器人创建成功"
},
"EDIT": {
"TITLE": "编辑机器人",
"SUCCESS": "机器人更新成功"
},
"DELETE": {
"TITLE": "删除机器人",
"MESSAGE": "确定要删除机器人「{name}」吗?",
"SUCCESS": "机器人删除成功"
},
"FIELDS": {
"NAME": "名称",
"DESCRIPTION": "描述",
"DESCRIPTION_PLACEHOLDER": "输入机器人描述",
"URL": "Webhook URL",
"TYPE": "类型",
"TYPE_HINT": "默认为 webhook"
},
"ERROR": "操作失败,请重试"
},
"CONFIGS": {
"TITLE": "安装配置",
"DESCRIPTION": "管理平台全局键值配置",
"SEARCH": "搜索配置...",
"EMPTY": "暂无配置项",
"TABLE": {
"NAME": "配置名称",
"VALUE": "值",
"ACTIONS": "操作"
},
"CREATE": {
"TITLE": "创建配置",
"SUCCESS": "配置创建成功"
},
"EDIT": {
"TITLE": "编辑配置",
"SUCCESS": "配置更新成功"
},
"DELETE": {
"TITLE": "删除配置",
"MESSAGE": "确定要删除配置「{name}」吗?",
"SUCCESS": "配置删除成功"
},
"FIELDS": {
"NAME": "配置名称",
"NAME_HINT": "使用大写下划线格式,如 INSTALLATION_NAME",
"VALUE": "配置值",
"VALUE_PLACEHOLDER": "输入配置值",
"LOCKED": "锁定",
"LOCKED_HINT": "锁定后非超级管理员不可修改"
},
"ERROR": "操作失败,请重试"
}
}
}
@@ -9,6 +9,7 @@ import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
import { routes as captainRoutes } from './captain/captain.routes';
import { routes as superAdminRoutes } from './super_admin/superAdmin.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
@@ -47,6 +48,7 @@ export default {
...notificationRoutes,
...helpcenterRoutes.routes,
...campaignsRoutes.routes,
...superAdminRoutes,
],
},
{
@@ -1,35 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
const SecurityIndex = {
render() {
return null;
},
};
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/security'),
meta: {
permissions: ['administrator'],
},
component: SettingsWrapper,
props: {
headerTitle: 'SECURITY_SETTINGS.TITLE',
icon: 'i-lucide-shield',
showNewButton: false,
},
children: [
{
path: '',
name: 'security_settings_index',
component: SecurityIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],
};
@@ -23,7 +23,6 @@ import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
@@ -64,7 +63,6 @@ export default {
...teams.routes,
...customRoles.routes,
...profile.routes,
...security.routes,
...conversationWorkflow.routes,
...captain.routes,
],
@@ -0,0 +1,136 @@
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { isSuperAdminUser } from '../settings/captain/utils';
import Logo from 'next/icon/Logo.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const accountId = useMapGetter('getCurrentAccountId');
const isSuperAdmin = computed(() => isSuperAdminUser(currentUser.value));
const navItems = computed(() => [
{
name: 'super_admin_dashboard',
label: t('SUPER_ADMIN.SIDEBAR.DASHBOARD'),
icon: 'i-lucide-layout-dashboard',
},
{
name: 'super_admin_accounts',
label: t('SUPER_ADMIN.SIDEBAR.ACCOUNTS'),
icon: 'i-lucide-building-2',
},
{
name: 'super_admin_users',
label: t('SUPER_ADMIN.SIDEBAR.USERS'),
icon: 'i-lucide-users',
},
{
name: 'super_admin_agent_bots',
label: t('SUPER_ADMIN.SIDEBAR.AGENT_BOTS'),
icon: 'i-lucide-bot',
},
{
name: 'super_admin_configs',
label: t('SUPER_ADMIN.SIDEBAR.CONFIGS'),
icon: 'i-lucide-settings-2',
},
]);
const navigateTo = name => {
router.push({ name, params: { accountId: accountId.value } });
};
const backToDashboard = () => {
router.push({
name: 'home',
params: { accountId: accountId.value },
});
};
const isActive = name => {
return route.name === name;
};
</script>
<template>
<div
v-if="isSuperAdmin"
class="flex w-full h-full overflow-hidden bg-n-surface-1"
>
<!-- Super Admin Sidebar -->
<aside
class="flex flex-col flex-shrink-0 w-56 h-full bg-n-background border-r border-n-weak"
>
<!-- Logo -->
<div class="flex items-center gap-2 px-4 h-14 border-b border-n-weak">
<div class="grid place-content-center size-6">
<Logo class="size-4" />
</div>
<span class="text-sm font-semibold text-n-slate-12 truncate">
{{ t('SUPER_ADMIN.SIDEBAR.TITLE') }}
</span>
</div>
<!-- Nav -->
<nav class="flex-1 px-2 py-3 overflow-y-auto no-scrollbar">
<ul class="flex flex-col gap-0.5 m-0 list-none">
<li v-for="item in navItems" :key="item.name">
<button
class="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors duration-100"
:class="
isActive(item.name)
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
"
@click="navigateTo(item.name)"
>
<span class="text-base flex-shrink-0" :class="item.icon" />
<span class="truncate">{{ item.label }}</span>
</button>
</li>
</ul>
</nav>
<!-- Back to Dashboard -->
<div class="p-2 border-t border-n-weak">
<Button
:label="t('SUPER_ADMIN.SIDEBAR.BACK_TO_DASHBOARD')"
icon="i-lucide-arrow-left"
variant="ghost"
color="slate"
size="sm"
class="w-full"
@click="backToDashboard"
/>
</div>
</aside>
<!-- Page Content -->
<main class="flex-1 overflow-y-auto">
<router-view />
</main>
</div>
<!-- Not super admin -->
<div
v-else
class="flex flex-col items-center justify-center w-full h-full gap-2 bg-n-surface-1"
>
<span class="i-lucide-shield-x size-12 text-n-slate-10" />
<p class="text-lg font-medium text-n-slate-12">
{{ t('SUPER_ADMIN.NO_ACCESS.TITLE') }}
</p>
<p class="text-sm text-n-slate-11">
{{ t('SUPER_ADMIN.NO_ACCESS.MESSAGE') }}
</p>
</div>
</template>
@@ -0,0 +1,236 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import PageHeader from '../components/PageHeader.vue';
import FormDialog from '../components/FormDialog.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
const store = useStore();
const { t } = useI18n();
const accounts = useMapGetter('platform/getAccounts');
const uiFlags = useMapGetter('platform/getUIFlags');
const searchQuery = ref('');
const modalType = ref(MODAL_TYPES.CREATE);
const selectedAccount = ref({});
const formDialogRef = ref(null);
const deleteDialogRef = ref(null);
const loading = ref({});
const isLoading = computed(() => uiFlags.value?.isFetchingAccounts);
const filteredAccounts = computed(() => {
const query = searchQuery.value.trim();
if (!query) return accounts.value;
return picoSearch(accounts.value, query, ['name']);
});
const dialogTitle = computed(() =>
modalType.value === MODAL_TYPES.CREATE
? t('SUPER_ADMIN.ACCOUNTS.CREATE.TITLE')
: t('SUPER_ADMIN.ACCOUNTS.EDIT.TITLE')
);
const formFields = computed(() => [
{
key: 'name',
label: t('SUPER_ADMIN.ACCOUNTS.FIELDS.NAME'),
placeholder: t('SUPER_ADMIN.ACCOUNTS.FIELDS.NAME_PLACEHOLDER'),
required: true,
},
]);
const openCreateModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedAccount.value = {};
formDialogRef.value?.open();
};
const openEditModal = account => {
modalType.value = MODAL_TYPES.EDIT;
selectedAccount.value = { ...account };
formDialogRef.value?.open();
};
const openDeleteDialog = account => {
selectedAccount.value = account;
deleteDialogRef.value?.open();
};
const handleConfirm = async data => {
try {
if (modalType.value === MODAL_TYPES.CREATE) {
await store.dispatch('platform/createAccount', data);
useAlert(t('SUPER_ADMIN.ACCOUNTS.CREATE.SUCCESS'));
} else {
await store.dispatch('platform/updateAccount', {
id: selectedAccount.value.id,
data,
});
useAlert(t('SUPER_ADMIN.ACCOUNTS.EDIT.SUCCESS'));
}
formDialogRef.value?.close();
} catch (error) {
useAlert(t('SUPER_ADMIN.ACCOUNTS.ERROR'));
}
};
const confirmDelete = async () => {
loading.value[selectedAccount.value.id] = true;
try {
await store.dispatch('platform/deleteAccount', selectedAccount.value.id);
useAlert(t('SUPER_ADMIN.ACCOUNTS.DELETE.SUCCESS'));
} catch (error) {
useAlert(t('SUPER_ADMIN.ACCOUNTS.ERROR'));
} finally {
loading.value[selectedAccount.value.id] = false;
deleteDialogRef.value?.close();
}
};
onMounted(() => {
store.dispatch('platform/fetchAccounts');
});
</script>
<template>
<PageHeader
:title="t('SUPER_ADMIN.ACCOUNTS.TITLE')"
:description="t('SUPER_ADMIN.ACCOUNTS.DESCRIPTION')"
v-model:search-query="searchQuery"
:search-placeholder="t('SUPER_ADMIN.ACCOUNTS.SEARCH')"
:button-label="t('SUPER_ADMIN.ACCOUNTS.CREATE.TITLE')"
:count="accounts?.length"
@click="openCreateModal"
/>
<div class="flex-1 px-6">
<div class="w-full max-w-5xl mx-auto py-6">
<div v-if="isLoading" class="flex items-center justify-center py-20">
<Spinner />
</div>
<div
v-else-if="filteredAccounts?.length"
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
>
<BaseTable
:headers="[
t('SUPER_ADMIN.ACCOUNTS.TABLE.NAME'),
t('SUPER_ADMIN.ACCOUNTS.TABLE.STATUS'),
t('SUPER_ADMIN.ACCOUNTS.TABLE.ACTIONS'),
]"
:items="filteredAccounts"
>
<template #row="{ items }">
<BaseTableRow
v-for="account in items"
:key="account.id"
:item="account"
>
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar
:name="account.name"
:size="36"
class="flex-shrink-0"
/>
<div class="min-w-0">
<span class="text-sm text-n-slate-12 truncate block">
{{ account.name }}
</span>
<span class="text-xs text-n-slate-10 truncate block">
ID: {{ account.id }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-xs px-2 py-0.5 rounded-md"
:class="
account.status === 'active'
? 'bg-n-green-3 text-n-green-11'
: 'bg-n-amber-3 text-n-amber-11'
"
>
{{ account.status || '—' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-2 justify-end flex-shrink-0">
<Button
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[account.id]"
@click="openEditModal(account)"
/>
<Button
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[account.id]"
@click="openDeleteDialog(account)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
<div
v-else
class="flex flex-col items-center justify-center py-20 gap-2"
>
<span class="i-lucide-building-2 size-10 text-n-slate-10" />
<p class="text-sm text-n-slate-10">
{{ t('SUPER_ADMIN.ACCOUNTS.EMPTY') }}
</p>
</div>
</div>
</div>
<FormDialog
ref="formDialogRef"
:title="dialogTitle"
:fields="formFields"
:initial-data="selectedAccount"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
@confirm="handleConfirm"
/>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t('SUPER_ADMIN.ACCOUNTS.DELETE.TITLE')"
:description="
t('SUPER_ADMIN.ACCOUNTS.DELETE.MESSAGE', {
name: selectedAccount?.name,
})
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
@confirm="confirmDelete"
/>
</template>
@@ -0,0 +1,252 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import PageHeader from '../components/PageHeader.vue';
import FormDialog from '../components/FormDialog.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
const store = useStore();
const { t } = useI18n();
const agentBots = useMapGetter('platform/getPlatformAgentBots');
const uiFlags = useMapGetter('platform/getUIFlags');
const searchQuery = ref('');
const modalType = ref(MODAL_TYPES.CREATE);
const selectedBot = ref({});
const formDialogRef = ref(null);
const deleteDialogRef = ref(null);
const loading = ref({});
const isLoading = computed(() => uiFlags.value?.isFetchingAgentBots);
const filteredBots = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentBots.value;
return picoSearch(agentBots.value, query, ['name', 'description']);
});
const dialogTitle = computed(() =>
modalType.value === MODAL_TYPES.CREATE
? t('SUPER_ADMIN.AGENT_BOTS.CREATE.TITLE')
: t('SUPER_ADMIN.AGENT_BOTS.EDIT.TITLE')
);
const formFields = computed(() => [
{
key: 'name',
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.NAME'),
placeholder: 'My Bot',
required: true,
},
{
key: 'description',
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.DESCRIPTION'),
type: 'textarea',
placeholder: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.DESCRIPTION_PLACEHOLDER'),
},
{
key: 'outgoing_url',
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.URL'),
placeholder: 'https://example.com/webhook',
required: true,
},
{
key: 'bot_type',
label: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.TYPE'),
placeholder: 'webhook',
hint: t('SUPER_ADMIN.AGENT_BOTS.FIELDS.TYPE_HINT'),
},
]);
const openCreateModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
formDialogRef.value?.open();
};
const openEditModal = bot => {
modalType.value = MODAL_TYPES.EDIT;
selectedBot.value = { ...bot };
formDialogRef.value?.open();
};
const openDeleteDialog = bot => {
selectedBot.value = bot;
deleteDialogRef.value?.open();
};
const handleConfirm = async data => {
try {
if (modalType.value === MODAL_TYPES.CREATE) {
await store.dispatch('platform/createAgentBot', data);
useAlert(t('SUPER_ADMIN.AGENT_BOTS.CREATE.SUCCESS'));
} else {
await store.dispatch('platform/updateAgentBot', {
id: selectedBot.value.id,
data,
});
useAlert(t('SUPER_ADMIN.AGENT_BOTS.EDIT.SUCCESS'));
}
formDialogRef.value?.close();
} catch (error) {
useAlert(t('SUPER_ADMIN.AGENT_BOTS.ERROR'));
}
};
const confirmDelete = async () => {
loading.value[selectedBot.value.id] = true;
try {
await store.dispatch('platform/deleteAgentBot', selectedBot.value.id);
useAlert(t('SUPER_ADMIN.AGENT_BOTS.DELETE.SUCCESS'));
} catch (error) {
useAlert(t('SUPER_ADMIN.AGENT_BOTS.ERROR'));
} finally {
loading.value[selectedBot.value.id] = false;
deleteDialogRef.value?.close();
}
};
onMounted(() => {
store.dispatch('platform/fetchAgentBots');
});
</script>
<template>
<PageHeader
:title="t('SUPER_ADMIN.AGENT_BOTS.TITLE')"
:description="t('SUPER_ADMIN.AGENT_BOTS.DESCRIPTION')"
v-model:search-query="searchQuery"
:search-placeholder="t('SUPER_ADMIN.AGENT_BOTS.SEARCH')"
:button-label="t('SUPER_ADMIN.AGENT_BOTS.CREATE.TITLE')"
:count="agentBots?.length"
@click="openCreateModal"
/>
<div class="flex-1 px-6">
<div class="w-full max-w-5xl mx-auto py-6">
<div v-if="isLoading" class="flex items-center justify-center py-20">
<Spinner />
</div>
<div
v-else-if="filteredBots?.length"
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
>
<BaseTable
:headers="[
t('SUPER_ADMIN.AGENT_BOTS.TABLE.NAME'),
t('SUPER_ADMIN.AGENT_BOTS.TABLE.URL'),
t('SUPER_ADMIN.AGENT_BOTS.TABLE.ACTIONS'),
]"
:items="filteredBots"
>
<template #row="{ items }">
<BaseTableRow v-for="bot in items" :key="bot.id" :item="bot">
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="36"
class="flex-shrink-0"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm text-n-slate-12 truncate">
{{ bot.name }}
</span>
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 rounded-md py-0.5 px-1 flex-shrink-0"
>
{{ t('SUPER_ADMIN.AGENT_BOTS.GLOBAL_BADGE') }}
</span>
</div>
<span class="text-xs text-n-slate-10 truncate block">
{{ bot.description }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span class="text-sm text-n-slate-11 truncate block">
{{ bot.outgoing_url || bot.bot_config?.webhook_url || '—' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-2 justify-end flex-shrink-0">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[bot.id]"
@click="openDeleteDialog(bot)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
<div
v-else
class="flex flex-col items-center justify-center py-20 gap-2"
>
<span class="i-lucide-bot size-10 text-n-slate-10" />
<p class="text-sm text-n-slate-10">
{{ t('SUPER_ADMIN.AGENT_BOTS.EMPTY') }}
</p>
</div>
</div>
</div>
<FormDialog
ref="formDialogRef"
:title="dialogTitle"
:fields="formFields"
:initial-data="selectedBot"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
@confirm="handleConfirm"
/>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t('SUPER_ADMIN.AGENT_BOTS.DELETE.TITLE')"
:description="
t('SUPER_ADMIN.AGENT_BOTS.DELETE.MESSAGE', { name: selectedBot?.name })
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
@confirm="confirmDelete"
/>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { ref, watch } from 'vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Textarea from 'dashboard/components-next/textarea/TextArea.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
title: {
type: String,
required: true,
},
fields: {
type: Array,
required: true,
},
initialData: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
confirmLabel: {
type: String,
default: '',
},
cancelLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['confirm', 'cancel']);
const dialogRef = ref(null);
const formData = ref({});
watch(
() => props.initialData,
(val) => {
formData.value = { ...val };
},
{ immediate: true, deep: true }
);
const open = () => {
formData.value = { ...props.initialData };
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
const handleConfirm = () => {
emit('confirm', { ...formData.value });
};
const handleCancel = () => {
emit('cancel');
close();
};
const isFieldType = field => field.type === 'select';
const isTextarea = field => field.type === 'textarea';
const isCheckbox = field => field.type === 'checkbox';
</script>
<template>
<Dialog
ref="dialogRef"
:title="title"
:is-loading="isLoading"
:confirm-button-label="confirmLabel"
:cancel-button-label="cancelLabel"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<div class="flex flex-col gap-4 py-2">
<div
v-for="field in fields"
:key="field.key"
class="flex flex-col gap-1.5"
>
<label
v-if="field.label && !isCheckbox(field)"
class="text-sm font-medium text-n-slate-12"
>
{{ field.label }}
<span v-if="field.required" class="text-n-ruby-11">*</span>
</label>
<!-- Select -->
<Select
v-if="isFieldType(field)"
v-model="formData[field.key]"
:options="field.options || []"
:placeholder="field.placeholder || ''"
class="w-full"
/>
<!-- Textarea -->
<Textarea
v-else-if="isTextarea(field)"
v-model="formData[field.key]"
:placeholder="field.placeholder || ''"
:rows="field.rows || 3"
size="sm"
/>
<!-- Checkbox -->
<div
v-else-if="isCheckbox(field)"
class="flex items-center gap-2"
>
<Checkbox v-model="formData[field.key]" />
<label class="text-sm text-n-slate-12 cursor-pointer">
{{ field.label }}
</label>
</div>
<!-- Input (default) -->
<Input
v-else
v-model="formData[field.key]"
:type="field.inputType || 'text'"
:placeholder="field.placeholder || ''"
size="sm"
/>
<p v-if="field.hint" class="text-xs text-n-slate-10">
{{ field.hint }}
</p>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,84 @@
<script setup>
import { useSlots } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
count: {
type: Number,
default: null,
},
});
const emit = defineEmits(['click', 'update:searchQuery']);
const slots = useSlots();
const searchQuery = defineModel('searchQuery', { type: String, default: '' });
</script>
<template>
<header class="sticky top-0 z-10 px-6 bg-n-surface-1">
<div class="w-full max-w-5xl mx-auto">
<div
class="flex items-start justify-between w-full py-6 gap-4"
>
<div class="flex flex-col gap-1.5 min-w-0">
<h1 class="text-xl font-medium text-n-slate-12 truncate">
{{ title }}
</h1>
<p v-if="description" class="text-sm text-n-slate-11 max-w-2xl">
{{ description }}
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<Input
v-if="searchPlaceholder"
v-model="searchQuery"
:placeholder="searchPlaceholder"
class="group w-56 min-w-0 [&>input]:ltr:!pl-8 [&>input]:rtl:!pr-8 [&>input]:!rounded-[0.625rem]"
size="sm"
type="search"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-1/2 -translate-y-1/2 text-n-slate-11 group-focus-within:text-n-brand size-3.5 ltr:left-2.5 rtl:right-2.5"
/>
</template>
</Input>
<span
v-if="count !== null"
class="text-sm text-n-slate-11 flex-shrink-0"
>
{{ count }}
</span>
<Button
v-if="buttonLabel"
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
@click="emit('click')"
/>
</div>
</div>
</div>
</header>
</template>
@@ -0,0 +1,240 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import PageHeader from '../components/PageHeader.vue';
import FormDialog from '../components/FormDialog.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
const store = useStore();
const { t } = useI18n();
const configs = useMapGetter('platform/getInstallationConfigs');
const uiFlags = useMapGetter('platform/getUIFlags');
const searchQuery = ref('');
const modalType = ref(MODAL_TYPES.CREATE);
const selectedConfig = ref({});
const formDialogRef = ref(null);
const deleteDialogRef = ref(null);
const loading = ref({});
const isLoading = computed(() => uiFlags.value?.isFetchingConfigs);
const filteredConfigs = computed(() => {
const query = searchQuery.value.trim();
if (!query) return configs.value;
return picoSearch(configs.value, query, ['name', 'value']);
});
const dialogTitle = computed(() =>
modalType.value === MODAL_TYPES.CREATE
? t('SUPER_ADMIN.CONFIGS.CREATE.TITLE')
: t('SUPER_ADMIN.CONFIGS.EDIT.TITLE')
);
const formFields = computed(() => [
{
key: 'name',
label: t('SUPER_ADMIN.CONFIGS.FIELDS.NAME'),
placeholder: 'INSTALLATION_NAME',
required: true,
hint: t('SUPER_ADMIN.CONFIGS.FIELDS.NAME_HINT'),
},
{
key: 'value',
label: t('SUPER_ADMIN.CONFIGS.FIELDS.VALUE'),
type: 'textarea',
placeholder: t('SUPER_ADMIN.CONFIGS.FIELDS.VALUE_PLACEHOLDER'),
required: true,
},
{
key: 'locked',
label: t('SUPER_ADMIN.CONFIGS.FIELDS.LOCKED'),
type: 'checkbox',
hint: t('SUPER_ADMIN.CONFIGS.FIELDS.LOCKED_HINT'),
},
]);
const openCreateModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedConfig.value = {};
formDialogRef.value?.open();
};
const openEditModal = config => {
modalType.value = MODAL_TYPES.EDIT;
selectedConfig.value = { ...config };
formDialogRef.value?.open();
};
const openDeleteDialog = config => {
selectedConfig.value = config;
deleteDialogRef.value?.open();
};
const handleConfirm = async data => {
try {
if (modalType.value === MODAL_TYPES.CREATE) {
await store.dispatch('platform/createInstallationConfig', data);
useAlert(t('SUPER_ADMIN.CONFIGS.CREATE.SUCCESS'));
} else {
await store.dispatch('platform/updateInstallationConfig', {
id: selectedConfig.value.id,
data,
});
useAlert(t('SUPER_ADMIN.CONFIGS.EDIT.SUCCESS'));
}
formDialogRef.value?.close();
} catch (error) {
useAlert(t('SUPER_ADMIN.CONFIGS.ERROR'));
}
};
const confirmDelete = async () => {
loading.value[selectedConfig.value.id] = true;
try {
await store.dispatch(
'platform/deleteInstallationConfig',
selectedConfig.value.id
);
useAlert(t('SUPER_ADMIN.CONFIGS.DELETE.SUCCESS'));
} catch (error) {
useAlert(t('SUPER_ADMIN.CONFIGS.ERROR'));
} finally {
loading.value[selectedConfig.value.id] = false;
deleteDialogRef.value?.close();
}
};
onMounted(() => {
store.dispatch('platform/fetchInstallationConfigs');
});
</script>
<template>
<PageHeader
:title="t('SUPER_ADMIN.CONFIGS.TITLE')"
:description="t('SUPER_ADMIN.CONFIGS.DESCRIPTION')"
v-model:search-query="searchQuery"
:search-placeholder="t('SUPER_ADMIN.CONFIGS.SEARCH')"
:button-label="t('SUPER_ADMIN.CONFIGS.CREATE.TITLE')"
:count="configs?.length"
@click="openCreateModal"
/>
<div class="flex-1 px-6">
<div class="w-full max-w-5xl mx-auto py-6">
<div v-if="isLoading" class="flex items-center justify-center py-20">
<Spinner />
</div>
<div
v-else-if="filteredConfigs?.length"
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
>
<BaseTable
:headers="[
t('SUPER_ADMIN.CONFIGS.TABLE.NAME'),
t('SUPER_ADMIN.CONFIGS.TABLE.VALUE'),
t('SUPER_ADMIN.CONFIGS.TABLE.ACTIONS'),
]"
:items="filteredConfigs"
>
<template #row="{ items }">
<BaseTableRow
v-for="config in items"
:key="config.id"
:item="config"
>
<template #default>
<BaseTableCell class="max-w-xs">
<div class="flex items-center gap-2 min-w-0">
<span
class="text-sm font-medium text-n-slate-12 truncate"
>
{{ config.name }}
</span>
<span
v-if="config.locked"
class="i-lucide-lock text-n-amber-11 size-3.5 flex-shrink-0"
/>
</div>
</BaseTableCell>
<BaseTableCell class="max-w-xs">
<span class="text-sm text-n-slate-11 truncate block">
{{ config.value || '—' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-2 justify-end flex-shrink-0">
<Button
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[config.id]"
@click="openEditModal(config)"
/>
<Button
v-if="!config.locked"
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[config.id]"
@click="openDeleteDialog(config)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
<div
v-else
class="flex flex-col items-center justify-center py-20 gap-2"
>
<span class="i-lucide-settings-2 size-10 text-n-slate-10" />
<p class="text-sm text-n-slate-10">
{{ t('SUPER_ADMIN.CONFIGS.EMPTY') }}
</p>
</div>
</div>
</div>
<FormDialog
ref="formDialogRef"
:title="dialogTitle"
:fields="formFields"
:initial-data="selectedConfig"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
@confirm="handleConfirm"
/>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t('SUPER_ADMIN.CONFIGS.DELETE.TITLE')"
:description="
t('SUPER_ADMIN.CONFIGS.DELETE.MESSAGE', { name: selectedConfig?.name })
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
@confirm="confirmDelete"
/>
</template>
@@ -0,0 +1,143 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import PageHeader from '../components/PageHeader.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const store = useStore();
const { t } = useI18n();
const accounts = useMapGetter('platform/getAccounts');
const users = useMapGetter('platform/getUsers');
const uiFlags = useMapGetter('platform/getUIFlags');
const stats = computed(() => [
{
label: t('SUPER_ADMIN.DASHBOARD.STATS.ACCOUNTS'),
value: accounts.value?.length ?? 0,
icon: 'i-lucide-building-2',
color: 'text-n-blue-11 bg-n-blue-3',
},
{
label: t('SUPER_ADMIN.DASHBOARD.STATS.USERS'),
value: users.value?.length ?? 0,
icon: 'i-lucide-users',
color: 'text-n-green-11 bg-n-green-3',
},
]);
const isLoading = computed(
() => uiFlags.value?.isFetchingAccounts || uiFlags.value?.isFetchingUsers
);
onMounted(() => {
store.dispatch('platform/fetchAccounts');
store.dispatch('platform/fetchUsers');
});
</script>
<template>
<PageHeader
:title="t('SUPER_ADMIN.DASHBOARD.TITLE')"
:description="t('SUPER_ADMIN.DASHBOARD.DESCRIPTION')"
/>
<div class="flex-1 px-6">
<div class="w-full max-w-5xl mx-auto py-6">
<div v-if="isLoading" class="flex items-center justify-center py-20">
<Spinner />
</div>
<div v-else class="flex flex-col gap-6">
<!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4">
<div
v-for="stat in stats"
:key="stat.label"
class="flex items-center gap-4 p-5 rounded-xl bg-n-background border border-n-weak"
>
<div
class="flex items-center justify-center size-12 rounded-lg flex-shrink-0"
:class="stat.color"
>
<span class="text-xl" :class="stat.icon" />
</div>
<div class="min-w-0">
<div class="text-2xl font-semibold text-n-slate-12">
{{ stat.value }}
</div>
<div class="text-sm text-n-slate-11 truncate">
{{ stat.label }}
</div>
</div>
</div>
</div>
<!-- Recent Accounts -->
<div
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
>
<div class="px-5 py-4 border-b border-n-weak">
<h2 class="text-base font-medium text-n-slate-12">
{{ t('SUPER_ADMIN.DASHBOARD.RECENT_ACCOUNTS') }}
</h2>
</div>
<BaseTable
v-if="accounts?.length"
:headers="[
t('SUPER_ADMIN.ACCOUNTS.TABLE.NAME'),
t('SUPER_ADMIN.ACCOUNTS.TABLE.STATUS'),
]"
:items="accounts.slice(0, 5)"
>
<template #row="{ items }">
<BaseTableRow
v-for="account in items"
:key="account.id"
:item="account"
>
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar
:name="account.name"
:size="32"
class="flex-shrink-0"
/>
<span class="text-sm text-n-slate-12 truncate">
{{ account.name }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-xs px-2 py-0.5 rounded-md"
:class="
account.status === 'active'
? 'bg-n-green-3 text-n-green-11'
: 'bg-n-amber-3 text-n-amber-11'
"
>
{{ account.status || '—' }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<div v-else class="px-5 py-10 text-center text-sm text-n-slate-10">
{{ t('SUPER_ADMIN.ACCOUNTS.EMPTY') }}
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,53 @@
import { frontendURL } from '../../../helper/URLHelper';
import SuperAdminLayout from './Layout.vue';
import DashboardIndex from './dashboard/Index.vue';
import AccountsIndex from './accounts/Index.vue';
import UsersIndex from './users/Index.vue';
import AgentBotsIndex from './agentBots/Index.vue';
import ConfigsIndex from './configs/Index.vue';
const meta = {
permissions: ['administrator', 'agent', 'custom_role'],
isSuperAdmin: true,
};
export const routes = [
{
path: frontendURL('accounts/:accountId/super_admin'),
component: SuperAdminLayout,
meta,
children: [
{
path: '',
name: 'super_admin_dashboard',
component: DashboardIndex,
meta,
},
{
path: 'accounts',
name: 'super_admin_accounts',
component: AccountsIndex,
meta,
},
{
path: 'users',
name: 'super_admin_users',
component: UsersIndex,
meta,
},
{
path: 'agent_bots',
name: 'super_admin_agent_bots',
component: AgentBotsIndex,
meta,
},
{
path: 'configs',
name: 'super_admin_configs',
component: ConfigsIndex,
meta,
},
],
},
];
@@ -0,0 +1,255 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import PageHeader from '../components/PageHeader.vue';
import FormDialog from '../components/FormDialog.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = { CREATE: 'create', EDIT: 'edit' };
const store = useStore();
const { t } = useI18n();
const users = useMapGetter('platform/getUsers');
const uiFlags = useMapGetter('platform/getUIFlags');
const searchQuery = ref('');
const modalType = ref(MODAL_TYPES.CREATE);
const selectedUser = ref({});
const formDialogRef = ref(null);
const deleteDialogRef = ref(null);
const loading = ref({});
const isLoading = computed(() => uiFlags.value?.isFetchingUsers);
const filteredUsers = computed(() => {
const query = searchQuery.value.trim();
if (!query) return users.value;
return picoSearch(users.value, query, ['name', 'email']);
});
const dialogTitle = computed(() =>
modalType.value === MODAL_TYPES.CREATE
? t('SUPER_ADMIN.USERS.CREATE.TITLE')
: t('SUPER_ADMIN.USERS.EDIT.TITLE')
);
const formFields = computed(() => [
{
key: 'name',
label: t('SUPER_ADMIN.USERS.FIELDS.NAME'),
placeholder: t('SUPER_ADMIN.USERS.FIELDS.NAME_PLACEHOLDER'),
required: true,
},
{
key: 'email',
label: t('SUPER_ADMIN.USERS.FIELDS.EMAIL'),
placeholder: 'user@example.com',
required: true,
},
{
key: 'password',
label: t('SUPER_ADMIN.USERS.FIELDS.PASSWORD'),
placeholder: '••••••••',
hint: t('SUPER_ADMIN.USERS.FIELDS.PASSWORD_HINT'),
},
{
key: 'type',
label: t('SUPER_ADMIN.USERS.FIELDS.TYPE'),
type: 'select',
options: [
{ value: 'user', label: t('SUPER_ADMIN.USERS.FIELDS.TYPE_USER') },
{ value: 'SuperAdmin', label: t('SUPER_ADMIN.USERS.FIELDS.TYPE_SUPER_ADMIN') },
],
hint: t('SUPER_ADMIN.USERS.FIELDS.TYPE_HINT'),
},
]);
const openCreateModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedUser.value = { type: 'user' };
formDialogRef.value?.open();
};
const openEditModal = user => {
modalType.value = MODAL_TYPES.EDIT;
selectedUser.value = { ...user, password: '' };
formDialogRef.value?.open();
};
const openDeleteDialog = user => {
selectedUser.value = user;
deleteDialogRef.value?.open();
};
const handleConfirm = async data => {
try {
if (modalType.value === MODAL_TYPES.CREATE) {
await store.dispatch('platform/createUser', data);
useAlert(t('SUPER_ADMIN.USERS.CREATE.SUCCESS'));
} else {
const updateData = { ...data };
if (!updateData.password) delete updateData.password;
await store.dispatch('platform/updateUser', {
id: selectedUser.value.id,
data: updateData,
});
useAlert(t('SUPER_ADMIN.USERS.EDIT.SUCCESS'));
}
formDialogRef.value?.close();
} catch (error) {
useAlert(t('SUPER_ADMIN.USERS.ERROR'));
}
};
const confirmDelete = async () => {
loading.value[selectedUser.value.id] = true;
try {
await store.dispatch('platform/deleteUser', selectedUser.value.id);
useAlert(t('SUPER_ADMIN.USERS.DELETE.SUCCESS'));
} catch (error) {
useAlert(t('SUPER_ADMIN.USERS.ERROR'));
} finally {
loading.value[selectedUser.value.id] = false;
deleteDialogRef.value?.close();
}
};
onMounted(() => {
store.dispatch('platform/fetchUsers');
});
</script>
<template>
<PageHeader
:title="t('SUPER_ADMIN.USERS.TITLE')"
:description="t('SUPER_ADMIN.USERS.DESCRIPTION')"
v-model:search-query="searchQuery"
:search-placeholder="t('SUPER_ADMIN.USERS.SEARCH')"
:button-label="t('SUPER_ADMIN.USERS.CREATE.TITLE')"
:count="users?.length"
@click="openCreateModal"
/>
<div class="flex-1 px-6">
<div class="w-full max-w-5xl mx-auto py-6">
<div v-if="isLoading" class="flex items-center justify-center py-20">
<Spinner />
</div>
<div
v-else-if="filteredUsers?.length"
class="flex flex-col rounded-xl bg-n-background border border-n-weak overflow-hidden"
>
<BaseTable
:headers="[
t('SUPER_ADMIN.USERS.TABLE.NAME'),
t('SUPER_ADMIN.USERS.TABLE.TYPE'),
t('SUPER_ADMIN.USERS.TABLE.ACTIONS'),
]"
:items="filteredUsers"
>
<template #row="{ items }">
<BaseTableRow v-for="user in items" :key="user.id" :item="user">
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar
:name="user.name || user.available_name"
:src="user.avatar_url"
:size="36"
class="flex-shrink-0"
/>
<div class="min-w-0">
<span class="text-sm text-n-slate-12 truncate block">
{{ user.name || user.available_name }}
</span>
<span class="text-xs text-n-slate-10 truncate block">
{{ user.email }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-xs px-2 py-0.5 rounded-md"
:class="
user.type === 'SuperAdmin'
? 'bg-n-violet-3 text-n-violet-11'
: 'bg-n-slate-3 text-n-slate-11'
"
>
{{ user.type || 'User' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-2 justify-end flex-shrink-0">
<Button
v-tooltip.top="t('SUPER_ADMIN.COMMON.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[user.id]"
@click="openEditModal(user)"
/>
<Button
v-tooltip.top="t('SUPER_ADMIN.COMMON.DELETE')"
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[user.id]"
@click="openDeleteDialog(user)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
<div
v-else
class="flex flex-col items-center justify-center py-20 gap-2"
>
<span class="i-lucide-users size-10 text-n-slate-10" />
<p class="text-sm text-n-slate-10">
{{ t('SUPER_ADMIN.USERS.EMPTY') }}
</p>
</div>
</div>
</div>
<FormDialog
ref="formDialogRef"
:title="dialogTitle"
:fields="formFields"
:initial-data="selectedUser"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
@confirm="handleConfirm"
/>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t('SUPER_ADMIN.USERS.DELETE.TITLE')"
:description="
t('SUPER_ADMIN.USERS.DELETE.MESSAGE', { name: selectedUser?.name })
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('SUPER_ADMIN.COMMON.CONFIRM_DELETE')"
:cancel-button-label="t('SUPER_ADMIN.COMMON.CANCEL')"
@confirm="confirmDelete"
/>
</template>
@@ -59,6 +59,7 @@ import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
import captainCustomTools from './captain/customTools';
import platform from './modules/platform';
const plugins = [];
@@ -123,6 +124,7 @@ export default createStore({
captainScenarios,
captainTools,
captainCustomTools,
platform,
},
plugins,
});
@@ -0,0 +1,394 @@
import types from '../mutation-types';
import PlatformAPI from '../../api/platform';
import { throwErrorMessage } from '../utils/api';
export const state = {
accounts: [],
users: [],
agentBots: [],
installationConfigs: [],
uiFlags: {
isFetchingAccounts: false,
isFetchingUsers: false,
isFetchingAgentBots: false,
isFetchingConfigs: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export const getters = {
getAccounts: $state => $state.accounts,
getUsers: $state => $state.users,
getPlatformAgentBots: $state => $state.agentBots,
getInstallationConfigs: $state => $state.installationConfigs,
getUIFlags: $state => $state.uiFlags,
};
export const actions = {
// ── Accounts ──────────────────────────────────────────
fetchAccounts: async ({ commit }, params) => {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingAccounts: true });
try {
const response = await PlatformAPI.getAccounts(params);
const payload = response.data?.data || response.data || [];
commit(types.SET_PLATFORM_ACCOUNTS, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingAccounts: false });
}
return null;
},
createAccount: async ({ commit }, accountData) => {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: true });
try {
const response = await PlatformAPI.createAccount(accountData);
const payload = response.data?.data || response.data;
commit(types.ADD_PLATFORM_ACCOUNT, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: false });
}
return null;
},
updateAccount: async ({ commit }, { id, data }) => {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: true });
try {
const response = await PlatformAPI.updateAccount(id, data);
commit(types.EDIT_PLATFORM_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: false });
}
return null;
},
deleteAccount: async ({ commit }, id) => {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: true });
try {
await PlatformAPI.deleteAccount(id);
commit(types.DELETE_PLATFORM_ACCOUNT, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: false });
}
},
// ── Users ─────────────────────────────────────────────
fetchUsers: async ({ commit }, params) => {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingUsers: true });
try {
const response = await PlatformAPI.getUsers(params);
const payload = response.data?.data || response.data || [];
commit(types.SET_PLATFORM_USERS, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingUsers: false });
}
return null;
},
createUser: async ({ commit }, userData) => {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: true });
try {
const response = await PlatformAPI.createUser(userData);
const payload = response.data?.data || response.data;
commit(types.ADD_PLATFORM_USER, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: false });
}
return null;
},
updateUser: async ({ commit }, { id, data }) => {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: true });
try {
const response = await PlatformAPI.updateUser(id, data);
commit(types.EDIT_PLATFORM_USER, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: false });
}
return null;
},
deleteUser: async ({ commit }, id) => {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: true });
try {
await PlatformAPI.deleteUser(id);
commit(types.DELETE_PLATFORM_USER, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: false });
}
},
// ── Agent Bots (platform-level) ───────────────────────
fetchAgentBots: async ({ commit }, params) => {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingAgentBots: true });
try {
const response = await PlatformAPI.getAgentBots(params);
const payload = response.data?.data || response.data || [];
commit(types.SET_PLATFORM_AGENT_BOTS, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingAgentBots: false });
}
return null;
},
createAgentBot: async ({ commit }, botData) => {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: true });
try {
const response = await PlatformAPI.createAgentBot(botData);
const payload = response.data?.data || response.data;
commit(types.ADD_PLATFORM_AGENT_BOT, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: false });
}
return null;
},
updateAgentBot: async ({ commit }, { id, data }) => {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: true });
try {
const response = await PlatformAPI.updateAgentBot(id, data);
commit(types.EDIT_PLATFORM_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: false });
}
return null;
},
deleteAgentBot: async ({ commit }, id) => {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: true });
try {
await PlatformAPI.deleteAgentBot(id);
commit(types.DELETE_PLATFORM_AGENT_BOT, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: false });
}
},
// ── Installation Config ───────────────────────────────
fetchInstallationConfigs: async ({ commit }, params) => {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingConfigs: true });
try {
const response = await PlatformAPI.getInstallationConfigs(params);
const payload = response.data?.data || response.data || [];
commit(types.SET_INSTALLATION_CONFIGS, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingConfigs: false });
}
return null;
},
createInstallationConfig: async ({ commit }, configData) => {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: true });
try {
const response = await PlatformAPI.createInstallationConfig(configData);
const payload = response.data?.data || response.data;
commit(types.ADD_INSTALLATION_CONFIG, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: false });
}
return null;
},
updateInstallationConfig: async ({ commit }, { id, data }) => {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: true });
try {
const response = await PlatformAPI.updateInstallationConfig(id, data);
commit(types.EDIT_INSTALLATION_CONFIG, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: false });
}
return null;
},
deleteInstallationConfig: async ({ commit }, id) => {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: true });
try {
await PlatformAPI.deleteInstallationConfig(id);
commit(types.DELETE_INSTALLATION_CONFIG, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: false });
}
},
// ── Banners ───────────────────────────────────────────
fetchBanners: async ({ commit }, params) => {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingBanners: true });
try {
const response = await PlatformAPI.getBanners(params);
const payload = response.data?.data || response.data || [];
commit(types.SET_PLATFORM_BANNERS, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isFetchingBanners: false });
}
return null;
},
createBanner: async ({ commit }, bannerData) => {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: true });
try {
const response = await PlatformAPI.createBanner(bannerData);
const payload = response.data?.data || response.data;
commit(types.ADD_PLATFORM_BANNER, payload);
return payload;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isCreating: false });
}
return null;
},
updateBanner: async ({ commit }, { id, data }) => {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: true });
try {
const response = await PlatformAPI.updateBanner(id, data);
commit(types.EDIT_PLATFORM_BANNER, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isUpdating: false });
}
return null;
},
deleteBanner: async ({ commit }, id) => {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: true });
try {
await PlatformAPI.deleteBanner(id);
commit(types.DELETE_PLATFORM_BANNER, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_PLATFORM_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_PLATFORM_UI_FLAG]($state, data) {
$state.uiFlags = { ...$state.uiFlags, ...data };
},
// Accounts
[types.SET_PLATFORM_ACCOUNTS]($state, data) {
$state.accounts = Array.isArray(data) ? data : data?.payload || [];
},
[types.ADD_PLATFORM_ACCOUNT]($state, account) {
$state.accounts.unshift(account);
},
[types.EDIT_PLATFORM_ACCOUNT]($state, account) {
const idx = $state.accounts.findIndex(a => a.id === account.id);
if (idx !== -1) $state.accounts[idx] = account;
},
[types.DELETE_PLATFORM_ACCOUNT]($state, id) {
$state.accounts = $state.accounts.filter(a => a.id !== id);
},
// Users
[types.SET_PLATFORM_USERS]($state, data) {
$state.users = Array.isArray(data) ? data : data?.payload || [];
},
[types.ADD_PLATFORM_USER]($state, user) {
$state.users.unshift(user);
},
[types.EDIT_PLATFORM_USER]($state, user) {
const idx = $state.users.findIndex(u => u.id === user.id);
if (idx !== -1) $state.users[idx] = user;
},
[types.DELETE_PLATFORM_USER]($state, id) {
$state.users = $state.users.filter(u => u.id !== id);
},
// Agent Bots
[types.SET_PLATFORM_AGENT_BOTS]($state, data) {
$state.agentBots = Array.isArray(data) ? data : data?.payload || [];
},
[types.ADD_PLATFORM_AGENT_BOT]($state, bot) {
$state.agentBots.unshift(bot);
},
[types.EDIT_PLATFORM_AGENT_BOT]($state, bot) {
const idx = $state.agentBots.findIndex(b => b.id === bot.id);
if (idx !== -1) $state.agentBots[idx] = bot;
},
[types.DELETE_PLATFORM_AGENT_BOT]($state, id) {
$state.agentBots = $state.agentBots.filter(b => b.id !== id);
},
// Installation Configs
[types.SET_INSTALLATION_CONFIGS]($state, data) {
$state.installationConfigs = Array.isArray(data)
? data
: data?.payload || [];
},
[types.ADD_INSTALLATION_CONFIG]($state, config) {
$state.installationConfigs.unshift(config);
},
[types.EDIT_INSTALLATION_CONFIG]($state, config) {
const idx = $state.installationConfigs.findIndex(
c => c.id === config.id
);
if (idx !== -1) $state.installationConfigs[idx] = config;
},
[types.DELETE_INSTALLATION_CONFIG]($state, id) {
$state.installationConfigs = $state.installationConfigs.filter(
c => c.id !== id
);
},
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};
@@ -392,4 +392,23 @@ export default {
EDIT_AGENT_CAPACITY_POLICIES_INBOXES: 'EDIT_AGENT_CAPACITY_POLICIES_INBOXES',
DELETE_AGENT_CAPACITY_POLICIES_INBOXES:
'DELETE_AGENT_CAPACITY_POLICIES_INBOXES',
// Platform (Super Admin)
SET_PLATFORM_UI_FLAG: 'SET_PLATFORM_UI_FLAG',
SET_PLATFORM_ACCOUNTS: 'SET_PLATFORM_ACCOUNTS',
ADD_PLATFORM_ACCOUNT: 'ADD_PLATFORM_ACCOUNT',
EDIT_PLATFORM_ACCOUNT: 'EDIT_PLATFORM_ACCOUNT',
DELETE_PLATFORM_ACCOUNT: 'DELETE_PLATFORM_ACCOUNT',
SET_PLATFORM_USERS: 'SET_PLATFORM_USERS',
ADD_PLATFORM_USER: 'ADD_PLATFORM_USER',
EDIT_PLATFORM_USER: 'EDIT_PLATFORM_USER',
DELETE_PLATFORM_USER: 'DELETE_PLATFORM_USER',
SET_PLATFORM_AGENT_BOTS: 'SET_PLATFORM_AGENT_BOTS',
ADD_PLATFORM_AGENT_BOT: 'ADD_PLATFORM_AGENT_BOT',
EDIT_PLATFORM_AGENT_BOT: 'EDIT_PLATFORM_AGENT_BOT',
DELETE_PLATFORM_AGENT_BOT: 'DELETE_PLATFORM_AGENT_BOT',
SET_INSTALLATION_CONFIGS: 'SET_INSTALLATION_CONFIGS',
ADD_INSTALLATION_CONFIG: 'ADD_INSTALLATION_CONFIG',
EDIT_INSTALLATION_CONFIG: 'EDIT_INSTALLATION_CONFIG',
DELETE_INSTALLATION_CONFIG: 'DELETE_INSTALLATION_CONFIG',
};