ClientFormModal.tsx 38 KB

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