import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Divider, Form, Input, message, Modal, Select, Tabs } from 'antd'; import { LoginOutlined, SaveOutlined } from '@ant-design/icons'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { HttpUtil } from '@/utils'; import { FormField } from '@/components/form/rhf'; import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers'; import './NordModal.css'; interface NordModalProps { open: boolean; templateSettings: { outbounds?: NordOutboundRow[] } | null; onClose: () => void; onAddOutbound: (outbound: Record) => void; onResetOutbound: (payload: { index: number; outbound: Record; oldTag?: string; newTag: string; }) => void; } interface NordOutboundRow { tag?: string; protocol?: string; settings?: unknown; } interface NordAddedRow { index: number; tag: string; endpoint: string; resettable: boolean; } interface NordData { token?: string; private_key?: string; } interface Country { id: number; name: string; code: string; } interface City { id: number; name: string; } interface NordServer { id: number; name: string; hostname: string; station: string; load: number; technologies?: { metadata?: { name: string; value: string }[] }[]; location_ids?: number[]; cityId?: number | null; cityName?: string; } interface NordServerOption { value: number; label: string; searchText: string; server: NordServer; } interface NordFormValues { token: string; manualKey: string; countryId: number | null; cityId: number | null; serverId: number | null; } const EMPTY: NordFormValues = { token: '', manualKey: '', countryId: null, cityId: null, serverId: null, }; function loadLevel(load: number): 'low' | 'medium' | 'high' { if (load < 30) return 'low'; if (load < 70) return 'medium'; return 'high'; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isResettableNordOutbound(outbound: NordOutboundRow): boolean { if (outbound.protocol !== 'wireguard' || !isRecord(outbound.settings)) return false; return ( Array.isArray(outbound.settings.address) && outbound.settings.address.length > 0 && Array.isArray(outbound.settings.peers) && outbound.settings.peers.length > 0 ); } function nordOutboundEndpoint(outbound: NordOutboundRow): string { if (!isRecord(outbound.settings) || !Array.isArray(outbound.settings.peers)) return ''; const peer = outbound.settings.peers.find(isRecord); return typeof peer?.endpoint === 'string' ? peer.endpoint : ''; } export default function NordModal({ open, templateSettings, onClose, onAddOutbound, onResetOutbound, }: NordModalProps) { const { t, i18n } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [loading, setLoading] = useState(false); const [nordData, setNordData] = useState(null); const [countries, setCountries] = useState([]); const [cities, setCities] = useState([]); const [servers, setServers] = useState([]); const methods = useForm({ defaultValues: EMPTY }); const cityId = useWatch({ control: methods.control, name: 'cityId' }); const serverId = useWatch({ control: methods.control, name: 'serverId' }); const locale = i18n.resolvedLanguage || i18n.language; const nordRows = useMemo(() => { const list = templateSettings?.outbounds; if (!list) return []; return list.flatMap((outbound, index) => { const tag = outbound?.tag; if (typeof tag !== 'string' || !tag.startsWith('nord-')) return []; return [ { index, tag, endpoint: nordOutboundEndpoint(outbound), resettable: isResettableNordOutbound(outbound), }, ]; }); }, [templateSettings?.outbounds]); const addedTags = useMemo(() => new Set(nordRows.map((row) => row.tag)), [nordRows]); const filteredServers = useMemo(() => { if (cityId == null) return servers; return servers.filter((s) => s.cityId === cityId); }, [cityId, servers]); const selectedServer = filteredServers.find((server) => server.id === serverId); const selectedTag = selectedServer ? `nord-${selectedServer.hostname}` : ''; const selectedAlreadyAdded = Boolean(selectedTag && addedTags.has(selectedTag)); const serverOptions = useMemo( () => filteredServers.map((server) => ({ value: server.id, label: server.hostname, searchText: `${server.cityName ?? ''} ${server.name} ${server.hostname} ${server.station}`.toLowerCase(), server, })), [filteredServers], ); useEffect(() => { methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null); }, [filteredServers, methods]); const fetchCountries = useCallback(async () => { const msg = await HttpUtil.post('/panel/api/xray/nord/countries'); if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj)); }, []); const fetchData = useCallback(async () => { setLoading(true); try { const msg = await HttpUtil.post('/panel/api/xray/nord/data'); if (msg?.success) { const next = msg.obj ? JSON.parse(msg.obj) : null; setNordData(next); if (next) await fetchCountries(); } } finally { setLoading(false); } }, [fetchCountries]); useEffect(() => { if (!open) return; let cancelled = false; void (async () => { await fetchData(); if (cancelled) return; })(); return () => { cancelled = true; }; }, [open, fetchData]); async function login() { setLoading(true); try { const msg = await HttpUtil.post('/panel/api/xray/nord/reg', { token: methods.getValues('token'), }); if (msg?.success && msg.obj) { setNordData(JSON.parse(msg.obj)); await fetchCountries(); } } finally { setLoading(false); } } async function saveKey() { setLoading(true); try { const msg = await HttpUtil.post('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey'), }); if (msg?.success && msg.obj) { setNordData(JSON.parse(msg.obj)); await fetchCountries(); } } finally { setLoading(false); } } async function logout() { setLoading(true); try { const msg = await HttpUtil.post('/panel/api/xray/nord/del'); if (msg?.success) { setNordData(null); methods.reset(EMPTY); setCountries([]); setCities([]); setServers([]); } } finally { setLoading(false); } } async function fetchServers(newCountryId: number) { setLoading(true); setServers([]); setCities([]); methods.setValue('serverId', null); methods.setValue('cityId', null); try { const msg = await HttpUtil.post('/panel/api/xray/nord/servers', { countryId: newCountryId, }); if (!msg?.success || !msg.obj) return; const data = JSON.parse(msg.obj); const locations = data.locations || []; const locToCity: Record = {}; const citiesMap = new Map(); for (const loc of locations) { if (loc.country?.city) { citiesMap.set(loc.country.city.id, loc.country.city); locToCity[loc.id] = loc.country.city; } } setCities(Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name))); const next: NordServer[] = (data.servers || []) .map((s: NordServer) => { const firstLocId = (s.location_ids || [])[0]; const city = firstLocId != null ? locToCity[firstLocId] : null; return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' }; }) .sort((a: NordServer, b: NordServer) => a.load - b.load); methods.setValue('cityId', null); setServers(next); if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers')); } finally { setLoading(false); } } function buildNordOutbound(): Record | null { const selectedServerId = methods.getValues('serverId'); const server = servers.find((s) => s.id === selectedServerId); if (!server) return null; const publicKey = server.technologies ?.flatMap((technology) => technology.metadata ?? []) .find((metadata) => metadata.name === 'public_key')?.value; if (!publicKey) { messageApi.error(t('pages.xray.nord.noPublicKey')); return null; } return { tag: `nord-${server.hostname}`, protocol: 'wireguard', settings: { secretKey: nordData?.private_key, address: ['10.5.0.2/32'], peers: [{ publicKey, endpoint: `${server.station}:51820` }], // Userspace TUN — same reasoning as the WARP outbound (#5205): kernel // TUN fails silently on many VPS setups and diverges from the data // path the panel's connectivity test exercises. noKernelTun: true, }, }; } function addOutbound() { if (selectedAlreadyAdded) return; const ob = buildNordOutbound(); if (!ob) return; const tag = typeof ob.tag === 'string' ? ob.tag : ''; if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return; onAddOutbound(ob); messageApi.success(t('pages.xray.nord.outboundAdded')); } function resetOutbound(index: number) { const existing = templateSettings?.outbounds?.[index]; if ( !existing?.tag?.startsWith?.('nord-') || !isResettableNordOutbound(existing) || !isRecord(existing.settings) || !nordData?.private_key ) { return; } const ob = { ...existing, settings: { ...existing.settings, secretKey: nordData.private_key }, }; onResetOutbound({ index, outbound: ob, oldTag: existing.tag, newTag: existing.tag, }); messageApi.success(t('pages.xray.nord.outboundUpdated')); } return ( <> {messageContextHolder} {nordData == null ? ( ), }, { key: 'key', label: t('pages.xray.nord.privateKey'), children: (
), }, ]} /> ) : ( <>
{nordData.token && ( )}
{t('pages.xray.nord.accessToken')} {nordData.token}
{t('pages.xray.nord.privateKey')} {nordData.private_key}
{t('pages.xray.warp.settings')}
v ?? undefined }} onAfterChange={(v) => fetchServers(v as number)} > ({ value: c.id, label: c.name })), ]} /> )} {filteredServers.length > 0 && (
data-testid="nord-server-select" classNames={{ popup: { root: 'nord-server-popup' } }} listHeight={320} listItemHeight={58} options={serverOptions} showSearch={{ filterOption: (input, option) => option?.searchText.includes(input.trim().toLowerCase()) ?? false, }} optionRender={(option) => { const server = option.data.server; return (
{server.name} {server.cityName} {server.hostname} {server.station}:51820
); }} labelRender={() => selectedServer ? ( {selectedServer.name} {selectedServer.hostname} {selectedServer.station}:51820 ) : null } />
)}
{selectedAlreadyAdded ? t('pages.xray.nord.alreadyAdded', { reset: t('reset') }) : null}
{nordRows.length > 0 && ( <> {t('pages.xray.nord.addedServers')} {nordRows.map((row) => ( ))}
{row.tag} {row.endpoint && ( {row.endpoint} )}
)} )}
); }