ClientQrModal.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Collapse, Modal, Spin, Tag } from 'antd';
  4. import { HttpUtil } from '@/utils';
  5. import { isPostQuantumLink } from '@/lib/xray/inbound-link';
  6. import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
  7. import { QrPanel } from '@/pages/inbounds/qr';
  8. import type { ClientRecord, InboundOption } from '@/hooks/useClients';
  9. import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
  10. import {
  11. buildWireguardClientConfig,
  12. findWireguardInbounds,
  13. isWireguardClient,
  14. } from './wireguardConfig';
  15. import {
  16. buildAmneziaWGClientConfig,
  17. findAmneziaWGInbounds,
  18. isAmneziaWGClient,
  19. } from './amneziawgConfig';
  20. import { buildTuicClientConfig, findTuicInbound, isTuicClient } from './tuicConfig';
  21. interface SubSettings {
  22. enable: boolean;
  23. subURI: string;
  24. subJsonURI: string;
  25. subJsonEnable: boolean;
  26. publicHost?: string;
  27. }
  28. interface ClientQrModalProps {
  29. open: boolean;
  30. client: ClientRecord | null;
  31. inboundsById: Record<number, InboundOption>;
  32. tunnelAllowedIPs?: Record<number, string>;
  33. subSettings?: SubSettings;
  34. onOpenChange: (open: boolean) => void;
  35. }
  36. interface ApiMsg<T = unknown> {
  37. success?: boolean;
  38. obj?: T;
  39. }
  40. const DEFAULT_SUB: SubSettings = {
  41. enable: false,
  42. subURI: '',
  43. subJsonURI: '',
  44. subJsonEnable: false,
  45. publicHost: '',
  46. };
  47. export default function ClientQrModal({
  48. open,
  49. client,
  50. inboundsById,
  51. tunnelAllowedIPs,
  52. subSettings = DEFAULT_SUB,
  53. onOpenChange,
  54. }: ClientQrModalProps) {
  55. const { t } = useTranslation();
  56. const [links, setLinks] = useState<string[]>([]);
  57. const [loading, setLoading] = useState(false);
  58. const subId = client?.subId;
  59. const subEnabled = !!subSettings?.enable;
  60. const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
  61. const subJsonLink =
  62. subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
  63. ? subSettings.subJsonURI + subId
  64. : '';
  65. const wgInbounds = useMemo(
  66. () => findWireguardInbounds(client, inboundsById),
  67. [client, inboundsById],
  68. );
  69. const wgConfigs = useMemo(() => {
  70. if (!client || !isWireguardClient(client)) return [];
  71. return wgInbounds
  72. .map((ib) => {
  73. const address = tunnelAllowedIPs?.[ib.id] ?? '';
  74. const text = buildWireguardClientConfig(
  75. client,
  76. ib,
  77. window.location.hostname,
  78. subSettings?.publicHost ?? '',
  79. address,
  80. );
  81. return { inbound: ib, text };
  82. })
  83. .filter((c) => !!c.text);
  84. }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
  85. const awgInbounds = useMemo(
  86. () => findAmneziaWGInbounds(client, inboundsById),
  87. [client, inboundsById],
  88. );
  89. const awgConfigs = useMemo(() => {
  90. if (!client || !isAmneziaWGClient(client)) return [];
  91. return awgInbounds
  92. .map((ib) => {
  93. const address = tunnelAllowedIPs?.[ib.id] ?? '';
  94. const text = buildAmneziaWGClientConfig(
  95. client,
  96. ib,
  97. window.location.hostname,
  98. subSettings?.publicHost ?? '',
  99. address,
  100. );
  101. return { inbound: ib, text };
  102. })
  103. .filter((c) => !!c.text);
  104. }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
  105. const tuicInbound = useMemo(() => findTuicInbound(client, inboundsById), [client, inboundsById]);
  106. const tuicConfigText = useMemo(() => {
  107. if (!client || !tuicInbound || !isTuicClient(client)) return '';
  108. return buildTuicClientConfig(
  109. client,
  110. tuicInbound,
  111. window.location.hostname,
  112. subSettings?.publicHost ?? '',
  113. );
  114. }, [client, tuicInbound, subSettings?.publicHost]);
  115. const hasAnything =
  116. !!subLink ||
  117. !!subJsonLink ||
  118. wgConfigs.length > 0 ||
  119. awgConfigs.length > 0 ||
  120. !!tuicConfigText ||
  121. links.length > 0;
  122. // The reset runs during render so the effect only carries the request.
  123. const openSubId = open ? (client?.subId ?? '') : '';
  124. const [syncedSubId, setSyncedSubId] = useState(openSubId);
  125. if (openSubId !== syncedSubId) {
  126. setSyncedSubId(openSubId);
  127. setLinks([]);
  128. setLoading(!!openSubId);
  129. }
  130. useEffect(() => {
  131. if (!open || !client?.subId) return;
  132. let cancelled = false;
  133. (async () => {
  134. try {
  135. const msg = (await HttpUtil.get(
  136. `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
  137. )) as ApiMsg<string[]>;
  138. if (!cancelled) {
  139. setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
  140. }
  141. } finally {
  142. if (!cancelled) setLoading(false);
  143. }
  144. })();
  145. return () => {
  146. cancelled = true;
  147. };
  148. }, [open, client?.subId]);
  149. const [activeKey, setActiveKey] = useState<string[]>([]);
  150. const items = useMemo(() => {
  151. const out: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [];
  152. if (subLink) {
  153. out.push({
  154. key: 'sub',
  155. label: t('subscription.title'),
  156. children: (
  157. <QrPanel value={subLink} remark={`${client?.email || ''} — ${t('subscription.title')}`} />
  158. ),
  159. });
  160. }
  161. if (subJsonLink) {
  162. out.push({
  163. key: 'subJson',
  164. label: `${t('subscription.title')} (JSON)`,
  165. children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
  166. });
  167. }
  168. links.forEach((link, idx) => {
  169. const parts = parseLinkParts(link);
  170. const meta = parts ? linkMetaText(parts) : '';
  171. const label: React.ReactNode = parts ? (
  172. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
  173. <LinkTags parts={parts} />
  174. {meta && <span style={{ opacity: 0.6, fontSize: 12 }}>({meta})</span>}
  175. </span>
  176. ) : (
  177. `${t('pages.clients.link')} ${idx + 1}`
  178. );
  179. out.push({
  180. key: `l${idx}`,
  181. label,
  182. children: (
  183. <QrPanel
  184. value={link}
  185. remark={parts?.remark || `${client?.email || ''} #${idx + 1}`}
  186. showQr={!isPostQuantumLink(link)}
  187. />
  188. ),
  189. });
  190. });
  191. wgConfigs.forEach(({ inbound, text }) => {
  192. const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length);
  193. const label = (
  194. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
  195. <Tag color="cyan" style={{ margin: 0 }}>
  196. {t('pages.clients.wireguardConfig')}
  197. </Tag>
  198. {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
  199. </span>
  200. );
  201. out.push({
  202. key: `wg-config-${inbound.id}`,
  203. label,
  204. children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
  205. });
  206. });
  207. awgConfigs.forEach(({ inbound, text }) => {
  208. const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length);
  209. const label = (
  210. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
  211. <Tag color="purple" style={{ margin: 0 }}>
  212. {t('pages.clients.amneziaWgConfig')}
  213. </Tag>
  214. {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
  215. </span>
  216. );
  217. out.push({
  218. key: `awg-config-${inbound.id}`,
  219. label,
  220. children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
  221. });
  222. });
  223. if (tuicConfigText) {
  224. out.push({
  225. key: 'tuic-config',
  226. label: (
  227. <Tag color="orange" style={{ margin: 0 }}>
  228. {t('pages.clients.tuicConfig')}
  229. </Tag>
  230. ),
  231. children: (
  232. <QrPanel
  233. value={tuicConfigText}
  234. remark={client?.email || 'tuic'}
  235. downloadName={`${client?.email || 'tuic'}.yaml`}
  236. />
  237. ),
  238. });
  239. }
  240. return out;
  241. }, [subLink, subJsonLink, wgConfigs, awgConfigs, tuicConfigText, links, client?.email, t]);
  242. // Expanding the first panel is a render-time adjustment, not a side effect.
  243. const firstKey = open && items.length > 0 ? items[0].key : null;
  244. const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
  245. if (firstKey !== syncedFirstKey) {
  246. setSyncedFirstKey(firstKey);
  247. setActiveKey(firstKey ? [firstKey] : []);
  248. }
  249. return (
  250. <Modal
  251. open={open}
  252. title={client ? `${t('qrCode')} — ${client.email}` : t('qrCode')}
  253. footer={null}
  254. width={520}
  255. centered
  256. onCancel={() => onOpenChange(false)}
  257. >
  258. <Spin spinning={loading}>
  259. {!client?.subId && !loading && (
  260. <div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
  261. {t('pages.clients.noSubId')}
  262. </div>
  263. )}
  264. {client?.subId && !hasAnything && !loading && (
  265. <div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
  266. {t('pages.clients.noLinks')}
  267. </div>
  268. )}
  269. {hasAnything && (
  270. <Collapse
  271. activeKey={activeKey}
  272. onChange={(keys) =>
  273. setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))
  274. }
  275. items={items}
  276. />
  277. )}
  278. </Spin>
  279. </Modal>
  280. );
  281. }