3x-ui/frontend/src/pages/clients/ClientQrModal.tsx
MHSanaei 5d0081a3b9
fix(qr): hide QR for post-quantum links on client QR page
Opening the client sublinks/QR modal crashed when a link used
post-quantum keys (ML-DSA-65 / ML-KEM-768): the encoded URL exceeds
the antd QRCode capacity and the component throws. The client QR modal
rendered the QRCode unconditionally, so it took down the page.

The names don't appear verbatim in a share link — mldsa65Verify rides
inside pqv=<base64> and ML-KEM-768 inside encryption=mlkem768x25519plus.
The QR modal and inbound QR modal used a literal-substring guard that
missed those encoded forms, leaving the QR (and the crash) in place.

Consolidate detection into a single isPostQuantumLink() helper in
inbound-link.ts and reuse it across the client QR, inbound QR, client
info, and sub surfaces. The copy/download link still works; only the
QR image is suppressed for oversized post-quantum links.

Closes #4656
2026-05-29 17:04:30 +02:00

143 lines
4.2 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Modal, Spin } from 'antd';
import { HttpUtil } from '@/utils';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import QrPanel from '@/pages/inbounds/QrPanel';
import type { ClientRecord } from '@/hooks/useClients';
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
interface ClientQrModalProps {
open: boolean;
client: ClientRecord | null;
subSettings?: SubSettings;
onOpenChange: (open: boolean) => void;
}
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
export default function ClientQrModal({
open,
client,
subSettings = DEFAULT_SUB,
onOpenChange,
}: ClientQrModalProps) {
const { t } = useTranslation();
const [links, setLinks] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const subLink = useMemo(() => {
if (!client?.subId || !subSettings?.enable || !subSettings?.subURI) return '';
return subSettings.subURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subURI]);
const subJsonLink = useMemo(() => {
if (!client?.subId || !subSettings?.enable) return '';
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
const hasAnything = !!subLink || !!subJsonLink || links.length > 0;
useEffect(() => {
if (!open || !client?.subId) {
setLinks([]);
return;
}
let cancelled = false;
setLoading(true);
(async () => {
try {
const msg = await HttpUtil.get(
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
) as ApiMsg<string[]>;
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<string[]>([]);
const items = useMemo(() => {
const out: { key: string; label: string; children: React.ReactNode }[] = [];
if (subLink) {
out.push({
key: 'sub',
label: t('subscription.title'),
children: <QrPanel value={subLink} remark={`${client?.email || ''}${t('subscription.title')}`} />,
});
}
if (subJsonLink) {
out.push({
key: 'subJson',
label: `${t('subscription.title')} (JSON)`,
children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
});
}
links.forEach((link, idx) => {
out.push({
key: `l${idx}`,
label: `${t('pages.clients.link')} ${idx + 1}`,
children: (
<QrPanel
value={link}
remark={`${client?.email || ''} #${idx + 1}`}
showQr={!isPostQuantumLink(link)}
/>
),
});
});
return out;
}, [subLink, subJsonLink, links, client?.email, t]);
useEffect(() => {
if (!open) {
setActiveKey([]);
return;
}
setActiveKey(items.length > 0 ? [items[0].key] : []);
}, [open, items]);
return (
<Modal
open={open}
title={client ? client.email : t('qrCode')}
footer={null}
width={520}
centered
onCancel={() => onOpenChange(false)}
>
<Spin spinning={loading}>
{!client?.subId && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noSubId')}</div>
)}
{client?.subId && !hasAnything && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>{t('pages.clients.noLinks')}</div>
)}
{hasAnything && (
<Collapse
activeKey={activeKey}
onChange={(keys) => setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))}
items={items}
/>
)}
</Spin>
</Modal>
);
}