feat(inbounds): attach existing clients to an inbound in one click

Adds an 'Attach Existing Clients' row action on multi-user inbounds (shown even when the inbound is empty). It opens a modal listing the whole client pool with search and group filter, all attachable clients pre-selected, and attaches the selection to that inbound via the existing bulkAttach endpoint. Clients already on the inbound are shown disabled and skipped. Translations added for all 13 locales.
This commit is contained in:
MHSanaei
2026-06-01 07:26:30 +02:00
parent 971843f669
commit dd14e9b3b0
17 changed files with 318 additions and 0 deletions
@@ -39,6 +39,7 @@ const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
@@ -53,6 +54,7 @@ type RowAction =
| 'resetTraffic'
| 'delAllClients'
| 'attachClients'
| 'attachExisting'
| 'detachClients'
| 'addToGroup'
| 'clone';
@@ -129,6 +131,8 @@ export default function InboundsPage() {
const [attachOpen, setAttachOpen] = useState(false);
const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
const [attachExistingOpen, setAttachExistingOpen] = useState(false);
const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
const [detachOpen, setDetachOpen] = useState(false);
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
@@ -523,6 +527,10 @@ export default function InboundsPage() {
setAttachSource(target);
setAttachOpen(true);
break;
case 'attachExisting':
setAttachExistingTarget(target);
setAttachExistingOpen(true);
break;
case 'detachClients':
setDetachSource(target);
setDetachOpen(true);
@@ -653,6 +661,14 @@ export default function InboundsPage() {
dbInbounds={dbInbounds}
/>
</LazyMount>
<LazyMount when={attachExistingOpen}>
<AttachExistingClientsModal
open={attachExistingOpen}
onClose={() => setAttachExistingOpen(false)}
onAttached={refresh}
target={attachExistingTarget}
/>
</LazyMount>
<LazyMount when={detachOpen}>
<DetachClientsModal
open={detachOpen}
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { HttpUtil } from '@/utils';
import type { DBInbound } from '@/models/dbinbound';
interface AttachExistingClientsModalProps {
open: boolean;
target: DBInbound | null;
onClose: () => void;
onAttached?: () => void;
}
interface BulkAttachResult {
attached?: string[];
skipped?: string[];
errors?: string[];
}
interface ClientRow {
email: string;
group: string;
enable: boolean;
alreadyAttached: boolean;
}
interface RawClient {
email?: string;
group?: string;
enable?: boolean;
inboundIds?: number[] | null;
}
export default function AttachExistingClientsModal({
open,
target,
onClose,
onAttached,
}: AttachExistingClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [clientRows, setClientRows] = useState<ClientRow[]>([]);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined);
useEffect(() => {
if (!open || !target) return;
let cancelled = false;
setLoading(true);
setSearch('');
setGroupFilter(undefined);
HttpUtil.get('/panel/api/clients/list', undefined, { silent: true })
.then((msg) => {
if (cancelled) return;
const list = Array.isArray(msg?.obj) ? (msg.obj as RawClient[]) : [];
const rows: ClientRow[] = list
.map((c) => ({
email: (c?.email || '').trim(),
group: (c?.group || '').trim(),
enable: c?.enable !== false,
alreadyAttached: Array.isArray(c?.inboundIds) && c.inboundIds.includes(target.id),
}))
.filter((r) => r.email);
setClientRows(rows);
setSelectedEmails(rows.filter((r) => !r.alreadyAttached).map((r) => r.email));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, target]);
const groupOptions = useMemo(() => {
const set = new Set<string>();
for (const r of clientRows) if (r.group) set.add(r.group);
return [...set].sort((a, b) => a.localeCompare(b)).map((g) => ({ value: g, label: g }));
}, [clientRows]);
const attachableCount = useMemo(
() => clientRows.filter((r) => !r.alreadyAttached).length,
[clientRows],
);
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
return clientRows.filter((r) => {
if (groupFilter && r.group !== groupFilter) return false;
if (!q) return true;
return r.email.toLowerCase().includes(q) || r.group.toLowerCase().includes(q);
});
}, [clientRows, search, groupFilter]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{
title: t('pages.inbounds.email'),
dataIndex: 'email',
key: 'email',
ellipsis: true,
},
{
title: t('pages.clients.group'),
dataIndex: 'group',
key: 'group',
width: 150,
ellipsis: true,
render: (group: string) =>
group ? <Tag color="geekblue">{group}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>,
},
{
title: t('enable'),
key: 'status',
width: 140,
render: (_v, row) => {
if (row.alreadyAttached) return <Tag color="default">{t('pages.inbounds.attachExistingStatusAttached')}</Tag>;
return row.enable ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
);
},
},
],
[t],
);
async function submit() {
if (!target || selectedEmails.length === 0) return;
setSaving(true);
try {
const msg = await HttpUtil.post(
'/panel/api/clients/bulkAttach',
{ emails: selectedEmails, inboundIds: [target.id] },
{ headers: { 'Content-Type': 'application/json' } },
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
return;
}
const result = (msg.obj || {}) as BulkAttachResult;
const attached = result.attached?.length ?? 0;
const skipped = result.skipped?.length ?? 0;
const errors = result.errors?.length ?? 0;
if (errors > 0) {
messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
} else {
messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
}
onAttached?.();
onClose();
} finally {
setSaving(false);
}
}
const noClients = !loading && clientRows.length === 0;
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')}
title={t('pages.inbounds.attachExistingTitle', { remark: target?.tag ?? '' })}
width={680}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.inbounds.attachExistingDesc', { count: attachableCount })}
</Typography.Paragraph>
{noClients ? (
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
) : (
<Spin spinning={loading}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Space wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ width: 260 }}
/>
{groupOptions.length > 0 && (
<Select
allowClear
value={groupFilter}
onChange={(v) => setGroupFilter(v)}
options={groupOptions}
placeholder={t('pages.clients.group')}
style={{ minWidth: 160 }}
optionFilterProp="label"
/>
)}
</Space>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: attachableCount,
})}
</Typography.Text>
</Space>
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 280 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
getCheckboxProps: (row) => ({ disabled: row.alreadyAttached }),
preserveSelectedRowKeys: true,
}}
/>
</Space>
</Spin>
)}
</Modal>
);
}
@@ -1,3 +1,4 @@
export { default as AttachClientsModal } from './AttachClientsModal';
export { default as AttachExistingClientsModal } from './AttachExistingClientsModal';
export { default as DetachClientsModal } from './DetachClientsModal';
export { default as AddClientsToGroupModal } from './AddClientsToGroupModal';
@@ -49,6 +49,9 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
if (isInboundMultiUser(record)) {
items.push({ key: 'attachExisting', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachExistingClients') });
}
if (isInboundMultiUser(record) && hasClients) {
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });