Add NordVPN outbound control in Tunnels (v2.5.0).

Connect and disconnect via official nordvpn CLI from Settings with optional country/city, alongside Cloudflare WARP.
This commit is contained in:
orohi
2026-07-28 09:34:10 +03:00
parent fd257a59f7
commit 77c6f0dab7
8 changed files with 369 additions and 2 deletions
+100
View File
@@ -284,6 +284,30 @@
</button>
</div>
</div>
<div class="tunnel-row" data-provider="nordvpn" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>NordVPN</strong>
<span class="badge badge-secondary" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="nordvpnStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_status_unknown') }}</div>
<div id="nordvpnServerText" class="form-hint hidden" style="margin-bottom: var(--space-sm);"></div>
<div id="nordvpnHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_hint') }}</div>
<div class="grid" style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
</div>
@@ -956,6 +980,7 @@
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {});
updateNordvpnStatus(data.nordvpn || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
@@ -1025,6 +1050,81 @@
}
}
function updateNordvpnStatus(status) {
const badge = document.getElementById('nordvpnInstallBadge');
const statusText = document.getElementById('nordvpnStatusText');
const serverText = document.getElementById('nordvpnServerText');
const hintText = document.getElementById('nordvpnHintText');
const connectBtn = document.getElementById('nordvpnConnectBtn');
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
const connectText = document.getElementById('nordvpnConnectBtnText');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
badge.textContent = connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed'));
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
if (serverText) {
const serverLine = status.server || '';
serverText.textContent = serverLine;
serverText.classList.toggle('hidden', !serverLine);
}
hintText.textContent = status.last_error || (installed ? _('nordvpn_hint') : (status.install_hint || _('nordvpn_install_hint')));
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
}
async function connectNordvpn() {
const btn = document.getElementById('nordvpnConnectBtn');
const spinner = document.getElementById('nordvpnConnectSpinner');
const text = document.getElementById('nordvpnConnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const country = (document.getElementById('nordvpnCountry') || {}).value || '';
const city = (document.getElementById('nordvpnCity') || {}).value || '';
const status = await apiCall('/api/settings/nordvpn/connect', 'POST', {
country: country.trim(),
city: city.trim(),
});
updateNordvpnStatus(status);
showToast(_('nordvpn_connected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
spinner.classList.add('hidden');
}
}
async function disconnectNordvpn() {
const btn = document.getElementById('nordvpnDisconnectBtn');
const spinner = document.getElementById('nordvpnDisconnectSpinner');
const text = document.getElementById('nordvpnDisconnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/nordvpn/disconnect', 'POST');
updateNordvpnStatus(status);
showToast(_('nordvpn_disconnected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('nordvpn_disconnect');
spinner.classList.add('hidden');
}
}
async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`);