mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 13:07:14 +00:00
feat(pia): add PIA login-and-add WireGuard outbounds (#6272)
* feat(pia): add login-and-add WireGuard outbounds (#2) * fix(pia): keep PIA outbounds identifiable after the editor strips hostname The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch.
This commit is contained in:
@@ -1699,7 +1699,7 @@ export const sections: readonly Section[] = [
|
||||
id: 'xray-settings',
|
||||
title: 'Xray Settings',
|
||||
description:
|
||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
|
||||
'Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1799,6 +1799,43 @@ export const sections: readonly Section[] = [
|
||||
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/pia/:action',
|
||||
summary: 'Manage PIA WireGuard integration. The action parameter selects the operation.',
|
||||
params: [
|
||||
{
|
||||
name: 'action',
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
desc: 'countries — list available countries from the signed PIA server list. servers — list regions and WireGuard servers in a country (sends countryCode). reg — sign in with a PIA username and password (sends username, password). data — return the signed-in account hint. del — delete stored PIA credentials. addKey — register a WireGuard key with the selected server (sends hostname) and return fields to build the outbound.',
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
},
|
||||
{
|
||||
name: 'countryCode',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=servers.',
|
||||
},
|
||||
{
|
||||
name: 'hostname',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=addKey.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/resetOutboundsTraffic',
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
detectBalancerCycles,
|
||||
} from './balancers/balancer-loopback';
|
||||
import { DnsTab } from './dns';
|
||||
import { WarpModal, NordModal } from './overrides';
|
||||
import { WarpModal, NordModal, PiaModal } from './overrides';
|
||||
import './XrayPage.css';
|
||||
|
||||
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
|
||||
@@ -82,6 +82,7 @@ export default function XrayPage() {
|
||||
|
||||
const [warpOpen, setWarpOpen] = useState(false);
|
||||
const [nordOpen, setNordOpen] = useState(false);
|
||||
const [piaOpen, setPiaOpen] = useState(false);
|
||||
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -264,6 +265,7 @@ export default function XrayPage() {
|
||||
onTestAll={testAllOutbounds}
|
||||
onShowWarp={() => setWarpOpen(true)}
|
||||
onShowNord={() => setNordOpen(true)}
|
||||
onShowPia={() => setPiaOpen(true)}
|
||||
onRefreshXrayData={fetchAll}
|
||||
/>
|
||||
);
|
||||
@@ -394,6 +396,13 @@ export default function XrayPage() {
|
||||
onRemoveOutbound={onRemoveOutboundByIndex}
|
||||
onRemoveRoutingRules={onRemoveRoutingRules}
|
||||
/>
|
||||
<PiaModal
|
||||
open={piaOpen}
|
||||
templateSettings={templateSettings}
|
||||
onClose={() => setPiaOpen(false)}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,7 @@ interface OutboundsTabProps {
|
||||
onTestAll: (mode: string) => void;
|
||||
onShowWarp: () => void;
|
||||
onShowNord: () => void;
|
||||
onShowPia: () => void;
|
||||
onRefreshXrayData?: () => void;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ export default function OutboundsTab({
|
||||
onTestAll,
|
||||
onShowWarp,
|
||||
onShowNord,
|
||||
onShowPia,
|
||||
onRefreshXrayData,
|
||||
}: OutboundsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -550,6 +552,12 @@ export default function OutboundsTab({
|
||||
items: [
|
||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||
{
|
||||
key: 'pia',
|
||||
icon: <ApiOutlined />,
|
||||
label: t('pages.xray.pia.menu'),
|
||||
onClick: onShowPia,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'import',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
.pia-data-table {
|
||||
margin: 5px 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.pia-data-table td {
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pia-data-table td:first-child {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.pia-data-table .row-odd {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.pia-already-added {
|
||||
margin-top: 8px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pia-added-table {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.pia-added-table td {
|
||||
padding: 6px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pia-added-table td:first-child {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.pia-added-table td:last-child {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Form, Input, message, Modal, Select } from 'antd';
|
||||
import { LoginOutlined } 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 './PiaModal.css';
|
||||
|
||||
interface PiaOutboundRow {
|
||||
tag?: string;
|
||||
piaHostname?: string;
|
||||
}
|
||||
|
||||
interface PiaModalProps {
|
||||
open: boolean;
|
||||
templateSettings: { outbounds?: PiaOutboundRow[] } | null;
|
||||
onClose: () => void;
|
||||
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
||||
onResetOutbound: (payload: {
|
||||
index: number;
|
||||
outbound: Record<string, unknown>;
|
||||
oldTag?: string;
|
||||
newTag: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface PiaAccount {
|
||||
username?: string;
|
||||
accountHint?: string;
|
||||
}
|
||||
|
||||
interface PiaCountry {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface PiaRegion {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PiaServer {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
regionId: string;
|
||||
regionName: string;
|
||||
}
|
||||
|
||||
interface PiaKey {
|
||||
tag: string;
|
||||
hostname: string;
|
||||
secretKey: string;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
interface PiaFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
countryCode: string | null;
|
||||
regionId: string | null;
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
const EMPTY: PiaFormValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
countryCode: null,
|
||||
regionId: null,
|
||||
hostname: null,
|
||||
};
|
||||
|
||||
function piaHostnameOf(outbound: PiaOutboundRow): string {
|
||||
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
|
||||
return outbound.piaHostname.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function piaTagPart(s: string, stripDomain: boolean): string {
|
||||
s = s.trim().toLowerCase();
|
||||
if (stripDomain) {
|
||||
const i = s.indexOf('.');
|
||||
if (i > 0) s = s.slice(0, i);
|
||||
}
|
||||
return s.replaceAll('_', '-');
|
||||
}
|
||||
|
||||
function piaOutboundTag(regionId: string, hostname: string): string {
|
||||
const region = piaTagPart(regionId, false);
|
||||
const server = piaTagPart(hostname, true);
|
||||
if (!region) return `pia-${server}`;
|
||||
return `pia-${region}-${server}`;
|
||||
}
|
||||
|
||||
function buildPiaOutbound(key: PiaKey): Record<string, unknown> {
|
||||
return {
|
||||
tag: key.tag || `pia-${key.hostname}`,
|
||||
piaHostname: key.hostname,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: key.secretKey,
|
||||
address: [key.address],
|
||||
mtu: 1420,
|
||||
noKernelTun: true,
|
||||
peers: [
|
||||
{
|
||||
publicKey: key.publicKey,
|
||||
endpoint: key.endpoint,
|
||||
allowedIPs: ['0.0.0.0/0'],
|
||||
keepAlive: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function PiaModal({
|
||||
open,
|
||||
templateSettings,
|
||||
onClose,
|
||||
onAddOutbound,
|
||||
onResetOutbound,
|
||||
}: PiaModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [piaData, setPiaData] = useState<PiaAccount | null>(null);
|
||||
const [countries, setCountries] = useState<PiaCountry[]>([]);
|
||||
const [regions, setRegions] = useState<PiaRegion[]>([]);
|
||||
const [servers, setServers] = useState<PiaServer[]>([]);
|
||||
const methods = useForm<PiaFormValues>({ defaultValues: EMPTY });
|
||||
const regionId = useWatch({ control: methods.control, name: 'regionId' });
|
||||
const hostname = useWatch({ control: methods.control, name: 'hostname' });
|
||||
const locale = i18n.resolvedLanguage || i18n.language;
|
||||
|
||||
const piaRows = useMemo(() => {
|
||||
const list = templateSettings?.outbounds;
|
||||
if (!list) return [];
|
||||
return list.flatMap((outbound, index) => {
|
||||
if (!outbound?.tag?.startsWith?.('pia-')) return [];
|
||||
return [{ index, tag: outbound.tag, hostname: piaHostnameOf(outbound) }];
|
||||
});
|
||||
}, [templateSettings?.outbounds]);
|
||||
|
||||
const addedHostnames = useMemo(
|
||||
() => new Set(piaRows.map((row) => row.hostname).filter(Boolean)),
|
||||
[piaRows],
|
||||
);
|
||||
const addedTags = useMemo(
|
||||
() => new Set(piaRows.map((row) => row.tag).filter(Boolean)),
|
||||
[piaRows],
|
||||
);
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!regionId) return servers;
|
||||
return servers.filter((s) => s.regionId === regionId);
|
||||
}, [regionId, servers]);
|
||||
|
||||
const selectedServer = filteredServers.find((s) => s.hostname === hostname);
|
||||
const selectedTag = selectedServer
|
||||
? piaOutboundTag(selectedServer.regionId, selectedServer.hostname)
|
||||
: '';
|
||||
const selectedAlreadyAdded = Boolean(
|
||||
(hostname && addedHostnames.has(hostname)) || (selectedTag && addedTags.has(selectedTag)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
methods.setValue('hostname', filteredServers.length > 0 ? filteredServers[0].hostname : null);
|
||||
}, [filteredServers, methods]);
|
||||
|
||||
const fetchCountries = useCallback(async () => {
|
||||
const msg = await HttpUtil.post<PiaCountry[]>('/panel/api/xray/pia/countries');
|
||||
if (msg?.success && Array.isArray(msg.obj)) setCountries(msg.obj);
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<PiaAccount | null>('/panel/api/xray/pia/data');
|
||||
if (msg?.success) {
|
||||
const next = msg.obj ?? null;
|
||||
setPiaData(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<PiaAccount>('/panel/api/xray/pia/reg', {
|
||||
username: methods.getValues('username'),
|
||||
password: methods.getValues('password'),
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setPiaData(msg.obj);
|
||||
methods.setValue('password', '');
|
||||
await fetchCountries();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/pia/del');
|
||||
if (msg?.success) {
|
||||
setPiaData(null);
|
||||
methods.reset(EMPTY);
|
||||
setCountries([]);
|
||||
setRegions([]);
|
||||
setServers([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServers(newCountryCode: string) {
|
||||
setLoading(true);
|
||||
setServers([]);
|
||||
setRegions([]);
|
||||
methods.setValue('hostname', null);
|
||||
methods.setValue('regionId', null);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ regions?: PiaRegion[]; servers?: PiaServer[] }>(
|
||||
'/panel/api/xray/pia/servers',
|
||||
{ countryCode: newCountryCode },
|
||||
);
|
||||
if (!msg?.success || !msg.obj) return;
|
||||
const nextRegions = msg.obj.regions || [];
|
||||
const nextServers = msg.obj.servers || [];
|
||||
setRegions(nextRegions);
|
||||
setServers(nextServers);
|
||||
if (nextServers.length === 0) messageApi.warning(t('pages.xray.pia.noServers'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionOutbound(
|
||||
selectedHostname: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!selectedHostname) return null;
|
||||
const msg = await HttpUtil.post<PiaKey>('/panel/api/xray/pia/addKey', {
|
||||
hostname: selectedHostname,
|
||||
});
|
||||
if (!msg?.success) return null;
|
||||
if (!msg.obj?.secretKey || !msg.obj.publicKey || !msg.obj.endpoint || !msg.obj.address) {
|
||||
messageApi.error(t('pages.xray.pia.provisionFailed'));
|
||||
return null;
|
||||
}
|
||||
return buildPiaOutbound(msg.obj);
|
||||
}
|
||||
|
||||
async function addOutbound() {
|
||||
const selected = methods.getValues('hostname');
|
||||
if (!selected || selectedAlreadyAdded) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const ob = await provisionOutbound(selected);
|
||||
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.pia.outboundAdded'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetOutbound(index: number, selectedHostname: string) {
|
||||
if (!selectedHostname) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const ob = await provisionOutbound(selectedHostname);
|
||||
if (!ob) return;
|
||||
const oldTag = templateSettings?.outbounds?.[index]?.tag;
|
||||
onResetOutbound({
|
||||
index,
|
||||
outbound: ob,
|
||||
oldTag,
|
||||
newTag: ob.tag as string,
|
||||
});
|
||||
messageApi.success(t('pages.xray.pia.outboundUpdated'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal open={open} title="Private Internet Access WireGuard" footer={null} onCancel={onClose}>
|
||||
<FormProvider {...methods}>
|
||||
{piaData == null ? (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<FormField name="username" label={t('pages.xray.pia.username')}>
|
||||
<Input placeholder={t('pages.xray.pia.username')} autoComplete="username" />
|
||||
</FormField>
|
||||
<FormField name="password" label={t('pages.xray.pia.password')}>
|
||||
<Input.Password
|
||||
placeholder={t('pages.xray.pia.password')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-10"
|
||||
loading={loading}
|
||||
icon={<LoginOutlined />}
|
||||
onClick={() => void login()}
|
||||
>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<>
|
||||
<table className="pia-data-table">
|
||||
<tbody>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.pia.account')}</td>
|
||||
<td>{piaData.accountHint || piaData.username}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
danger
|
||||
className="mt-8"
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-10"
|
||||
>
|
||||
<FormField
|
||||
name="countryCode"
|
||||
label={t('pages.xray.outbound.country')}
|
||||
transform={{ input: (v) => v ?? undefined }}
|
||||
onAfterChange={(v) => void fetchServers(v as string)}
|
||||
>
|
||||
<Select
|
||||
data-testid="pia-country-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={countries.map((c) => {
|
||||
const name = countryName(c.code, locale) || c.code;
|
||||
const flag = countryFlag(c.code);
|
||||
return {
|
||||
value: c.code,
|
||||
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{regions.length > 0 && (
|
||||
<FormField name="regionId" label={t('pages.xray.pia.region')}>
|
||||
<Select
|
||||
data-testid="pia-region-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={[
|
||||
{ value: null, label: t('pages.xray.pia.allRegions') },
|
||||
...regions.map((r) => ({ value: r.id, label: r.name })),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{filteredServers.length > 0 && (
|
||||
<FormField name="hostname" label={t('pages.xray.outbound.server')}>
|
||||
<Select
|
||||
data-testid="pia-server-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={filteredServers.map((s) => ({
|
||||
value: s.hostname,
|
||||
label: `${s.regionName} ${s.hostname} ${s.ip}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-10"
|
||||
disabled={!hostname || selectedAlreadyAdded}
|
||||
loading={loading}
|
||||
onClick={() => void addOutbound()}
|
||||
>
|
||||
{t('pages.xray.warp.addOutbound')}
|
||||
</Button>
|
||||
{selectedAlreadyAdded && (
|
||||
<div className="pia-already-added">
|
||||
{t('pages.xray.pia.alreadyAdded', { reset: t('reset') })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{piaRows.length > 0 && (
|
||||
<>
|
||||
<Divider className="my-10">{t('pages.xray.pia.addedServers')}</Divider>
|
||||
<table className="pia-added-table" data-testid="pia-added-table">
|
||||
<tbody>
|
||||
{piaRows.map((row) => (
|
||||
<tr key={`${row.index}-${row.tag}`}>
|
||||
<td>{row.tag}</td>
|
||||
<td>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={!row.tag}
|
||||
data-testid={`pia-reset-${row.index}`}
|
||||
onClick={() =>
|
||||
void resetOutbound(row.index, row.hostname || row.tag || '')
|
||||
}
|
||||
>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as WarpModal } from './WarpModal';
|
||||
export { default as NordModal } from './NordModal';
|
||||
export { default as PiaModal } from './PiaModal';
|
||||
|
||||
Reference in New Issue
Block a user