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
@@ -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>