ClientFormModal.tsx 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. AutoComplete,
  5. Button,
  6. Col,
  7. Form,
  8. Input,
  9. InputNumber,
  10. Modal,
  11. Popconfirm,
  12. Row,
  13. Select,
  14. Space,
  15. Switch,
  16. Tabs,
  17. Tag,
  18. Tooltip,
  19. Typography,
  20. message,
  21. } from 'antd';
  22. import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
  23. import dayjs from 'dayjs';
  24. import type { Dayjs } from 'dayjs';
  25. import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
  26. import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
  27. import { formatInboundLabel } from '@/lib/inbounds/label';
  28. import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
  29. import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
  30. import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
  31. import { FormField } from '@/components/form/rhf';
  32. import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
  33. import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
  34. import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
  35. import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
  36. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  37. const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
  38. const MULTI_CLIENT_PROTOCOLS = new Set([
  39. 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
  40. ]);
  41. const CLIENT_FORM_MODAL_Z_INDEX = 1000;
  42. const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
  43. interface ExternalLinkRow {
  44. kind: 'link' | 'subscription';
  45. value: string;
  46. remark: string;
  47. }
  48. interface ApiMsg<T = unknown> {
  49. success?: boolean;
  50. msg?: string;
  51. obj?: T;
  52. }
  53. interface ClientHwidInfo {
  54. id: number;
  55. firstSeen: number;
  56. lastSeen: number;
  57. userAgent: string;
  58. deviceOs: string;
  59. osVersion: string;
  60. deviceModel: string;
  61. }
  62. type Mode = 'add' | 'edit';
  63. interface SaveMetaEdit {
  64. isEdit: true;
  65. email: string;
  66. attach: number[];
  67. detach: number[];
  68. externalLinks: ExternalLinkInput[];
  69. }
  70. interface SaveMetaCreate {
  71. isEdit: false;
  72. email: string;
  73. externalLinks: ExternalLinkInput[];
  74. }
  75. interface SaveCreatePayload {
  76. client: Record<string, unknown>;
  77. inboundIds: number[];
  78. }
  79. interface ClientFormModalProps {
  80. open: boolean;
  81. mode: Mode;
  82. client: ClientRecord | null;
  83. inbounds: InboundOption[];
  84. attachedExternalLinks?: ExternalLink[];
  85. attachedIds?: number[];
  86. tgBotEnable?: boolean;
  87. groups?: string[];
  88. save: (
  89. payload: Record<string, unknown> | SaveCreatePayload,
  90. meta: SaveMetaEdit | SaveMetaCreate,
  91. ) => Promise<ApiMsg | null>;
  92. resetTraffic?: (client: ClientRecord) => Promise<ApiMsg | null>;
  93. onOpenChange: (open: boolean) => void;
  94. }
  95. type Values = ClientFormValues & {
  96. expiryDate: number;
  97. limitHwid: number;
  98. externalLinks: ExternalLinkRow[];
  99. wgPrivateKey: string;
  100. wgPublicKey: string;
  101. wgPreSharedKey: string;
  102. wgAllowedIPs: string;
  103. secret: string;
  104. adTag: string;
  105. };
  106. const EMPTY: Values = {
  107. email: '',
  108. subId: '',
  109. uuid: '',
  110. password: '',
  111. auth: '',
  112. flow: '',
  113. security: 'auto',
  114. reverseTag: '',
  115. totalGB: 0,
  116. expiryDate: 0,
  117. delayedStart: false,
  118. delayedDays: 0,
  119. reset: 0,
  120. resetMax: 0,
  121. limitIp: 0,
  122. limitHwid: 0,
  123. tgId: 0,
  124. group: '',
  125. comment: '',
  126. enable: true,
  127. inboundIds: [],
  128. externalLinks: [],
  129. wgPrivateKey: '',
  130. wgPublicKey: '',
  131. wgPreSharedKey: '',
  132. wgAllowedIPs: '',
  133. secret: '',
  134. adTag: '',
  135. };
  136. function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
  137. return (links || []).map((l) => ({
  138. kind: l.kind === 'subscription' ? 'subscription' : 'link',
  139. value: l.value || '',
  140. remark: l.remark || '',
  141. }));
  142. }
  143. function bytesToGB(bytes: number): number {
  144. if (!bytes || bytes <= 0) return 0;
  145. return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
  146. }
  147. export function gbToBytes(gb: number): number {
  148. if (!gb || gb <= 0) return 0;
  149. return Math.round(gb * 1024 * 1024 * 1024);
  150. }
  151. export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number {
  152. if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
  153. return originalBytes;
  154. }
  155. return gbToBytes(displayedGB);
  156. }
  157. export default function ClientFormModal({
  158. open,
  159. mode,
  160. client,
  161. inbounds,
  162. attachedExternalLinks = [],
  163. attachedIds = [],
  164. tgBotEnable = false,
  165. groups = [],
  166. save,
  167. resetTraffic,
  168. onOpenChange,
  169. }: ClientFormModalProps) {
  170. const { t } = useTranslation();
  171. const [messageApi, messageContextHolder] = message.useMessage();
  172. const isEdit = mode === 'edit';
  173. const methods = useForm<Values>({ defaultValues: EMPTY });
  174. const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
  175. const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
  176. const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
  177. const enable = useWatch({ control: methods.control, name: 'enable' });
  178. const flow = useWatch({ control: methods.control, name: 'flow' });
  179. const reverseTag = useWatch({ control: methods.control, name: 'reverseTag' });
  180. const secret = useWatch({ control: methods.control, name: 'secret' });
  181. const email = useWatch({ control: methods.control, name: 'email' });
  182. const uuid = useWatch({ control: methods.control, name: 'uuid' });
  183. const password = useWatch({ control: methods.control, name: 'password' });
  184. const subId = useWatch({ control: methods.control, name: 'subId' });
  185. const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
  186. const auth = useWatch({ control: methods.control, name: 'auth' });
  187. const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
  188. const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
  189. const {
  190. fields: externalLinkFields,
  191. append: appendExternalLink,
  192. remove: removeExternalLink,
  193. } = useFieldArray({ control: methods.control, name: 'externalLinks' });
  194. const [submitting, setSubmitting] = useState(false);
  195. const [resetting, setResetting] = useState(false);
  196. const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
  197. const [ipsLoading, setIpsLoading] = useState(false);
  198. const [ipsClearing, setIpsClearing] = useState(false);
  199. const [ipsModalOpen, setIpsModalOpen] = useState(false);
  200. const [clientHwids, setClientHwids] = useState<ClientHwidInfo[]>([]);
  201. const [hwidsLoading, setHwidsLoading] = useState(false);
  202. const [hwidsClearing, setHwidsClearing] = useState(false);
  203. const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
  204. const fail2ban = useFail2banStatusQuery();
  205. const limitIpDisabled = !fail2ban.usable;
  206. const limitIpNotice = getLimitIpNotice(fail2ban, t);
  207. function addExternalLinkRow(kind: 'link' | 'subscription') {
  208. appendExternalLink({ kind, value: '', remark: '' });
  209. }
  210. useEffect(() => {
  211. if (!open) return;
  212. setIpsModalOpen(false);
  213. setHwidsModalOpen(false);
  214. if (isEdit && client) {
  215. const et = Number(client.expiryTime) || 0;
  216. const seed: Values = {
  217. ...EMPTY,
  218. email: client.email || '',
  219. subId: client.subId || '',
  220. uuid: client.uuid || '',
  221. password: client.password || '',
  222. auth: client.auth || '',
  223. flow: client.flow || '',
  224. security: !client.security || client.security === 'none' || client.security === 'zero'
  225. ? 'auto'
  226. : client.security,
  227. reverseTag: client.reverse?.tag || '',
  228. totalGB: bytesToGB(client.totalGB || 0),
  229. reset: Number(client.reset) || 0,
  230. resetMax: Number(client.resetMax) || 0,
  231. limitIp: client.limitIp || 0,
  232. limitHwid: client.limitHwid || 0,
  233. tgId: Number(client.tgId) || 0,
  234. group: client.group || '',
  235. comment: client.comment || '',
  236. enable: !!client.enable,
  237. inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
  238. externalLinks: toExternalLinkRows(attachedExternalLinks),
  239. wgPrivateKey: client.privateKey || '',
  240. wgPublicKey: client.publicKey || '',
  241. wgPreSharedKey: client.preSharedKey || '',
  242. wgAllowedIPs: client.allowedIPs || '',
  243. secret: client.secret || '',
  244. adTag: client.adTag || '',
  245. };
  246. if (et < 0) {
  247. seed.delayedStart = true;
  248. seed.delayedDays = Math.round(et / -86400000);
  249. seed.expiryDate = 0;
  250. } else {
  251. seed.delayedStart = false;
  252. seed.delayedDays = 0;
  253. seed.expiryDate = et > 0 ? et : 0;
  254. }
  255. methods.reset(seed);
  256. void loadIps();
  257. void loadHwids();
  258. } else {
  259. const wgKeypair = Wireguard.generateKeypair();
  260. methods.reset({
  261. ...EMPTY,
  262. email: RandomUtil.randomLowerAndNum(10),
  263. uuid: RandomUtil.randomUUID(),
  264. subId: RandomUtil.randomLowerAndNum(16),
  265. password: RandomUtil.randomLowerAndNum(16),
  266. auth: RandomUtil.randomLowerAndNum(16),
  267. wgPrivateKey: wgKeypair.privateKey,
  268. wgPublicKey: wgKeypair.publicKey,
  269. });
  270. }
  271. // eslint-disable-next-line react-hooks/exhaustive-deps
  272. }, [open, isEdit]);
  273. const flowCapableIds = useMemo(() => {
  274. const ids = new Set<number>();
  275. for (const row of inbounds || []) {
  276. if (row?.tlsFlowCapable) ids.add(row.id);
  277. }
  278. return ids;
  279. }, [inbounds]);
  280. const vlessLikeIds = useMemo(() => {
  281. const ids = new Set<number>();
  282. for (const row of inbounds || []) {
  283. if (row && row.protocol === 'vless') ids.add(row.id);
  284. }
  285. return ids;
  286. }, [inbounds]);
  287. const vmessIds = useMemo(() => {
  288. const ids = new Set<number>();
  289. for (const row of inbounds || []) {
  290. if (row && row.protocol === 'vmess') ids.add(row.id);
  291. }
  292. return ids;
  293. }, [inbounds]);
  294. const wireguardIds = useMemo(() => {
  295. const ids = new Set<number>();
  296. for (const row of inbounds || []) {
  297. if (row && row.protocol === 'wireguard') ids.add(row.id);
  298. }
  299. return ids;
  300. }, [inbounds]);
  301. const mtprotoIds = useMemo(() => {
  302. const ids = new Set<number>();
  303. for (const row of inbounds || []) {
  304. if (row && row.protocol === 'mtproto') ids.add(row.id);
  305. }
  306. return ids;
  307. }, [inbounds]);
  308. const mtprotoDomain = useMemo(() => {
  309. for (const id of inboundIds || []) {
  310. const ib = (inbounds || []).find((row) => row.id === id);
  311. if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain;
  312. }
  313. return 'www.cloudflare.com';
  314. }, [inboundIds, inbounds]);
  315. const ss2022Method = useMemo(() => {
  316. for (const id of inboundIds || []) {
  317. const ib = (inbounds || []).find((row) => row.id === id);
  318. const method = ib?.ssMethod;
  319. if (method && method.substring(0, 4) === '2022') return method;
  320. }
  321. return '';
  322. }, [inboundIds, inbounds]);
  323. function regeneratePassword() {
  324. methods.setValue('password', ss2022Method
  325. ? RandomUtil.randomShadowsocksPassword(ss2022Method)
  326. : RandomUtil.randomLowerAndNum(16));
  327. }
  328. const showFlow = useMemo(
  329. () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
  330. [inboundIds, flowCapableIds],
  331. );
  332. const showReverseTag = useMemo(
  333. () => (inboundIds || []).some((id) => vlessLikeIds.has(id)),
  334. [inboundIds, vlessLikeIds],
  335. );
  336. const showSecurity = useMemo(
  337. () => (inboundIds || []).some((id) => vmessIds.has(id)),
  338. [inboundIds, vmessIds],
  339. );
  340. const showWireguard = useMemo(
  341. () => (inboundIds || []).some((id) => wireguardIds.has(id)),
  342. [inboundIds, wireguardIds],
  343. );
  344. const showMtproto = useMemo(
  345. () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
  346. [inboundIds, mtprotoIds],
  347. );
  348. function regenerateWireguardKeys() {
  349. const kp = Wireguard.generateKeypair();
  350. methods.setValue('wgPrivateKey', kp.privateKey);
  351. methods.setValue('wgPublicKey', kp.publicKey);
  352. }
  353. function regenerateMtprotoSecret() {
  354. methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
  355. }
  356. useEffect(() => {
  357. // Only clear the flow once we actually have inbound options to judge
  358. // capability from. While the options list is momentarily empty (e.g. the
  359. // options query is (re)loading and `inbounds` falls back to `[]`), showFlow
  360. // is a false negative, so clearing here would silently drop a valid
  361. // xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
  362. if (inbounds.length > 0 && !showFlow && flow) {
  363. methods.setValue('flow', '');
  364. }
  365. }, [inbounds, showFlow, flow, methods]);
  366. useEffect(() => {
  367. if (!showReverseTag && reverseTag) {
  368. methods.setValue('reverseTag', '');
  369. }
  370. }, [showReverseTag, reverseTag, methods]);
  371. useEffect(() => {
  372. if (!ss2022Method) return;
  373. const current = methods.getValues('password');
  374. if (!RandomUtil.isShadowsocks2022Password(current, ss2022Method)) {
  375. methods.setValue('password', RandomUtil.randomShadowsocksPassword(ss2022Method));
  376. }
  377. }, [ss2022Method, methods]);
  378. useEffect(() => {
  379. if (showMtproto && !secret) {
  380. methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
  381. }
  382. }, [showMtproto, secret, mtprotoDomain, methods]);
  383. const inboundOptions = useMemo(
  384. () => (inbounds || [])
  385. .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
  386. .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
  387. .map((ib) => ({
  388. label: formatInboundLabel(ib.tag, ib.remark),
  389. value: ib.id,
  390. title: formatInboundLabel(ib.tag, ib.remark),
  391. })),
  392. [inbounds, inboundIds],
  393. );
  394. const expiryDayjs = useMemo<Dayjs | null>(
  395. () => (expiryDate > 0 ? dayjs(expiryDate) : null),
  396. [expiryDate],
  397. );
  398. const linkRows = externalLinkFields
  399. .map((field, index) => ({ field, index }))
  400. .filter((row) => row.field.kind === 'link');
  401. const subscriptionRows = externalLinkFields
  402. .map((field, index) => ({ field, index }))
  403. .filter((row) => row.field.kind === 'subscription');
  404. async function loadIps() {
  405. if (!isEdit || !client?.email) return;
  406. setIpsLoading(true);
  407. try {
  408. const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
  409. if (!msg?.success) { setClientIps([]); return; }
  410. setClientIps(normalizeClientIps(msg.obj));
  411. } finally {
  412. setIpsLoading(false);
  413. }
  414. }
  415. function openIpsModal() {
  416. setIpsModalOpen(true);
  417. if (clientIps.length === 0) void loadIps();
  418. }
  419. async function clearIps() {
  420. if (!isEdit || !client?.email) return;
  421. setIpsClearing(true);
  422. try {
  423. const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
  424. if (msg?.success) setClientIps([]);
  425. } finally {
  426. setIpsClearing(false);
  427. }
  428. }
  429. async function loadHwids() {
  430. if (!isEdit || !client?.email) return;
  431. setHwidsLoading(true);
  432. try {
  433. const msg = await HttpUtil.post(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
  434. if (!msg?.success || !Array.isArray(msg.obj)) { setClientHwids([]); return; }
  435. setClientHwids(msg.obj.filter((x): x is ClientHwidInfo => !!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number'));
  436. } finally {
  437. setHwidsLoading(false);
  438. }
  439. }
  440. function openHwidsModal() {
  441. setHwidsModalOpen(true);
  442. if (clientHwids.length === 0) void loadHwids();
  443. }
  444. async function clearHwids() {
  445. if (!isEdit || !client?.email) return;
  446. setHwidsClearing(true);
  447. try {
  448. const msg = await HttpUtil.delete(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg;
  449. if (msg?.success) setClientHwids([]);
  450. } finally {
  451. setHwidsClearing(false);
  452. }
  453. }
  454. function close() {
  455. onOpenChange(false);
  456. }
  457. async function onResetTraffic() {
  458. if (!isEdit || !client?.email || !resetTraffic) return;
  459. setResetting(true);
  460. try {
  461. const msg = await resetTraffic(client);
  462. if (msg?.success) {
  463. messageApi.success(t('pages.clients.toasts.trafficReset'));
  464. } else {
  465. messageApi.error(msg?.msg || t('somethingWentWrong'));
  466. }
  467. } finally {
  468. setResetting(false);
  469. }
  470. }
  471. async function onSubmit() {
  472. const values = methods.getValues();
  473. const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
  474. const validated = schema.safeParse({
  475. email: values.email,
  476. subId: values.subId,
  477. uuid: values.uuid,
  478. password: values.password,
  479. auth: values.auth,
  480. flow: values.flow,
  481. security: values.security,
  482. reverseTag: values.reverseTag,
  483. totalGB: values.totalGB,
  484. delayedStart: values.delayedStart,
  485. delayedDays: values.delayedDays,
  486. reset: values.reset,
  487. resetMax: values.resetMax,
  488. limitIp: values.limitIp,
  489. limitHwid: values.limitHwid,
  490. tgId: values.tgId,
  491. group: values.group,
  492. comment: values.comment,
  493. enable: values.enable,
  494. inboundIds: values.inboundIds,
  495. });
  496. if (!validated.success) {
  497. const issue = validated.error.issues[0];
  498. messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
  499. return;
  500. }
  501. const expiryTime = values.delayedStart
  502. ? -86400000 * (Number(values.delayedDays) || 0)
  503. : (values.expiryDate || 0);
  504. const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
  505. const clientPayload: Record<string, unknown> = {
  506. email: values.email.trim(),
  507. subId: values.subId,
  508. id: values.uuid,
  509. password: values.password,
  510. auth: values.auth,
  511. flow: showFlow ? (values.flow || '') : '',
  512. security: showSecurity ? (values.security || 'auto') : 'auto',
  513. totalGB: totalBytes,
  514. expiryTime,
  515. reset: Number(values.reset) || 0,
  516. resetMax: Number(values.resetMax) || 0,
  517. limitIp: Number(values.limitIp) || 0,
  518. limitHwid: Number(values.limitHwid) || 0,
  519. tgId: Number(values.tgId) || 0,
  520. group: values.group,
  521. comment: values.comment,
  522. enable: !!values.enable,
  523. };
  524. const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
  525. if (reverseTagValue) {
  526. clientPayload.reverse = { tag: reverseTagValue };
  527. }
  528. if (showWireguard) {
  529. clientPayload.privateKey = values.wgPrivateKey;
  530. clientPayload.publicKey = values.wgPublicKey;
  531. if (values.wgPreSharedKey) {
  532. clientPayload.preSharedKey = values.wgPreSharedKey;
  533. }
  534. const allowedIPs = values.wgAllowedIPs
  535. .split(',')
  536. .map((s) => s.trim())
  537. .filter((s) => s !== '');
  538. if (allowedIPs.length > 0) {
  539. clientPayload.allowedIPs = allowedIPs;
  540. }
  541. }
  542. if (showMtproto) {
  543. const adTag = values.adTag.trim();
  544. if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) {
  545. messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid'));
  546. return;
  547. }
  548. clientPayload.secret = values.secret;
  549. clientPayload.adTag = adTag;
  550. }
  551. const externalLinks: ExternalLinkInput[] = values.externalLinks
  552. .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
  553. .filter((r) => r.value !== '');
  554. setSubmitting(true);
  555. try {
  556. let msg;
  557. if (isEdit && client) {
  558. const original = new Set(attachedIds || []);
  559. const next = new Set(values.inboundIds || []);
  560. const toAttach = [...next].filter((id) => !original.has(id));
  561. const toDetach = [...original].filter((id) => !next.has(id));
  562. msg = await save(clientPayload, {
  563. isEdit: true,
  564. email: client.email,
  565. attach: toAttach,
  566. detach: toDetach,
  567. externalLinks,
  568. });
  569. } else {
  570. msg = await save(
  571. { client: clientPayload, inboundIds: values.inboundIds },
  572. { isEdit: false, email: clientPayload.email as string, externalLinks },
  573. );
  574. }
  575. if (msg?.success) close();
  576. } finally {
  577. setSubmitting(false);
  578. }
  579. }
  580. return (
  581. <>
  582. {messageContextHolder}
  583. <Modal
  584. open={open}
  585. title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
  586. destroyOnHidden
  587. width={720}
  588. zIndex={CLIENT_FORM_MODAL_Z_INDEX}
  589. style={{ top: 20 }}
  590. styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
  591. onCancel={close}
  592. footer={
  593. <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  594. {isEdit && resetTraffic && (
  595. <Popconfirm
  596. title={t('pages.inbounds.resetTraffic')}
  597. description={t('pages.inbounds.resetTrafficContent')}
  598. okText={t('reset')}
  599. cancelText={t('cancel')}
  600. zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
  601. onConfirm={onResetTraffic}
  602. >
  603. <Button color="danger" variant="filled" icon={<RetweetOutlined />} loading={resetting}>
  604. {t('pages.inbounds.resetTraffic')}
  605. </Button>
  606. </Popconfirm>
  607. )}
  608. <div style={{ marginInlineStart: 'auto', display: 'flex', gap: 8 }}>
  609. <Button onClick={close}>{t('cancel')}</Button>
  610. <Button type="primary" loading={submitting} onClick={onSubmit}>
  611. {isEdit ? t('save') : t('create')}
  612. </Button>
  613. </div>
  614. </div>
  615. }
  616. >
  617. <FormProvider {...methods}>
  618. <Form layout="vertical">
  619. <Tabs
  620. defaultActiveKey="basic"
  621. items={[
  622. {
  623. key: 'basic',
  624. label: t('pages.clients.tabBasics'),
  625. children: (
  626. <>
  627. <Row gutter={16}>
  628. <Col xs={24} md={12}>
  629. <Form.Item label={t('pages.clients.email')} required>
  630. <Space.Compact style={{ display: 'flex' }}>
  631. <Input
  632. value={email}
  633. placeholder={t('pages.clients.email')}
  634. style={{ flex: 1 }}
  635. onChange={(e) => methods.setValue('email', e.target.value)}
  636. />
  637. {!isEdit && (
  638. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('email', RandomUtil.randomLowerAndNum(12))} />
  639. )}
  640. </Space.Compact>
  641. </Form.Item>
  642. </Col>
  643. <Col xs={24} md={6}>
  644. <FormField
  645. name="totalGB"
  646. label={t('pages.clients.totalGB')}
  647. tooltip={t('pages.clients.totalGBDesc')}
  648. transform={{ output: (v) => Number(v) || 0 }}
  649. >
  650. <InputNumber min={0} step={1} style={{ width: '100%' }} />
  651. </FormField>
  652. </Col>
  653. <Col xs={24} md={6}>
  654. <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
  655. <Tooltip title={limitIpNotice || undefined}>
  656. <span style={{ display: 'flex', width: '100%' }}>
  657. <Space.Compact style={{ display: 'flex', flex: 1 }}>
  658. <InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
  659. style={{ flex: 1, ...(limitIpDisabled ? { pointerEvents: 'none' } : null) }}
  660. onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
  661. {isEdit && (
  662. <Tooltip title={t('pages.clients.ipLog')}>
  663. <Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
  664. {clientIps.length > 0 ? clientIps.length : ''}
  665. </Button>
  666. </Tooltip>
  667. )}
  668. </Space.Compact>
  669. </span>
  670. </Tooltip>
  671. </Form.Item>
  672. </Col>
  673. <Col xs={24} md={6}>
  674. <Form.Item label={t('pages.clients.limitHwid')} tooltip={t('pages.clients.limitHwidDesc')}>
  675. <Space.Compact style={{ display: 'flex' }}>
  676. <InputNumber value={limitHwid} min={0} style={{ flex: 1 }}
  677. onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)} />
  678. {isEdit && (
  679. <Tooltip title={t('pages.clients.hwidLog')}>
  680. <Button aria-label={t('pages.clients.hwidLog')} icon={<EyeOutlined />} loading={hwidsLoading} onClick={openHwidsModal}>
  681. {clientHwids.length > 0 ? clientHwids.length : ''}
  682. </Button>
  683. </Tooltip>
  684. )}
  685. </Space.Compact>
  686. </Form.Item>
  687. </Col>
  688. </Row>
  689. <Row gutter={16}>
  690. <Col xs={24} md={12}>
  691. {delayedStart ? (
  692. <FormField
  693. name="delayedDays"
  694. label={t('pages.clients.expireDays')}
  695. transform={{ output: (v) => Number(v) || 0 }}
  696. >
  697. <InputNumber min={0} style={{ width: '100%' }} />
  698. </FormField>
  699. ) : (
  700. <Form.Item label={t('pages.clients.expiryTime')}>
  701. <DateTimePicker
  702. value={expiryDayjs}
  703. onChange={(d) => methods.setValue('expiryDate', d ? d.valueOf() : 0)}
  704. />
  705. </Form.Item>
  706. )}
  707. </Col>
  708. <Col xs={12} md={6}>
  709. <Form.Item label={t('pages.clients.delayedStart')}>
  710. <Switch
  711. checked={delayedStart}
  712. onChange={(v) => {
  713. methods.setValue('delayedStart', v);
  714. if (v) methods.setValue('expiryDate', 0);
  715. else methods.setValue('delayedDays', 0);
  716. }}
  717. />
  718. </Form.Item>
  719. </Col>
  720. <Col xs={12} md={6}>
  721. <FormField
  722. name="reset"
  723. label={t('pages.clients.renewDays')}
  724. tooltip={t('pages.clients.renewDesc')}
  725. transform={{ output: (v) => Number(v) || 0 }}
  726. >
  727. <InputNumber min={0} style={{ width: '100%' }} />
  728. </FormField>
  729. </Col>
  730. <Col xs={12} md={6}>
  731. <FormField
  732. name="resetMax"
  733. label={t('pages.clients.renewMax')}
  734. tooltip={t('pages.clients.renewMaxDesc')}
  735. transform={{ output: (v) => Number(v) || 0 }}
  736. >
  737. <InputNumber min={0} style={{ width: '100%' }} />
  738. </FormField>
  739. </Col>
  740. </Row>
  741. <Row gutter={16}>
  742. <Col xs={24} md={12}>
  743. <FormField name="comment" label={t('pages.clients.comment')}>
  744. <Input />
  745. </FormField>
  746. </Col>
  747. <Col xs={24} md={12}>
  748. <FormField
  749. name="group"
  750. label={t('pages.clients.group')}
  751. tooltip={t('pages.clients.groupDesc')}
  752. transform={{ output: (v) => v ?? '' }}
  753. >
  754. <AutoComplete
  755. placeholder={t('pages.clients.groupPlaceholder')}
  756. options={groups.map((g) => ({ value: g }))}
  757. allowClear
  758. />
  759. </FormField>
  760. </Col>
  761. </Row>
  762. {(tgBotEnable || showReverseTag) && (
  763. <Row gutter={16}>
  764. {tgBotEnable && (
  765. <Col xs={24} md={12}>
  766. <FormField
  767. name="tgId"
  768. label={t('pages.clients.telegramId')}
  769. transform={{ output: (v) => Number(v) || 0 }}
  770. >
  771. <InputNumber min={0} controls={false}
  772. placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }} />
  773. </FormField>
  774. </Col>
  775. )}
  776. {showReverseTag && (
  777. <Col xs={24} md={12}>
  778. <FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
  779. <Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
  780. </FormField>
  781. </Col>
  782. )}
  783. </Row>
  784. )}
  785. <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
  786. <SelectAllClearButtons
  787. options={inboundOptions}
  788. value={inboundIds}
  789. onChange={(v) => methods.setValue('inboundIds', v)}
  790. />
  791. <Select
  792. mode="multiple"
  793. value={inboundIds}
  794. onChange={(v) => methods.setValue('inboundIds', v)}
  795. options={inboundOptions}
  796. placeholder={t('pages.clients.selectInbound')}
  797. maxTagCount="responsive"
  798. placement="topLeft"
  799. listHeight={220}
  800. showSearch={{
  801. filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
  802. }}
  803. />
  804. </Form.Item>
  805. <Form.Item>
  806. <Switch aria-label={t('enable')} checked={enable} onChange={(v) => methods.setValue('enable', v)} />
  807. <span style={{ marginLeft: 8 }}>{t('enable')}</span>
  808. </Form.Item>
  809. </>
  810. ),
  811. },
  812. {
  813. key: 'config',
  814. label: t('pages.clients.tabCredentials'),
  815. children: (
  816. <>
  817. <Form.Item label={t('pages.clients.uuid')}>
  818. <Space.Compact style={{ display: 'flex' }}>
  819. <Input value={uuid} style={{ flex: 1 }} onChange={(e) => methods.setValue('uuid', e.target.value)} />
  820. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())} />
  821. </Space.Compact>
  822. </Form.Item>
  823. <Form.Item label={t('pages.clients.password')} tooltip={t('pages.clients.passwordDesc')}>
  824. <Space.Compact style={{ display: 'flex' }}>
  825. <Input value={password} style={{ flex: 1 }} onChange={(e) => methods.setValue('password', e.target.value)} />
  826. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
  827. </Space.Compact>
  828. </Form.Item>
  829. <Form.Item label={t('pages.clients.subId')}>
  830. <Space.Compact style={{ display: 'flex' }}>
  831. <Input value={subId} style={{ flex: 1 }} onChange={(e) => methods.setValue('subId', e.target.value)} />
  832. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))} />
  833. </Space.Compact>
  834. </Form.Item>
  835. <Form.Item label={t('pages.clients.hysteriaAuth')} tooltip={t('pages.clients.hysteriaAuthDesc')}>
  836. <Space.Compact style={{ display: 'flex' }}>
  837. <Input value={auth} style={{ flex: 1 }} onChange={(e) => methods.setValue('auth', e.target.value)} />
  838. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('auth', RandomUtil.randomLowerAndNum(16))} />
  839. </Space.Compact>
  840. </Form.Item>
  841. {showFlow && (
  842. <FormField name="flow" label={t('pages.clients.flow')}>
  843. <Select
  844. options={[
  845. { value: '', label: t('none') },
  846. ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
  847. ]}
  848. />
  849. </FormField>
  850. )}
  851. {showSecurity && (
  852. <FormField name="security" label={t('pages.clients.vmessSecurity')}>
  853. <Select
  854. options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
  855. />
  856. </FormField>
  857. )}
  858. {showWireguard && (
  859. <>
  860. <Form.Item label={t('pages.clients.wireguardPrivateKey')}>
  861. <Space.Compact style={{ display: 'flex' }}>
  862. <Input
  863. value={wgPrivateKey}
  864. style={{ flex: 1 }}
  865. onChange={(e) => {
  866. const priv = e.target.value;
  867. methods.setValue('wgPrivateKey', priv);
  868. methods.setValue('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
  869. }}
  870. />
  871. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
  872. </Space.Compact>
  873. </Form.Item>
  874. <FormField name="wgPublicKey" label={t('pages.clients.wireguardPublicKey')}>
  875. <Input disabled />
  876. </FormField>
  877. <FormField name="wgPreSharedKey" label={t('pages.clients.wireguardPreSharedKey')}>
  878. <Input />
  879. </FormField>
  880. <FormField
  881. name="wgAllowedIPs"
  882. label={t('pages.clients.wireguardAllowedIPs')}
  883. extra={t('pages.clients.wireguardAllowedIPsHint')}
  884. >
  885. <Input placeholder="10.0.0.2/32" />
  886. </FormField>
  887. </>
  888. )}
  889. {showMtproto && (
  890. <>
  891. <Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
  892. <Space.Compact style={{ display: 'flex' }}>
  893. <Input value={secret} style={{ flex: 1 }} onChange={(e) => methods.setValue('secret', e.target.value)} />
  894. <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
  895. </Space.Compact>
  896. </Form.Item>
  897. <FormField
  898. name="adTag"
  899. label={t('pages.clients.mtprotoAdTag')}
  900. extra={t('pages.clients.mtprotoAdTagHint')}
  901. >
  902. <Input
  903. allowClear
  904. placeholder="0123456789abcdef0123456789abcdef"
  905. />
  906. </FormField>
  907. </>
  908. )}
  909. </>
  910. ),
  911. },
  912. {
  913. key: 'links',
  914. label: t('pages.clients.tabLinks'),
  915. children: (
  916. <>
  917. <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
  918. {t('pages.clients.linksHint')}
  919. </Typography.Paragraph>
  920. <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
  921. {t('pages.clients.addExternalLink')}
  922. </Button>
  923. <div style={{ marginTop: 12, marginBottom: 24 }}>
  924. {linkRows.length === 0 ? (
  925. <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
  926. ) : linkRows.map(({ field, index }) => (
  927. <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
  928. <FormField name={`externalLinks.${index}.value`} noStyle>
  929. <Input
  930. style={{ flex: 1 }}
  931. aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
  932. placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
  933. />
  934. </FormField>
  935. <FormField name={`externalLinks.${index}.remark`} noStyle>
  936. <Input
  937. style={{ width: 140 }}
  938. aria-label={t('remark')}
  939. placeholder={t('remark')}
  940. />
  941. </FormField>
  942. <Tooltip title={t('delete')}>
  943. <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
  944. </Tooltip>
  945. </div>
  946. ))}
  947. </div>
  948. <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
  949. {t('pages.clients.addExternalSubscription')}
  950. </Button>
  951. <div style={{ marginTop: 12 }}>
  952. {subscriptionRows.length === 0 ? (
  953. <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
  954. ) : subscriptionRows.map(({ field, index }) => (
  955. <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
  956. <FormField name={`externalLinks.${index}.value`} noStyle>
  957. <Input
  958. style={{ flex: 1 }}
  959. aria-label="https://provider.example/sub/…"
  960. placeholder="https://provider.example/sub/…"
  961. />
  962. </FormField>
  963. <Tooltip title={t('delete')}>
  964. <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
  965. </Tooltip>
  966. </div>
  967. ))}
  968. </div>
  969. </>
  970. ),
  971. },
  972. ]}
  973. />
  974. </Form>
  975. </FormProvider>
  976. </Modal>
  977. <Modal
  978. open={ipsModalOpen}
  979. title={`${t('pages.clients.ipLog')}${client?.email ? ` — ${client.email}` : ''}`}
  980. width={440}
  981. zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
  982. onCancel={() => setIpsModalOpen(false)}
  983. footer={[
  984. <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
  985. {t('refresh')}
  986. </Button>,
  987. <Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
  988. {t('pages.clients.clearAll')}
  989. </Button>,
  990. <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
  991. {t('close')}
  992. </Button>,
  993. ]}
  994. >
  995. {clientIps.length > 0 ? (
  996. <div style={{ maxHeight: 360, overflowY: 'auto' }}>
  997. {clientIps.map((entry, idx) => (
  998. <Tag
  999. key={idx}
  1000. color="blue"
  1001. style={{
  1002. display: 'block',
  1003. width: 'fit-content',
  1004. maxWidth: '100%',
  1005. marginBottom: 6,
  1006. padding: '2px 8px',
  1007. fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
  1008. }}
  1009. >
  1010. {entry.ip}{entry.time ? ` (${entry.time})` : ''}
  1011. {entry.node ? (
  1012. <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
  1013. ) : null}
  1014. </Tag>
  1015. ))}
  1016. </div>
  1017. ) : (
  1018. <Tag>{t('tgbot.noIpRecord')}</Tag>
  1019. )}
  1020. </Modal>
  1021. <Modal
  1022. open={hwidsModalOpen}
  1023. title={`${t('pages.clients.hwidLog')}${client?.email ? ` — ${client.email}` : ''}`}
  1024. width={520}
  1025. zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
  1026. onCancel={() => setHwidsModalOpen(false)}
  1027. footer={[
  1028. <Button key="refresh" icon={<ReloadOutlined />} loading={hwidsLoading} onClick={loadHwids}>
  1029. {t('refresh')}
  1030. </Button>,
  1031. <Button key="clear" danger loading={hwidsClearing} disabled={clientHwids.length === 0} onClick={clearHwids}>
  1032. {t('pages.clients.clearAll')}
  1033. </Button>,
  1034. <Button key="close" type="primary" onClick={() => setHwidsModalOpen(false)}>
  1035. {t('close')}
  1036. </Button>,
  1037. ]}
  1038. >
  1039. {clientHwids.length > 0 ? (
  1040. <div style={{ maxHeight: 360, overflowY: 'auto' }}>
  1041. {clientHwids.map((entry) => (
  1042. <div key={entry.id} style={{ borderBottom: '1px solid var(--ant-color-border-secondary)', padding: '8px 0' }}>
  1043. <Typography.Text strong>{entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}</Typography.Text>
  1044. <br />
  1045. <Typography.Text type="secondary">
  1046. {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')}
  1047. </Typography.Text>
  1048. <br />
  1049. <Typography.Text type="secondary">
  1050. {t('pages.clients.firstSeen')}: {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'}
  1051. </Typography.Text>
  1052. <br />
  1053. <Typography.Text type="secondary">
  1054. {t('pages.clients.lastSeen')}: {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'}
  1055. </Typography.Text>
  1056. {entry.userAgent && (
  1057. <>
  1058. <br />
  1059. <Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>{entry.userAgent}</Typography.Text>
  1060. </>
  1061. )}
  1062. </div>
  1063. ))}
  1064. </div>
  1065. ) : (
  1066. <Tag>{t('pages.clients.noHwids')}</Tag>
  1067. )}
  1068. </Modal>
  1069. </>
  1070. );
  1071. }