ClientFormModal.tsx 24 KB

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