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 { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form'; import { HttpUtil, RandomUtil, Wireguard } from '@/utils'; import { formatInboundLabel } from '@/lib/inbounds/label'; import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults'; import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log'; import { DateTimePicker, SelectAllClearButtons } from '@/components/form'; import { FormField } from '@/components/form/rhf'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients'; import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery'; import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client'; const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const; const MULTI_CLIENT_PROTOCOLS = new Set([ 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto', ]); const CLIENT_FORM_MODAL_Z_INDEX = 1000; const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1; interface ExternalLinkRow { 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; } type Values = ClientFormValues & { expiryDate: number; externalLinks: ExternalLinkRow[]; wgPrivateKey: string; wgPublicKey: string; wgPreSharedKey: string; wgAllowedIPs: string; secret: string; adTag: string; }; const EMPTY: Values = { email: '', subId: '', uuid: '', password: '', auth: '', flow: '', security: 'auto', reverseTag: '', totalGB: 0, expiryDate: 0, delayedStart: false, delayedDays: 0, reset: 0, limitIp: 0, tgId: 0, group: '', comment: '', enable: true, inboundIds: [], externalLinks: [], wgPrivateKey: '', wgPublicKey: '', wgPreSharedKey: '', wgAllowedIPs: '', secret: '', adTag: '', }; function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] { return (links || []).map((l) => ({ 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; } export function gbToBytes(gb: number): number { if (!gb || gb <= 0) return 0; return Math.round(gb * 1024 * 1024 * 1024); } export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number { if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) { return originalBytes; } return gbToBytes(displayedGB); } 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 methods = useForm({ defaultValues: EMPTY }); const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' }); const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' }); const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' }); const enable = useWatch({ control: methods.control, name: 'enable' }); const flow = useWatch({ control: methods.control, name: 'flow' }); const reverseTag = useWatch({ control: methods.control, name: 'reverseTag' }); const secret = useWatch({ control: methods.control, name: 'secret' }); const email = useWatch({ control: methods.control, name: 'email' }); const uuid = useWatch({ control: methods.control, name: 'uuid' }); const password = useWatch({ control: methods.control, name: 'password' }); const subId = useWatch({ control: methods.control, name: 'subId' }); const auth = useWatch({ control: methods.control, name: 'auth' }); const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' }); const limitIp = useWatch({ control: methods.control, name: 'limitIp' }); const { fields: externalLinkFields, append: appendExternalLink, remove: removeExternalLink, } = useFieldArray({ control: methods.control, name: 'externalLinks' }); 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); const fail2ban = useFail2banStatusQuery(); const limitIpDisabled = !fail2ban.usable; const limitIpNotice = getLimitIpNotice(fail2ban, t); function addExternalLinkRow(kind: 'link' | 'subscription') { appendExternalLink({ kind, value: '' }); } useEffect(() => { if (!open) return; setIpsModalOpen(false); if (isEdit && client) { const et = Number(client.expiryTime) || 0; const seed: Values = { ...EMPTY, email: client.email || '', subId: client.subId || '', uuid: client.uuid || '', password: client.password || '', auth: client.auth || '', flow: client.flow || '', security: !client.security || client.security === 'none' || client.security === 'zero' ? 'auto' : client.security, 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), wgPrivateKey: client.privateKey || '', wgPublicKey: client.publicKey || '', wgPreSharedKey: client.preSharedKey || '', wgAllowedIPs: client.allowedIPs || '', secret: client.secret || '', adTag: client.adTag || '', }; if (et < 0) { seed.delayedStart = true; seed.delayedDays = Math.round(et / -86400000); seed.expiryDate = 0; } else { seed.delayedStart = false; seed.delayedDays = 0; seed.expiryDate = et > 0 ? et : 0; } methods.reset(seed); void loadIps(); } else { const wgKeypair = Wireguard.generateKeypair(); methods.reset({ ...EMPTY, email: RandomUtil.randomLowerAndNum(10), uuid: RandomUtil.randomUUID(), subId: RandomUtil.randomLowerAndNum(16), password: RandomUtil.randomLowerAndNum(16), auth: RandomUtil.randomLowerAndNum(16), wgPrivateKey: wgKeypair.privateKey, wgPublicKey: wgKeypair.publicKey, }); } // 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 wireguardIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { if (row && row.protocol === 'wireguard') ids.add(row.id); } return ids; }, [inbounds]); const mtprotoIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { if (row && row.protocol === 'mtproto') ids.add(row.id); } return ids; }, [inbounds]); const mtprotoDomain = useMemo(() => { for (const id of inboundIds || []) { const ib = (inbounds || []).find((row) => row.id === id); if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain; } return 'www.cloudflare.com'; }, [inboundIds, inbounds]); const ss2022Method = useMemo(() => { for (const id of inboundIds || []) { const ib = (inbounds || []).find((row) => row.id === id); const method = ib?.ssMethod; if (method && method.substring(0, 4) === '2022') return method; } return ''; }, [inboundIds, inbounds]); function regeneratePassword() { methods.setValue('password', ss2022Method ? RandomUtil.randomShadowsocksPassword(ss2022Method) : RandomUtil.randomLowerAndNum(16)); } const showFlow = useMemo( () => (inboundIds || []).some((id) => flowCapableIds.has(id)), [inboundIds, flowCapableIds], ); const showReverseTag = useMemo( () => (inboundIds || []).some((id) => vlessLikeIds.has(id)), [inboundIds, vlessLikeIds], ); const showSecurity = useMemo( () => (inboundIds || []).some((id) => vmessIds.has(id)), [inboundIds, vmessIds], ); const showWireguard = useMemo( () => (inboundIds || []).some((id) => wireguardIds.has(id)), [inboundIds, wireguardIds], ); const showMtproto = useMemo( () => (inboundIds || []).some((id) => mtprotoIds.has(id)), [inboundIds, mtprotoIds], ); function regenerateWireguardKeys() { const kp = Wireguard.generateKeypair(); methods.setValue('wgPrivateKey', kp.privateKey); methods.setValue('wgPublicKey', kp.publicKey); } function regenerateMtprotoSecret() { methods.setValue('secret', generateMtprotoSecret(mtprotoDomain)); } useEffect(() => { if (!showFlow && flow) { methods.setValue('flow', ''); } }, [showFlow, flow, methods]); useEffect(() => { if (!showReverseTag && reverseTag) { methods.setValue('reverseTag', ''); } }, [showReverseTag, reverseTag, methods]); useEffect(() => { if (!ss2022Method) return; const current = methods.getValues('password'); if (!RandomUtil.isShadowsocks2022Password(current, ss2022Method)) { methods.setValue('password', RandomUtil.randomShadowsocksPassword(ss2022Method)); } }, [ss2022Method, methods]); useEffect(() => { if (showMtproto && !secret) { methods.setValue('secret', generateMtprotoSecret(mtprotoDomain)); } }, [showMtproto, secret, mtprotoDomain, methods]); const inboundOptions = useMemo( () => (inbounds || []) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id)) .map((ib) => ({ label: formatInboundLabel(ib.tag, ib.remark), value: ib.id, title: formatInboundLabel(ib.tag, ib.remark), })), [inbounds, inboundIds], ); const expiryDayjs = useMemo( () => (expiryDate > 0 ? dayjs(expiryDate) : null), [expiryDate], ); const linkRows = externalLinkFields .map((field, index) => ({ field, index })) .filter((row) => row.field.kind === 'link'); const subscriptionRows = externalLinkFields .map((field, index) => ({ field, index })) .filter((row) => row.field.kind === 'subscription'); 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 values = methods.getValues(); const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema; const validated = schema.safeParse({ email: values.email, subId: values.subId, uuid: values.uuid, password: values.password, auth: values.auth, flow: values.flow, security: values.security, reverseTag: values.reverseTag, totalGB: values.totalGB, delayedStart: values.delayedStart, delayedDays: values.delayedDays, reset: values.reset, limitIp: values.limitIp, tgId: values.tgId, group: values.group, comment: values.comment, enable: values.enable, inboundIds: values.inboundIds, }); if (!validated.success) { const issue = validated.error.issues[0]; messageApi.error(t(issue?.message ?? 'somethingWentWrong')); return; } const expiryTime = values.delayedStart ? -86400000 * (Number(values.delayedDays) || 0) : (values.expiryDate || 0); const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB); const clientPayload: Record = { email: values.email.trim(), subId: values.subId, id: values.uuid, password: values.password, auth: values.auth, flow: showFlow ? (values.flow || '') : '', security: showSecurity ? (values.security || 'auto') : 'auto', totalGB: totalBytes, expiryTime, reset: Number(values.reset) || 0, limitIp: Number(values.limitIp) || 0, tgId: Number(values.tgId) || 0, group: values.group, comment: values.comment, enable: !!values.enable, }; const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : ''; if (reverseTagValue) { clientPayload.reverse = { tag: reverseTagValue }; } if (showWireguard) { clientPayload.privateKey = values.wgPrivateKey; clientPayload.publicKey = values.wgPublicKey; if (values.wgPreSharedKey) { clientPayload.preSharedKey = values.wgPreSharedKey; } const allowedIPs = values.wgAllowedIPs .split(',') .map((s) => s.trim()) .filter((s) => s !== ''); if (allowedIPs.length > 0) { clientPayload.allowedIPs = allowedIPs; } } if (showMtproto) { const adTag = values.adTag.trim(); if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) { messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid')); return; } clientPayload.secret = values.secret; clientPayload.adTag = adTag; } const externalLinks: ExternalLinkInput[] = values.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(values.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: values.inboundIds }, { isEdit: false, email: clientPayload.email as string, externalLinks }, ); } if (msg?.success) close(); } finally { setSubmitting(false); } } return ( <> {messageContextHolder} {isEdit && resetTraffic && ( )}
} >
methods.setValue('email', e.target.value)} /> {!isEdit && ( )} {delayedStart ? ( Number(v) || 0 }} > ) : ( methods.setValue('expiryDate', d ? d.valueOf() : 0)} /> )} { methods.setValue('delayedStart', v); if (v) methods.setValue('expiryDate', 0); else methods.setValue('delayedDays', 0); }} /> Number(v) || 0 }} > v ?? '' }} > ({ value: g }))} allowClear /> {(tgBotEnable || showReverseTag) && ( {tgBotEnable && ( Number(v) || 0 }} > )} {showReverseTag && ( )} )} methods.setValue('inboundIds', v)} /> methods.setValue('uuid', e.target.value)} />
{linkRows.length === 0 ? ( {t('pages.clients.noExternalLinks')} ) : linkRows.map(({ field, index }) => (
))}
{subscriptionRows.length === 0 ? ( {t('pages.clients.noExternalSubscriptions')} ) : subscriptionRows.map(({ field, index }) => (
))}
), }, ]} />
setIpsModalOpen(false)} footer={[ , , , ]} > {clientIps.length > 0 ? (
{clientIps.map((entry, idx) => ( {entry.ip}{entry.time ? ` (${entry.time})` : ''} {entry.node ? ( @ {entry.node} ) : null} ))}
) : ( {t('tgbot.noIpRecord')} )}
); }