ClientFormModal.tsx 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562
  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 {
  23. DeleteOutlined,
  24. EyeOutlined,
  25. PlusOutlined,
  26. ReloadOutlined,
  27. RetweetOutlined,
  28. } from '@ant-design/icons';
  29. import dayjs from 'dayjs';
  30. import type { Dayjs } from 'dayjs';
  31. import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
  32. import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
  33. import { formatInboundLabel } from '@/lib/inbounds/label';
  34. import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
  35. import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
  36. import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
  37. import { useDatepicker } from '@/hooks/useDatepicker';
  38. import { useClientHwids } from '@/hooks/useClientHwids';
  39. import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
  40. import { FormField } from '@/components/form/rhf';
  41. import ClientHwidListModal from '@/components/clients/ClientHwidList';
  42. import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
  43. import type {
  44. ClientRecord,
  45. InboundOption,
  46. ExternalLink,
  47. ExternalLinkInput,
  48. } from '@/hooks/useClients';
  49. import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
  50. import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
  51. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  52. const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
  53. const MULTI_CLIENT_PROTOCOLS = new Set([
  54. 'shadowsocks',
  55. 'vless',
  56. 'vmess',
  57. 'trojan',
  58. 'hysteria',
  59. 'wireguard',
  60. 'mtproto',
  61. 'amneziawg',
  62. ]);
  63. const CLIENT_FORM_MODAL_Z_INDEX = 1000;
  64. const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
  65. interface ExternalLinkRow {
  66. kind: 'link' | 'subscription';
  67. value: string;
  68. remark: string;
  69. enable: boolean;
  70. expiryTime: number;
  71. namePrefix: string;
  72. lastFetchAt: number;
  73. lastFetchError: string;
  74. }
  75. interface ApiMsg<T = unknown> {
  76. success?: boolean;
  77. msg?: string;
  78. obj?: T;
  79. }
  80. type Mode = 'add' | 'edit';
  81. interface SaveMetaEdit {
  82. isEdit: true;
  83. email: string;
  84. attach: number[];
  85. detach: number[];
  86. externalLinks: ExternalLinkInput[];
  87. }
  88. interface SaveMetaCreate {
  89. isEdit: false;
  90. email: string;
  91. externalLinks: ExternalLinkInput[];
  92. }
  93. interface SaveCreatePayload {
  94. client: Record<string, unknown>;
  95. inboundIds: number[];
  96. }
  97. interface ClientFormModalProps {
  98. open: boolean;
  99. mode: Mode;
  100. client: ClientRecord | null;
  101. inbounds: InboundOption[];
  102. attachedExternalLinks?: ExternalLink[];
  103. attachedIds?: number[];
  104. tunnelAllowedIPs?: Record<number, string>;
  105. tgBotEnable?: boolean;
  106. groups?: string[];
  107. save: (
  108. payload: Record<string, unknown> | SaveCreatePayload,
  109. meta: SaveMetaEdit | SaveMetaCreate,
  110. ) => Promise<ApiMsg | null>;
  111. resetTraffic?: (client: ClientRecord) => Promise<ApiMsg | null>;
  112. onOpenChange: (open: boolean) => void;
  113. }
  114. type Values = ClientFormValues & {
  115. expiryDate: number;
  116. limitHwid: number;
  117. externalLinks: ExternalLinkRow[];
  118. wgPrivateKey: string;
  119. wgPublicKey: string;
  120. wgPreSharedKey: string;
  121. wgAllowedIPs: string;
  122. awgAllowedIPs: string;
  123. awgForwardedPorts: string;
  124. secret: string;
  125. adTag: string;
  126. };
  127. const EMPTY: Values = {
  128. email: '',
  129. subId: '',
  130. uuid: '',
  131. password: '',
  132. auth: '',
  133. flow: '',
  134. security: 'auto',
  135. reverseTag: '',
  136. totalGB: 0,
  137. expiryDate: 0,
  138. delayedStart: false,
  139. delayedDays: 0,
  140. reset: 0,
  141. resetDay: 0,
  142. resetMax: 0,
  143. trafficReset: 'never' as const,
  144. trafficResetDay: 1,
  145. limitIp: 0,
  146. limitHwid: 0,
  147. tgId: 0,
  148. group: '',
  149. comment: '',
  150. enable: true,
  151. inboundIds: [],
  152. externalLinks: [],
  153. wgPrivateKey: '',
  154. wgPublicKey: '',
  155. wgPreSharedKey: '',
  156. wgAllowedIPs: '',
  157. awgAllowedIPs: '',
  158. awgForwardedPorts: '',
  159. secret: '',
  160. adTag: '',
  161. };
  162. function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
  163. return (links || []).map((l) => ({
  164. kind: l.kind === 'subscription' ? 'subscription' : 'link',
  165. value: l.value || '',
  166. remark: l.remark || '',
  167. enable: l.enable !== false,
  168. expiryTime: Number(l.expiryTime) || 0,
  169. namePrefix: l.namePrefix || '',
  170. lastFetchAt: Number(l.lastFetchAt) || 0,
  171. lastFetchError: l.lastFetchError || '',
  172. }));
  173. }
  174. function bytesToGB(bytes: number): number {
  175. if (!bytes || bytes <= 0) return 0;
  176. return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
  177. }
  178. export function gbToBytes(gb: number): number {
  179. if (!gb || gb <= 0) return 0;
  180. return Math.round(gb * 1024 * 1024 * 1024);
  181. }
  182. export function parseAllowedIPsList(raw: string): string[] {
  183. return raw
  184. .split(',')
  185. .map((s) => s.trim())
  186. .filter((s) => s !== '');
  187. }
  188. // Maps each of the two AllowedIPs fields to the specific wg/awg inbound the
  189. // client is currently attached to, so a save with both protocols attached at
  190. // once can send each its own value instead of one shared field ambiguously
  191. // covering both (see model.Client.AllowedIPsByInbound on the Go side).
  192. // Absent from the result when the client isn't actually attached to that
  193. // protocol's inbound (e.g. mid-edit, before the attach takes effect).
  194. export function resolveTunnelAllowedIPsByInbound(
  195. attachedInboundIds: number[],
  196. wireguardInboundIds: Set<number>,
  197. amneziawgInboundIds: Set<number>,
  198. wgAllowedIPs: string[],
  199. awgAllowedIPs: string[],
  200. ): Record<number, string[]> {
  201. const wgId = attachedInboundIds.find((id) => wireguardInboundIds.has(id));
  202. const awgId = attachedInboundIds.find((id) => amneziawgInboundIds.has(id));
  203. const result: Record<number, string[]> = {};
  204. if (wgId != null) result[wgId] = wgAllowedIPs;
  205. if (awgId != null) result[awgId] = awgAllowedIPs;
  206. return result;
  207. }
  208. export function resolveTotalBytes(
  209. originalBytes: number | null | undefined,
  210. displayedGB: number,
  211. ): number {
  212. if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
  213. return originalBytes;
  214. }
  215. return gbToBytes(displayedGB);
  216. }
  217. export default function ClientFormModal({
  218. open,
  219. mode,
  220. client,
  221. inbounds,
  222. attachedExternalLinks = [],
  223. attachedIds = [],
  224. tunnelAllowedIPs = {},
  225. tgBotEnable = false,
  226. groups = [],
  227. save,
  228. resetTraffic,
  229. onOpenChange,
  230. }: ClientFormModalProps) {
  231. const { t } = useTranslation();
  232. const [messageApi, messageContextHolder] = message.useMessage();
  233. const isEdit = mode === 'edit';
  234. const methods = useForm<Values>({ defaultValues: EMPTY });
  235. const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
  236. const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
  237. const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
  238. const enable = useWatch({ control: methods.control, name: 'enable' });
  239. const flow = useWatch({ control: methods.control, name: 'flow' });
  240. const reverseTag = useWatch({ control: methods.control, name: 'reverseTag' });
  241. const secret = useWatch({ control: methods.control, name: 'secret' });
  242. const email = useWatch({ control: methods.control, name: 'email' });
  243. const uuid = useWatch({ control: methods.control, name: 'uuid' });
  244. const trafficReset = useWatch({ control: methods.control, name: 'trafficReset' });
  245. const password = useWatch({ control: methods.control, name: 'password' });
  246. const subId = useWatch({ control: methods.control, name: 'subId' });
  247. const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
  248. const auth = useWatch({ control: methods.control, name: 'auth' });
  249. const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
  250. const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
  251. const {
  252. fields: externalLinkFields,
  253. append: appendExternalLink,
  254. remove: removeExternalLink,
  255. } = useFieldArray({ control: methods.control, name: 'externalLinks' });
  256. const [submitting, setSubmitting] = useState(false);
  257. const [resetting, setResetting] = useState(false);
  258. const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
  259. const [ipsLoading, setIpsLoading] = useState(false);
  260. const [ipsClearing, setIpsClearing] = useState(false);
  261. const [ipsModalOpen, setIpsModalOpen] = useState(false);
  262. const {
  263. clientHwids,
  264. hwidsLoading,
  265. hwidsClearing,
  266. deletingHwidId,
  267. loadHwids,
  268. clearHwids,
  269. deleteHwid,
  270. } = useClientHwids(client?.email);
  271. const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
  272. const { datepicker } = useDatepicker();
  273. const hwidDateLabel = (ts: number) =>
  274. !ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker);
  275. const fail2ban = useFail2banStatusQuery();
  276. const limitIpDisabled = !fail2ban.usable;
  277. const limitIpNotice = getLimitIpNotice(fail2ban, t);
  278. // Declared ahead of the seeding effect below (which needs them to resolve
  279. // which specific wg/awg inbound this client is attached to, for seeding
  280. // wgAllowedIPs/awgAllowedIPs from tunnelAllowedIPs) -- both are pure
  281. // derivations of the stable `inbounds` prop, so moving them earlier is
  282. // just a declaration-order change, not a behavior change.
  283. const wireguardIds = useMemo(() => {
  284. const ids = new Set<number>();
  285. for (const row of inbounds || []) {
  286. if (row && row.protocol === 'wireguard') ids.add(row.id);
  287. }
  288. return ids;
  289. }, [inbounds]);
  290. const amneziawgIds = useMemo(() => {
  291. const ids = new Set<number>();
  292. for (const row of inbounds || []) {
  293. if (row && row.protocol === 'amneziawg') ids.add(row.id);
  294. }
  295. return ids;
  296. }, [inbounds]);
  297. function addExternalLinkRow(kind: 'link' | 'subscription') {
  298. appendExternalLink({
  299. kind,
  300. value: '',
  301. remark: '',
  302. enable: true,
  303. expiryTime: 0,
  304. namePrefix: '',
  305. lastFetchAt: 0,
  306. lastFetchError: '',
  307. });
  308. }
  309. useEffect(() => {
  310. if (!open) return;
  311. setIpsModalOpen(false);
  312. setHwidsModalOpen(false);
  313. if (isEdit && client) {
  314. const et = Number(client.expiryTime) || 0;
  315. const seedIds = Array.isArray(attachedIds) ? attachedIds : [];
  316. const attachedWireguardId = seedIds.find((id) => wireguardIds.has(id));
  317. const attachedAmneziawgId = seedIds.find((id) => amneziawgIds.has(id));
  318. const wgTunnelIPs =
  319. attachedWireguardId != null ? tunnelAllowedIPs[attachedWireguardId] : undefined;
  320. const awgTunnelIPs =
  321. attachedAmneziawgId != null ? tunnelAllowedIPs[attachedAmneziawgId] : undefined;
  322. const seed: Values = {
  323. ...EMPTY,
  324. email: client.email || '',
  325. subId: client.subId || '',
  326. uuid: client.uuid || '',
  327. password: client.password || '',
  328. auth: client.auth || '',
  329. flow: client.flow || '',
  330. security:
  331. !client.security || client.security === 'none' || client.security === 'zero'
  332. ? 'auto'
  333. : client.security,
  334. reverseTag: client.reverse?.tag || '',
  335. totalGB: bytesToGB(client.totalGB || 0),
  336. reset: Number(client.reset) || 0,
  337. resetDay: Number(client.resetDay) || 0,
  338. resetMax: Number(client.resetMax) || 0,
  339. trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
  340. trafficResetDay: Number(client.trafficResetDay) || 1,
  341. limitIp: client.limitIp || 0,
  342. limitHwid: client.limitHwid || 0,
  343. tgId: Number(client.tgId) || 0,
  344. group: client.group || '',
  345. comment: client.comment || '',
  346. enable: !!client.enable,
  347. inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
  348. externalLinks: toExternalLinkRows(attachedExternalLinks),
  349. wgPrivateKey: client.privateKey || '',
  350. wgPublicKey: client.publicKey || '',
  351. wgPreSharedKey: client.preSharedKey || '',
  352. wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
  353. awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
  354. awgForwardedPorts: client.forwardedPorts || '',
  355. secret: client.secret || '',
  356. adTag: client.adTag || '',
  357. };
  358. if (et < 0) {
  359. seed.delayedStart = true;
  360. seed.delayedDays = Math.round(et / -86400000);
  361. seed.expiryDate = 0;
  362. } else {
  363. seed.delayedStart = false;
  364. seed.delayedDays = 0;
  365. seed.expiryDate = et > 0 ? et : 0;
  366. }
  367. methods.reset(seed);
  368. void loadIps();
  369. void loadHwids();
  370. } else {
  371. const wgKeypair = Wireguard.generateKeypair();
  372. methods.reset({
  373. ...EMPTY,
  374. email: RandomUtil.randomLowerAndNum(10),
  375. uuid: RandomUtil.randomUUID(),
  376. subId: RandomUtil.randomLowerAndNum(16),
  377. password: RandomUtil.randomLowerAndNum(16),
  378. auth: RandomUtil.randomLowerAndNum(16),
  379. wgPrivateKey: wgKeypair.privateKey,
  380. wgPublicKey: wgKeypair.publicKey,
  381. });
  382. }
  383. // eslint-disable-next-line react-hooks/exhaustive-deps
  384. }, [open, isEdit]);
  385. const flowCapableIds = useMemo(() => {
  386. const ids = new Set<number>();
  387. for (const row of inbounds || []) {
  388. if (row?.tlsFlowCapable) ids.add(row.id);
  389. }
  390. return ids;
  391. }, [inbounds]);
  392. const vlessLikeIds = useMemo(() => {
  393. const ids = new Set<number>();
  394. for (const row of inbounds || []) {
  395. if (row && row.protocol === 'vless') ids.add(row.id);
  396. }
  397. return ids;
  398. }, [inbounds]);
  399. const vmessIds = useMemo(() => {
  400. const ids = new Set<number>();
  401. for (const row of inbounds || []) {
  402. if (row && row.protocol === 'vmess') ids.add(row.id);
  403. }
  404. return ids;
  405. }, [inbounds]);
  406. const mtprotoIds = useMemo(() => {
  407. const ids = new Set<number>();
  408. for (const row of inbounds || []) {
  409. if (row && row.protocol === 'mtproto') ids.add(row.id);
  410. }
  411. return ids;
  412. }, [inbounds]);
  413. const mtprotoDomain = useMemo(() => {
  414. for (const id of inboundIds || []) {
  415. const ib = (inbounds || []).find((row) => row.id === id);
  416. if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain;
  417. }
  418. return 'www.cloudflare.com';
  419. }, [inboundIds, inbounds]);
  420. const ss2022Method = useMemo(() => {
  421. for (const id of inboundIds || []) {
  422. const ib = (inbounds || []).find((row) => row.id === id);
  423. const method = ib?.ssMethod;
  424. if (method && method.substring(0, 4) === '2022') return method;
  425. }
  426. return '';
  427. }, [inboundIds, inbounds]);
  428. function regeneratePassword() {
  429. methods.setValue(
  430. 'password',
  431. ss2022Method
  432. ? RandomUtil.randomShadowsocksPassword(ss2022Method)
  433. : RandomUtil.randomLowerAndNum(16),
  434. );
  435. }
  436. const showFlow = useMemo(
  437. () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
  438. [inboundIds, flowCapableIds],
  439. );
  440. const showReverseTag = useMemo(
  441. () => (inboundIds || []).some((id) => vlessLikeIds.has(id)),
  442. [inboundIds, vlessLikeIds],
  443. );
  444. const showSecurity = useMemo(
  445. () => (inboundIds || []).some((id) => vmessIds.has(id)),
  446. [inboundIds, vmessIds],
  447. );
  448. const showWireguard = useMemo(
  449. () => (inboundIds || []).some((id) => wireguardIds.has(id)),
  450. [inboundIds, wireguardIds],
  451. );
  452. const showAmneziawg = useMemo(
  453. () => (inboundIds || []).some((id) => amneziawgIds.has(id)),
  454. [inboundIds, amneziawgIds],
  455. );
  456. const showMtproto = useMemo(
  457. () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
  458. [inboundIds, mtprotoIds],
  459. );
  460. function regenerateWireguardKeys() {
  461. const kp = Wireguard.generateKeypair();
  462. methods.setValue('wgPrivateKey', kp.privateKey);
  463. methods.setValue('wgPublicKey', kp.publicKey);
  464. }
  465. function regenerateMtprotoSecret() {
  466. methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
  467. }
  468. useEffect(() => {
  469. // Only clear the flow once we actually have inbound options to judge
  470. // capability from. While the options list is momentarily empty (e.g. the
  471. // options query is (re)loading and `inbounds` falls back to `[]`), showFlow
  472. // is a false negative, so clearing here would silently drop a valid
  473. // xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
  474. if (inbounds.length > 0 && !showFlow && flow) {
  475. methods.setValue('flow', '');
  476. }
  477. }, [inbounds, showFlow, flow, methods]);
  478. useEffect(() => {
  479. if (!showReverseTag && reverseTag) {
  480. methods.setValue('reverseTag', '');
  481. }
  482. }, [showReverseTag, reverseTag, methods]);
  483. useEffect(() => {
  484. if (!ss2022Method) return;
  485. const current = methods.getValues('password');
  486. if (!RandomUtil.isShadowsocks2022Password(current, ss2022Method)) {
  487. methods.setValue('password', RandomUtil.randomShadowsocksPassword(ss2022Method));
  488. }
  489. }, [ss2022Method, methods]);
  490. useEffect(() => {
  491. if (showMtproto && !secret) {
  492. methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
  493. }
  494. }, [showMtproto, secret, mtprotoDomain, methods]);
  495. const inboundOptions = useMemo(
  496. () =>
  497. (inbounds || [])
  498. .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
  499. .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
  500. .map((ib) => ({
  501. label: formatInboundLabel(ib.tag, ib.remark),
  502. value: ib.id,
  503. title: formatInboundLabel(ib.tag, ib.remark),
  504. })),
  505. [inbounds, inboundIds],
  506. );
  507. const expiryDayjs = useMemo<Dayjs | null>(
  508. () => (expiryDate > 0 ? dayjs(expiryDate) : null),
  509. [expiryDate],
  510. );
  511. const linkRows = externalLinkFields
  512. .map((field, index) => ({ field, index }))
  513. .filter((row) => row.field.kind === 'link');
  514. const subscriptionRows = externalLinkFields
  515. .map((field, index) => ({ field, index }))
  516. .filter((row) => row.field.kind === 'subscription');
  517. async function loadIps() {
  518. if (!isEdit || !client?.email) return;
  519. setIpsLoading(true);
  520. try {
  521. const msg = (await HttpUtil.post(
  522. `/panel/api/clients/ips/${encodeURIComponent(client.email)}`,
  523. )) as ApiMsg<unknown[]>;
  524. if (!msg?.success) {
  525. setClientIps([]);
  526. return;
  527. }
  528. setClientIps(normalizeClientIps(msg.obj));
  529. } finally {
  530. setIpsLoading(false);
  531. }
  532. }
  533. function openIpsModal() {
  534. setIpsModalOpen(true);
  535. if (clientIps.length === 0) void loadIps();
  536. }
  537. async function clearIps() {
  538. if (!isEdit || !client?.email) return;
  539. setIpsClearing(true);
  540. try {
  541. const msg = (await HttpUtil.post(
  542. `/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`,
  543. )) as ApiMsg;
  544. if (msg?.success) setClientIps([]);
  545. } finally {
  546. setIpsClearing(false);
  547. }
  548. }
  549. function openHwidsModal() {
  550. setHwidsModalOpen(true);
  551. if (clientHwids.length === 0) void loadHwids();
  552. }
  553. function close() {
  554. onOpenChange(false);
  555. }
  556. async function onResetTraffic() {
  557. if (!isEdit || !client?.email || !resetTraffic) return;
  558. setResetting(true);
  559. try {
  560. const msg = await resetTraffic(client);
  561. if (msg?.success) {
  562. messageApi.success(t('pages.clients.toasts.trafficReset'));
  563. } else {
  564. messageApi.error(msg?.msg || t('somethingWentWrong'));
  565. }
  566. } finally {
  567. setResetting(false);
  568. }
  569. }
  570. async function onSubmit() {
  571. const values = methods.getValues();
  572. const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
  573. const validated = schema.safeParse({
  574. email: values.email,
  575. subId: values.subId,
  576. uuid: values.uuid,
  577. password: values.password,
  578. auth: values.auth,
  579. flow: values.flow,
  580. security: values.security,
  581. reverseTag: values.reverseTag,
  582. totalGB: values.totalGB,
  583. delayedStart: values.delayedStart,
  584. delayedDays: values.delayedDays,
  585. reset: values.reset,
  586. resetDay: values.resetDay,
  587. resetMax: values.resetMax,
  588. trafficReset: values.trafficReset,
  589. trafficResetDay: values.trafficResetDay,
  590. limitIp: values.limitIp,
  591. limitHwid: values.limitHwid,
  592. tgId: values.tgId,
  593. group: values.group,
  594. comment: values.comment,
  595. enable: values.enable,
  596. inboundIds: values.inboundIds,
  597. });
  598. if (!validated.success) {
  599. const issue = validated.error.issues[0];
  600. messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
  601. return;
  602. }
  603. const expiryTime = values.delayedStart
  604. ? -86400000 * (Number(values.delayedDays) || 0)
  605. : values.expiryDate || 0;
  606. const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
  607. const clientPayload: Record<string, unknown> = {
  608. email: values.email.trim(),
  609. subId: values.subId,
  610. id: values.uuid,
  611. password: values.password,
  612. auth: values.auth,
  613. flow: showFlow ? values.flow || '' : '',
  614. security: showSecurity ? values.security || 'auto' : 'auto',
  615. totalGB: totalBytes,
  616. expiryTime,
  617. reset: Number(values.reset) || 0,
  618. resetDay: Number(values.resetDay) || 0,
  619. resetMax: Number(values.resetMax) || 0,
  620. trafficReset: values.trafficReset || 'never',
  621. trafficResetDay: Number(values.trafficResetDay) || 1,
  622. limitIp: Number(values.limitIp) || 0,
  623. limitHwid: Number(values.limitHwid) || 0,
  624. tgId: Number(values.tgId) || 0,
  625. group: values.group,
  626. comment: values.comment,
  627. enable: !!values.enable,
  628. };
  629. const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
  630. if (reverseTagValue) {
  631. clientPayload.reverse = { tag: reverseTagValue };
  632. }
  633. if (showWireguard || showAmneziawg) {
  634. // AmneziaWG peers are wire-identical to WireGuard peers (same
  635. // privateKey/publicKey/preSharedKey/allowedIPs fields on model.Client),
  636. // so both protocols share this one field set — see wgPrivateKey etc.
  637. // below and the AmneziaWG-labeled variants of the same inputs.
  638. clientPayload.privateKey = values.wgPrivateKey;
  639. clientPayload.publicKey = values.wgPublicKey;
  640. if (values.wgPreSharedKey) {
  641. clientPayload.preSharedKey = values.wgPreSharedKey;
  642. }
  643. const wgAllowedIPs = parseAllowedIPsList(values.wgAllowedIPs);
  644. if (showWireguard && showAmneziawg) {
  645. // Both protocols are attached at once: the two fields hold genuinely
  646. // different addresses, so each must land on its own inbound instead
  647. // of one broadcast value overwriting the other's (allowedIPsByInbound
  648. // is what Update/Create key their per-inbound override off of).
  649. const awgAllowedIPs = parseAllowedIPsList(values.awgAllowedIPs);
  650. clientPayload.allowedIPsByInbound = resolveTunnelAllowedIPsByInbound(
  651. values.inboundIds || [],
  652. wireguardIds,
  653. amneziawgIds,
  654. wgAllowedIPs,
  655. awgAllowedIPs,
  656. );
  657. if (wgAllowedIPs.length > 0) {
  658. clientPayload.allowedIPs = wgAllowedIPs;
  659. }
  660. } else if (wgAllowedIPs.length > 0) {
  661. clientPayload.allowedIPs = wgAllowedIPs;
  662. }
  663. // Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
  664. // has no host-level iptables layer to hang per-client DNAT off of.
  665. if (showAmneziawg) {
  666. clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
  667. }
  668. }
  669. if (showMtproto) {
  670. const adTag = values.adTag.trim();
  671. if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) {
  672. messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid'));
  673. return;
  674. }
  675. clientPayload.secret = values.secret;
  676. clientPayload.adTag = adTag;
  677. }
  678. const externalLinks: ExternalLinkInput[] = values.externalLinks
  679. .map((r) => ({
  680. kind: r.kind,
  681. value: r.value.trim(),
  682. remark: (r.remark || '').trim(),
  683. enable: r.enable !== false,
  684. expiryTime: Number(r.expiryTime) || 0,
  685. namePrefix: (r.namePrefix || '').trim(),
  686. }))
  687. .filter((r) => r.value !== '');
  688. setSubmitting(true);
  689. try {
  690. let msg;
  691. if (isEdit && client) {
  692. const original = new Set(attachedIds || []);
  693. const next = new Set(values.inboundIds || []);
  694. const toAttach = [...next].filter((id) => !original.has(id));
  695. const toDetach = [...original].filter((id) => !next.has(id));
  696. msg = await save(clientPayload, {
  697. isEdit: true,
  698. email: client.email,
  699. attach: toAttach,
  700. detach: toDetach,
  701. externalLinks,
  702. });
  703. } else {
  704. msg = await save(
  705. { client: clientPayload, inboundIds: values.inboundIds },
  706. { isEdit: false, email: clientPayload.email as string, externalLinks },
  707. );
  708. }
  709. if (msg?.success) close();
  710. } finally {
  711. setSubmitting(false);
  712. }
  713. }
  714. return (
  715. <>
  716. {messageContextHolder}
  717. <Modal
  718. open={open}
  719. title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
  720. destroyOnHidden
  721. width={720}
  722. zIndex={CLIENT_FORM_MODAL_Z_INDEX}
  723. style={{ top: 20 }}
  724. styles={{
  725. body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' },
  726. }}
  727. onCancel={close}
  728. footer={
  729. <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  730. {isEdit && resetTraffic && (
  731. <Popconfirm
  732. title={t('pages.inbounds.resetTraffic')}
  733. description={t('pages.inbounds.resetTrafficContent')}
  734. okText={t('reset')}
  735. cancelText={t('cancel')}
  736. zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
  737. onConfirm={onResetTraffic}
  738. >
  739. <Button
  740. color="danger"
  741. variant="filled"
  742. icon={<RetweetOutlined />}
  743. loading={resetting}
  744. >
  745. {t('pages.inbounds.resetTraffic')}
  746. </Button>
  747. </Popconfirm>
  748. )}
  749. <div style={{ marginInlineStart: 'auto', display: 'flex', gap: 8 }}>
  750. <Button onClick={close}>{t('cancel')}</Button>
  751. <Button type="primary" loading={submitting} onClick={onSubmit}>
  752. {isEdit ? t('save') : t('create')}
  753. </Button>
  754. </div>
  755. </div>
  756. }
  757. >
  758. <FormProvider {...methods}>
  759. <Form layout="vertical">
  760. <Tabs
  761. defaultActiveKey="basic"
  762. items={[
  763. {
  764. key: 'basic',
  765. label: t('pages.clients.tabBasics'),
  766. children: (
  767. <>
  768. <Row gutter={16}>
  769. <Col xs={24} md={12}>
  770. <Form.Item label={t('pages.clients.email')} required>
  771. <Space.Compact style={{ display: 'flex' }}>
  772. <Input
  773. value={email}
  774. placeholder={t('pages.clients.email')}
  775. style={{ flex: 1 }}
  776. onChange={(e) => methods.setValue('email', e.target.value)}
  777. />
  778. {!isEdit && (
  779. <Button
  780. aria-label={t('regenerate')}
  781. icon={<ReloadOutlined />}
  782. onClick={() =>
  783. methods.setValue('email', RandomUtil.randomLowerAndNum(12))
  784. }
  785. />
  786. )}
  787. </Space.Compact>
  788. </Form.Item>
  789. </Col>
  790. <Col xs={24} md={12}>
  791. <FormField
  792. name="totalGB"
  793. label={t('pages.clients.totalGB')}
  794. tooltip={t('pages.clients.totalGBDesc')}
  795. transform={{ output: (v) => Number(v) || 0 }}
  796. >
  797. <InputNumber min={0} step={1} style={{ width: '100%' }} />
  798. </FormField>
  799. </Col>
  800. <Col xs={24} md={12}>
  801. <Form.Item
  802. label={t('pages.clients.limitIp')}
  803. tooltip={t('pages.clients.limitIpDesc')}
  804. >
  805. <Tooltip title={limitIpNotice || undefined}>
  806. <span style={{ display: 'flex', width: '100%' }}>
  807. <Space.Compact style={{ display: 'flex', flex: 1 }}>
  808. <InputNumber
  809. value={limitIp}
  810. min={0}
  811. disabled={limitIpDisabled}
  812. style={{
  813. flex: 1,
  814. ...(limitIpDisabled ? { pointerEvents: 'none' } : null),
  815. }}
  816. onChange={(v) => methods.setValue('limitIp', Number(v) || 0)}
  817. />
  818. {isEdit && (
  819. <Tooltip title={t('pages.clients.ipLog')}>
  820. <Button
  821. aria-label={t('pages.clients.ipLog')}
  822. icon={<EyeOutlined />}
  823. loading={ipsLoading}
  824. onClick={openIpsModal}
  825. >
  826. {clientIps.length > 0 ? clientIps.length : ''}
  827. </Button>
  828. </Tooltip>
  829. )}
  830. </Space.Compact>
  831. </span>
  832. </Tooltip>
  833. </Form.Item>
  834. </Col>
  835. <Col xs={24} md={12}>
  836. <Form.Item
  837. label={t('pages.clients.limitHwid')}
  838. tooltip={t('pages.clients.limitHwidDesc')}
  839. >
  840. <Space.Compact style={{ display: 'flex' }}>
  841. <InputNumber
  842. value={limitHwid}
  843. min={0}
  844. style={{ flex: 1 }}
  845. onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)}
  846. />
  847. {isEdit && (
  848. <Tooltip title={t('pages.clients.hwidLog')}>
  849. <Button
  850. aria-label={t('pages.clients.hwidLog')}
  851. icon={<EyeOutlined />}
  852. loading={hwidsLoading}
  853. onClick={openHwidsModal}
  854. >
  855. {clientHwids.length > 0 ? clientHwids.length : ''}
  856. </Button>
  857. </Tooltip>
  858. )}
  859. </Space.Compact>
  860. </Form.Item>
  861. </Col>
  862. </Row>
  863. <Row gutter={16}>
  864. <Col xs={24} md={12}>
  865. {delayedStart ? (
  866. <FormField
  867. name="delayedDays"
  868. label={t('pages.clients.expireDays')}
  869. transform={{ output: (v) => Number(v) || 0 }}
  870. >
  871. <InputNumber min={0} style={{ width: '100%' }} />
  872. </FormField>
  873. ) : (
  874. <Form.Item label={t('pages.clients.expiryTime')}>
  875. <DateTimePicker
  876. value={expiryDayjs}
  877. onChange={(d) =>
  878. methods.setValue('expiryDate', d ? d.valueOf() : 0)
  879. }
  880. />
  881. </Form.Item>
  882. )}
  883. </Col>
  884. <Col xs={12} md={6}>
  885. <Form.Item label={t('pages.clients.delayedStart')}>
  886. <Switch
  887. checked={delayedStart}
  888. onChange={(v) => {
  889. methods.setValue('delayedStart', v);
  890. if (v) methods.setValue('expiryDate', 0);
  891. else methods.setValue('delayedDays', 0);
  892. }}
  893. />
  894. </Form.Item>
  895. </Col>
  896. <Col xs={12} md={6}>
  897. <FormField
  898. name="reset"
  899. label={t('pages.clients.renewDays')}
  900. tooltip={t('pages.clients.renewDesc')}
  901. transform={{ output: (v) => Number(v) || 0 }}
  902. >
  903. <InputNumber min={0} style={{ width: '100%' }} />
  904. </FormField>
  905. </Col>
  906. <Col xs={12} md={6}>
  907. <FormField
  908. name="resetDay"
  909. label={t('pages.clients.renewOnDay')}
  910. tooltip={t('pages.clients.renewOnDayDesc')}
  911. transform={{ output: (v) => Number(v) || 0 }}
  912. >
  913. <InputNumber min={0} max={31} style={{ width: '100%' }} />
  914. </FormField>
  915. </Col>
  916. <Col xs={12} md={6}>
  917. <FormField
  918. name="resetMax"
  919. label={t('pages.clients.renewMax')}
  920. tooltip={t('pages.clients.renewMaxDesc')}
  921. transform={{ output: (v) => Number(v) || 0 }}
  922. >
  923. <InputNumber min={0} style={{ width: '100%' }} />
  924. </FormField>
  925. </Col>
  926. <Col xs={12} md={6}>
  927. <FormField
  928. name="trafficReset"
  929. label={t('pages.inbounds.periodicTrafficResetTitle')}
  930. >
  931. <Select
  932. options={TRAFFIC_RESETS.map((r) => ({
  933. value: r,
  934. label: t(`pages.inbounds.periodicTrafficReset.${r}`),
  935. }))}
  936. />
  937. </FormField>
  938. </Col>
  939. {trafficReset === 'monthly' && (
  940. <Col xs={12} md={6}>
  941. <FormField
  942. name="trafficResetDay"
  943. label={t('pages.inbounds.periodicTrafficResetDay')}
  944. transform={{ output: (v) => Number(v) || 1 }}
  945. >
  946. <InputNumber min={1} max={31} style={{ width: '100%' }} />
  947. </FormField>
  948. </Col>
  949. )}
  950. </Row>
  951. <Row gutter={16}>
  952. <Col xs={24} md={12}>
  953. <FormField name="comment" label={t('pages.clients.comment')}>
  954. <Input />
  955. </FormField>
  956. </Col>
  957. <Col xs={24} md={12}>
  958. <FormField
  959. name="group"
  960. label={t('pages.clients.group')}
  961. tooltip={t('pages.clients.groupDesc')}
  962. transform={{ output: (v) => v ?? '' }}
  963. >
  964. <AutoComplete
  965. placeholder={t('pages.clients.groupPlaceholder')}
  966. options={groups.map((g) => ({ value: g }))}
  967. allowClear
  968. />
  969. </FormField>
  970. </Col>
  971. </Row>
  972. {(tgBotEnable || showReverseTag) && (
  973. <Row gutter={16}>
  974. {tgBotEnable && (
  975. <Col xs={24} md={12}>
  976. <FormField
  977. name="tgId"
  978. label={t('pages.clients.telegramId')}
  979. transform={{ output: (v) => Number(v) || 0 }}
  980. >
  981. <InputNumber
  982. min={0}
  983. controls={false}
  984. placeholder={t('pages.clients.telegramIdPlaceholder')}
  985. style={{ width: '100%' }}
  986. />
  987. </FormField>
  988. </Col>
  989. )}
  990. {showReverseTag && (
  991. <Col xs={24} md={12}>
  992. <FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
  993. <Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
  994. </FormField>
  995. </Col>
  996. )}
  997. </Row>
  998. )}
  999. <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
  1000. <SelectAllClearButtons
  1001. options={inboundOptions}
  1002. value={inboundIds}
  1003. onChange={(v) => methods.setValue('inboundIds', v)}
  1004. />
  1005. <Select
  1006. mode="multiple"
  1007. value={inboundIds}
  1008. onChange={(v) => methods.setValue('inboundIds', v)}
  1009. options={inboundOptions}
  1010. placeholder={t('pages.clients.selectInbound')}
  1011. maxTagCount="responsive"
  1012. placement="topLeft"
  1013. listHeight={220}
  1014. showSearch={{
  1015. filterOption: (input, option) =>
  1016. ((option?.label as string) || '')
  1017. .toLowerCase()
  1018. .includes(input.toLowerCase()),
  1019. }}
  1020. />
  1021. </Form.Item>
  1022. <Form.Item>
  1023. <Switch
  1024. aria-label={t('enable')}
  1025. checked={enable}
  1026. onChange={(v) => methods.setValue('enable', v)}
  1027. />
  1028. <span style={{ marginLeft: 8 }}>{t('enable')}</span>
  1029. </Form.Item>
  1030. </>
  1031. ),
  1032. },
  1033. {
  1034. key: 'config',
  1035. label: t('pages.clients.tabCredentials'),
  1036. children: (
  1037. <>
  1038. <Form.Item label={t('pages.clients.uuid')}>
  1039. <Space.Compact style={{ display: 'flex' }}>
  1040. <Input
  1041. value={uuid}
  1042. style={{ flex: 1 }}
  1043. onChange={(e) => methods.setValue('uuid', e.target.value)}
  1044. />
  1045. <Button
  1046. aria-label={t('regenerate')}
  1047. icon={<ReloadOutlined />}
  1048. onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())}
  1049. />
  1050. </Space.Compact>
  1051. </Form.Item>
  1052. <Form.Item
  1053. label={t('pages.clients.password')}
  1054. tooltip={t('pages.clients.passwordDesc')}
  1055. >
  1056. <Space.Compact style={{ display: 'flex' }}>
  1057. <Input
  1058. value={password}
  1059. style={{ flex: 1 }}
  1060. onChange={(e) => methods.setValue('password', e.target.value)}
  1061. />
  1062. <Button
  1063. aria-label={t('regenerate')}
  1064. icon={<ReloadOutlined />}
  1065. onClick={regeneratePassword}
  1066. />
  1067. </Space.Compact>
  1068. </Form.Item>
  1069. <Form.Item label={t('pages.clients.subId')}>
  1070. <Space.Compact style={{ display: 'flex' }}>
  1071. <Input
  1072. value={subId}
  1073. style={{ flex: 1 }}
  1074. onChange={(e) => methods.setValue('subId', e.target.value)}
  1075. />
  1076. <Button
  1077. aria-label={t('regenerate')}
  1078. icon={<ReloadOutlined />}
  1079. onClick={() =>
  1080. methods.setValue('subId', RandomUtil.randomLowerAndNum(16))
  1081. }
  1082. />
  1083. </Space.Compact>
  1084. </Form.Item>
  1085. <Form.Item
  1086. label={t('pages.clients.hysteriaAuth')}
  1087. tooltip={t('pages.clients.hysteriaAuthDesc')}
  1088. >
  1089. <Space.Compact style={{ display: 'flex' }}>
  1090. <Input
  1091. value={auth}
  1092. style={{ flex: 1 }}
  1093. onChange={(e) => methods.setValue('auth', e.target.value)}
  1094. />
  1095. <Button
  1096. aria-label={t('regenerate')}
  1097. icon={<ReloadOutlined />}
  1098. onClick={() =>
  1099. methods.setValue('auth', RandomUtil.randomLowerAndNum(16))
  1100. }
  1101. />
  1102. </Space.Compact>
  1103. </Form.Item>
  1104. {showFlow && (
  1105. <FormField name="flow" label={t('pages.clients.flow')}>
  1106. <Select
  1107. options={[
  1108. { value: '', label: t('none') },
  1109. ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
  1110. ]}
  1111. />
  1112. </FormField>
  1113. )}
  1114. {showSecurity && (
  1115. <FormField name="security" label={t('pages.clients.vmessSecurity')}>
  1116. <Select
  1117. options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
  1118. />
  1119. </FormField>
  1120. )}
  1121. {(showWireguard || showAmneziawg) && (
  1122. <>
  1123. <Form.Item
  1124. label={t(
  1125. showAmneziawg
  1126. ? 'pages.clients.amneziaWgPrivateKey'
  1127. : 'pages.clients.wireguardPrivateKey',
  1128. )}
  1129. >
  1130. <Space.Compact style={{ display: 'flex' }}>
  1131. <Input
  1132. value={wgPrivateKey}
  1133. style={{ flex: 1 }}
  1134. onChange={(e) => {
  1135. const priv = e.target.value;
  1136. methods.setValue('wgPrivateKey', priv);
  1137. methods.setValue(
  1138. 'wgPublicKey',
  1139. priv ? Wireguard.generateKeypair(priv).publicKey : '',
  1140. );
  1141. }}
  1142. />
  1143. <Button
  1144. aria-label={t('regenerate')}
  1145. icon={<ReloadOutlined />}
  1146. onClick={regenerateWireguardKeys}
  1147. />
  1148. </Space.Compact>
  1149. </Form.Item>
  1150. <FormField
  1151. name="wgPublicKey"
  1152. label={t(
  1153. showAmneziawg
  1154. ? 'pages.clients.amneziaWgPublicKey'
  1155. : 'pages.clients.wireguardPublicKey',
  1156. )}
  1157. >
  1158. <Input disabled />
  1159. </FormField>
  1160. <FormField
  1161. name="wgPreSharedKey"
  1162. label={t(
  1163. showAmneziawg
  1164. ? 'pages.clients.amneziaWgPreSharedKey'
  1165. : 'pages.clients.wireguardPreSharedKey',
  1166. )}
  1167. >
  1168. <Input />
  1169. </FormField>
  1170. {showWireguard && showAmneziawg ? (
  1171. <>
  1172. <FormField
  1173. name="wgAllowedIPs"
  1174. label={t('pages.clients.wireguardAllowedIPs')}
  1175. extra={t('pages.clients.wireguardAllowedIPsHint')}
  1176. >
  1177. <Input placeholder="10.0.0.2/32" />
  1178. </FormField>
  1179. <FormField
  1180. name="awgAllowedIPs"
  1181. label={t('pages.clients.amneziaWgAllowedIPs')}
  1182. extra={t('pages.clients.amneziaWgAllowedIPsHint')}
  1183. >
  1184. <Input placeholder="10.8.1.2/32" />
  1185. </FormField>
  1186. </>
  1187. ) : (
  1188. <FormField
  1189. name="wgAllowedIPs"
  1190. label={t(
  1191. showAmneziawg
  1192. ? 'pages.clients.amneziaWgAllowedIPs'
  1193. : 'pages.clients.wireguardAllowedIPs',
  1194. )}
  1195. extra={t(
  1196. showAmneziawg
  1197. ? 'pages.clients.amneziaWgAllowedIPsHint'
  1198. : 'pages.clients.wireguardAllowedIPsHint',
  1199. )}
  1200. >
  1201. <Input placeholder="10.8.1.2/32" />
  1202. </FormField>
  1203. )}
  1204. {showAmneziawg && (
  1205. <FormField
  1206. name="awgForwardedPorts"
  1207. label={t('pages.clients.amneziaWgForwardedPorts')}
  1208. extra={t('pages.clients.amneziaWgForwardedPortsHint')}
  1209. >
  1210. <Input placeholder="80, 443, 8000-8100" />
  1211. </FormField>
  1212. )}
  1213. </>
  1214. )}
  1215. {showMtproto && (
  1216. <>
  1217. <Form.Item
  1218. label={t('pages.clients.mtprotoSecret')}
  1219. extra={t('pages.clients.mtprotoSecretHint')}
  1220. >
  1221. <Space.Compact style={{ display: 'flex' }}>
  1222. <Input
  1223. value={secret}
  1224. style={{ flex: 1 }}
  1225. onChange={(e) => methods.setValue('secret', e.target.value)}
  1226. />
  1227. <Button
  1228. aria-label={t('regenerate')}
  1229. icon={<ReloadOutlined />}
  1230. onClick={regenerateMtprotoSecret}
  1231. />
  1232. </Space.Compact>
  1233. </Form.Item>
  1234. <FormField
  1235. name="adTag"
  1236. label={t('pages.clients.mtprotoAdTag')}
  1237. extra={t('pages.clients.mtprotoAdTagHint')}
  1238. >
  1239. <Input allowClear placeholder="0123456789abcdef0123456789abcdef" />
  1240. </FormField>
  1241. </>
  1242. )}
  1243. </>
  1244. ),
  1245. },
  1246. {
  1247. key: 'links',
  1248. label: t('pages.clients.tabLinks'),
  1249. children: (
  1250. <>
  1251. <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
  1252. {t('pages.clients.linksHint')}
  1253. </Typography.Paragraph>
  1254. <Button
  1255. type="primary"
  1256. icon={<PlusOutlined />}
  1257. onClick={() => addExternalLinkRow('link')}
  1258. >
  1259. {t('pages.clients.addExternalLink')}
  1260. </Button>
  1261. <div style={{ marginTop: 12, marginBottom: 24 }}>
  1262. {linkRows.length === 0 ? (
  1263. <Typography.Text type="secondary">
  1264. {t('pages.clients.noExternalLinks')}
  1265. </Typography.Text>
  1266. ) : (
  1267. linkRows.map(({ field, index }) => (
  1268. <div key={field.id} className="external-link-card">
  1269. <div className="external-link-row">
  1270. <div className="external-link-enable">
  1271. <FormField
  1272. name={`externalLinks.${index}.enable`}
  1273. valueProp="checked"
  1274. noStyle
  1275. >
  1276. <Switch size="small" />
  1277. </FormField>
  1278. <span>{t('enable')}</span>
  1279. </div>
  1280. <FormField name={`externalLinks.${index}.value`} noStyle>
  1281. <Input
  1282. aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
  1283. placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
  1284. />
  1285. </FormField>
  1286. <Tooltip title={t('delete')}>
  1287. <Button
  1288. aria-label={t('delete')}
  1289. danger
  1290. icon={<DeleteOutlined />}
  1291. onClick={() => removeExternalLink(index)}
  1292. />
  1293. </Tooltip>
  1294. </div>
  1295. <div className="external-link-details two-cols">
  1296. <FormField name={`externalLinks.${index}.remark`} noStyle>
  1297. <Input aria-label={t('remark')} placeholder={t('remark')} />
  1298. </FormField>
  1299. <Controller
  1300. control={methods.control}
  1301. name={`externalLinks.${index}.expiryTime`}
  1302. render={({ field: expiryField }) => {
  1303. const displayedExpiry = resolveExternalLinkExpiry(
  1304. expiryField.value,
  1305. expiryDate,
  1306. );
  1307. const hasSpecificExpiry = Number(expiryField.value) > 0;
  1308. return (
  1309. <DateTimePicker
  1310. value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
  1311. onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
  1312. placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
  1313. allowClear={hasSpecificExpiry}
  1314. maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
  1315. />
  1316. );
  1317. }}
  1318. />
  1319. </div>
  1320. </div>
  1321. ))
  1322. )}
  1323. </div>
  1324. <Button
  1325. type="primary"
  1326. icon={<PlusOutlined />}
  1327. onClick={() => addExternalLinkRow('subscription')}
  1328. >
  1329. {t('pages.clients.addExternalSubscription')}
  1330. </Button>
  1331. <div style={{ marginTop: 12 }}>
  1332. {subscriptionRows.length === 0 ? (
  1333. <Typography.Text type="secondary">
  1334. {t('pages.clients.noExternalSubscriptions')}
  1335. </Typography.Text>
  1336. ) : (
  1337. subscriptionRows.map(({ field, index }) => (
  1338. <div key={field.id} className="external-link-card">
  1339. <div className="external-link-row">
  1340. <div className="external-link-enable">
  1341. <FormField
  1342. name={`externalLinks.${index}.enable`}
  1343. valueProp="checked"
  1344. noStyle
  1345. >
  1346. <Switch size="small" />
  1347. </FormField>
  1348. <span>{t('enable')}</span>
  1349. </div>
  1350. <FormField name={`externalLinks.${index}.value`} noStyle>
  1351. <Input
  1352. aria-label="https://provider.example/sub/…"
  1353. placeholder="https://provider.example/sub/…"
  1354. />
  1355. </FormField>
  1356. <Tooltip title={t('delete')}>
  1357. <Button
  1358. aria-label={t('delete')}
  1359. danger
  1360. icon={<DeleteOutlined />}
  1361. onClick={() => removeExternalLink(index)}
  1362. />
  1363. </Tooltip>
  1364. </div>
  1365. <div className="external-link-details three-cols">
  1366. <FormField name={`externalLinks.${index}.remark`} noStyle>
  1367. <Input aria-label={t('remark')} placeholder={t('remark')} />
  1368. </FormField>
  1369. <FormField name={`externalLinks.${index}.namePrefix`} noStyle>
  1370. <Input
  1371. aria-label={t('pages.clients.namePrefix')}
  1372. placeholder={t('pages.clients.namePrefix')}
  1373. />
  1374. </FormField>
  1375. <Controller
  1376. control={methods.control}
  1377. name={`externalLinks.${index}.expiryTime`}
  1378. render={({ field: expiryField }) => {
  1379. const displayedExpiry = resolveExternalLinkExpiry(
  1380. expiryField.value,
  1381. expiryDate,
  1382. );
  1383. const hasSpecificExpiry = Number(expiryField.value) > 0;
  1384. return (
  1385. <DateTimePicker
  1386. value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
  1387. onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
  1388. placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
  1389. allowClear={hasSpecificExpiry}
  1390. maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
  1391. />
  1392. );
  1393. }}
  1394. />
  1395. </div>
  1396. <Typography.Text
  1397. type={field.lastFetchError ? 'danger' : 'secondary'}
  1398. className="external-link-fetch-status"
  1399. >
  1400. {field.lastFetchError
  1401. ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
  1402. : field.lastFetchAt > 0
  1403. ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
  1404. : t('pages.clients.neverFetched')}
  1405. </Typography.Text>
  1406. </div>
  1407. ))
  1408. )}
  1409. </div>
  1410. </>
  1411. ),
  1412. },
  1413. ]}
  1414. />
  1415. </Form>
  1416. </FormProvider>
  1417. </Modal>
  1418. <Modal
  1419. open={ipsModalOpen}
  1420. title={`${t('pages.clients.ipLog')}${client?.email ? ` — ${client.email}` : ''}`}
  1421. width={440}
  1422. zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
  1423. onCancel={() => setIpsModalOpen(false)}
  1424. footer={[
  1425. <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
  1426. {t('refresh')}
  1427. </Button>,
  1428. <Button
  1429. key="clear"
  1430. danger
  1431. loading={ipsClearing}
  1432. disabled={clientIps.length === 0}
  1433. onClick={clearIps}
  1434. >
  1435. {t('pages.clients.clearAll')}
  1436. </Button>,
  1437. <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
  1438. {t('close')}
  1439. </Button>,
  1440. ]}
  1441. >
  1442. {clientIps.length > 0 ? (
  1443. <div style={{ maxHeight: 360, overflowY: 'auto' }}>
  1444. {clientIps.map((entry, idx) => (
  1445. <Tag
  1446. key={idx}
  1447. color="blue"
  1448. style={{
  1449. display: 'block',
  1450. width: 'fit-content',
  1451. maxWidth: '100%',
  1452. marginBottom: 6,
  1453. padding: '2px 8px',
  1454. fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
  1455. }}
  1456. >
  1457. {entry.ip}
  1458. {entry.time ? ` (${entry.time})` : ''}
  1459. {entry.node ? (
  1460. <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
  1461. @ {entry.node}
  1462. </span>
  1463. ) : null}
  1464. </Tag>
  1465. ))}
  1466. </div>
  1467. ) : (
  1468. <Tag>{t('tgbot.noIpRecord')}</Tag>
  1469. )}
  1470. </Modal>
  1471. <ClientHwidListModal
  1472. open={hwidsModalOpen}
  1473. email={client?.email}
  1474. zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
  1475. hwids={clientHwids}
  1476. loading={hwidsLoading}
  1477. clearing={hwidsClearing}
  1478. deletingId={deletingHwidId}
  1479. formatDate={hwidDateLabel}
  1480. onRefresh={loadHwids}
  1481. onClearAll={clearHwids}
  1482. onDelete={deleteHwid}
  1483. onClose={() => setHwidsModalOpen(false)}
  1484. />
  1485. </>
  1486. );
  1487. }