ClientFormModal.tsx 34 KB

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