feat(clients): add top-level Clients tab and CRUD API

Adds /panel/api/clients endpoints (list, get, add, update, del,
attach, detach) backed by ClientService methods that orchestrate
the per-inbound Add/Update/Del flows so a single client row is
created once and attached to many inbounds in one operation.

The frontend gains a dedicated Clients page (frontend/clients.html
+ src/pages/clients/) with an AntD table, multi-inbound attach
modal, and full CRUD. Axios interceptor learns to honour
Content-Type: application/json so the JSON endpoints work
alongside the legacy form-encoded ones.

The legacy per-inbound client modal stays untouched in this PR —
both flows now write to the same source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 07:28:55 +02:00
parent ba3c581372
commit 2bcf287cf1
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
15 changed files with 1221 additions and 4 deletions

13
frontend/clients.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clients</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/clients.js"></script>
</body>
</html>

View file

@ -76,7 +76,14 @@ export function setupAxios() {
if (config.data instanceof FormData) { if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data'; config.headers['Content-Type'] = 'multipart/form-data';
} else { } else {
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' }); const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
if (declaredType.toLowerCase().startsWith('application/json')) {
if (config.data !== undefined && typeof config.data !== 'string') {
config.data = JSON.stringify(config.data);
}
} else {
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
}
} }
return config; return config;
}, },
@ -104,9 +111,14 @@ export function setupAxios() {
if (token) { if (token) {
cfg.headers = cfg.headers || {}; cfg.headers = cfg.headers || {};
cfg.headers['X-CSRF-Token'] = token; cfg.headers['X-CSRF-Token'] = token;
// axios re-stringifies on retry, so unwind our qs.stringify before const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
// letting the same request flow through the interceptor again. if (typeof cfg.data === 'string') {
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data); if (declaredType.toLowerCase().startsWith('application/json')) {
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
} else {
cfg.data = qs.parse(cfg.data);
}
}
return axios(cfg); return axios(cfg);
} }
} }

View file

