ClientQrModal.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useNavigate } from 'react-router';
  4. import { Alert, Button, Collapse, Empty, Modal, Segmented, Spin, Tag, Typography } from 'antd';
  5. import { LockOutlined } from '@ant-design/icons';
  6. import { HttpUtil } from '@/utils';
  7. import type { HappLinkResult } from '@/generated/types';
  8. import { HappLinkResultSchema } from '@/generated/zod';
  9. import { isPostQuantumLink } from '@/lib/xray/inbound-link';
  10. import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
  11. import { QrPanel } from '@/pages/inbounds/qr';
  12. import type { ClientRecord, InboundOption } from '@/hooks/useClients';
  13. import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
  14. import {
  15. buildWireguardClientConfig,
  16. findWireguardInbounds,
  17. isWireguardClient,
  18. } from './wireguardConfig';
  19. import {
  20. buildAmneziaWGClientConfig,
  21. findAmneziaWGInbounds,
  22. isAmneziaWGClient,
  23. } from './amneziawgConfig';
  24. import { buildTuicClientConfig, findTuicInbound, isTuicClient } from './tuicConfig';
  25. import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints';
  26. import type { HostRecord } from '@/schemas/api/host';
  27. interface SubSettings {
  28. enable: boolean;
  29. happLinkEnable?: boolean;
  30. subURI: string;
  31. subJsonURI: string;
  32. subJsonEnable: boolean;
  33. publicHost?: string;
  34. }
  35. interface ClientQrModalProps {
  36. open: boolean;
  37. client: ClientRecord | null;
  38. inboundsById: Record<number, InboundOption>;
  39. tunnelAllowedIPs?: Record<number, string>;
  40. subSettings?: SubSettings;
  41. hosts?: HostRecord[];
  42. onOpenChange: (open: boolean) => void;
  43. }
  44. const NO_HOSTS: HostRecord[] = [];
  45. interface ApiMsg<T = unknown> {
  46. success?: boolean;
  47. obj?: T;
  48. }
  49. type QrVariant = 'standard' | 'happ';
  50. type HappError = 'too_long' | 'unavailable' | null;
  51. const HAPP_CRYPT5_PREFIX = 'happ://crypt5/';
  52. const HAPP_SETTINGS_PATH = '/settings?subscriptionTab=happ#subscription';
  53. // QrPanel encodes at error level L; QR version 40 holds 2953 UTF-8 bytes at that level.
  54. const HAPP_QR_MAX_BYTES = 2953;
  55. const UTF8_ENCODER = new TextEncoder();
  56. function hasHappForbiddenCharacter(link: string) {
  57. return Array.from(link).some((character) => {
  58. const codePoint = character.codePointAt(0) ?? 0;
  59. return /\s/u.test(character) || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
  60. });
  61. }
  62. function isValidHappCrypt5Link(link: string) {
  63. return (
  64. link.startsWith(HAPP_CRYPT5_PREFIX) &&
  65. link.length > HAPP_CRYPT5_PREFIX.length &&
  66. !hasHappForbiddenCharacter(link)
  67. );
  68. }
  69. function canRenderHappQr(link: string) {
  70. return UTF8_ENCODER.encode(link).byteLength <= HAPP_QR_MAX_BYTES;
  71. }
  72. interface SubscriptionQrPresentationProps {
  73. variant: QrVariant;
  74. standardLink: string;
  75. remark: string;
  76. happLink: string;
  77. happLoading: boolean;
  78. happError: HappError;
  79. happLinkEnabled: boolean;
  80. onVariantChange: (variant: QrVariant) => void;
  81. onRegenerate: () => void;
  82. onOpenHappSettings: () => void;
  83. }
  84. function SubscriptionQrPresentation({
  85. variant,
  86. standardLink,
  87. remark,
  88. happLink,
  89. happLoading,
  90. happError,
  91. happLinkEnabled,
  92. onVariantChange,
  93. onRegenerate,
  94. onOpenHappSettings,
  95. }: SubscriptionQrPresentationProps) {
  96. const { t } = useTranslation();
  97. const showHappQr = canRenderHappQr(happLink);
  98. return (
  99. <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
  100. <Segmented<QrVariant>
  101. block
  102. value={variant}
  103. options={[
  104. { label: t('pages.clients.qrStandard'), value: 'standard' },
  105. {
  106. label: (
  107. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
  108. {!happLinkEnabled ? (
  109. <LockOutlined aria-label={t('pages.clients.happLinkDisabledHint')} />
  110. ) : null}
  111. <span>{t('pages.clients.happLinkOptionLabel')}</span>
  112. </span>
  113. ),
  114. value: 'happ',
  115. },
  116. ]}
  117. onChange={onVariantChange}
  118. />
  119. {variant === 'standard' ? (
  120. <QrPanel value={standardLink} remark={remark} />
  121. ) : !happLinkEnabled ? (
  122. <Empty
  123. image={<LockOutlined aria-hidden style={{ fontSize: 40, opacity: 0.45 }} />}
  124. styles={{ image: { height: 44, marginBottom: 12 } }}
  125. style={{
  126. minHeight: 190,
  127. margin: 0,
  128. padding: '20px 12px',
  129. display: 'flex',
  130. flexDirection: 'column',
  131. justifyContent: 'center',
  132. }}
  133. description={
  134. <div style={{ maxWidth: 400, margin: '0 auto' }}>
  135. <Typography.Text strong>{t('pages.clients.happLinkDisabledTitle')}</Typography.Text>
  136. <Typography.Paragraph type="secondary" style={{ margin: '6px 0 0' }}>
  137. {t('pages.clients.happLinkDisabledDescription')}
  138. </Typography.Paragraph>
  139. </div>
  140. }
  141. >
  142. <Button type="primary" onClick={onOpenHappSettings}>
  143. {t('pages.clients.happLinkSettingsAction')}
  144. </Button>
  145. </Empty>
  146. ) : (
  147. <div>
  148. <Alert
  149. style={{ marginBottom: 16 }}
  150. type="warning"
  151. showIcon
  152. title={t('pages.clients.happLinkDisclosure')}
  153. />
  154. <Spin spinning={happLoading}>
  155. <div style={{ minHeight: happLoading ? 48 : undefined }}>
  156. {happLink ? (
  157. <>
  158. {!showHappQr ? (
  159. <Alert
  160. style={{ marginBottom: 12 }}
  161. type="info"
  162. showIcon
  163. title={t('pages.clients.happLinkQrTooLong')}
  164. />
  165. ) : null}
  166. <QrPanel value={happLink} remark={remark} showQr={showHappQr} />
  167. </>
  168. ) : null}
  169. {happError ? (
  170. <Alert
  171. type="error"
  172. showIcon
  173. title={
  174. happError === 'too_long'
  175. ? t('pages.clients.happLinkSourceTooLong')
  176. : t('pages.clients.happLinkErrorHint', {
  177. dashboard: t('menu.dashboard'),
  178. logs: t('pages.index.logs'),
  179. })
  180. }
  181. />
  182. ) : null}
  183. </div>
  184. </Spin>
  185. {happLink || happError === 'unavailable' ? (
  186. <Button style={{ marginTop: 12 }} onClick={onRegenerate}>
  187. {happError ? t('pages.clients.happLinkRetry') : t('regenerate')}
  188. </Button>
  189. ) : null}
  190. </div>
  191. )}
  192. </div>
  193. );
  194. }
  195. const DEFAULT_SUB: SubSettings = {
  196. enable: false,
  197. happLinkEnable: false,
  198. subURI: '',
  199. subJsonURI: '',
  200. subJsonEnable: false,
  201. publicHost: '',
  202. };
  203. export default function ClientQrModal(props: ClientQrModalProps) {
  204. const subSettings = props.subSettings ?? DEFAULT_SUB;
  205. const subId = props.client?.subId ?? '';
  206. const subLink =
  207. subId && subSettings.enable && subSettings.subURI ? subSettings.subURI + subId : '';
  208. const happLinkEnabled = subSettings.happLinkEnable === true;
  209. // A gate or source change remounts this scope to clear Happ state and retire any in-flight response.
  210. const scopeKey = `${props.client?.id ?? ''}\0${subId}\0${subLink}\0${happLinkEnabled ? 1 : 0}`;
  211. return <ClientQrModalContent key={scopeKey} {...props} />;
  212. }
  213. function ClientQrModalContent({
  214. open,
  215. client,
  216. inboundsById,
  217. tunnelAllowedIPs,
  218. subSettings = DEFAULT_SUB,
  219. hosts = NO_HOSTS,
  220. onOpenChange,
  221. }: ClientQrModalProps) {
  222. const { t } = useTranslation();
  223. const navigate = useNavigate();
  224. const [links, setLinks] = useState<string[]>([]);
  225. const [loading, setLoading] = useState(false);
  226. const subId = client?.subId;
  227. const subEnabled = !!subSettings?.enable;
  228. const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
  229. const subJsonLink =
  230. subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
  231. ? subSettings.subJsonURI + subId
  232. : '';
  233. const clientId = client?.id;
  234. const clientSubId = subId ?? '';
  235. const happLinkEnabled = subSettings.happLinkEnable === true;
  236. const [variant, setVariant] = useState<QrVariant>('standard');
  237. const [happAttempt, setHappAttempt] = useState(0);
  238. const [happLink, setHappLink] = useState('');
  239. const [happLoading, setHappLoading] = useState(false);
  240. const [happError, setHappError] = useState<HappError>(null);
  241. const canGenerateHapp =
  242. happLinkEnabled &&
  243. typeof clientId === 'number' &&
  244. Number.isSafeInteger(clientId) &&
  245. clientId > 0 &&
  246. !!clientSubId &&
  247. !!subLink;
  248. useEffect(() => {
  249. if (!open || variant !== 'happ' || !canGenerateHapp) return;
  250. let cancelled = false;
  251. (async () => {
  252. try {
  253. const msg = await HttpUtil.post<HappLinkResult>(
  254. `/panel/api/clients/happLink/${clientId}`,
  255. undefined,
  256. { silent: true },
  257. );
  258. if (cancelled) return;
  259. const result = HappLinkResultSchema.safeParse(msg?.obj);
  260. if (msg?.success && result.success && isValidHappCrypt5Link(result.data.encryptedLink)) {
  261. setHappLink(result.data.encryptedLink);
  262. } else {
  263. // Only this fixed API code is safe to localize; arbitrary error messages stay hidden.
  264. setHappError(
  265. msg?.success === false && msg.msg === 'happ_source_too_long'
  266. ? 'too_long'
  267. : 'unavailable',
  268. );
  269. }
  270. } catch {
  271. if (!cancelled) setHappError('unavailable');
  272. } finally {
  273. if (!cancelled) setHappLoading(false);
  274. }
  275. })();
  276. return () => {
  277. // A retired generation must never replace the QR for a newer modal scope.
  278. cancelled = true;
  279. };
  280. }, [open, variant, clientId, clientSubId, subLink, happAttempt, canGenerateHapp]);
  281. const selectVariant = useCallback(
  282. (nextVariant: QrVariant) => {
  283. const generateHapp = nextVariant === 'happ' && happLinkEnabled;
  284. setVariant(nextVariant);
  285. setHappLink('');
  286. setHappLoading(generateHapp && canGenerateHapp);
  287. setHappError(generateHapp && !canGenerateHapp ? 'unavailable' : null);
  288. },
  289. [canGenerateHapp, happLinkEnabled],
  290. );
  291. const regenerateHappLink = useCallback(() => {
  292. setHappLink('');
  293. setHappLoading(canGenerateHapp);
  294. setHappError(canGenerateHapp ? null : 'unavailable');
  295. if (!canGenerateHapp) return;
  296. setHappAttempt((attempt) => attempt + 1);
  297. }, [canGenerateHapp]);
  298. const openHappSettings = useCallback(() => {
  299. // This path only exposes the operator gate; authorization and saving remain explicit in Settings.
  300. onOpenChange(false);
  301. navigate(HAPP_SETTINGS_PATH);
  302. }, [navigate, onOpenChange]);
  303. const wgInbounds = useMemo(
  304. () => findWireguardInbounds(client, inboundsById),
  305. [client, inboundsById],
  306. );
  307. const wgConfigs = useMemo(() => {
  308. if (!client || !isWireguardClient(client)) return [];
  309. const host = window.location.hostname;
  310. const publicHost = subSettings.publicHost ?? '';
  311. return wgInbounds
  312. .flatMap((ib) => {
  313. const address = tunnelAllowedIPs?.[ib.id] ?? '';
  314. return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
  315. inbound: ib,
  316. endpoint: tunnelEndpointLabel(ep),
  317. text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep),
  318. }));
  319. })
  320. .filter((c) => !!c.text);
  321. }, [client, wgInbounds, tunnelAllowedIPs, subSettings.publicHost, hosts]);
  322. const awgInbounds = useMemo(
  323. () => findAmneziaWGInbounds(client, inboundsById),
  324. [client, inboundsById],
  325. );
  326. const awgConfigs = useMemo(() => {
  327. if (!client || !isAmneziaWGClient(client)) return [];
  328. const host = window.location.hostname;
  329. const publicHost = subSettings.publicHost ?? '';
  330. return awgInbounds
  331. .flatMap((ib) => {
  332. const address = tunnelAllowedIPs?.[ib.id] ?? '';
  333. return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
  334. inbound: ib,
  335. endpoint: tunnelEndpointLabel(ep),
  336. text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep),
  337. }));
  338. })
  339. .filter((c) => !!c.text);
  340. }, [client, awgInbounds, tunnelAllowedIPs, subSettings.publicHost, hosts]);
  341. const tuicInbound = useMemo(() => findTuicInbound(client, inboundsById), [client, inboundsById]);
  342. const tuicConfigs = useMemo(() => {
  343. if (!client || !tuicInbound || !isTuicClient(client)) return [];
  344. const host = window.location.hostname;
  345. const publicHost = subSettings.publicHost ?? '';
  346. return tunnelConfigEndpoints(tuicInbound, hosts, host, publicHost, 'clash').map((ep) => ({
  347. endpoint: tunnelEndpointLabel(ep),
  348. text: buildTuicClientConfig(client, tuicInbound, host, publicHost, ep),
  349. }));
  350. }, [client, tuicInbound, subSettings.publicHost, hosts]);
  351. const hasAnything =
  352. !!subLink ||
  353. !!subJsonLink ||
  354. wgConfigs.length > 0 ||
  355. awgConfigs.length > 0 ||
  356. tuicConfigs.length > 0 ||
  357. links.length > 0;
  358. // The reset runs during render so the effect only carries the request.
  359. const openSubId = open ? (client?.subId ?? '') : '';
  360. const [syncedSubId, setSyncedSubId] = useState(openSubId);
  361. if (openSubId !== syncedSubId) {
  362. setSyncedSubId(openSubId);
  363. setLinks([]);
  364. setLoading(!!openSubId);
  365. setVariant('standard');
  366. setHappLink('');
  367. setHappLoading(false);
  368. setHappError(null);
  369. }
  370. useEffect(() => {
  371. if (!open || !client?.subId) return;
  372. let cancelled = false;
  373. (async () => {
  374. try {
  375. const msg = (await HttpUtil.get(
  376. `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
  377. )) as ApiMsg<string[]>;
  378. if (!cancelled) {
  379. setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
  380. }
  381. } finally {
  382. if (!cancelled) setLoading(false);
  383. }
  384. })();
  385. return () => {
  386. cancelled = true;
  387. };
  388. }, [open, client?.subId]);
  389. const [activeKey, setActiveKey] = useState<string[]>([]);
  390. const items = useMemo(() => {
  391. const out: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [];
  392. if (subLink) {
  393. out.push({
  394. key: 'sub',
  395. label: t('subscription.title'),
  396. children: (
  397. <SubscriptionQrPresentation
  398. variant={variant}
  399. standardLink={subLink}
  400. remark={`${client?.email || ''} — ${t('subscription.title')}`}
  401. happLink={happLink}
  402. happLoading={happLoading}
  403. happError={happError}
  404. happLinkEnabled={happLinkEnabled}
  405. onVariantChange={selectVariant}
  406. onRegenerate={regenerateHappLink}
  407. onOpenHappSettings={openHappSettings}
  408. />
  409. ),
  410. });
  411. }
  412. if (subJsonLink) {
  413. out.push({
  414. key: 'subJson',
  415. label: `${t('subscription.title')} (JSON)`,
  416. children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
  417. });
  418. }
  419. links.forEach((link, idx) => {
  420. const parts = parseLinkParts(link);
  421. const meta = parts ? linkMetaText(parts) : '';
  422. const label: React.ReactNode = parts ? (
  423. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
  424. <LinkTags parts={parts} />
  425. {meta && <span style={{ opacity: 0.6, fontSize: 12 }}>({meta})</span>}
  426. </span>
  427. ) : (
  428. `${t('pages.clients.link')} ${idx + 1}`
  429. );
  430. out.push({
  431. key: `l${idx}`,
  432. label,
  433. children: (
  434. <QrPanel
  435. value={link}
  436. remark={parts?.remark || `${client?.email || ''} #${idx + 1}`}
  437. showQr={!isPostQuantumLink(link)}
  438. />
  439. ),
  440. });
  441. });
  442. wgConfigs.forEach(({ inbound, endpoint, text }) => {
  443. const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length, endpoint);
  444. const label = (
  445. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
  446. <Tag color="cyan" style={{ margin: 0 }}>
  447. {t('pages.clients.wireguardConfig')}
  448. </Tag>
  449. {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
  450. </span>
  451. );
  452. out.push({
  453. key: `wg-config-${inbound.id}-${endpoint}`,
  454. label,
  455. children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
  456. });
  457. });
  458. awgConfigs.forEach(({ inbound, endpoint, text }) => {
  459. const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length, endpoint);
  460. const label = (
  461. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
  462. <Tag color="purple" style={{ margin: 0 }}>
  463. {t('pages.clients.amneziaWgConfig')}
  464. </Tag>
  465. {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
  466. </span>
  467. );
  468. out.push({
  469. key: `awg-config-${inbound.id}-${endpoint}`,
  470. label,
  471. children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
  472. });
  473. });
  474. tuicConfigs.forEach(({ endpoint, text }) => {
  475. const name = client?.email || 'tuic';
  476. const multi = tuicConfigs.length > 1 ? endpoint : '';
  477. out.push({
  478. key: `tuic-config-${endpoint}`,
  479. label: (
  480. <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
  481. <Tag color="orange" style={{ margin: 0 }}>
  482. {t('pages.clients.tuicConfig')}
  483. </Tag>
  484. {multi && <span style={{ opacity: 0.85, fontSize: 12 }}>{multi}</span>}
  485. </span>
  486. ),
  487. children: (
  488. <QrPanel
  489. value={text}
  490. remark={[name, multi].filter(Boolean).join(' - ')}
  491. downloadName={`${[name, multi.replace(/[^\w.-]+/g, '_')].filter(Boolean).join('-')}.yaml`}
  492. />
  493. ),
  494. });
  495. });
  496. return out;
  497. }, [
  498. subLink,
  499. subJsonLink,
  500. variant,
  501. happLink,
  502. happLoading,
  503. happError,
  504. happLinkEnabled,
  505. wgConfigs,
  506. awgConfigs,
  507. links,
  508. client?.email,
  509. selectVariant,
  510. regenerateHappLink,
  511. openHappSettings,
  512. tuicConfigs,
  513. t,
  514. ]);
  515. // Expanding the first panel is a render-time adjustment, not a side effect.
  516. const firstKey = open && items.length > 0 ? items[0].key : null;
  517. const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
  518. if (firstKey !== syncedFirstKey) {
  519. setSyncedFirstKey(firstKey);
  520. setActiveKey(firstKey ? [firstKey] : []);
  521. }
  522. return (
  523. <Modal
  524. open={open}
  525. title={client ? `${t('qrCode')} — ${client.email}` : t('qrCode')}
  526. footer={null}
  527. width={520}
  528. centered
  529. onCancel={() => onOpenChange(false)}
  530. >
  531. <Spin spinning={loading}>
  532. {!client?.subId && !loading && (
  533. <div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
  534. {t('pages.clients.noSubId')}
  535. </div>
  536. )}
  537. {client?.subId && !hasAnything && !loading && (
  538. <div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
  539. {t('pages.clients.noLinks')}
  540. </div>
  541. )}
  542. {hasAnything && (
  543. <Collapse
  544. activeKey={activeKey}
  545. onChange={(keys) =>
  546. setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))
  547. }
  548. items={items}
  549. />
  550. )}
  551. </Spin>
  552. </Modal>
  553. );
  554. }