ClientFormModal.tsx 30 KB

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