ClientFormModal.tsx 60 KB

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