ClientFormModal.tsx 21 KB

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