import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Collapse, Modal, Spin, Tag } from 'antd'; import { HttpUtil } from '@/utils'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label'; import { QrPanel } from '@/pages/inbounds/qr'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { formatTunnelConfigMeta } from '@/lib/inbounds/label'; import { buildWireguardClientConfig, findWireguardInbounds, isWireguardClient, } from './wireguardConfig'; import { buildAmneziaWGClientConfig, findAmneziaWGInbounds, isAmneziaWGClient, } from './amneziawgConfig'; interface SubSettings { enable: boolean; subURI: string; subJsonURI: string; subJsonEnable: boolean; publicHost?: string; } interface ClientQrModalProps { open: boolean; client: ClientRecord | null; inboundsById: Record; tunnelAllowedIPs?: Record; subSettings?: SubSettings; onOpenChange: (open: boolean) => void; } interface ApiMsg { success?: boolean; obj?: T; } const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, publicHost: '', }; export default function ClientQrModal({ open, client, inboundsById, tunnelAllowedIPs, subSettings = DEFAULT_SUB, onOpenChange, }: ClientQrModalProps) { const { t } = useTranslation(); const [links, setLinks] = useState([]); const [loading, setLoading] = useState(false); const subId = client?.subId; const subEnabled = !!subSettings?.enable; const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : ''; const subJsonLink = subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI ? subSettings.subJsonURI + subId : ''; const wgInbounds = useMemo( () => findWireguardInbounds(client, inboundsById), [client, inboundsById], ); const wgConfigs = useMemo(() => { if (!client || !isWireguardClient(client)) return []; return wgInbounds .map((ib) => { const address = tunnelAllowedIPs?.[ib.id] ?? ''; const text = buildWireguardClientConfig( client, ib, window.location.hostname, subSettings?.publicHost ?? '', address, ); return { inbound: ib, text }; }) .filter((c) => !!c.text); }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]); const awgInbounds = useMemo( () => findAmneziaWGInbounds(client, inboundsById), [client, inboundsById], ); const awgConfigs = useMemo(() => { if (!client || !isAmneziaWGClient(client)) return []; return awgInbounds .map((ib) => { const address = tunnelAllowedIPs?.[ib.id] ?? ''; const text = buildAmneziaWGClientConfig( client, ib, window.location.hostname, subSettings?.publicHost ?? '', address, ); return { inbound: ib, text }; }) .filter((c) => !!c.text); }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]); const hasAnything = !!subLink || !!subJsonLink || wgConfigs.length > 0 || awgConfigs.length > 0 || links.length > 0; // The reset runs during render so the effect only carries the request. const openSubId = open ? (client?.subId ?? '') : ''; const [syncedSubId, setSyncedSubId] = useState(openSubId); if (openSubId !== syncedSubId) { setSyncedSubId(openSubId); setLinks([]); setLoading(!!openSubId); } useEffect(() => { if (!open || !client?.subId) return; let cancelled = false; (async () => { try { const msg = (await HttpUtil.get( `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`, )) as ApiMsg; if (!cancelled) { setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [open, client?.subId]); const [activeKey, setActiveKey] = useState([]); const items = useMemo(() => { const out: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []; if (subLink) { out.push({ key: 'sub', label: t('subscription.title'), children: ( ), }); } if (subJsonLink) { out.push({ key: 'subJson', label: `${t('subscription.title')} (JSON)`, children: , }); } links.forEach((link, idx) => { const parts = parseLinkParts(link); const meta = parts ? linkMetaText(parts) : ''; const label: React.ReactNode = parts ? ( {meta && ({meta})} ) : ( `${t('pages.clients.link')} ${idx + 1}` ); out.push({ key: `l${idx}`, label, children: ( ), }); }); wgConfigs.forEach(({ inbound, text }) => { const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length); const label = ( {t('pages.clients.wireguardConfig')} {meta.label && {meta.label}} ); out.push({ key: `wg-config-${inbound.id}`, label, children: , }); }); awgConfigs.forEach(({ inbound, text }) => { const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length); const label = ( {t('pages.clients.amneziaWgConfig')} {meta.label && {meta.label}} ); out.push({ key: `awg-config-${inbound.id}`, label, children: , }); }); return out; }, [subLink, subJsonLink, wgConfigs, awgConfigs, links, client?.email, t]); // Expanding the first panel is a render-time adjustment, not a side effect. const firstKey = open && items.length > 0 ? items[0].key : null; const [syncedFirstKey, setSyncedFirstKey] = useState(null); if (firstKey !== syncedFirstKey) { setSyncedFirstKey(firstKey); setActiveKey(firstKey ? [firstKey] : []); } return ( onOpenChange(false)} > {!client?.subId && !loading && (
{t('pages.clients.noSubId')}
)} {client?.subId && !hasAnything && !loading && (
{t('pages.clients.noLinks')}
)} {hasAnything && ( setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[])) } items={items} /> )}
); }