mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 6-vi — WARP + NordVPN provisioning modals
Replaces the toast stubs on the Basics tab and Outbounds toolbar
with the legacy WARP + NordVPN provisioning flows. Both modals now
stage their wireguard outbounds back into templateSettings.outbounds
through the same event channels OutboundsTab uses, so the existing
add / reset / delete / refresh-traffic surface keeps working.
- WarpModal.vue: empty state shows a single Create button that
generates a wireguard keypair locally (Wireguard.generateKeypair)
and posts it to /panel/xray/warp/reg; populated state surfaces
the access_token / device_id / license_key / private_key, lets
the user upgrade to WARP+ via /panel/xray/warp/license, refreshes
the account info from /panel/xray/warp/config (plan / quota /
usage in human-readable bytes), and stages a wireguard outbound
with the WARP-specific reserved-byte encoding pulled from
client_id. Add / Reset / Delete go through events the parent
routes back to templateSettings.outbounds.
- NordModal.vue: dual-tab login (NordVPN access token →
/panel/xray/nord/reg, or paste a NordLynx private key →
/panel/xray/nord/setKey). Once authenticated, country / city /
server selectors fetch from /panel/xray/nord/{countries,servers},
servers sort by load ascending, the lowest-load server in the
current city auto-selects. Reset emits oldTag/newTag so the
parent renames matching routing rules in place; logout emits a
remove-routing-rules event with prefix `nord-` to purge any
dangling references.
- XrayPage.vue: holds warpOpen / nordOpen flags, ensures the
outbounds array exists before mutating it, and wires the modal
events (add-outbound / reset-outbound / remove-outbound /
remove-routing-rules) to in-place edits of templateSettings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8c8085f985
commit
a31a42fcc5
3 changed files with 810 additions and 3 deletions
395
frontend/src/pages/xray/NordModal.vue
Normal file
395
frontend/src/pages/xray/NordModal.vue
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { LoginOutlined, SaveOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
// NordVPN provisioning modal — mirrors the legacy nord_modal.
|
||||
//
|
||||
// Login routes:
|
||||
// • access token (NordVPN account) → /panel/xray/nord/reg
|
||||
// • manual private key (existing wireguard key from NordLynx) →
|
||||
// /panel/xray/nord/setKey
|
||||
// Once authenticated, the country / city / server selectors fetch
|
||||
// from /panel/xray/nord/{countries,servers}, and the user can stage
|
||||
// a wireguard outbound (tag `nord-<hostname>`) for the parent's
|
||||
// outbound list.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
templateSettings: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:open',
|
||||
'add-outbound',
|
||||
'reset-outbound',
|
||||
'remove-outbound',
|
||||
// Routing rules referencing the deleted nord-* outbound need the
|
||||
// parent to clean them up — we emit, the parent purges.
|
||||
'remove-routing-rules',
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const nordData = ref(null);
|
||||
const token = ref('');
|
||||
const manualKey = ref('');
|
||||
|
||||
const countries = ref([]);
|
||||
const cities = ref([]);
|
||||
const servers = ref([]);
|
||||
const countryId = ref(null);
|
||||
const cityId = ref(null);
|
||||
const serverId = ref(null);
|
||||
|
||||
const nordOutboundIndex = computed(() => {
|
||||
const list = props.templateSettings?.outbounds;
|
||||
if (!list) return -1;
|
||||
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
|
||||
});
|
||||
|
||||
const filteredServers = computed(() => {
|
||||
if (!cityId.value) return servers.value;
|
||||
return servers.value.filter((s) => s.cityId === cityId.value);
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) fetchData();
|
||||
});
|
||||
|
||||
watch(() => filteredServers.value, (list) => {
|
||||
// Auto-select the first server in the visible list (lowest load
|
||||
// because servers were sorted ascending by load on fetch).
|
||||
serverId.value = list.length > 0 ? list[0].id : null;
|
||||
});
|
||||
|
||||
// === API actions ====================================================
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/data');
|
||||
if (msg?.success) {
|
||||
nordData.value = msg.obj ? JSON.parse(msg.obj) : null;
|
||||
if (nordData.value) await fetchCountries();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: token.value });
|
||||
if (msg?.success) {
|
||||
nordData.value = JSON.parse(msg.obj);
|
||||
await fetchCountries();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveKey() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey.value });
|
||||
if (msg?.success) {
|
||||
nordData.value = JSON.parse(msg.obj);
|
||||
await fetchCountries();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/del');
|
||||
if (msg?.success) {
|
||||
// Clean up the staged outbound + matching routing rules first
|
||||
// so a re-login doesn't carry stale references.
|
||||
emit('remove-outbound', nordOutboundIndex.value);
|
||||
emit('remove-routing-rules', { prefix: 'nord-' });
|
||||
nordData.value = null;
|
||||
token.value = '';
|
||||
manualKey.value = '';
|
||||
countries.value = [];
|
||||
cities.value = [];
|
||||
servers.value = [];
|
||||
countryId.value = null;
|
||||
cityId.value = null;
|
||||
serverId.value = null;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCountries() {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/countries');
|
||||
if (msg?.success) countries.value = JSON.parse(msg.obj);
|
||||
}
|
||||
|
||||
async function fetchServers() {
|
||||
if (!countryId.value) return;
|
||||
loading.value = true;
|
||||
servers.value = [];
|
||||
cities.value = [];
|
||||
serverId.value = null;
|
||||
cityId.value = null;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: countryId.value });
|
||||
if (!msg?.success) return;
|
||||
const data = JSON.parse(msg.obj);
|
||||
const locations = data.locations || [];
|
||||
const locToCity = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
cities.value = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
servers.value = (data.servers || [])
|
||||
.map((s) => {
|
||||
const firstLocId = (s.location_ids || [])[0];
|
||||
const city = locToCity[firstLocId];
|
||||
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
|
||||
})
|
||||
.sort((a, b) => a.load - b.load);
|
||||
|
||||
if (servers.value.length === 0) {
|
||||
message.warning('No servers found for the selected country');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Outbound staging ==============================================
|
||||
// NordVPN exposes its WireGuard public key via a "technologies"
|
||||
// array entry with id 35; the legacy modal pulls the key from the
|
||||
// metadata field of that entry. Same here.
|
||||
function buildNordOutbound() {
|
||||
const server = servers.value.find((s) => s.id === serverId.value);
|
||||
if (!server) return null;
|
||||
const tech = server.technologies?.find((t) => t.id === 35);
|
||||
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
||||
if (!publicKey) {
|
||||
message.error('Selected server does not advertise a NordLynx public key.');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tag: `nord-${server.hostname}`,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: nordData.value.private_key,
|
||||
address: ['10.5.0.2/32'],
|
||||
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
|
||||
noKernelTun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addOutbound() {
|
||||
const ob = buildNordOutbound();
|
||||
if (!ob) return;
|
||||
emit('add-outbound', ob);
|
||||
message.success('NordVPN outbound added');
|
||||
close();
|
||||
}
|
||||
|
||||
function resetOutbound() {
|
||||
if (nordOutboundIndex.value === -1) return;
|
||||
const ob = buildNordOutbound();
|
||||
if (!ob) return;
|
||||
// Tag rename across routing.rules is the parent's job — pass
|
||||
// both old and new tag in the payload.
|
||||
const oldTag = props.templateSettings.outbounds[nordOutboundIndex.value]?.tag;
|
||||
emit('reset-outbound', {
|
||||
index: nordOutboundIndex.value,
|
||||
outbound: ob,
|
||||
oldTag,
|
||||
newTag: ob.tag,
|
||||
});
|
||||
message.success('NordVPN outbound updated');
|
||||
close();
|
||||
}
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="NordVPN NordLynx"
|
||||
:footer="null"
|
||||
:closable="true"
|
||||
:mask-closable="true"
|
||||
@cancel="close"
|
||||
>
|
||||
<!-- Not authenticated → tabbed login (token or manual key) -->
|
||||
<template v-if="nordData == null">
|
||||
<a-tabs default-active-key="token">
|
||||
<a-tab-pane key="token" tab="Access token">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: { span: 6 } }"
|
||||
:wrapper-col="{ md: { span: 18 } }"
|
||||
class="mt-20"
|
||||
>
|
||||
<a-form-item label="Access token">
|
||||
<a-input v-model:value="token" placeholder="Access token" />
|
||||
<a-button type="primary" class="mt-10" :loading="loading" @click="login">
|
||||
<template #icon><LoginOutlined /></template>
|
||||
Login
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="key" tab="Private key">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: { span: 6 } }"
|
||||
:wrapper-col="{ md: { span: 18 } }"
|
||||
class="mt-20"
|
||||
>
|
||||
<a-form-item label="Private key">
|
||||
<a-input v-model:value="manualKey" placeholder="Private key" />
|
||||
<a-button type="primary" class="mt-10" :loading="loading" @click="saveKey">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
Save
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
|
||||
<!-- Authenticated → server picker + outbound controls -->
|
||||
<template v-else>
|
||||
<table class="nord-data-table">
|
||||
<tbody>
|
||||
<tr v-if="nordData.token" class="row-odd">
|
||||
<td>Access token</td>
|
||||
<td>{{ nordData.token }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Private key</td>
|
||||
<td>{{ nordData.private_key }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a-button :loading="loading" type="primary" danger class="mt-8" @click="logout">Logout</a-button>
|
||||
|
||||
<a-divider class="zero-margin">Settings</a-divider>
|
||||
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: { span: 6 } }"
|
||||
:wrapper-col="{ md: { span: 18 } }"
|
||||
class="mt-10"
|
||||
>
|
||||
<a-form-item label="Country">
|
||||
<a-select
|
||||
v-model:value="countryId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
@change="fetchServers"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
:label="c.name"
|
||||
>
|
||||
{{ c.name }} ({{ c.code }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="cities.length > 0" label="City">
|
||||
<a-select
|
||||
v-model:value="cityId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
>
|
||||
<a-select-option :value="null" label="All cities">All cities</a-select-option>
|
||||
<a-select-option
|
||||
v-for="c in cities"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
:label="c.name"
|
||||
>{{ c.name }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="filteredServers.length > 0" label="Server">
|
||||
<a-select v-model:value="serverId">
|
||||
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
|
||||
{{ s.cityName }} - {{ s.name }} (load: {{ s.load }}%)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-divider class="my-10">Outbound status</a-divider>
|
||||
|
||||
<template v-if="nordOutboundIndex >= 0">
|
||||
<a-tag color="green">Enabled</a-tag>
|
||||
<a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
|
||||
Reset
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="orange">Disabled</a-tag>
|
||||
<a-button
|
||||
type="primary"
|
||||
class="ml-8"
|
||||
:disabled="!serverId"
|
||||
:loading="loading"
|
||||
@click="addOutbound"
|
||||
>Add outbound</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nord-data-table {
|
||||
margin: 5px 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.nord-data-table td {
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.nord-data-table td:first-child {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 130px;
|
||||
}
|
||||
.row-odd {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
:global(body.dark) .row-odd {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.zero-margin { margin: 0; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-10 { margin-top: 10px; }
|
||||
.mt-20 { margin-top: 20px; }
|
||||
.my-10 { margin: 10px 0; }
|
||||
.ml-8 { margin-left: 8px; }
|
||||
</style>
|
||||
343
frontend/src/pages/xray/WarpModal.vue
Normal file
343
frontend/src/pages/xray/WarpModal.vue
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
|
||||
|
||||
// Cloudflare WARP provisioning modal. Mirrors the legacy warp_modal:
|
||||
// • when no WARP account is registered yet, a single Create button
|
||||
// generates a wireguard keypair locally and posts it to
|
||||
// /panel/xray/warp/reg to create a Cloudflare device record;
|
||||
// • once registered, the modal displays the access_token /
|
||||
// device_id / license_key / private_key, lets the user upgrade
|
||||
// to WARP+ via /panel/xray/warp/license, fetches the current
|
||||
// account config (premium data / quota / usage) via
|
||||
// /panel/xray/warp/config, and stages a wireguard outbound
|
||||
// ready for adding to templateSettings.outbounds.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
templateSettings: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'add-outbound', 'reset-outbound', 'remove-outbound']);
|
||||
|
||||
const loading = ref(false);
|
||||
const warpData = ref(null);
|
||||
const warpConfig = ref(null);
|
||||
const warpPlus = ref('');
|
||||
// Held in memory so the parent's add/reset handlers receive the same
|
||||
// object the modal computed from getConfig().
|
||||
const stagedOutbound = ref(null);
|
||||
|
||||
const warpOutboundIndex = computed(() => {
|
||||
const list = props.templateSettings?.outbounds;
|
||||
if (!list) return -1;
|
||||
return list.findIndex((o) => o?.tag === 'warp');
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
warpConfig.value = null;
|
||||
stagedOutbound.value = null;
|
||||
fetchData();
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/data');
|
||||
if (msg?.success) {
|
||||
const raw = msg.obj;
|
||||
warpData.value = raw && raw.length > 0 ? JSON.parse(raw) : null;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function register() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const keys = Wireguard.generateKeypair();
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/reg', keys);
|
||||
if (msg?.success) {
|
||||
const resp = JSON.parse(msg.obj);
|
||||
warpData.value = resp.data;
|
||||
warpConfig.value = resp.config;
|
||||
collectConfig();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/config');
|
||||
if (msg?.success) {
|
||||
warpConfig.value = JSON.parse(msg.obj);
|
||||
collectConfig();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLicense() {
|
||||
if (warpPlus.value.length < 26) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
|
||||
if (msg?.success) {
|
||||
warpData.value = JSON.parse(msg.obj);
|
||||
warpConfig.value = null;
|
||||
warpPlus.value = '';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function delConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/del');
|
||||
if (msg?.success) {
|
||||
warpData.value = null;
|
||||
warpConfig.value = null;
|
||||
stagedOutbound.value = null;
|
||||
emit('remove-outbound', 'warp');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the wireguard outbound shape from the WARP account data.
|
||||
// Keep this here (not on the parent) because the encoding of the
|
||||
// reserved bytes from `client_id` is WARP-specific.
|
||||
function collectConfig() {
|
||||
const config = warpConfig.value?.config;
|
||||
if (!config?.peers?.length) return;
|
||||
const peer = config.peers[0];
|
||||
stagedOutbound.value = {
|
||||
tag: 'warp',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
mtu: 1420,
|
||||
secretKey: warpData.value.private_key,
|
||||
address: addressesFor(config.interface?.addresses || {}),
|
||||
reserved: reservedFor(warpData.value.client_id),
|
||||
domainStrategy: 'ForceIP',
|
||||
peers: [{
|
||||
publicKey: peer.public_key,
|
||||
endpoint: peer.endpoint?.host,
|
||||
}],
|
||||
noKernelTun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addressesFor(addrs) {
|
||||
const out = [];
|
||||
if (addrs.v4) out.push(`${addrs.v4}/32`);
|
||||
if (addrs.v6) out.push(`${addrs.v6}/128`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// WARP encodes its reserved bytes as a base64-decoded triplet pulled
|
||||
// from `client_id`. We turn those bytes into an int array — same
|
||||
// algorithm the legacy modal used.
|
||||
function reservedFor(clientId) {
|
||||
if (!clientId) return [];
|
||||
const decoded = atob(clientId);
|
||||
const out = [];
|
||||
for (let i = 0; i < decoded.length; i++) out.push(decoded.charCodeAt(i));
|
||||
return out;
|
||||
}
|
||||
|
||||
function addOutbound() {
|
||||
if (!stagedOutbound.value) {
|
||||
message.warning('Fetch the WARP config first.');
|
||||
return;
|
||||
}
|
||||
emit('add-outbound', stagedOutbound.value);
|
||||
close();
|
||||
}
|
||||
|
||||
function resetOutbound() {
|
||||
if (!stagedOutbound.value) return;
|
||||
emit('reset-outbound', { index: warpOutboundIndex.value, outbound: stagedOutbound.value });
|
||||
close();
|
||||
}
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
|
||||
const hasWarp = computed(() => !ObjectUtil.isEmpty(warpData.value));
|
||||
const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="Cloudflare WARP"
|
||||
:footer="null"
|
||||
:closable="true"
|
||||
:mask-closable="true"
|
||||
@cancel="close"
|
||||
>
|
||||
<!-- Not registered yet → single Create CTA -->
|
||||
<template v-if="!hasWarp">
|
||||
<a-button type="primary" :loading="loading" @click="register">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
Create WARP account
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- Registered → account display + license + config + outbound controls -->
|
||||
<template v-else>
|
||||
<table class="warp-data-table">
|
||||
<tbody>
|
||||
<tr class="row-odd">
|
||||
<td>Access token</td>
|
||||
<td>{{ warpData.access_token }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Device ID</td>
|
||||
<td>{{ warpData.device_id }}</td>
|
||||
</tr>
|
||||
<tr class="row-odd">
|
||||
<td>License key</td>
|
||||
<td>{{ warpData.license_key }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Private key</td>
|
||||
<td>{{ warpData.private_key }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a-button :loading="loading" type="primary" danger class="mt-8" @click="delConfig">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
Delete account
|
||||
</a-button>
|
||||
|
||||
<a-divider class="zero-margin">Settings</a-divider>
|
||||
|
||||
<a-collapse class="my-10">
|
||||
<a-collapse-panel header="WARP / WARP+ license key">
|
||||
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||
<a-form-item label="Key">
|
||||
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
|
||||
<a-button
|
||||
type="primary"
|
||||
class="mt-8"
|
||||
:disabled="warpPlus.length < 26"
|
||||
:loading="loading"
|
||||
@click="updateLicense"
|
||||
>Update</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
<a-divider class="zero-margin">Account info</a-divider>
|
||||
<a-button class="my-8" :loading="loading" type="primary" @click="getConfig">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
Refresh
|
||||
</a-button>
|
||||
|
||||
<template v-if="hasConfig">
|
||||
<table class="warp-data-table">
|
||||
<tbody>
|
||||
<tr class="row-odd">
|
||||
<td>Device name</td>
|
||||
<td>{{ warpConfig.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Device model</td>
|
||||
<td>{{ warpConfig.model }}</td>
|
||||
</tr>
|
||||
<tr class="row-odd">
|
||||
<td>Device enabled</td>
|
||||
<td>{{ warpConfig.enabled }}</td>
|
||||
</tr>
|
||||
<template v-if="warpConfig.account">
|
||||
<tr>
|
||||
<td>Account type</td>
|
||||
<td>{{ warpConfig.account.account_type }}</td>
|
||||
</tr>
|
||||
<tr class="row-odd">
|
||||
<td>Role</td>
|
||||
<td>{{ warpConfig.account.role }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>WARP+ data</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(warpConfig.account.premium_data) }}</td>
|
||||
</tr>
|
||||
<tr class="row-odd">
|
||||
<td>Quota</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(warpConfig.account.quota) }}</td>
|
||||
</tr>
|
||||
<tr v-if="warpConfig.account.usage">
|
||||
<td>Usage</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(warpConfig.account.usage) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a-divider class="my-10">Outbound status</a-divider>
|
||||
<template v-if="warpOutboundIndex >= 0">
|
||||
<a-tag color="green">Enabled</a-tag>
|
||||
<a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
|
||||
Reset
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="orange">Disabled</a-tag>
|
||||
<a-button type="primary" :loading="loading" class="ml-8" @click="addOutbound">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
Add outbound
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warp-data-table {
|
||||
margin: 5px 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.warp-data-table td {
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.warp-data-table td:first-child {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 130px;
|
||||
}
|
||||
.row-odd {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
:global(body.dark) .row-odd {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.zero-margin { margin: 0; }
|
||||
.my-8 { margin: 8px 0; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.my-10 { margin: 10px 0; }
|
||||
.ml-8 { margin-left: 8px; }
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { theme as antdTheme, Modal } from 'ant-design-vue';
|
||||
import {
|
||||
SettingOutlined,
|
||||
|
|
@ -20,6 +20,8 @@ import RoutingTab from './RoutingTab.vue';
|
|||
import OutboundsTab from './OutboundsTab.vue';
|
||||
import BalancersTab from './BalancersTab.vue';
|
||||
import DnsTab from './DnsTab.vue';
|
||||
import WarpModal from './WarpModal.vue';
|
||||
import NordModal from './NordModal.vue';
|
||||
import { useXraySetting } from './useXraySetting.js';
|
||||
|
||||
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
|
||||
|
|
@ -68,8 +70,59 @@ const nordExist = computed(
|
|||
() => !!templateSettings.value?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')),
|
||||
);
|
||||
|
||||
function showWarp() { message.info('WARP outbound modal — coming in 6-v'); }
|
||||
function showNord() { message.info('NordVPN outbound modal — coming in 6-v'); }
|
||||
// === WARP / NordVPN provisioning modals ============================
|
||||
const warpOpen = ref(false);
|
||||
const nordOpen = ref(false);
|
||||
|
||||
function showWarp() { warpOpen.value = true; }
|
||||
function showNord() { nordOpen.value = true; }
|
||||
|
||||
function ensureOutbounds() {
|
||||
if (!templateSettings.value) return null;
|
||||
if (!Array.isArray(templateSettings.value.outbounds)) {
|
||||
templateSettings.value.outbounds = [];
|
||||
}
|
||||
return templateSettings.value.outbounds;
|
||||
}
|
||||
|
||||
function onAddOutbound(outbound) {
|
||||
const list = ensureOutbounds();
|
||||
if (list) list.push(outbound);
|
||||
}
|
||||
function onResetOutbound({ index, outbound, oldTag, newTag }) {
|
||||
const list = ensureOutbounds();
|
||||
if (!list || index < 0) return;
|
||||
list[index] = outbound;
|
||||
// Tag rename across routing rules — preserves Nord's
|
||||
// server-switch flow without dangling references.
|
||||
if (oldTag && newTag && oldTag !== newTag) {
|
||||
const rules = templateSettings.value?.routing?.rules || [];
|
||||
for (const r of rules) {
|
||||
if (r?.outboundTag === oldTag) r.outboundTag = newTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
function onRemoveOutboundByTag(tag) {
|
||||
const list = ensureOutbounds();
|
||||
if (!list) return;
|
||||
const idx = list.findIndex((o) => o?.tag === tag);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
}
|
||||
function onRemoveOutboundByIndex(index) {
|
||||
const list = ensureOutbounds();
|
||||
if (list && index >= 0) list.splice(index, 1);
|
||||
}
|
||||
function onRemoveRoutingRules({ prefix }) {
|
||||
const rules = templateSettings.value?.routing?.rules;
|
||||
if (!Array.isArray(rules)) return;
|
||||
templateSettings.value.routing.rules = rules.filter(
|
||||
(r) => !r?.outboundTag?.startsWith?.(prefix),
|
||||
);
|
||||
}
|
||||
|
||||
// `message` is used by some of the in-progress UX flows (kept around
|
||||
// because future provisioning errors will surface through it).
|
||||
void message;
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
|
|
@ -223,6 +276,22 @@ function confirmRestart() {
|
|||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<WarpModal
|
||||
v-model:open="warpOpen"
|
||||
:template-settings="templateSettings"
|
||||
@add-outbound="onAddOutbound"
|
||||
@reset-outbound="onResetOutbound"
|
||||
@remove-outbound="onRemoveOutboundByTag"
|
||||
/>
|
||||
<NordModal
|
||||
v-model:open="nordOpen"
|
||||
:template-settings="templateSettings"
|
||||
@add-outbound="onAddOutbound"
|
||||
@reset-outbound="onResetOutbound"
|
||||
@remove-outbound="onRemoveOutboundByIndex"
|
||||
@remove-routing-rules="onRemoveRoutingRules"
|
||||
/>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue