import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AutoComplete, Button, Col, Form, Input, InputNumber, Modal, Popconfirm, Row, Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message, } from 'antd'; import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import { HttpUtil, RandomUtil } from '@/utils'; import { formatInboundLabel } from '@/lib/inbounds/label'; import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log'; import { DateTimePicker, SelectAllClearButtons } from '@/components/form'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients'; import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client'; const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const; const MULTI_CLIENT_PROTOCOLS = new Set([ 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', ]); const CLIENT_FORM_MODAL_Z_INDEX = 1000; const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1; // One editable row in the Links tab. `key` is a stable client-side id for React. interface ExternalLinkRow { key: number; kind: 'link' | 'subscription'; value: string; } interface ApiMsg { success?: boolean; msg?: string; obj?: T; } type Mode = 'add' | 'edit'; interface SaveMetaEdit { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[]; } interface SaveMetaCreate { isEdit: false; email: string; externalLinks: ExternalLinkInput[]; } interface SaveCreatePayload { client: Record; inboundIds: number[]; } interface ClientFormModalProps { open: boolean; mode: Mode; client: ClientRecord | null; inbounds: InboundOption[]; attachedExternalLinks?: ExternalLink[]; attachedIds?: number[]; tgBotEnable?: boolean; groups?: string[]; save: ( payload: Record | SaveCreatePayload, meta: SaveMetaEdit | SaveMetaCreate, ) => Promise; resetTraffic?: (client: ClientRecord) => Promise; onOpenChange: (open: boolean) => void; } interface FormState { email: string; subId: string; uuid: string; password: string; auth: string; flow: string; security: string; reverseTag: string; totalGB: number; expiryDate: Dayjs | null; delayedStart: boolean; delayedDays: number; reset: number; limitIp: number; tgId: number; group: string; comment: string; enable: boolean; inboundIds: number[]; externalLinks: ExternalLinkRow[]; } function emptyForm(): FormState { return { email: '', subId: '', uuid: '', password: '', auth: '', flow: '', security: 'auto', reverseTag: '', totalGB: 0, expiryDate: null, delayedStart: false, delayedDays: 0, reset: 0, limitIp: 0, tgId: 0, group: '', comment: '', enable: true, inboundIds: [], externalLinks: [], }; } let externalLinkRowSeq = 0; function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] { return (links || []).map((l) => ({ key: (externalLinkRowSeq += 1), kind: l.kind === 'subscription' ? 'subscription' : 'link', value: l.value || '', })); } function bytesToGB(bytes: number): number { if (!bytes || bytes <= 0) return 0; return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100; } function gbToBytes(gb: number): number { if (!gb || gb <= 0) return 0; return Math.round(gb * 1024 * 1024 * 1024); } export default function ClientFormModal({ open, mode, client, inbounds, attachedExternalLinks = [], attachedIds = [], tgBotEnable = false, groups = [], save, resetTraffic, onOpenChange, }: ClientFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const isEdit = mode === 'edit'; const [form, setForm] = useState(emptyForm); const [submitting, setSubmitting] = useState(false); const [resetting, setResetting] = useState(false); const [clientIps, setClientIps] = useState([]); const [ipsLoading, setIpsLoading] = useState(false); const [ipsClearing, setIpsClearing] = useState(false); const [ipsModalOpen, setIpsModalOpen] = useState(false); function update(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })); } function addExternalLinkRow(kind: 'link' | 'subscription') { setForm((prev) => ({ ...prev, externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }], })); } function updateExternalLinkRow(key: number, value: string) { setForm((prev) => ({ ...prev, externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)), })); } function removeExternalLinkRow(key: number) { setForm((prev) => ({ ...prev, externalLinks: prev.externalLinks.filter((r) => r.key !== key), })); } useEffect(() => { if (!open) return; setIpsModalOpen(false); if (isEdit && client) { const et = Number(client.expiryTime) || 0; const next: FormState = { ...emptyForm(), email: client.email || '', subId: client.subId || '', uuid: client.uuid || '', password: client.password || '', auth: client.auth || '', flow: client.flow || '', security: client.security || 'auto', reverseTag: client.reverse?.tag || '', totalGB: bytesToGB(client.totalGB || 0), reset: Number(client.reset) || 0, limitIp: client.limitIp || 0, tgId: Number(client.tgId) || 0, group: client.group || '', comment: client.comment || '', enable: !!client.enable, inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [], externalLinks: toExternalLinkRows(attachedExternalLinks), }; if (et < 0) { next.delayedStart = true; next.delayedDays = Math.round(et / -86400000); next.expiryDate = null; } else { next.delayedStart = false; next.delayedDays = 0; next.expiryDate = et > 0 ? dayjs(et) : null; } setForm(next); void loadIps(); } else { setForm({ ...emptyForm(), email: RandomUtil.randomLowerAndNum(10), uuid: RandomUtil.randomUUID(), subId: RandomUtil.randomLowerAndNum(16), password: RandomUtil.randomLowerAndNum(16), auth: RandomUtil.randomLowerAndNum(16), }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isEdit]); const flowCapableIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { if (row?.tlsFlowCapable) ids.add(row.id); } return ids; }, [inbounds]); const vlessLikeIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { if (row && row.protocol === 'vless') ids.add(row.id); } return ids; }, [inbounds]); const vmessIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { if (row && row.protocol === 'vmess') ids.add(row.id); } return ids; }, [inbounds]); const ss2022Method = useMemo(() => { for (const id of form.inboundIds || []) { const ib = (inbounds || []).find((row) => row.id === id); const method = ib?.ssMethod; if (method && method.substring(0, 4) === '2022') return method; } return ''; }, [form.inboundIds, inbounds]); function regeneratePassword() { update('password', ss2022Method ? RandomUtil.randomShadowsocksPassword(ss2022Method) : RandomUtil.randomLowerAndNum(16)); } const showFlow = useMemo( () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)), [form.inboundIds, flowCapableIds], ); const showReverseTag = useMemo( () => (form.inboundIds || []).some((id) => vlessLikeIds.has(id)), [form.inboundIds, vlessLikeIds], ); const showSecurity = useMemo( () => (form.inboundIds || []).some((id) => vmessIds.has(id)), [form.inboundIds, vmessIds], ); useEffect(() => { if (!showFlow && form.flow) { update('flow', ''); } }, [showFlow, form.flow]); useEffect(() => { if (!showReverseTag && form.reverseTag) { update('reverseTag', ''); } }, [showReverseTag, form.reverseTag]); useEffect(() => { if (!ss2022Method) return; setForm((prev) => ( RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method) ? prev : { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) } )); }, [ss2022Method]); const inboundOptions = useMemo( () => (inbounds || []) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) .map((ib) => ({ label: formatInboundLabel(ib.tag, ib.remark), value: ib.id, title: formatInboundLabel(ib.tag, ib.remark), })), [inbounds], ); const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]); const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]); async function loadIps() { if (!isEdit || !client?.email) return; setIpsLoading(true); try { const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg; if (!msg?.success) { setClientIps([]); return; } setClientIps(normalizeClientIps(msg.obj)); } finally { setIpsLoading(false); } } function openIpsModal() { setIpsModalOpen(true); if (clientIps.length === 0) void loadIps(); } async function clearIps() { if (!isEdit || !client?.email) return; setIpsClearing(true); try { const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg; if (msg?.success) setClientIps([]); } finally { setIpsClearing(false); } } function close() { onOpenChange(false); } async function onResetTraffic() { if (!isEdit || !client?.email || !resetTraffic) return; setResetting(true); try { const msg = await resetTraffic(client); if (msg?.success) { messageApi.success(t('pages.clients.toasts.trafficReset')); } else { messageApi.error(msg?.msg || t('somethingWentWrong')); } } finally { setResetting(false); } } async function onSubmit() { const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema; const validated = schema.safeParse({ email: form.email, subId: form.subId, uuid: form.uuid, password: form.password, auth: form.auth, flow: form.flow, security: form.security, reverseTag: form.reverseTag, totalGB: form.totalGB, delayedStart: form.delayedStart, delayedDays: form.delayedDays, reset: form.reset, limitIp: form.limitIp, tgId: form.tgId, group: form.group, comment: form.comment, enable: form.enable, inboundIds: form.inboundIds, }); if (!validated.success) { const issue = validated.error.issues[0]; messageApi.error(t(issue?.message ?? 'somethingWentWrong')); return; } const expiryTime = form.delayedStart ? -86400000 * (Number(form.delayedDays) || 0) : (form.expiryDate ? form.expiryDate.valueOf() : 0); const clientPayload: Record = { email: form.email.trim(), subId: form.subId, id: form.uuid, password: form.password, auth: form.auth, flow: showFlow ? (form.flow || '') : '', security: showSecurity ? (form.security || 'auto') : 'auto', totalGB: gbToBytes(form.totalGB), expiryTime, reset: Number(form.reset) || 0, limitIp: Number(form.limitIp) || 0, tgId: Number(form.tgId) || 0, group: form.group, comment: form.comment, enable: !!form.enable, }; const reverseTag = showReverseTag ? (form.reverseTag || '').trim() : ''; if (reverseTag) { clientPayload.reverse = { tag: reverseTag }; } const externalLinks: ExternalLinkInput[] = form.externalLinks .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' })) .filter((r) => r.value !== ''); setSubmitting(true); try { let msg; if (isEdit && client) { const original = new Set(attachedIds || []); const next = new Set(form.inboundIds || []); const toAttach = [...next].filter((id) => !original.has(id)); const toDetach = [...original].filter((id) => !next.has(id)); msg = await save(clientPayload, { isEdit: true, email: client.email, attach: toAttach, detach: toDetach, externalLinks, }); } else { msg = await save( { client: clientPayload, inboundIds: form.inboundIds }, { isEdit: false, email: clientPayload.email as string, externalLinks }, ); } if (msg?.success) close(); } finally { setSubmitting(false); } } return ( <> {messageContextHolder} {isEdit && resetTraffic && ( )}
} >
update('email', e.target.value)} /> {!isEdit && ( )} {form.delayedStart ? ( update('delayedDays', Number(v) || 0)} /> ) : ( update('expiryDate', d || null)} /> )} { update('delayedStart', v); if (v) update('expiryDate', null); else update('delayedDays', 0); }} /> update('reset', Number(v) || 0)} /> update('comment', e.target.value)} /> ({ value: g }))} onChange={(v) => update('group', v ?? '')} allowClear /> {(tgBotEnable || showReverseTag) && ( {tgBotEnable && ( update('tgId', Number(v) || 0)} /> )} {showReverseTag && ( update('reverseTag', e.target.value)} /> )} )} update('inboundIds', v)} /> update('uuid', e.target.value)} />
{linkRows.length === 0 ? ( {t('pages.clients.noExternalLinks')} ) : linkRows.map((row) => (
updateExternalLinkRow(row.key, e.target.value)} placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://" />
))}
{subscriptionRows.length === 0 ? ( {t('pages.clients.noExternalSubscriptions')} ) : subscriptionRows.map((row) => (
updateExternalLinkRow(row.key, e.target.value)} placeholder="https://provider.example/sub/…" />
))}
), }, ]} />
setIpsModalOpen(false)} footer={[ , , , ]} > {clientIps.length > 0 ? (
{clientIps.map((entry, idx) => ( {entry.ip}{entry.time ? ` (${entry.time})` : ''} {entry.node ? ( @ {entry.node} ) : null} ))}
) : ( {t('tgbot.noIpRecord')} )}
); }