Add 3x-ui VLESS configs and guest access without registration.
Guests can open a public link to view or create VPN configs; panel creates VLESS clients via the 3x-ui API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title_extra %} — {{ _('guest_title') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 600px; margin: 2rem auto;">
|
||||
<div class="card-header"
|
||||
style="justify-content: center; text-align: center; flex-direction: column; gap: var(--space-xs);">
|
||||
<h2 class="card-title">{{ _('guest_title') }}</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('guest_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{% if need_password %}
|
||||
<div style="padding: var(--space-lg); text-align: center;">
|
||||
<div class="logo-icon" style="font-size: 3rem; margin-bottom: var(--space-md);">🔐</div>
|
||||
<p style="margin-bottom: var(--space-md);">{{ _('guest_protected_desc') }}</p>
|
||||
|
||||
<form id="authForm" onsubmit="authGuest(event)"
|
||||
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
|
||||
<div class="form-group">
|
||||
<input type="password" id="guestPassword" class="form-input" placeholder="{{ _('password') }}" required
|
||||
autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="authBtn">
|
||||
<span id="authBtnText">{{ _('login') }}</span>
|
||||
<div class="spinner hidden" id="authSpinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="connectionsList" style="padding: var(--space-md);">
|
||||
{% if allow_create %}
|
||||
<div style="margin-bottom: var(--space-md);">
|
||||
<button class="btn btn-primary" style="width:100%;" onclick="createGuestConfig()" id="createBtn">
|
||||
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
||||
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
||||
</button>
|
||||
<div class="form-hint" style="text-align:center; margin-top: var(--space-xs);">
|
||||
{{ _('guest_get_config_hint') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="text-align: center; padding: 2rem;" id="loadingState">
|
||||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto;"></div>
|
||||
<p style="margin-top: 1rem; color: var(--text-muted);">{{ _('loading_share_conns') }}</p>
|
||||
</div>
|
||||
<div id="connectionsGrid" class="hidden" style="display: grid; gap: var(--space-md);">
|
||||
</div>
|
||||
<div id="emptyState" class="hidden" style="text-align: center; padding: 2rem;">
|
||||
<p style="color: var(--text-muted);">{{ _('no_active_conns') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="configModal">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="config-tabs">
|
||||
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
|
||||
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
||||
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="config-panel active" id="panel-conf">
|
||||
<div class="config-display">
|
||||
<textarea class="config-text" id="configText" readonly rows="12"
|
||||
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config')
|
||||
}}</button>
|
||||
<a id="downloadBtn" class="btn btn-primary btn-sm"
|
||||
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center;">
|
||||
{{ _('download_conf') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-panel" id="panel-vpn">
|
||||
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
|
||||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" style="flex:1">{{ _('copy_key')
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-panel" id="panel-qr">
|
||||
<div class="qr-container">
|
||||
<div id="qrcode"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = "{{ token }}";
|
||||
const ALLOW_CREATE = {{ 'true' if allow_create else 'false' }};
|
||||
|
||||
async function authGuest(e) {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('guestPassword').value;
|
||||
const btn = document.getElementById('authBtn');
|
||||
const text = document.getElementById('authBtnText');
|
||||
const spinner = document.getElementById('authSpinner');
|
||||
|
||||
btn.disabled = true;
|
||||
text.classList.add('hidden');
|
||||
spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/guest/${TOKEN}/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.error || _('login_error'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`${_('error')}: ` + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.classList.remove('hidden');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function protoLabel(p) {
|
||||
if (p === 'awg') return 'AmneziaWG';
|
||||
if (p === 'awg2') return 'AmneziaWG 2.0';
|
||||
if (p === 'awg_legacy') return 'AWG Legacy';
|
||||
if (p === 'xray') return 'Xray';
|
||||
if (p === 'xui') return '3x-ui VLESS';
|
||||
return (p || '').toUpperCase();
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
if (document.getElementById('loadingState') === null) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/guest/${TOKEN}/connections`);
|
||||
if (res.status === 401) return;
|
||||
|
||||
const data = await res.json();
|
||||
document.getElementById('loadingState').classList.add('hidden');
|
||||
|
||||
if (!data.connections || data.connections.length === 0) {
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
document.getElementById('connectionsGrid').classList.add('hidden');
|
||||
document.getElementById('connectionsGrid').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
const grid = document.getElementById('connectionsGrid');
|
||||
grid.classList.remove('hidden');
|
||||
grid.innerHTML = data.connections.map(c => `
|
||||
<div class="card" style="padding: var(--space-md); border: 1px solid var(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-sm);">
|
||||
<div style="text-align: initial;">
|
||||
<div style="font-weight: 600; font-size: 1.1rem;">${c.name}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted);">${protoLabel(c.protocol)} • ${c.server_name || ''}</div>
|
||||
</div>
|
||||
<span class="badge badge-success">${_('active')}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm w-full" onclick="showConfig('${c.id}', '${(c.name || '').replace(/'/g, "\\'")}')">
|
||||
${_('show_settings_btn')}
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGuestConfig() {
|
||||
const btn = document.getElementById('createBtn');
|
||||
const text = document.getElementById('createBtnText');
|
||||
const spinner = document.getElementById('createSpinner');
|
||||
btn.disabled = true;
|
||||
text.classList.add('hidden');
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch(`/api/guest/${TOKEN}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Guest VPN' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
||||
if (data.config) {
|
||||
openConfigModal(data.connection?.name || 'Guest VPN', data.config, data.vpn_link || '');
|
||||
}
|
||||
await loadConnections();
|
||||
document.getElementById('emptyState')?.classList.add('hidden');
|
||||
} catch (err) {
|
||||
alert(`${_('error')}: ` + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.classList.remove('hidden');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function switchConfigTab(tab) {
|
||||
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
const tabs = document.querySelectorAll('.config-tab');
|
||||
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
|
||||
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
|
||||
|
||||
tabs[tabIdx[tab]].classList.add('active');
|
||||
document.getElementById(panels[tab]).classList.add('active');
|
||||
}
|
||||
|
||||
let currentConfig = '';
|
||||
let currentVpnLink = '';
|
||||
|
||||
function openConfigModal(name, config, vpnLink) {
|
||||
currentConfig = config;
|
||||
currentVpnLink = vpnLink || '';
|
||||
document.getElementById('configText').value = config;
|
||||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||
document.getElementById('configModalTitle').textContent = name;
|
||||
const dl = document.getElementById('downloadBtn');
|
||||
dl.onclick = () => downloadFile(config, `${name}.conf`);
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
qrContainer.innerHTML = '';
|
||||
new QRCode(qrContainer, {
|
||||
text: config,
|
||||
width: 256,
|
||||
height: 256,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
});
|
||||
switchConfigTab('conf');
|
||||
openModal('configModal');
|
||||
}
|
||||
|
||||
async function showConfig(connId, name) {
|
||||
try {
|
||||
const res = await fetch(`/api/guest/${TOKEN}/config/${connId}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
openConfigModal(name, data.config, data.vpn_link || '');
|
||||
} catch (err) {
|
||||
alert(`${_('error')}: ` + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
copyToClipboard(currentConfig);
|
||||
}
|
||||
|
||||
function copyVpnLink() {
|
||||
copyToClipboard(currentVpnLink);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConnections);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.w-full { width: 100%; }
|
||||
.spinner {
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 3px solid var(--accent-color);
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -131,6 +131,15 @@
|
||||
<div class="spinner hidden" id="loginSpinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if guest_link %}
|
||||
<div style="margin-top: var(--space-lg); text-align: center; border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
||||
<a href="{{ guest_link }}" class="btn btn-secondary" style="width:100%; text-decoration:none;">
|
||||
{{ _('guest_access_btn') }}
|
||||
</a>
|
||||
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('guest_access_login_hint') }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+210
-3
@@ -45,6 +45,97 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK Guest Access -->
|
||||
<div class="card">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('guest_settings_title') }}</h3>
|
||||
<form id="guestForm">
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||
<input type="checkbox" id="guest_enabled" {% if settings.guest.enabled %}checked{% endif %}
|
||||
onchange="document.getElementById('guestFields').style.display = this.checked ? 'block' : 'none'">
|
||||
{{ _('guest_enable') }}
|
||||
</label>
|
||||
<div class="form-hint">{{ _('guest_enable_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="guestFields" style="{% if not settings.guest.enabled %}display:none;{% endif %}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('guest_link_label') }}</label>
|
||||
<div style="display:flex; gap: var(--space-sm);">
|
||||
<input type="text" class="form-input" id="guest_link_display" readonly
|
||||
value="{% if settings.guest.token %}{{ request.url.scheme }}://{{ request.url.netloc }}/guest/{{ settings.guest.token }}{% endif %}"
|
||||
placeholder="{{ _('guest_link_placeholder') }}">
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyGuestLink()">📋</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="regenGuestToken()" title="{{ _('guest_regen_token') }}">🔄</button>
|
||||
</div>
|
||||
<input type="hidden" id="guest_token" value="{{ settings.guest.token or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('guest_user_label') }}</label>
|
||||
<select class="form-select" id="guest_user_id">
|
||||
<option value="">{{ _('guest_user_none') }}</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}" {% if settings.guest.user_id == u.id %}selected{% endif %}>
|
||||
{{ u.username }} ({{ u.role }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-hint">{{ _('guest_user_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('guest_password_label') }}</label>
|
||||
<input type="password" class="form-input" id="guest_password"
|
||||
placeholder="{% if settings.guest.password_hash %}{{ _('guest_password_keep') }}{% else %}{{ _('share_password_hint') }}{% endif %}"
|
||||
autocomplete="new-password">
|
||||
{% if settings.guest.password_hash %}
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;">
|
||||
<input type="checkbox" id="guest_clear_password">
|
||||
{{ _('guest_clear_password') }}
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||
<input type="checkbox" id="guest_allow_create" {% if settings.guest.allow_create %}checked{% endif %}
|
||||
onchange="document.getElementById('guestCreateFields').style.display = this.checked ? 'block' : 'none'">
|
||||
{{ _('guest_allow_create') }}
|
||||
</label>
|
||||
<div class="form-hint">{{ _('guest_allow_create_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="guestCreateFields"
|
||||
style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
|
||||
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
|
||||
{% for s in servers %}
|
||||
{% set server_idx = loop.index0 %}
|
||||
{% for key, info in (s.protocols or {}).items() %}
|
||||
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
|
||||
<option value="{{ key }}|{{ server_idx }}"
|
||||
{% if settings.guest.create_protocol == key and settings.guest.create_server_id == server_idx %}selected{% endif %}>
|
||||
{{ s.name or s.host }} — {{ key }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="guest_create_inbound_id">
|
||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK Telegram Bot -->
|
||||
<div class="card">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('telegram_bot_title') }}</h3>
|
||||
@@ -394,6 +485,14 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" name="xui_inbound_id" id="xuiInboundSelect">
|
||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="xuiAutoConnFields"
|
||||
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
|
||||
<div class="form-group">
|
||||
@@ -616,8 +715,39 @@
|
||||
|
||||
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
|
||||
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
if (e.target.checked) loadXuiInbounds();
|
||||
});
|
||||
|
||||
const xuiDefaultInboundId = {{ settings.sync.xui_inbound_id | default(0) | int }};
|
||||
|
||||
async function loadXuiInbounds() {
|
||||
const select = document.getElementById('xuiInboundSelect');
|
||||
if (!select) return;
|
||||
const prev = select.value || String(xuiDefaultInboundId || '0');
|
||||
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||||
try {
|
||||
const res = await fetch('/api/settings/xui/inbounds');
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if ([...select.options].some(o => o.value === String(prev))) {
|
||||
select.value = String(prev);
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep auto option; credentials may not be saved yet
|
||||
console.warn('loadXuiInbounds:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.querySelector('[name="xui_sync"]').checked) {
|
||||
loadXuiInbounds();
|
||||
}
|
||||
|
||||
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
|
||||
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
if (e.target.checked) updateProtocolsForSync();
|
||||
@@ -897,7 +1027,8 @@
|
||||
xui_api_token: syncForm.xui_api_token.value.trim(),
|
||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
||||
xui_protocol: syncForm.xui_protocol.value
|
||||
xui_protocol: syncForm.xui_protocol.value,
|
||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0')
|
||||
};
|
||||
const res = await fetch('/api/settings/xui_sync_now', {
|
||||
method: 'POST',
|
||||
@@ -978,7 +1109,8 @@
|
||||
xui_sync_users: syncForm.xui_sync_users.checked,
|
||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
||||
xui_protocol: syncForm.xui_protocol.value
|
||||
xui_protocol: syncForm.xui_protocol.value,
|
||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0')
|
||||
};
|
||||
|
||||
|
||||
@@ -1001,11 +1133,34 @@
|
||||
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
|
||||
};
|
||||
|
||||
let createProtocol = 'xui';
|
||||
let createServerId = 0;
|
||||
const guestProtoRaw = document.getElementById('guest_create_protocol').value;
|
||||
if (guestProtoRaw === 'xui') {
|
||||
createProtocol = 'xui';
|
||||
} else if (guestProtoRaw.includes('|')) {
|
||||
const parts = guestProtoRaw.split('|');
|
||||
createProtocol = parts[0];
|
||||
createServerId = parseInt(parts[1] || '0');
|
||||
}
|
||||
|
||||
const guest = {
|
||||
enabled: document.getElementById('guest_enabled').checked,
|
||||
token: document.getElementById('guest_token').value || '',
|
||||
password: document.getElementById('guest_password').value || '',
|
||||
clear_password: !!(document.getElementById('guest_clear_password') && document.getElementById('guest_clear_password').checked),
|
||||
user_id: document.getElementById('guest_user_id').value || '',
|
||||
allow_create: document.getElementById('guest_allow_create').checked,
|
||||
create_protocol: createProtocol,
|
||||
create_server_id: createServerId,
|
||||
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0')
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl })
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest })
|
||||
});
|
||||
if (!res.ok) throw new Error(_('error'));
|
||||
showToast(_('settings_saved'), 'success');
|
||||
@@ -1018,6 +1173,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateGuestCreateFields() {
|
||||
const v = document.getElementById('guest_create_protocol')?.value;
|
||||
const group = document.getElementById('guestInboundGroup');
|
||||
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function copyGuestLink() {
|
||||
const el = document.getElementById('guest_link_display');
|
||||
if (el && el.value) copyToClipboard(el.value);
|
||||
}
|
||||
|
||||
async function regenGuestToken() {
|
||||
if (!confirm(_('guest_regen_confirm'))) return;
|
||||
try {
|
||||
const res = await fetch('/api/settings/guest/regenerate_token', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || _('error'));
|
||||
document.getElementById('guest_token').value = data.token;
|
||||
const link = `${window.location.origin}/guest/${data.token}`;
|
||||
document.getElementById('guest_link_display').value = link;
|
||||
showToast(_('guest_token_updated'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGuestInbounds() {
|
||||
const select = document.getElementById('guest_create_inbound_id');
|
||||
if (!select) return;
|
||||
const preferred = {{ settings.guest.create_inbound_id | default(0) | int }};
|
||||
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||||
try {
|
||||
const res = await fetch('/api/settings/xui/inbounds');
|
||||
const data = await res.json();
|
||||
if (!res.ok) return;
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
if (String(ib.id) === String(preferred)) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (document.getElementById('guest_allow_create')?.checked) {
|
||||
loadGuestInbounds();
|
||||
}
|
||||
document.getElementById('guest_allow_create')?.addEventListener('change', (e) => {
|
||||
if (e.target.checked) loadGuestInbounds();
|
||||
});
|
||||
|
||||
async function toggleBot() {
|
||||
const btn = document.getElementById('toggleBotBtn');
|
||||
const text = document.getElementById('toggleBotBtnText');
|
||||
|
||||
+60
-10
@@ -291,6 +291,14 @@
|
||||
<div class="form-hint">{{ _('existing_conn_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="ucXuiInbound">
|
||||
<option value="">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="ucNameGroup">
|
||||
<label class="form-label">{{ _('conn_name_panel') }}</label>
|
||||
<input class="form-input" type="text" id="ucName" placeholder="{{ _('connection_name_placeholder') }}">
|
||||
@@ -397,6 +405,8 @@
|
||||
let searchTimeout = null;
|
||||
|
||||
const serversData = {{ servers | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
|
||||
|
||||
function populateTimeSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
@@ -587,16 +597,11 @@
|
||||
const group = groupId ? document.getElementById(groupId) : null;
|
||||
select.innerHTML = '';
|
||||
const vpnBases = new Set(['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']);
|
||||
|
||||
if (serverId === '') {
|
||||
if (group) group.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const server = serversData[serverId];
|
||||
const protocols = server.protocols || {};
|
||||
let count = 0;
|
||||
|
||||
const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null;
|
||||
const protocols = (server && server.protocols) || {};
|
||||
|
||||
for (const [key, info] of Object.entries(protocols)) {
|
||||
if (!info.installed) continue;
|
||||
const base = key.split('__')[0];
|
||||
@@ -608,6 +613,14 @@
|
||||
count++;
|
||||
}
|
||||
|
||||
if (xuiConfigured && selectId === 'ucProtocol') {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'xui';
|
||||
opt.textContent = '3x-ui VLESS';
|
||||
select.appendChild(opt);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
if (group) group.style.display = '';
|
||||
} else {
|
||||
@@ -617,6 +630,39 @@
|
||||
select.appendChild(opt);
|
||||
if (group) group.style.display = '';
|
||||
}
|
||||
if (selectId === 'ucProtocol') {
|
||||
updateXuiInboundVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadXuiInbounds() {
|
||||
const select = document.getElementById('ucXuiInbound');
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${_('xui_inbound_auto')}</option>`;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/xui/inbounds');
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
if (String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateXuiInboundVisibility() {
|
||||
const proto = document.getElementById('ucProtocol')?.value;
|
||||
const group = document.getElementById('ucXuiInboundGroup');
|
||||
if (!group) return;
|
||||
if (proto === 'xui') {
|
||||
group.style.display = 'block';
|
||||
loadXuiInbounds();
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide connection fields
|
||||
@@ -649,6 +695,7 @@
|
||||
fetchExistingClients();
|
||||
}
|
||||
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
||||
updateXuiInboundVisibility();
|
||||
});
|
||||
|
||||
async function addUser(e) {
|
||||
@@ -806,7 +853,7 @@
|
||||
|
||||
try {
|
||||
const body = {
|
||||
server_id: parseInt(document.getElementById('ucServer').value),
|
||||
server_id: parseInt(document.getElementById('ucServer').value) || 0,
|
||||
protocol: document.getElementById('ucProtocol').value,
|
||||
name: document.getElementById('ucName').value || 'VPN Connection',
|
||||
};
|
||||
@@ -815,6 +862,9 @@
|
||||
const clientId = document.getElementById('ucExistingClient').value;
|
||||
if (!clientId) throw new Error(_('select_connection'));
|
||||
body.client_id = clientId;
|
||||
} else if (body.protocol === 'xui') {
|
||||
const inbound = document.getElementById('ucXuiInbound').value;
|
||||
if (inbound) body.xui_inbound_id = parseInt(inbound);
|
||||
} else if (body.protocol === 'telemt') {
|
||||
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
||||
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
||||
@@ -870,7 +920,7 @@
|
||||
<div class="client-name">${c.name || 'Connection'}</div>
|
||||
<div class="client-meta">
|
||||
<span>🖥 ${c.server_name || ''}</span>
|
||||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : c.protocol.toUpperCase())))}</span>
|
||||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : (c.protocol === 'xui' ? '3x-ui VLESS' : c.protocol.toUpperCase()))))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user