Add 3x-ui user sync via panel API (login or Bearer token).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 00:14:27 +03:00
co-authored by Cursor
parent 865260cc9d
commit 0acd27d840
10 changed files with 551 additions and 8 deletions
+188 -4
View File
@@ -249,9 +249,9 @@
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Marzban
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> 3x-ui
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="xui_sync" {% if settings.sync.xui_sync %}checked{% endif %}>
3x-ui
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
@@ -336,6 +336,85 @@
</div>
</div>
</div>
<div id="xuiFields"
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('xui_url_label') }}</label>
<input type="url" class="form-input" name="xui_url"
value="{{ settings.sync.xui_url }}" placeholder="https://panel.example.com:2053/path">
<div class="form-hint">{{ _('xui_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_api_token_label') }}</label>
<input type="password" class="form-input" name="xui_api_token"
value="{{ settings.sync.xui_api_token }}" placeholder="{{ _('xui_api_token_placeholder') }}">
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('xui_username_label') }}</label>
<input type="text" class="form-input" name="xui_username"
value="{{ settings.sync.xui_username }}" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_password_label') }}</label>
<input type="password" class="form-input" name="xui_password"
value="{{ settings.sync.xui_password }}" autocomplete="off">
</div>
</div>
<div class="form-group">
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="checkbox" name="xui_sync_users" {% if settings.sync.xui_sync_users %}checked{% endif %}>
{{ _('enable_sync') }}
</label>
<div class="form-hint"
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
{{ _('xui_sync_hint') }}
</div>
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
<button type="button" class="btn btn-secondary btn-sm" onclick="syncXuiNow()" id="xuiSyncNowBtn">
<span id="xuiSyncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
<div class="spinner hidden" id="xuiSyncNowSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncXui()" id="xuiSyncDelBtn">
<span id="xuiSyncDelBtnText">🗑 {{ _('delete_sync_btn') }}</span>
<div class="spinner hidden" id="xuiSyncDelSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="xui_create_conns" id="xuiSyncCreateConns" {% if
settings.sync.xui_create_conns %}checked{% endif %}>
{{ _('auto_create_conns') }}
</label>
</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">
<label class="form-label">{{ _('sync_server_label') }}</label>
<select class="form-select" name="xui_server_id" onchange="updateProtocolsForXuiSync()">
{% for s in servers %}
<option value="{{ loop.index0 }}" {% if settings.sync.xui_server_id==loop.index0
%}selected{% endif %}>{{ s.name or s.host }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" name="xui_protocol" id="xuiSyncProtocolSelect">
<option value="xray" {% if settings.sync.xui_protocol=='xray' %}selected{% endif %}>Xray</option>
<option value="awg" {% if settings.sync.xui_protocol=='awg' %}selected{% endif %}>AmneziaWG</option>
<option value="awg2" {% if settings.sync.xui_protocol=='awg2' %}selected{% endif %}>AmneziaWG 2.0</option>
</select>
</div>
</div>
</div>
</form>
</div>
@@ -502,15 +581,53 @@
}
}
function updateProtocolsForXuiSync() {
const serverIdx = document.querySelector('[name="xui_server_id"]').value;
const protoSelect = document.getElementById('xuiSyncProtocolSelect');
protoSelect.innerHTML = '';
if (serverIdx === '' || !serversData[serverIdx]) return;
const protocols = serversData[serverIdx].protocols || {};
let count = 0;
for (const [key, info] of Object.entries(protocols)) {
if (info.installed) {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
if (key === "{{ settings.sync.xui_protocol }}") opt.selected = true;
protoSelect.appendChild(opt);
count++;
}
}
if (count === 0) {
const opt = document.createElement('option');
opt.textContent = _('no_protocols');
opt.disabled = true;
protoSelect.appendChild(opt);
}
}
document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => {
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
});
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
});
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForSync();
});
document.getElementById('xuiSyncCreateConns').addEventListener('change', (e) => {
document.getElementById('xuiAutoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForXuiSync();
});
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
});
@@ -760,6 +877,64 @@
}
}
async function syncXuiNow() {
const btn = document.getElementById('xuiSyncNowBtn');
const text = document.getElementById('xuiSyncNowBtnText');
const spinner = document.getElementById('xuiSyncNowSpinner');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/xui_sync_now', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_success').replace('{}', data.count), 'success');
if (data.message && data.count === 0) {
showToast(data.message, 'error');
} else {
setTimeout(() => window.location.reload(), 1500);
}
} else {
throw new Error(data.message || 'Error occurred');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🔄 ${_('sync_now_btn')}`;
spinner.classList.add('hidden');
}
}
async function deleteSyncXui() {
if (!confirm(_('delete_sync_confirm'))) return;
const btn = document.getElementById('xuiSyncDelBtn');
const text = document.getElementById('xuiSyncDelBtnText');
const spinner = document.getElementById('xuiSyncDelSpinner');
btn.disabled = true;
text.textContent = _('deleting');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/xui_sync_delete', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_deleted').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🗑 ${_('delete_sync_btn')}`;
spinner.classList.add('hidden');
}
}
async function saveSettings() {
const btn = document.getElementById('saveBtn');
const spinner = document.getElementById('saveSpinner');
@@ -782,7 +957,16 @@
remnawave_sync_users: syncForm.remnawave_sync_users.checked,
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
remnawave_protocol: syncForm.remnawave_protocol.value
remnawave_protocol: syncForm.remnawave_protocol.value,
xui_sync: syncForm.xui_sync.checked,
xui_url: syncForm.xui_url.value,
xui_username: syncForm.xui_username.value,
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value,
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
};