mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-01 19:52:09 +03:00
aee45ca3fe
Invariant: an inbound's enabled Hosts are the endpoints every client config for it advertises, whichever surface renders that config. WireGuard and AmneziaWG broke it. Their raw generators ignored the externalProxy entries Hosts are injected as and always emitted resolveInboundAddress, so the raw subscription, the sub page .conf, the clients links API and "export all links" gave out the panel address while the JSON and Clash formats of the same inbound used the Host. advertisedEndpoints now states the fan-out once for mtproto, wireguard and amneziawg. The browser-built configs had the same gap. The Clients page WireGuard and AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the panel hostname next to server links that already used Hosts; the Inbounds page peer configs, QR and export ignored them too. withMtprotoHostEndpoints becomes withHostEndpoints over a shared hostEndpointsFor mirror of the backend, the tunnel fan-outs render one config per Host, and the clients page waits for the hosts list the way the inbounds page does, so an empty list means "no hosts" rather than "not loaded yet".
919 lines
32 KiB
TypeScript
919 lines
32 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
|
|
import {
|
|
CopyOutlined,
|
|
DownloadOutlined,
|
|
EyeOutlined,
|
|
QrcodeOutlined,
|
|
ReloadOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
|
import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label';
|
|
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
|
import { useClientHwids } from '@/hooks/useClientHwids';
|
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
|
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
|
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
|
import { QrPanel } from '@/pages/inbounds/qr';
|
|
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
|
import {
|
|
buildWireguardClientConfig,
|
|
findWireguardInbounds,
|
|
isWireguardClient,
|
|
} from './wireguardConfig';
|
|
import {
|
|
buildAmneziaWGClientConfig,
|
|
findAmneziaWGInbounds,
|
|
isAmneziaWGClient,
|
|
} from './amneziawgConfig';
|
|
import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints';
|
|
import type { HostRecord } from '@/schemas/api/host';
|
|
import './ClientInfoModal.css';
|
|
|
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|
vless: 'blue',
|
|
vmess: 'geekblue',
|
|
trojan: 'volcano',
|
|
shadowsocks: 'magenta',
|
|
hysteria: 'cyan',
|
|
hysteria2: 'green',
|
|
wireguard: 'gold',
|
|
amneziawg: 'yellow',
|
|
http: 'purple',
|
|
mixed: 'lime',
|
|
tunnel: 'orange',
|
|
tuic: 'orange',
|
|
};
|
|
|
|
const INBOUND_CHIP_LIMIT = 1;
|
|
|
|
interface SubSettings {
|
|
enable: boolean;
|
|
subURI: string;
|
|
subJsonURI: string;
|
|
subJsonEnable: boolean;
|
|
subClashURI: string;
|
|
subClashEnable: boolean;
|
|
publicHost?: string;
|
|
}
|
|
|
|
interface ClientInfoModalProps {
|
|
open: boolean;
|
|
client: ClientRecord | null;
|
|
inboundsById: Record<number, InboundOption>;
|
|
tunnelAllowedIPs?: Record<number, string>;
|
|
isOnline: boolean;
|
|
subSettings?: SubSettings;
|
|
hosts?: HostRecord[];
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
const NO_HOSTS: HostRecord[] = [];
|
|
|
|
interface ApiMsg<T = unknown> {
|
|
success?: boolean;
|
|
obj?: T;
|
|
}
|
|
|
|
const DEFAULT_SUB: SubSettings = {
|
|
enable: false,
|
|
subURI: '',
|
|
subJsonURI: '',
|
|
subJsonEnable: false,
|
|
subClashURI: '',
|
|
subClashEnable: false,
|
|
publicHost: '',
|
|
};
|
|
|
|
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
|
standard: 'subscription-standard.txt',
|
|
json: 'subscription-json.json',
|
|
clash: 'subscription-clash.yaml',
|
|
} as const;
|
|
|
|
export default function ClientInfoModal({
|
|
open,
|
|
client,
|
|
inboundsById,
|
|
tunnelAllowedIPs,
|
|
isOnline,
|
|
subSettings = DEFAULT_SUB,
|
|
hosts = NO_HOSTS,
|
|
onOpenChange,
|
|
}: ClientInfoModalProps) {
|
|
const { datepicker } = useDatepicker();
|
|
const { t } = useTranslation();
|
|
const expiryLabel = (ts?: number) => {
|
|
if (!ts) return '∞';
|
|
if (ts < 0) {
|
|
const days = Math.round(ts / -86400000);
|
|
return `${t('pages.clients.delayedStart')}: ${days}d`;
|
|
}
|
|
return IntlUtil.formatDate(ts, datepicker);
|
|
};
|
|
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
const [links, setLinks] = useState<string[]>([]);
|
|
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
|
const [ipsLoading, setIpsLoading] = useState(false);
|
|
const [ipsClearing, setIpsClearing] = useState(false);
|
|
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
|
const {
|
|
clientHwids,
|
|
hwidsLoading,
|
|
hwidsClearing,
|
|
deletingHwidId,
|
|
loadHwids,
|
|
clearHwids,
|
|
deleteHwid,
|
|
resetHwids,
|
|
} = useClientHwids(client?.email);
|
|
const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
|
|
const [downloadingFormat, setDownloadingFormat] = useState<
|
|
keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
|
|
>(null);
|
|
|
|
// Clearing on close happens during render; the effect owns only the fetch.
|
|
const openSubId = open ? (client?.subId ?? '') : null;
|
|
const [syncedSubId, setSyncedSubId] = useState(openSubId);
|
|
if (openSubId !== syncedSubId) {
|
|
setSyncedSubId(openSubId);
|
|
if (openSubId === null) {
|
|
setLinks([]);
|
|
setClientIps([]);
|
|
setIpsModalOpen(false);
|
|
resetHwids();
|
|
setHwidsModalOpen(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open || !client?.subId) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const msg = (await HttpUtil.get(
|
|
`/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
|
|
)) as ApiMsg<string[]>;
|
|
if (cancelled) return;
|
|
setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, client?.subId]);
|
|
|
|
const traffic = client?.traffic || null;
|
|
const totalBytes = client?.totalGB || 0;
|
|
const used = (traffic?.up || 0) + (traffic?.down || 0);
|
|
const remaining = useMemo(() => {
|
|
if (totalBytes <= 0) return -1;
|
|
const r = totalBytes - used;
|
|
return r > 0 ? r : 0;
|
|
}, [totalBytes, used]);
|
|
|
|
const subId = client?.subId;
|
|
const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
|
|
const subJsonLink =
|
|
subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
|
|
? subSettings.subJsonURI + subId
|
|
: '';
|
|
const subClashLink =
|
|
subId && subSettings?.subClashEnable && subSettings?.subClashURI
|
|
? subSettings.subClashURI + subId
|
|
: '';
|
|
|
|
const showSubscription = !!(subSettings?.enable && client?.subId);
|
|
const wgInbounds = useMemo(
|
|
() => findWireguardInbounds(client, inboundsById),
|
|
[client, inboundsById],
|
|
);
|
|
const wgConfigs = useMemo(() => {
|
|
if (!client || !isWireguardClient(client)) return [];
|
|
const host = window.location.hostname;
|
|
const publicHost = subSettings?.publicHost ?? '';
|
|
return wgInbounds
|
|
.flatMap((ib) => {
|
|
const address = tunnelAllowedIPs?.[ib.id] ?? '';
|
|
return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
|
|
inbound: ib,
|
|
endpoint: tunnelEndpointLabel(ep),
|
|
text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep),
|
|
}));
|
|
})
|
|
.filter((c) => !!c.text);
|
|
}, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
|
|
|
|
const awgInbounds = useMemo(
|
|
() => findAmneziaWGInbounds(client, inboundsById),
|
|
[client, inboundsById],
|
|
);
|
|
const awgConfigs = useMemo(() => {
|
|
if (!client || !isAmneziaWGClient(client)) return [];
|
|
const host = window.location.hostname;
|
|
const publicHost = subSettings?.publicHost ?? '';
|
|
return awgInbounds
|
|
.flatMap((ib) => {
|
|
const address = tunnelAllowedIPs?.[ib.id] ?? '';
|
|
return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
|
|
inbound: ib,
|
|
endpoint: tunnelEndpointLabel(ep),
|
|
text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep),
|
|
}));
|
|
})
|
|
.filter((c) => !!c.text);
|
|
}, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
|
|
|
|
async function copyValue(text: string) {
|
|
if (!text) return;
|
|
const ok = await ClipboardManager.copyText(String(text));
|
|
if (ok) messageApi.success(t('copied'));
|
|
}
|
|
|
|
async function downloadSubscription(
|
|
url: string,
|
|
format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES,
|
|
) {
|
|
if (!url || downloadingFormat) return;
|
|
setDownloadingFormat(format);
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error('Subscription download failed');
|
|
const content = await response.text();
|
|
FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
|
|
} catch (_) {
|
|
messageApi.error(t('somethingWentWrong'));
|
|
} finally {
|
|
setDownloadingFormat(null);
|
|
}
|
|
}
|
|
|
|
async function loadIps() {
|
|
if (!client?.email) return;
|
|
setIpsLoading(true);
|
|
try {
|
|
const msg = (await HttpUtil.post(
|
|
`/panel/api/clients/ips/${encodeURIComponent(client.email)}`,
|
|
)) as ApiMsg<unknown[]>;
|
|
if (!msg?.success) {
|
|
setClientIps([]);
|
|
return;
|
|
}
|
|
setClientIps(normalizeClientIps(msg.obj));
|
|
} finally {
|
|
setIpsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function clearIps() {
|
|
if (!client?.email) return;
|
|
setIpsClearing(true);
|
|
try {
|
|
const msg = (await HttpUtil.post(
|
|
`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`,
|
|
)) as ApiMsg;
|
|
if (msg?.success) setClientIps([]);
|
|
} finally {
|
|
setIpsClearing(false);
|
|
}
|
|
}
|
|
|
|
function openIpsModal() {
|
|
setIpsModalOpen(true);
|
|
if (clientIps.length === 0) void loadIps();
|
|
}
|
|
|
|
function openHwidsModal() {
|
|
setHwidsModalOpen(true);
|
|
if (clientHwids.length === 0) void loadHwids();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{messageContextHolder}
|
|
<Modal
|
|
open={open}
|
|
title={
|
|
client
|
|
? `${t('pages.clients.clientInfo')} — ${client.email}`
|
|
: t('pages.clients.clientInfo')
|
|
}
|
|
footer={null}
|
|
width={640}
|
|
onCancel={() => onOpenChange(false)}
|
|
>
|
|
{client && (
|
|
<>
|
|
<table className="info-table block">
|
|
<tbody>
|
|
<tr>
|
|
<td>{t('pages.clients.online')}</td>
|
|
<td>
|
|
{client.enable && isOnline ? (
|
|
<Tag color="green">{t('pages.clients.online')}</Tag>
|
|
) : (
|
|
<Tag>{t('pages.clients.offline')}</Tag>
|
|
)}
|
|
<span className="hint">
|
|
{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}
|
|
{' · '}
|
|
{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('status')}</td>
|
|
<td>
|
|
<Tag color={client.enable ? 'green' : 'default'}>
|
|
{client.enable ? t('enabled') : t('disabled')}
|
|
</Tag>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.clients.email')}</td>
|
|
<td>
|
|
{client.email ? (
|
|
<Tag color="green">{client.email}</Tag>
|
|
) : (
|
|
<Tag color="red">{t('none')}</Tag>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.clients.subId')}</td>
|
|
<td>
|
|
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
|
|
{client.subId && (
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(client.subId!)}
|
|
/>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{client.uuid && (
|
|
<tr>
|
|
<td>{t('pages.clients.uuid')}</td>
|
|
<td>
|
|
<Tag className="info-large-tag">{client.uuid}</Tag>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(client.uuid!)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{client.password && (
|
|
<tr>
|
|
<td>{t('password')}</td>
|
|
<td>
|
|
<Tag className="info-large-tag">{client.password}</Tag>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(client.password!)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{client.auth && (
|
|
<tr>
|
|
<td>{t('pages.clients.auth')}</td>
|
|
<td>
|
|
<Tag className="info-large-tag">{client.auth}</Tag>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(client.auth!)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
<tr>
|
|
<td>{t('pages.clients.flow')}</td>
|
|
<td>
|
|
{client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.inbounds.traffic')}</td>
|
|
<td>
|
|
<Tag>
|
|
↑ {SizeFormatter.sizeFormat(traffic?.up || 0)} / ↓{' '}
|
|
{SizeFormatter.sizeFormat(traffic?.down || 0)}
|
|
</Tag>
|
|
<span className="hint">
|
|
{SizeFormatter.sizeFormat(used)} /{' '}
|
|
{totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('remained')}</td>
|
|
<td>
|
|
{remaining < 0 ? (
|
|
<Tag color="purple">∞</Tag>
|
|
) : (
|
|
<Tag color={remaining > 0 ? '' : 'red'}>
|
|
{SizeFormatter.sizeFormat(remaining)}
|
|
</Tag>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.inbounds.expireDate')}</td>
|
|
<td>
|
|
{!client.expiryTime ? (
|
|
<Tag color="purple">∞</Tag>
|
|
) : (
|
|
<Tag color={client.expiryTime < 0 ? 'blue' : undefined}>
|
|
{expiryLabel(client.expiryTime)}
|
|
</Tag>
|
|
)}
|
|
{(client.expiryTime ?? 0) > 0 && (
|
|
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.clients.ipLimit')}</td>
|
|
<td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.inbounds.IPLimitlog')}</td>
|
|
<td>
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
aria-label={t('pages.clients.ipLog')}
|
|
loading={ipsLoading}
|
|
onClick={openIpsModal}
|
|
>
|
|
{clientIps.length > 0 ? clientIps.length : ''}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
{(traffic?.resetMax ?? 0) > 0 && (
|
|
<tr>
|
|
<td>{t('pages.clients.renewsUsed')}</td>
|
|
<td>
|
|
<Tag
|
|
color={
|
|
(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'
|
|
}
|
|
>
|
|
{traffic?.resetCount ?? 0} / {traffic?.resetMax}
|
|
</Tag>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
<tr>
|
|
<td>{t('pages.clients.limitHwid')}</td>
|
|
<td>{!client.limitHwid ? <Tag>∞</Tag> : <Tag>{client.limitHwid}</Tag>}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.clients.hwidLog')}</td>
|
|
<td>
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
aria-label={t('pages.clients.hwidLog')}
|
|
loading={hwidsLoading}
|
|
onClick={openHwidsModal}
|
|
>
|
|
{clientHwids.length > 0 ? clientHwids.length : ''}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.inbounds.createdAt')}</td>
|
|
<td>
|
|
<Tag>{dateLabel(client.createdAt)}</Tag>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{t('pages.inbounds.updatedAt')}</td>
|
|
<td>
|
|
<Tag>{dateLabel(client.updatedAt)}</Tag>
|
|
</td>
|
|
</tr>
|
|
{client.group && (
|
|
<tr>
|
|
<td>{t('pages.clients.group')}</td>
|
|
<td>
|
|
<Tag color="geekblue">{client.group}</Tag>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{client.comment && (
|
|
<tr>
|
|
<td>{t('pages.clients.comment')}</td>
|
|
<td>
|
|
<Tag className="info-large-tag">{client.comment}</Tag>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
<tr>
|
|
<td>{t('pages.clients.attachedInbounds')}</td>
|
|
<td>
|
|
{(() => {
|
|
const ids = client.inboundIds || [];
|
|
if (ids.length === 0) return <span className="hint">—</span>;
|
|
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
|
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
|
const inboundChip = (id: number) => {
|
|
const ib = inboundsById[id];
|
|
const proto = (ib?.protocol || '').toLowerCase();
|
|
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
|
const label = formatInboundLabel(ib?.tag, ib?.remark);
|
|
return (
|
|
<Tooltip key={id} title={label}>
|
|
<Tag color={color}>{label}</Tag>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
return (
|
|
<div className="chips">
|
|
{visible.map((id) => inboundChip(id))}
|
|
{overflow.length > 0 && (
|
|
<Popover
|
|
trigger="click"
|
|
placement="bottomRight"
|
|
content={
|
|
<div className="chips chips-stack">
|
|
{overflow.map((id) => inboundChip(id))}
|
|
</div>
|
|
}
|
|
>
|
|
<Tag color="default" className="chip-more">
|
|
+{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
|
|
</Tag>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
{showSubscription && subLink && (
|
|
<>
|
|
<Divider>{t('subscription.title')}</Divider>
|
|
<div className="link-row">
|
|
<Tag color="green" className="link-row-tag">
|
|
SUB
|
|
</Tag>
|
|
<a
|
|
href={subLink}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="link-row-title link-row-title-anchor"
|
|
title={subLink}
|
|
>
|
|
{client.subId}
|
|
</a>
|
|
<div className="link-row-actions">
|
|
<Tooltip title={t('copy')}>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(subLink)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('download')}>
|
|
<Button
|
|
size="small"
|
|
icon={<DownloadOutlined />}
|
|
aria-label={t('download')}
|
|
loading={downloadingFormat === 'standard'}
|
|
disabled={downloadingFormat !== null}
|
|
onClick={() => void downloadSubscription(subLink, 'standard')}
|
|
/>
|
|
</Tooltip>
|
|
<Popover
|
|
trigger="click"
|
|
placement="left"
|
|
destroyOnHidden
|
|
content={
|
|
<QrPanel
|
|
value={subLink}
|
|
remark={`${client.email} — ${t('subscription.title')}`}
|
|
size={220}
|
|
/>
|
|
}
|
|
>
|
|
<Tooltip title={t('pages.clients.qrCode')}>
|
|
<Button
|
|
size="small"
|
|
icon={<QrcodeOutlined />}
|
|
aria-label={t('pages.clients.qrCode')}
|
|
/>
|
|
</Tooltip>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
{subJsonLink && (
|
|
<div className="link-row">
|
|
<Tag color="purple" className="link-row-tag">
|
|
JSON
|
|
</Tag>
|
|
<a
|
|
href={subJsonLink}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="link-row-title link-row-title-anchor"
|
|
title={subJsonLink}
|
|
>
|
|
{client.subId}
|
|
</a>
|
|
<div className="link-row-actions">
|
|
<Tooltip title={t('copy')}>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(subJsonLink)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('download')}>
|
|
<Button
|
|
size="small"
|
|
icon={<DownloadOutlined />}
|
|
aria-label={t('download')}
|
|
loading={downloadingFormat === 'json'}
|
|
disabled={downloadingFormat !== null}
|
|
onClick={() => void downloadSubscription(subJsonLink, 'json')}
|
|
/>
|
|
</Tooltip>
|
|
<Popover
|
|
trigger="click"
|
|
placement="left"
|
|
destroyOnHidden
|
|
content={
|
|
<QrPanel
|
|
value={subJsonLink}
|
|
remark={`${client.email} — JSON`}
|
|
size={220}
|
|
/>
|
|
}
|
|
>
|
|
<Tooltip title={t('pages.clients.qrCode')}>
|
|
<Button
|
|
size="small"
|
|
icon={<QrcodeOutlined />}
|
|
aria-label={t('pages.clients.qrCode')}
|
|
/>
|
|
</Tooltip>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{subClashLink && (
|
|
<div className="link-row">
|
|
<Tooltip title="Clash / Mihomo">
|
|
<Tag color="gold" className="link-row-tag">
|
|
CLASH
|
|
</Tag>
|
|
</Tooltip>
|
|
<a
|
|
href={subClashLink}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="link-row-title link-row-title-anchor"
|
|
title={subClashLink}
|
|
>
|
|
{client.subId}
|
|
</a>
|
|
<div className="link-row-actions">
|
|
<Tooltip title={t('copy')}>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(subClashLink)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('download')}>
|
|
<Button
|
|
size="small"
|
|
icon={<DownloadOutlined />}
|
|
aria-label={t('download')}
|
|
loading={downloadingFormat === 'clash'}
|
|
disabled={downloadingFormat !== null}
|
|
onClick={() => void downloadSubscription(subClashLink, 'clash')}
|
|
/>
|
|
</Tooltip>
|
|
<Popover
|
|
trigger="click"
|
|
placement="left"
|
|
destroyOnHidden
|
|
content={
|
|
<QrPanel
|
|
value={subClashLink}
|
|
remark={`${client.email} — Clash / Mihomo`}
|
|
size={220}
|
|
/>
|
|
}
|
|
>
|
|
<Tooltip title={t('pages.clients.qrCode')}>
|
|
<Button
|
|
size="small"
|
|
icon={<QrcodeOutlined />}
|
|
aria-label={t('pages.clients.qrCode')}
|
|
/>
|
|
</Tooltip>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{links.length > 0 && (
|
|
<>
|
|
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
|
{links.map((link, idx) => {
|
|
const parts = parseLinkParts(link);
|
|
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
|
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
|
const qrRemark = parts?.remark || rowTitle;
|
|
const canQr = !isPostQuantumLink(link);
|
|
return (
|
|
<div key={idx} className="link-row">
|
|
{parts ? (
|
|
<LinkTags parts={parts} />
|
|
) : (
|
|
<Tag className="link-row-tag">LINK</Tag>
|
|
)}
|
|
<span className="link-row-title" title={rowTitle}>
|
|
{rowTitle}
|
|
</span>
|
|
<div className="link-row-actions">
|
|
<Tooltip title={t('copy')}>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label={t('copy')}
|
|
onClick={() => copyValue(link)}
|
|
/>
|
|
</Tooltip>
|
|
{canQr && (
|
|
<Popover
|
|
trigger="click"
|
|
placement="left"
|
|
destroyOnHidden
|
|
content={<QrPanel value={link} remark={qrRemark} size={220} />}
|
|
>
|
|
<Tooltip title={t('pages.clients.qrCode')}>
|
|
<Button
|
|
size="small"
|
|
icon={<QrcodeOutlined />}
|
|
aria-label={t('pages.clients.qrCode')}
|
|
/>
|
|
</Tooltip>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{wgConfigs.length > 0 && client && (
|
|
<>
|
|
<Divider>{t('pages.clients.wireguardConfig')}</Divider>
|
|
{wgConfigs.map(({ inbound, endpoint, text }) => {
|
|
const meta = formatTunnelConfigMeta(
|
|
inbound,
|
|
client.email,
|
|
wgConfigs.length,
|
|
endpoint,
|
|
);
|
|
return (
|
|
<ConfigBlock
|
|
key={`wg-${inbound.id}-${endpoint}`}
|
|
label={meta.label || t('pages.clients.config')}
|
|
text={text}
|
|
fileName={meta.fileName}
|
|
qrRemark={meta.qrRemark}
|
|
tagColor="cyan"
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{awgConfigs.length > 0 && client && (
|
|
<>
|
|
<Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
|
|
{awgConfigs.map(({ inbound, endpoint, text }) => {
|
|
const meta = formatTunnelConfigMeta(
|
|
inbound,
|
|
client.email,
|
|
awgConfigs.length,
|
|
endpoint,
|
|
);
|
|
return (
|
|
<ConfigBlock
|
|
key={`awg-${inbound.id}-${endpoint}`}
|
|
label={meta.label || t('pages.clients.config')}
|
|
text={text}
|
|
fileName={meta.fileName}
|
|
qrRemark={meta.qrRemark}
|
|
tagColor="purple"
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={ipsModalOpen}
|
|
title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? ` — ${client.email}` : ''}`}
|
|
width={440}
|
|
onCancel={() => setIpsModalOpen(false)}
|
|
footer={[
|
|
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
|
|
{t('refresh')}
|
|
</Button>,
|
|
<Button
|
|
key="clear"
|
|
danger
|
|
loading={ipsClearing}
|
|
disabled={clientIps.length === 0}
|
|
onClick={clearIps}
|
|
>
|
|
{t('pages.clients.clearAll')}
|
|
</Button>,
|
|
<Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
|
|
{t('close')}
|
|
</Button>,
|
|
]}
|
|
>
|
|
{clientIps.length > 0 ? (
|
|
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
|
{clientIps.map((entry, idx) => (
|
|
<Tag
|
|
key={idx}
|
|
color="blue"
|
|
style={{
|
|
display: 'block',
|
|
width: 'fit-content',
|
|
maxWidth: '100%',
|
|
marginBottom: 6,
|
|
padding: '2px 8px',
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
}}
|
|
>
|
|
{entry.ip}
|
|
{entry.time ? ` (${entry.time})` : ''}
|
|
{entry.node ? (
|
|
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
|
|
@ {entry.node}
|
|
</span>
|
|
) : null}
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Tag>{t('tgbot.noIpRecord')}</Tag>
|
|
)}
|
|
</Modal>
|
|
|
|
<ClientHwidListModal
|
|
open={hwidsModalOpen}
|
|
email={client?.email}
|
|
hwids={clientHwids}
|
|
loading={hwidsLoading}
|
|
clearing={hwidsClearing}
|
|
deletingId={deletingHwidId}
|
|
formatDate={dateLabel}
|
|
onRefresh={loadHwids}
|
|
onClearAll={clearHwids}
|
|
onDelete={deleteHwid}
|
|
onClose={() => setHwidsModalOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|