ClientInfoModal.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
  4. import {
  5. CopyOutlined,
  6. DownloadOutlined,
  7. EyeOutlined,
  8. QrcodeOutlined,
  9. ReloadOutlined,
  10. } from '@ant-design/icons';
  11. import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
  12. import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label';
  13. import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
  14. import { useDatepicker } from '@/hooks/useDatepicker';
  15. import { useClientHwids } from '@/hooks/useClientHwids';
  16. import type { ClientRecord, InboundOption } from '@/hooks/useClients';
  17. import { isPostQuantumLink } from '@/lib/xray/inbound-link';
  18. import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
  19. import { QrPanel } from '@/pages/inbounds/qr';
  20. import ClientHwidListModal from '@/components/clients/ClientHwidList';
  21. import ConfigBlock from '@/components/clients/ConfigBlock';
  22. import {
  23. buildWireguardClientConfig,
  24. findWireguardInbounds,
  25. isWireguardClient,
  26. } from './wireguardConfig';
  27. import {
  28. buildAmneziaWGClientConfig,
  29. findAmneziaWGInbounds,
  30. isAmneziaWGClient,
  31. } from './amneziawgConfig';
  32. import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints';
  33. import type { HostRecord } from '@/schemas/api/host';
  34. import './ClientInfoModal.css';
  35. const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
  36. vless: 'blue',
  37. vmess: 'geekblue',
  38. trojan: 'volcano',
  39. shadowsocks: 'magenta',
  40. hysteria: 'cyan',
  41. hysteria2: 'green',
  42. wireguard: 'gold',
  43. amneziawg: 'yellow',
  44. http: 'purple',
  45. mixed: 'lime',
  46. tunnel: 'orange',
  47. tuic: 'orange',
  48. };
  49. const INBOUND_CHIP_LIMIT = 1;
  50. interface SubSettings {
  51. enable: boolean;
  52. subURI: string;
  53. subJsonURI: string;
  54. subJsonEnable: boolean;
  55. subClashURI: string;
  56. subClashEnable: boolean;
  57. publicHost?: string;
  58. }
  59. interface ClientInfoModalProps {
  60. open: boolean;
  61. client: ClientRecord | null;
  62. inboundsById: Record<number, InboundOption>;
  63. tunnelAllowedIPs?: Record<number, string>;
  64. isOnline: boolean;
  65. subSettings?: SubSettings;
  66. hosts?: HostRecord[];
  67. onOpenChange: (open: boolean) => void;
  68. }
  69. const NO_HOSTS: HostRecord[] = [];
  70. interface ApiMsg<T = unknown> {
  71. success?: boolean;
  72. obj?: T;
  73. }
  74. const DEFAULT_SUB: SubSettings = {
  75. enable: false,
  76. subURI: '',
  77. subJsonURI: '',
  78. subJsonEnable: false,
  79. subClashURI: '',
  80. subClashEnable: false,
  81. publicHost: '',
  82. };
  83. const SUBSCRIPTION_DOWNLOAD_NAMES = {
  84. standard: 'subscription-standard.txt',
  85. json: 'subscription-json.json',
  86. clash: 'subscription-clash.yaml',
  87. } as const;
  88. export default function ClientInfoModal({
  89. open,
  90. client,
  91. inboundsById,
  92. tunnelAllowedIPs,
  93. isOnline,
  94. subSettings = DEFAULT_SUB,
  95. hosts = NO_HOSTS,
  96. onOpenChange,
  97. }: ClientInfoModalProps) {
  98. const { datepicker } = useDatepicker();
  99. const { t } = useTranslation();
  100. const expiryLabel = (ts?: number) => {
  101. if (!ts) return '∞';
  102. if (ts < 0) {
  103. const days = Math.round(ts / -86400000);
  104. return `${t('pages.clients.delayedStart')}: ${days}d`;
  105. }
  106. return IntlUtil.formatDate(ts, datepicker);
  107. };
  108. const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
  109. const [messageApi, messageContextHolder] = message.useMessage();
  110. const [links, setLinks] = useState<string[]>([]);
  111. const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
  112. const [ipsLoading, setIpsLoading] = useState(false);
  113. const [ipsClearing, setIpsClearing] = useState(false);
  114. const [ipsModalOpen, setIpsModalOpen] = useState(false);
  115. const {
  116. clientHwids,
  117. hwidsLoading,
  118. hwidsClearing,
  119. deletingHwidId,
  120. loadHwids,
  121. clearHwids,
  122. deleteHwid,
  123. resetHwids,
  124. } = useClientHwids(client?.email);
  125. const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
  126. const [downloadingFormat, setDownloadingFormat] = useState<
  127. keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
  128. >(null);
  129. // Clearing on close happens during render; the effect owns only the fetch.
  130. const openSubId = open ? (client?.subId ?? '') : null;
  131. const [syncedSubId, setSyncedSubId] = useState(openSubId);
  132. if (openSubId !== syncedSubId) {
  133. setSyncedSubId(openSubId);
  134. if (openSubId === null) {
  135. setLinks([]);
  136. setClientIps([]);
  137. setIpsModalOpen(false);
  138. resetHwids();
  139. setHwidsModalOpen(false);
  140. }
  141. }
  142. useEffect(() => {
  143. if (!open || !client?.subId) return;
  144. let cancelled = false;
  145. (async () => {
  146. const msg = (await HttpUtil.get(
  147. `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
  148. )) as ApiMsg<string[]>;
  149. if (cancelled) return;
  150. setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
  151. })();
  152. return () => {
  153. cancelled = true;
  154. };
  155. }, [open, client?.subId]);
  156. const traffic = client?.traffic || null;
  157. const totalBytes = client?.totalGB || 0;
  158. const used = (traffic?.up || 0) + (traffic?.down || 0);
  159. const remaining = useMemo(() => {
  160. if (totalBytes <= 0) return -1;
  161. const r = totalBytes - used;
  162. return r > 0 ? r : 0;
  163. }, [totalBytes, used]);
  164. const subId = client?.subId;
  165. const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
  166. const subJsonLink =
  167. subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
  168. ? subSettings.subJsonURI + subId
  169. : '';
  170. const subClashLink =
  171. subId && subSettings?.subClashEnable && subSettings?.subClashURI
  172. ? subSettings.subClashURI + subId
  173. : '';
  174. const showSubscription = !!(subSettings?.enable && client?.subId);
  175. const wgInbounds = useMemo(
  176. () => findWireguardInbounds(client, inboundsById),
  177. [client, inboundsById],
  178. );
  179. const wgConfigs = useMemo(() => {
  180. if (!client || !isWireguardClient(client)) return [];
  181. const host = window.location.hostname;
  182. const publicHost = subSettings?.publicHost ?? '';
  183. return wgInbounds
  184. .flatMap((ib) => {
  185. const address = tunnelAllowedIPs?.[ib.id] ?? '';
  186. return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
  187. inbound: ib,
  188. endpoint: tunnelEndpointLabel(ep),
  189. text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep),
  190. }));
  191. })
  192. .filter((c) => !!c.text);
  193. }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
  194. const awgInbounds = useMemo(
  195. () => findAmneziaWGInbounds(client, inboundsById),
  196. [client, inboundsById],
  197. );
  198. const awgConfigs = useMemo(() => {
  199. if (!client || !isAmneziaWGClient(client)) return [];
  200. const host = window.location.hostname;
  201. const publicHost = subSettings?.publicHost ?? '';
  202. return awgInbounds
  203. .flatMap((ib) => {
  204. const address = tunnelAllowedIPs?.[ib.id] ?? '';
  205. return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
  206. inbound: ib,
  207. endpoint: tunnelEndpointLabel(ep),
  208. text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep),
  209. }));
  210. })
  211. .filter((c) => !!c.text);
  212. }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
  213. async function copyValue(text: string) {
  214. if (!text) return;
  215. const ok = await ClipboardManager.copyText(String(text));
  216. if (ok) messageApi.success(t('copied'));
  217. }
  218. async function downloadSubscription(
  219. url: string,
  220. format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES,
  221. ) {
  222. if (!url || downloadingFormat) return;
  223. setDownloadingFormat(format);
  224. try {
  225. const response = await fetch(url);
  226. if (!response.ok) throw new Error('Subscription download failed');
  227. const content = await response.text();
  228. FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
  229. } catch (_) {
  230. messageApi.error(t('somethingWentWrong'));
  231. } finally {
  232. setDownloadingFormat(null);
  233. }
  234. }
  235. async function loadIps() {
  236. if (!client?.email) return;
  237. setIpsLoading(true);
  238. try {
  239. const msg = (await HttpUtil.post(
  240. `/panel/api/clients/ips/${encodeURIComponent(client.email)}`,
  241. )) as ApiMsg<unknown[]>;
  242. if (!msg?.success) {
  243. setClientIps([]);
  244. return;
  245. }
  246. setClientIps(normalizeClientIps(msg.obj));
  247. } finally {
  248. setIpsLoading(false);
  249. }
  250. }
  251. async function clearIps() {
  252. if (!client?.email) return;
  253. setIpsClearing(true);
  254. try {
  255. const msg = (await HttpUtil.post(
  256. `/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`,
  257. )) as ApiMsg;
  258. if (msg?.success) setClientIps([]);
  259. } finally {
  260. setIpsClearing(false);
  261. }
  262. }
  263. function openIpsModal() {
  264. setIpsModalOpen(true);
  265. if (clientIps.length === 0) void loadIps();
  266. }
  267. function openHwidsModal() {
  268. setHwidsModalOpen(true);
  269. if (clientHwids.length === 0) void loadHwids();
  270. }
  271. return (
  272. <>
  273. {messageContextHolder}
  274. <Modal
  275. open={open}
  276. title={
  277. client
  278. ? `${t('pages.clients.clientInfo')} — ${client.email}`
  279. : t('pages.clients.clientInfo')
  280. }
  281. footer={null}
  282. width={640}
  283. onCancel={() => onOpenChange(false)}
  284. >
  285. {client && (
  286. <>
  287. <table className="info-table block">
  288. <tbody>
  289. <tr>
  290. <td>{t('pages.clients.online')}</td>
  291. <td>
  292. {client.enable && isOnline ? (
  293. <Tag color="green">{t('pages.clients.online')}</Tag>
  294. ) : (
  295. <Tag>{t('pages.clients.offline')}</Tag>
  296. )}
  297. <span className="hint">
  298. {t('lastOnline')}: {dateLabel(traffic?.lastOnline)}
  299. {' · '}
  300. {t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
  301. </span>
  302. </td>
  303. </tr>
  304. <tr>
  305. <td>{t('status')}</td>
  306. <td>
  307. <Tag color={client.enable ? 'green' : 'default'}>
  308. {client.enable ? t('enabled') : t('disabled')}
  309. </Tag>
  310. </td>
  311. </tr>
  312. <tr>
  313. <td>{t('pages.clients.email')}</td>
  314. <td>
  315. {client.email ? (
  316. <Tag color="green">{client.email}</Tag>
  317. ) : (
  318. <Tag color="red">{t('none')}</Tag>
  319. )}
  320. </td>
  321. </tr>
  322. <tr>
  323. <td>{t('pages.clients.subId')}</td>
  324. <td>
  325. <Tag className="info-large-tag">{client.subId || '-'}</Tag>
  326. {client.subId && (
  327. <Button
  328. size="small"
  329. type="text"
  330. icon={<CopyOutlined />}
  331. aria-label={t('copy')}
  332. onClick={() => copyValue(client.subId!)}
  333. />
  334. )}
  335. </td>
  336. </tr>
  337. {client.uuid && (
  338. <tr>
  339. <td>{t('pages.clients.uuid')}</td>
  340. <td>
  341. <Tag className="info-large-tag">{client.uuid}</Tag>
  342. <Button
  343. size="small"
  344. type="text"
  345. icon={<CopyOutlined />}
  346. aria-label={t('copy')}
  347. onClick={() => copyValue(client.uuid!)}
  348. />
  349. </td>
  350. </tr>
  351. )}
  352. {client.password && (
  353. <tr>
  354. <td>{t('password')}</td>
  355. <td>
  356. <Tag className="info-large-tag">{client.password}</Tag>
  357. <Button
  358. size="small"
  359. type="text"
  360. icon={<CopyOutlined />}
  361. aria-label={t('copy')}
  362. onClick={() => copyValue(client.password!)}
  363. />
  364. </td>
  365. </tr>
  366. )}
  367. {client.auth && (
  368. <tr>
  369. <td>{t('pages.clients.auth')}</td>
  370. <td>
  371. <Tag className="info-large-tag">{client.auth}</Tag>
  372. <Button
  373. size="small"
  374. type="text"
  375. icon={<CopyOutlined />}
  376. aria-label={t('copy')}
  377. onClick={() => copyValue(client.auth!)}
  378. />
  379. </td>
  380. </tr>
  381. )}
  382. <tr>
  383. <td>{t('pages.clients.flow')}</td>
  384. <td>
  385. {client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
  386. </td>
  387. </tr>
  388. <tr>
  389. <td>{t('pages.inbounds.traffic')}</td>
  390. <td>
  391. <Tag>
  392. ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)} / ↓{' '}
  393. {SizeFormatter.sizeFormat(traffic?.down || 0)}
  394. </Tag>
  395. <span className="hint">
  396. {SizeFormatter.sizeFormat(used)} /{' '}
  397. {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
  398. </span>
  399. </td>
  400. </tr>
  401. <tr>
  402. <td>{t('remained')}</td>
  403. <td>
  404. {remaining < 0 ? (
  405. <Tag color="purple">∞</Tag>
  406. ) : (
  407. <Tag color={remaining > 0 ? '' : 'red'}>
  408. {SizeFormatter.sizeFormat(remaining)}
  409. </Tag>
  410. )}
  411. </td>
  412. </tr>
  413. <tr>
  414. <td>{t('pages.inbounds.expireDate')}</td>
  415. <td>
  416. {!client.expiryTime ? (
  417. <Tag color="purple">∞</Tag>
  418. ) : (
  419. <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>
  420. {expiryLabel(client.expiryTime)}
  421. </Tag>
  422. )}
  423. {(client.expiryTime ?? 0) > 0 && (
  424. <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
  425. )}
  426. </td>
  427. </tr>
  428. <tr>
  429. <td>{t('pages.clients.ipLimit')}</td>
  430. <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
  431. </tr>
  432. <tr>
  433. <td>{t('pages.inbounds.IPLimitlog')}</td>
  434. <td>
  435. <Button
  436. size="small"
  437. icon={<EyeOutlined />}
  438. aria-label={t('pages.clients.ipLog')}
  439. loading={ipsLoading}
  440. onClick={openIpsModal}
  441. >
  442. {clientIps.length > 0 ? clientIps.length : ''}
  443. </Button>
  444. </td>
  445. </tr>
  446. {(traffic?.resetMax ?? 0) > 0 && (
  447. <tr>
  448. <td>{t('pages.clients.renewsUsed')}</td>
  449. <td>
  450. <Tag
  451. color={
  452. (traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'
  453. }
  454. >
  455. {traffic?.resetCount ?? 0} / {traffic?.resetMax}
  456. </Tag>
  457. </td>
  458. </tr>
  459. )}
  460. <tr>
  461. <td>{t('pages.clients.limitHwid')}</td>
  462. <td>{!client.limitHwid ? <Tag>∞</Tag> : <Tag>{client.limitHwid}</Tag>}</td>
  463. </tr>
  464. <tr>
  465. <td>{t('pages.clients.hwidLog')}</td>
  466. <td>
  467. <Button
  468. size="small"
  469. icon={<EyeOutlined />}
  470. aria-label={t('pages.clients.hwidLog')}
  471. loading={hwidsLoading}
  472. onClick={openHwidsModal}
  473. >
  474. {clientHwids.length > 0 ? clientHwids.length : ''}
  475. </Button>
  476. </td>
  477. </tr>
  478. <tr>
  479. <td>{t('pages.inbounds.createdAt')}</td>
  480. <td>
  481. <Tag>{dateLabel(client.createdAt)}</Tag>
  482. </td>
  483. </tr>
  484. <tr>
  485. <td>{t('pages.inbounds.updatedAt')}</td>
  486. <td>
  487. <Tag>{dateLabel(client.updatedAt)}</Tag>
  488. </td>
  489. </tr>
  490. {client.group && (
  491. <tr>
  492. <td>{t('pages.clients.group')}</td>
  493. <td>
  494. <Tag color="geekblue">{client.group}</Tag>
  495. </td>
  496. </tr>
  497. )}
  498. {client.comment && (
  499. <tr>
  500. <td>{t('pages.clients.comment')}</td>
  501. <td>
  502. <Tag className="info-large-tag">{client.comment}</Tag>
  503. </td>
  504. </tr>
  505. )}
  506. <tr>
  507. <td>{t('pages.clients.attachedInbounds')}</td>
  508. <td>
  509. {(() => {
  510. const ids = client.inboundIds || [];
  511. if (ids.length === 0) return <span className="hint">—</span>;
  512. const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
  513. const overflow = ids.slice(INBOUND_CHIP_LIMIT);
  514. const inboundChip = (id: number) => {
  515. const ib = inboundsById[id];
  516. const proto = (ib?.protocol || '').toLowerCase();
  517. const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
  518. const label = formatInboundLabel(ib?.tag, ib?.remark);
  519. return (
  520. <Tooltip key={id} title={label}>
  521. <Tag color={color}>{label}</Tag>
  522. </Tooltip>
  523. );
  524. };
  525. return (
  526. <div className="chips">
  527. {visible.map((id) => inboundChip(id))}
  528. {overflow.length > 0 && (
  529. <Popover
  530. trigger="click"
  531. placement="bottomRight"
  532. content={
  533. <div className="chips chips-stack">
  534. {overflow.map((id) => inboundChip(id))}
  535. </div>
  536. }
  537. >
  538. <Tag color="default" className="chip-more">
  539. +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
  540. </Tag>
  541. </Popover>
  542. )}
  543. </div>
  544. );
  545. })()}
  546. </td>
  547. </tr>
  548. </tbody>
  549. </table>
  550. {showSubscription && subLink && (
  551. <>
  552. <Divider>{t('subscription.title')}</Divider>
  553. <div className="link-row">
  554. <Tag color="green" className="link-row-tag">
  555. SUB
  556. </Tag>
  557. <a
  558. href={subLink}
  559. target="_blank"
  560. rel="noopener noreferrer"
  561. className="link-row-title link-row-title-anchor"
  562. title={subLink}
  563. >
  564. {client.subId}
  565. </a>
  566. <div className="link-row-actions">
  567. <Tooltip title={t('copy')}>
  568. <Button
  569. size="small"
  570. icon={<CopyOutlined />}
  571. aria-label={t('copy')}
  572. onClick={() => copyValue(subLink)}
  573. />
  574. </Tooltip>
  575. <Tooltip title={t('download')}>
  576. <Button
  577. size="small"
  578. icon={<DownloadOutlined />}
  579. aria-label={t('download')}
  580. loading={downloadingFormat === 'standard'}
  581. disabled={downloadingFormat !== null}
  582. onClick={() => void downloadSubscription(subLink, 'standard')}
  583. />
  584. </Tooltip>
  585. <Popover
  586. trigger="click"
  587. placement="left"
  588. destroyOnHidden
  589. content={
  590. <QrPanel
  591. value={subLink}
  592. remark={`${client.email} — ${t('subscription.title')}`}
  593. size={220}
  594. />
  595. }
  596. >
  597. <Tooltip title={t('pages.clients.qrCode')}>
  598. <Button
  599. size="small"
  600. icon={<QrcodeOutlined />}
  601. aria-label={t('pages.clients.qrCode')}
  602. />
  603. </Tooltip>
  604. </Popover>
  605. </div>
  606. </div>
  607. {subJsonLink && (
  608. <div className="link-row">
  609. <Tag color="purple" className="link-row-tag">
  610. JSON
  611. </Tag>
  612. <a
  613. href={subJsonLink}
  614. target="_blank"
  615. rel="noopener noreferrer"
  616. className="link-row-title link-row-title-anchor"
  617. title={subJsonLink}
  618. >
  619. {client.subId}
  620. </a>
  621. <div className="link-row-actions">
  622. <Tooltip title={t('copy')}>
  623. <Button
  624. size="small"
  625. icon={<CopyOutlined />}
  626. aria-label={t('copy')}
  627. onClick={() => copyValue(subJsonLink)}
  628. />
  629. </Tooltip>
  630. <Tooltip title={t('download')}>
  631. <Button
  632. size="small"
  633. icon={<DownloadOutlined />}
  634. aria-label={t('download')}
  635. loading={downloadingFormat === 'json'}
  636. disabled={downloadingFormat !== null}
  637. onClick={() => void downloadSubscription(subJsonLink, 'json')}
  638. />
  639. </Tooltip>
  640. <Popover
  641. trigger="click"
  642. placement="left"
  643. destroyOnHidden
  644. content={
  645. <QrPanel
  646. value={subJsonLink}
  647. remark={`${client.email} — JSON`}
  648. size={220}
  649. />
  650. }
  651. >
  652. <Tooltip title={t('pages.clients.qrCode')}>
  653. <Button
  654. size="small"
  655. icon={<QrcodeOutlined />}
  656. aria-label={t('pages.clients.qrCode')}
  657. />
  658. </Tooltip>
  659. </Popover>
  660. </div>
  661. </div>
  662. )}
  663. {subClashLink && (
  664. <div className="link-row">
  665. <Tooltip title="Clash / Mihomo">
  666. <Tag color="gold" className="link-row-tag">
  667. CLASH
  668. </Tag>
  669. </Tooltip>
  670. <a
  671. href={subClashLink}
  672. target="_blank"
  673. rel="noopener noreferrer"
  674. className="link-row-title link-row-title-anchor"
  675. title={subClashLink}
  676. >
  677. {client.subId}
  678. </a>
  679. <div className="link-row-actions">
  680. <Tooltip title={t('copy')}>
  681. <Button
  682. size="small"
  683. icon={<CopyOutlined />}
  684. aria-label={t('copy')}
  685. onClick={() => copyValue(subClashLink)}
  686. />
  687. </Tooltip>
  688. <Tooltip title={t('download')}>
  689. <Button
  690. size="small"
  691. icon={<DownloadOutlined />}
  692. aria-label={t('download')}
  693. loading={downloadingFormat === 'clash'}
  694. disabled={downloadingFormat !== null}
  695. onClick={() => void downloadSubscription(subClashLink, 'clash')}
  696. />
  697. </Tooltip>
  698. <Popover
  699. trigger="click"
  700. placement="left"
  701. destroyOnHidden
  702. content={
  703. <QrPanel
  704. value={subClashLink}
  705. remark={`${client.email} — Clash / Mihomo`}
  706. size={220}
  707. />
  708. }
  709. >
  710. <Tooltip title={t('pages.clients.qrCode')}>
  711. <Button
  712. size="small"
  713. icon={<QrcodeOutlined />}
  714. aria-label={t('pages.clients.qrCode')}
  715. />
  716. </Tooltip>
  717. </Popover>
  718. </div>
  719. </div>
  720. )}
  721. </>
  722. )}
  723. {links.length > 0 && (
  724. <>
  725. <Divider>{t('pages.inbounds.copyLink')}</Divider>
  726. {links.map((link, idx) => {
  727. const parts = parseLinkParts(link);
  728. const fallback = `${t('pages.clients.link')} ${idx + 1}`;
  729. const rowTitle = (parts && linkMetaText(parts)) || fallback;
  730. const qrRemark = parts?.remark || rowTitle;
  731. const canQr = !isPostQuantumLink(link);
  732. return (
  733. <div key={idx} className="link-row">
  734. {parts ? (
  735. <LinkTags parts={parts} />
  736. ) : (
  737. <Tag className="link-row-tag">LINK</Tag>
  738. )}
  739. <span className="link-row-title" title={rowTitle}>
  740. {rowTitle}
  741. </span>
  742. <div className="link-row-actions">
  743. <Tooltip title={t('copy')}>
  744. <Button
  745. size="small"
  746. icon={<CopyOutlined />}
  747. aria-label={t('copy')}
  748. onClick={() => copyValue(link)}
  749. />
  750. </Tooltip>
  751. {canQr && (
  752. <Popover
  753. trigger="click"
  754. placement="left"
  755. destroyOnHidden
  756. content={<QrPanel value={link} remark={qrRemark} size={220} />}
  757. >
  758. <Tooltip title={t('pages.clients.qrCode')}>
  759. <Button
  760. size="small"
  761. icon={<QrcodeOutlined />}
  762. aria-label={t('pages.clients.qrCode')}
  763. />
  764. </Tooltip>
  765. </Popover>
  766. )}
  767. </div>
  768. </div>
  769. );
  770. })}
  771. </>
  772. )}
  773. {wgConfigs.length > 0 && client && (
  774. <>
  775. <Divider>{t('pages.clients.wireguardConfig')}</Divider>
  776. {wgConfigs.map(({ inbound, endpoint, text }) => {
  777. const meta = formatTunnelConfigMeta(
  778. inbound,
  779. client.email,
  780. wgConfigs.length,
  781. endpoint,
  782. );
  783. return (
  784. <ConfigBlock
  785. key={`wg-${inbound.id}-${endpoint}`}
  786. label={meta.label || t('pages.clients.config')}
  787. text={text}
  788. fileName={meta.fileName}
  789. qrRemark={meta.qrRemark}
  790. tagColor="cyan"
  791. />
  792. );
  793. })}
  794. </>
  795. )}
  796. {awgConfigs.length > 0 && client && (
  797. <>
  798. <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
  799. {awgConfigs.map(({ inbound, endpoint, text }) => {
  800. const meta = formatTunnelConfigMeta(
  801. inbound,
  802. client.email,
  803. awgConfigs.length,
  804. endpoint,
  805. );
  806. return (
  807. <ConfigBlock
  808. key={`awg-${inbound.id}-${endpoint}`}
  809. label={meta.label || t('pages.clients.config')}
  810. text={text}
  811. fileName={meta.fileName}
  812. qrRemark={meta.qrRemark}
  813. tagColor="purple"
  814. />
  815. );
  816. })}
  817. </>
  818. )}
  819. </>
  820. )}
  821. </Modal>
  822. <Modal
  823. open={ipsModalOpen}
  824. title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? ` — ${client.email}` : ''}`}
  825. width={440}
  826. onCancel={() => setIpsModalOpen(false)}
  827. footer={[
  828. <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
  829. {t('refresh')}
  830. </Button>,
  831. <Button
  832. key="clear"
  833. danger
  834. loading={ipsClearing}
  835. disabled={clientIps.length === 0}
  836. onClick={clearIps}
  837. >
  838. {t('pages.clients.clearAll')}
  839. </Button>,
  840. <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
  841. {t('close')}
  842. </Button>,
  843. ]}
  844. >
  845. {clientIps.length > 0 ? (
  846. <div style={{ maxHeight: 360, overflowY: 'auto' }}>
  847. {clientIps.map((entry, idx) => (
  848. <Tag
  849. key={idx}
  850. color="blue"
  851. style={{
  852. display: 'block',
  853. width: 'fit-content',
  854. maxWidth: '100%',
  855. marginBottom: 6,
  856. padding: '2px 8px',
  857. fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
  858. }}
  859. >
  860. {entry.ip}
  861. {entry.time ? ` (${entry.time})` : ''}
  862. {entry.node ? (
  863. <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
  864. @ {entry.node}
  865. </span>
  866. ) : null}
  867. </Tag>
  868. ))}
  869. </div>
  870. ) : (
  871. <Tag>{t('tgbot.noIpRecord')}</Tag>
  872. )}
  873. </Modal>
  874. <ClientHwidListModal
  875. open={hwidsModalOpen}
  876. email={client?.email}
  877. hwids={clientHwids}
  878. loading={hwidsLoading}
  879. clearing={hwidsClearing}
  880. deletingId={deletingHwidId}
  881. formatDate={dateLabel}
  882. onRefresh={loadHwids}
  883. onClearAll={clearHwids}
  884. onDelete={deleteHwid}
  885. onClose={() => setHwidsModalOpen(false)}
  886. />
  887. </>
  888. );
  889. }