@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { import {
DashboardOutlined, DashboardOutlined,
UserOutlined, UserOutlined,
TeamOutlined,
SettingOutlined, SettingOutlined,
ToolOutlined, ToolOutlined,
ClusterOutlined, ClusterOutlined,
@ -30,6 +31,7 @@ const props = defineProps({
const iconByName = { const iconByName = {
dashboard: DashboardOutlined, dashboard: DashboardOutlined,
user: UserOutlined, user: UserOutlined,
team: TeamOutlined,
setting: SettingOutlined, setting: SettingOutlined,
tool: ToolOutlined, tool: ToolOutlined,
cluster: ClusterOutlined, cluster: ClusterOutlined,
@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
const tabs = computed(() => [ const tabs = computed(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') }, { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') }, { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },

View file

@ -0,0 +1,21 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ClientsPage from '@/pages/clients/ClientsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -494,6 +494,85 @@ export const sections = [
], ],
}, },
{
id: 'clients',
title: 'Clients',
description:
'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.',
endpoints: [
{
method: 'GET',
path: '/panel/api/clients/list',
summary: 'List every client with its attached inbound IDs and traffic record.',
response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "email": "alice@example.com",\n "subId": "abcd1234",\n "uuid": "...",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "enable": true,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true }\n }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/clients/get/:id',
summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/add',
summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON.',
params: [
{ name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, totalGB, expiryTime, limitIp, comment, enable.' },
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
],
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000\n },\n "inboundIds": [3, 5]\n}',
response: '{\n "success": true,\n "msg": "Client added"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/update/:id',
summary: 'Update an existing client. Changes propagate to every attached inbound. Body is the JSON client payload.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
],
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/del/:id',
summary: 'Delete a client. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
],
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:id/attach',
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
],
body: '{\n "inboundIds": [7, 9]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:id/detach',
summary: 'Detach a client from one or more inbounds without deleting the client.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
],
body: '{\n "inboundIds": [5]\n}',
response: '{\n "success": true\n}',
},
],
},
{ {
id: 'nodes', id: 'nodes',
title: 'Nodes', title: 'Nodes',

View file

@ -0,0 +1,230 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { RandomUtil } from '@/utils';
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'add' },
client: { type: Object, default: null },
inbounds: { type: Array, default: () => [] },
attachedIds: { type: Array, default: () => [] },
save: { type: Function, required: true },
});
const emit = defineEmits(['update:open']);
const { t } = useI18n();
const submitting = ref(false);
const form = reactive(emptyForm());
function emptyForm() {
return {
email: '',
subId: '',
uuid: '',
password: '',
auth: '',
totalGB: 0,
expiryTime: null,
limitIp: 0,
comment: '',
enable: true,
inboundIds: [],
};
}
const isEdit = computed(() => props.mode === 'edit');
watch(
() => props.open,
(next) => {
if (!next) return;
Object.assign(form, emptyForm());
if (isEdit.value && props.client) {
form.email = props.client.email || '';
form.subId = props.client.subId || '';
form.uuid = props.client.uuid || '';
form.password = props.client.password || '';
form.auth = props.client.auth || '';
form.totalGB = bytesToGB(props.client.totalGB || 0);
form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null;
form.limitIp = props.client.limitIp || 0;
form.comment = props.client.comment || '';
form.enable = !!props.client.enable;
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
} else {
form.uuid = RandomUtil.randomUUID();
form.subId = RandomUtil.randomLowerAndNum(16);
form.password = RandomUtil.randomLowerAndNum(16);
form.auth = RandomUtil.randomLowerAndNum(16);
}
},
);
function bytesToGB(bytes) {
if (!bytes || bytes <= 0) return 0;
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
}
function gbToBytes(gb) {
if (!gb || gb <= 0) return 0;
return Math.round(gb * 1024 * 1024 * 1024);
}
const inboundOptions = computed(() =>
(props.inbounds || []).map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
})),
);
function close() {
emit('update:open', false);
}
function regenerateUUID() {
form.uuid = RandomUtil.randomUUID();
}
function regeneratePassword() {
form.password = RandomUtil.randomLowerAndNum(16);
}
function regenerateAuth() {
form.auth = RandomUtil.randomLowerAndNum(16);
}
function regenerateSubId() {
form.subId = RandomUtil.randomLowerAndNum(16);
}
async function onSubmit() {
if (!form.email || form.email.trim() === '') {
message.error(t('pages.inbounds.client.email') + ' *');
return;
}
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
message.error(t('pages.clients.selectInbound'));
return;
}
const clientPayload = {
email: form.email.trim(),
subId: form.subId,
id: form.uuid,
password: form.password,
auth: form.auth,
totalGB: gbToBytes(form.totalGB),
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
limitIp: Number(form.limitIp) || 0,
comment: form.comment,
enable: !!form.enable,
};
submitting.value = true;
try {
let msg;
if (isEdit.value) {
msg = await props.save(clientPayload, { isEdit: true, id: props.client.id });
} else {
msg = await props.save(
{ client: clientPayload, inboundIds: form.inboundIds },
{ isEdit: false },
);
}
if (msg?.success) close();
} finally {
submitting.value = false;
}
}
</script>
<template>
<a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('add')" :cancel-text="t('cancel')"
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.email')" required>
<a-input v-model:value="form.email" :placeholder="t('pages.inbounds.client.email')" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.subId') || 'subId'">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.subId" style="flex: 1" />
<a-button @click="regenerateSubId"></a-button>
</a-input-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="UUID">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.uuid" style="flex: 1" />
<a-button @click="regenerateUUID"></a-button>
</a-input-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.password') || 'Password'">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.password" style="flex: 1" />
<a-button @click="regeneratePassword"></a-button>
</a-input-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="Auth (Hysteria)">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.auth" style="flex: 1" />
<a-button @click="regenerateAuth"></a-button>
</a-input-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.limitIp') || 'IP limit'">
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.totalGB') || 'Total (GB, 0 = unlimited)'">
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.expiryTime') || 'Expiry'">
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('pages.inbounds.client.comment') || 'Comment'">
<a-input v-model:value="form.comment" />
</a-form-item>
<a-form-item v-if="!isEdit" :label="t('pages.clients.attachedInbounds') || 'Attach to inbounds'" required>
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
:placeholder="t('pages.clients.selectInbound') || 'Select one or more inbounds'"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
</a-form-item>
<a-form-item>
<a-switch v-model:checked="form.enable" />
<span style="margin-left: 8px">{{ t('enable') }}</span>
</a-form-item>
</a-form>
</a-modal>
</template>

View file

@ -0,0 +1,217 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { PlusOutlined, UserOutlined } from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import { SizeFormatter, IntlUtil } from '@/utils';
import { useClients } from './useClients.js';
import ClientFormModal from './ClientFormModal.vue';
const { t } = useI18n();
const {
clients,
inbounds,
loading,
fetched,
create,
update,
remove,
} = useClients();
const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const formOpen = ref(false);
const formMode = ref('add');
const editingClient = ref(null);
const editingAttachedIds = ref([]);
const inboundsById = computed(() => {
const out = {};
for (const ib of inbounds.value) out[ib.id] = ib;
return out;
});
function inboundLabel(id) {
const ib = inboundsById.value[id];
if (!ib) return `#${id}`;
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
}
function onAdd() {
formMode.value = 'add';
editingClient.value = null;
editingAttachedIds.value = [];
formOpen.value = true;
}
function onEdit(row) {
formMode.value = 'edit';
editingClient.value = { ...row };
editingAttachedIds.value = Array.isArray(row.inboundIds) ? [...row.inboundIds] : [];
formOpen.value = true;
}
function onDelete(row) {
Modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }) || `Delete ${row.email}?`,
content: t('pages.clients.deleteConfirmContent')
|| 'This removes the client from every attached inbound and drops its traffic record.',
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(row.id);
if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted');
},
});
}
async function onSave(payload, meta) {
if (meta?.isEdit) {
return update(meta.id, payload);
}
return create(payload);
}
function trafficLabel(row) {
const t0 = row.traffic;
if (!t0) return '-';
const used = (t0.up || 0) + (t0.down || 0);
const total = row.totalGB || 0;
if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
}
function expiryLabel(row) {
if (!row.expiryTime || row.expiryTime <= 0) return '-';
return IntlUtil.formatDate(row.expiryTime);
}
const columns = computed(() => [
{ title: t('pages.inbounds.client.email') || 'Email', dataIndex: 'email', key: 'email' },
{ title: 'subId', dataIndex: 'subId', key: 'subId' },
{ title: t('pages.clients.attachedInbounds') || 'Attached inbounds', key: 'inboundIds' },
{ title: t('pages.inbounds.traffic') || 'Traffic', key: 'traffic' },
{ title: t('pages.inbounds.client.expiryTime') || 'Expiry', key: 'expiryTime' },
{ title: t('enable'), key: 'enable', width: 90 },
{ title: t('actions') || 'Actions', key: 'actions', width: 160 },
]);
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="clients-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<a-col :span="24">
<a-card size="small" :title="t('menu.clients') || 'Clients'">
<template #extra>
<a-button type="primary" @click="onAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('add') }}
</a-button>
</template>
<a-table :columns="columns" :data-source="clients" :loading="loading" row-key="id" :pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10','20','50','100'] }" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'inboundIds'">
<a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
{{ inboundLabel(id) }}
</a-tag>
<span v-if="!record.inboundIds || record.inboundIds.length === 0" style="color: rgba(0,0,0,0.45)"></span>
</template>
<template v-else-if="column.key === 'traffic'">
{{ trafficLabel(record) }}
</template>
<template v-else-if="column.key === 'expiryTime'">
{{ expiryLabel(record) }}
</template>
<template v-else-if="column.key === 'enable'">
<a-tag :color="record.enable ? 'green' : 'default'">
{{ record.enable ? t('enable') : t('disable') }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="onEdit(record)">{{ t('edit') }}</a-button>
<a-button size="small" danger @click="onDelete(record)">{{ t('delete') }}</a-button>
</a-space>
</template>
</template>
<template #emptyText>
<div style="padding: 32px 0; color: rgba(0, 0, 0, 0.45); text-align: center">
<UserOutlined style="font-size: 32px; margin-bottom: 8px" />
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
</div>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
:attached-ids="editingAttachedIds" :inbounds="inbounds" :save="onSave" />
</a-layout>
</a-config-provider>
</template>
<style scoped>
.clients-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.clients-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.clients-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.clients-page :deep(.ant-layout),
.clients-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
@media (max-width: 768px) {
.content-area {
padding: 8px;
}
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
</style>

View file

@ -0,0 +1,78 @@
import { onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
export function useClients() {
const clients = shallowRef([]);
const inbounds = shallowRef([]);
const loading = ref(false);
const fetched = ref(false);
async function refresh() {
loading.value = true;
try {
const [clientsMsg, inboundsMsg] = await Promise.all([
HttpUtil.get('/panel/api/clients/list'),
HttpUtil.get('/panel/api/inbounds/list'),
]);
if (clientsMsg?.success) {
clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
}
if (inboundsMsg?.success) {
inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
}
fetched.value = true;
} finally {
loading.value = false;
}
}
async function create(payload) {
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function update(id, client) {
const msg = await HttpUtil.post(`/panel/api/clients/update/${id}`, client, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function remove(id, keepTraffic = false) {
const url = keepTraffic
? `/panel/api/clients/del/${id}?keepTraffic=1`
: `/panel/api/clients/del/${id}`;
const msg = await HttpUtil.post(url);
if (msg?.success) await refresh();
return msg;
}
async function attach(id, inboundIds) {
const msg = await HttpUtil.post(`/panel/api/clients/${id}/attach`, { inboundIds }, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function detach(id, inboundIds) {
const msg = await HttpUtil.post(`/panel/api/clients/${id}/detach`, { inboundIds }, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
onMounted(refresh);
return {
clients,
inbounds,
loading,
fetched,
refresh,
create,
update,
remove,
attach,
detach,
};
}

View file

@ -22,6 +22,8 @@ const BASE_MIGRATED_ROUTES = {
'panel/settings/': '/settings.html', 'panel/settings/': '/settings.html',
'panel/inbounds': '/inbounds.html', 'panel/inbounds': '/inbounds.html',
'panel/inbounds/': '/inbounds.html', 'panel/inbounds/': '/inbounds.html',
'panel/clients': '/clients.html',
'panel/clients/': '/clients.html',
'panel/xray': '/xray.html', 'panel/xray': '/xray.html',
'panel/xray/': '/xray.html', 'panel/xray/': '/xray.html',
'panel/nodes': '/nodes.html', 'panel/nodes': '/nodes.html',
@ -150,6 +152,7 @@ export default defineConfig({
login: path.resolve(__dirname, 'login.html'), login: path.resolve(__dirname, 'login.html'),
settings: path.resolve(__dirname, 'settings.html'), settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'), inbounds: path.resolve(__dirname, 'inbounds.html'),
clients: path.resolve(__dirname, 'clients.html'),
xray: path.resolve(__dirname, 'xray.html'), xray: path.resolve(__dirname, 'xray.html'),
nodes: path.resolve(__dirname, 'nodes.html'), nodes: path.resolve(__dirname, 'nodes.html'),
apiDocs: path.resolve(__dirname, 'api-docs.html'), apiDocs: path.resolve(__dirname, 'api-docs.html'),

View file

@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
inbounds := api.Group("/inbounds") inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds) a.inboundController = NewInboundController(inbounds)
clients := api.Group("/clients")
NewClientController(clients)
// Server API // Server API
server := api.Group("/server") server := api.Group("/server")
a.serverController = NewServerController(server) a.serverController = NewServerController(server)

View file

@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
basePath = "/panel/api" basePath = "/panel/api"
case "inbound.go": case "inbound.go":
basePath = "/panel/api/inbounds" basePath = "/panel/api/inbounds"
case "client.go":
basePath = "/panel/api/clients"
case "server.go": case "server.go":
basePath = "/panel/api/server" basePath = "/panel/api/server"
case "node.go": case "node.go":
@ -127,6 +129,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
// Skip SPA page routes (these are UI pages, not API endpoints) // Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{ spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true, "/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/clients": true,
"/panel/nodes": true, "/panel/settings": true, "/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/api-docs": true, "/panel/xray": true, "/panel/api-docs": true,
} }

165
web/controller/client.go Normal file
View file

@ -0,0 +1,165 @@
package controller
import (
"strconv"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin"
)
type ClientController struct {
clientService service.ClientService
inboundService service.InboundService
xrayService service.XrayService
}
func NewClientController(g *gin.RouterGroup) *ClientController {
a := &ClientController{}
a.initRouter(g)
return a
}
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/get/:id", a.get)
g.POST("/add", a.create)
g.POST("/update/:id", a.update)
g.POST("/del/:id", a.delete)
g.POST("/:id/attach", a.attach)
g.POST("/:id/detach", a.detach)
}
func (a *ClientController) list(c *gin.Context) {
rows, err := a.clientService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *ClientController) get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
rec, err := a.clientService.GetByID(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inboundIds, err := a.clientService.GetInboundIdsForRecord(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
}
func (a *ClientController) create(c *gin.Context) {
var payload service.ClientCreatePayload
if err := c.ShouldBindJSON(&payload); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *ClientController) update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
var updated model.Client
if err := c.ShouldBindJSON(&updated); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Update(&a.inboundService, id, updated)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *ClientController) delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
type attachDetachBody struct {
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) attach(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *ClientController) detach(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}

View file

@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)
g.GET("/clients", a.clients)
g.GET("/nodes", a.nodes) g.GET("/nodes", a.nodes)
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
serveDistPage(c, "inbounds.html") serveDistPage(c, "inbounds.html")
} }
func (a *XUIController) clients(c *gin.Context) {
serveDistPage(c, "clients.html")
}
// nodes renders the multi-panel nodes management page. // nodes renders the multi-panel nodes management page.
func (a *XUIController) nodes(c *gin.Context) { func (a *XUIController) nodes(c *gin.Context) {
serveDistPage(c, "nodes.html") serveDistPage(c, "nodes.html")

View file

@ -1,15 +1,42 @@
package service package service
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database" "github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm" "gorm.io/gorm"
) )
type ClientWithAttachments struct {
model.ClientRecord
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
}
func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
if rec == nil {
return ""
}
switch p {
case model.Trojan:
return rec.Password
case model.Shadowsocks:
return rec.Email
case model.Hysteria, model.Hysteria2:
return rec.Auth
default:
return rec.UUID
}
}
type ClientService struct{} type ClientService struct{}
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error { func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
@ -141,3 +168,347 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
} }
return ids, nil return ids, nil
} }
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
row := &model.ClientRecord{}
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
return nil, err
}
return row, nil
}
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
var ids []int
err := database.GetDB().Table("client_inbounds").
Where("client_id = ?", id).
Order("inbound_id ASC").
Pluck("inbound_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) List() ([]ClientWithAttachments, error) {
db := database.GetDB()
var rows []model.ClientRecord
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return []ClientWithAttachments{}, nil
}
clientIds := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
for i := range rows {
clientIds = append(clientIds, rows[i].Id)
if rows[i].Email != "" {
emails = append(emails, rows[i].Email)
}
}
var links []model.ClientInbound
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
return nil, err
}
attachments := make(map[int][]int, len(rows))
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
return nil, err
}
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
}
}
out := make([]ClientWithAttachments, 0, len(rows))
for i := range rows {
out = append(out, ClientWithAttachments{
ClientRecord: rows[i],
InboundIds: attachments[rows[i].Id],
Traffic: trafficByEmail[rows[i].Email],
})
}
return out, nil
}
type ClientCreatePayload struct {
Client model.Client `json:"client"`
InboundIds []int `json:"inboundIds"`
}
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
if payload == nil {
return false, common.NewError("empty payload")
}
client := payload.Client
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required")
}
if client.SubID == "" {
client.SubID = uuid.NewString()
}
if !client.Enable {
client.Enable = true
}
now := time.Now().UnixMilli()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
existing := &model.ClientRecord{}
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
if emailTaken {
if existing.SubID == "" || existing.SubID != client.SubID {
return false, common.NewError("email already in use:", client.Email)
}
}
needRestart := false
for _, ibId := range payload.InboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error {
switch p {
case model.VMESS, model.VLESS:
if c.ID == "" {
c.ID = uuid.NewString()
}
case model.Trojan, model.Shadowsocks:
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
case model.Hysteria, model.Hysteria2:
if c.Auth == "" {
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
return nil
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
}
if updated.SubID == "" {
updated.SubID = existing.SubID
}
if updated.SubID == "" {
updated.SubID = uuid.NewString()
}
updated.UpdatedAt = time.Now().UnixMilli()
if updated.CreatedAt == 0 {
updated.CreatedAt = existing.CreatedAt
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
oldKey := clientKeyForProtocol(inbound.Protocol, existing)
if oldKey == "" {
continue
}
if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
if mErr != nil {
return needRestart, mErr
}
nr, upErr := inboundSvc.UpdateInboundClient(&model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, oldKey)
if upErr != nil {
return needRestart, upErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
key := clientKeyForProtocol(inbound.Protocol, existing)
if key == "" {
continue
}
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
if delErr != nil {
return needRestart, delErr
}
if nr {
needRestart = true
}
}
db := database.GetDB()
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err
}
if !keepTraffic && existing.Email != "" {
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
}
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return needRestart, err
}
}
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
clientWire := existing.ToClient()
clientWire.UpdatedAt = time.Now().UnixMilli()
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
copyClient := *clientWire
if err := s.fillProtocolDefaults(&copyClient, inbound.Protocol); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; !attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
key := clientKeyForProtocol(inbound.Protocol, existing)
if key == "" {
continue
}
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
if delErr != nil {
return needRestart, delErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}

View file

@ -94,6 +94,7 @@
"ultraDark": "Ultra Dark", "ultraDark": "Ultra Dark",
"dashboard": "Overview", "dashboard": "Overview",
"inbounds": "Inbounds", "inbounds": "Inbounds",
"clients": "Clients",
"nodes": "Nodes", "nodes": "Nodes",
"settings": "Panel Settings", "settings": "Panel Settings",
"xray": "Xray Configs", "xray": "Xray Configs",
@ -397,6 +398,19 @@
"renew": "Auto Renew", "renew": "Auto Renew",
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)" "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
}, },
"clients": {
"title": "Clients",
"addTitle": "Add Client",
"editTitle": "Edit Client",
"attachedInbounds": "Attached inbounds",
"selectInbound": "Select one or more inbounds",
"empty": "No clients yet — add one to get started.",
"deleteConfirmTitle": "Delete client {email}?",
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
"toasts": {
"deleted": "Client deleted"
}
},
"nodes": { "nodes": {
"title": "Nodes", "title": "Nodes",
"addNode": "Add Node", "addNode": "Add Node",