Add invite links with admin-configurable config creation limits.

Admins can create shareable links that allow redeeming a VPN config a set number of times (or unlimited).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 01:23:16 +03:00
co-authored by Cursor
parent 5386c4d40e
commit 7b3d8aac34
11 changed files with 1197 additions and 0 deletions
+1
View File
@@ -45,6 +45,7 @@
{% if current_user.role in ['admin', 'support'] %}
<a href="/" class="nav-link">{{ _('nav_servers') }}</a>
<a href="/users" class="nav-link">{{ _('nav_users') }}</a>
<a href="/invites" class="nav-link">{{ _('nav_invites') }}</a>
{% endif %}
{% if current_user.role == 'admin' %}
<a href="/settings" class="nav-link">{{ _('nav_settings') }}</a>
+207
View File
@@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
{% block content %}
<div class="card" style="max-width: 560px; 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">{{ invite.name }}</h2>
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('invite_public_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);">{{ _('invite_protected_desc') }}</p>
<form id="authForm" onsubmit="authInvite(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="invitePassword" 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 style="padding: var(--space-lg); text-align: center;">
<div id="statusBox" style="margin-bottom: var(--space-md); color: var(--text-muted); font-size: 0.95rem;">
{% if invite.unlimited %}
{{ _('invite_uses_left_unlimited').replace('{}', invite.used_count|string) }}
{% elif invite.remaining is not none %}
{{ _('invite_uses_left').replace('{}', invite.remaining|string) }}
{% endif %}
</div>
{% if invite.available %}
<button class="btn btn-primary" style="width:100%; max-width:320px;" onclick="createInviteConfig()" 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="margin-top: var(--space-sm);">{{ _('invite_get_config_hint') }}</div>
{% elif invite.expired %}
<p style="color:#ef4444;">{{ _('invite_expired') }}</p>
{% elif invite.exhausted %}
<p style="color:#ef4444;">{{ _('invite_exhausted') }}</p>
{% else %}
<p style="color:#ef4444;">{{ _('disabled') }}</p>
{% endif %}
</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 }}";
let currentConfig = '';
let currentVpnLink = '';
async function authInvite(e) {
e.preventDefault();
const password = document.getElementById('invitePassword').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/invite/${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 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');
}
function openConfigModal(name, config, vpnLink) {
currentConfig = config;
currentVpnLink = vpnLink || '';
document.getElementById('configText').value = config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('configModalTitle').textContent = name;
document.getElementById('downloadBtn').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');
}
function copyConfig() { copyToClipboard(currentConfig); }
function copyVpnLink() { copyToClipboard(currentVpnLink); }
function updateStatus(invite) {
const box = document.getElementById('statusBox');
if (!box || !invite) return;
if (invite.unlimited) {
box.textContent = _('invite_uses_left_unlimited').replace('{}', String(invite.used_count || 0));
} else if (invite.remaining != null) {
box.textContent = _('invite_uses_left').replace('{}', String(invite.remaining));
}
if (!invite.available) {
const btn = document.getElementById('createBtn');
if (btn) btn.style.display = 'none';
}
}
async function createInviteConfig() {
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/invite/${TOKEN}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Invite VPN' })
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || _('error'));
if (data.config) openConfigModal(data.connection?.name || 'VPN', data.config, data.vpn_link || '');
updateStatus(data.invite);
} catch (err) {
alert(`${_('error')}: ` + err.message);
} finally {
btn.disabled = false;
text.classList.remove('hidden');
spinner.classList.add('hidden');
}
}
</script>
<style>
.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 { to { transform: rotate(360deg); } }
</style>
{% endblock %}
+334
View File
@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title_extra %} — {{ _('invites_title') }}{% endblock %}
{% block content %}
<section>
<div class="flex items-center justify-between"
style="margin-bottom: var(--space-lg); gap: var(--space-md); flex-wrap: wrap;">
<h1 class="section-title" style="margin-bottom:0;">
<span class="icon">🔗</span>
{{ _('invites_title') }}
</h1>
<button class="btn btn-primary" onclick="openCreateInvite()">
<span></span> {{ _('invite_create') }}
</button>
</div>
<p class="form-hint" style="margin-bottom: var(--space-lg);">{{ _('invites_hint') }}</p>
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
<div class="empty-icon">🔗</div>
<div class="empty-title">{{ _('invites_empty') }}</div>
<div class="empty-desc">{{ _('invites_empty_desc') }}</div>
</div>
<div class="clients-list" id="invitesGrid"
style="display: grid; gap: var(--space-md); {% if not invites %}display:none;{% endif %}">
{% for inv in invites %}
<div class="client-item" id="invite-{{ inv.id }}" style="{% if not inv.enabled %}opacity:0.55;{% endif %}">
<div class="client-info">
<div class="client-avatar">🔗</div>
<div>
<div class="client-name">
{{ inv.name }}
{% if inv.available %}
<span class="badge badge-success">{{ _('invite_active') }}</span>
{% elif inv.expired %}
<span class="badge badge-warn">{{ _('invite_expired') }}</span>
{% elif inv.exhausted %}
<span class="badge badge-warn">{{ _('invite_exhausted') }}</span>
{% else %}
<span class="badge badge-secondary">{{ _('disabled') }}</span>
{% endif %}
</div>
<div class="client-meta">
<span>{{ _('invite_uses') }}:
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
</span>
<span>{{ inv.protocol if inv.protocol != 'xui' else '3x-ui VLESS' }}</span>
{% if inv.has_password %}<span>🔐</span>{% endif %}
</div>
</div>
</div>
<div class="client-actions" style="display:flex; gap:var(--space-xs); flex-wrap:wrap;">
<button class="btn btn-secondary btn-sm" onclick="copyInviteUrl('{{ inv.token }}')" title="{{ _('copy') }}">📋</button>
<button class="btn btn-secondary btn-sm" onclick="editInvite('{{ inv.id }}')">✏️</button>
<button class="btn btn-secondary btn-sm" onclick="toggleInvite('{{ inv.id }}', {{ 'false' if inv.enabled else 'true' }})">
{% if inv.enabled %}⏸{% else %}▶️{% endif %}
</button>
<button class="btn btn-danger btn-sm" onclick="deleteInvite('{{ inv.id }}')">🗑</button>
</div>
</div>
{% endfor %}
</div>
</section>
<!-- Create / Edit Modal -->
<div class="modal-backdrop" id="inviteModal">
<div class="modal" style="max-width: 520px;">
<div class="modal-header">
<h2 class="modal-title" id="inviteModalTitle">{{ _('invite_create') }}</h2>
<button class="modal-close" onclick="closeModal('inviteModal')">×</button>
</div>
<input type="hidden" id="inviteEditId" value="">
<div class="form-group">
<label class="form-label">{{ _('invite_name_label') }}</label>
<input class="form-input" type="text" id="inviteName" placeholder="Promo / Friends">
</div>
<div class="form-group">
<label class="form-label">{{ _('invite_max_uses_label') }}</label>
<input class="form-input" type="number" id="inviteMaxUses" min="0" value="1">
<div class="form-hint">{{ _('invite_max_uses_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('invite_user_label') }}</label>
<select class="form-select" id="inviteUserId">
<option value="">{{ _('guest_user_none') }}</option>
{% for u in users %}
<option value="{{ u.id }}">{{ u.username }} ({{ u.role }})</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('invite_user_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
<option value="xui">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 }}">{{ s.name or s.host }} — {{ key }}</option>
{% endif %}
{% endfor %}
{% endfor %}
</select>
</div>
<div class="form-group" id="inviteInboundGroup">
<label class="form-label">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="inviteInboundId">
<option value="0">{{ _('xui_inbound_auto') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ _('invite_password_label') }}</label>
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
<label style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;" id="inviteClearPwdWrap">
<input type="checkbox" id="inviteClearPassword"> {{ _('guest_clear_password') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('invite_expires_label') }}</label>
<input class="form-input" type="datetime-local" id="inviteExpires">
<label style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;" id="inviteClearExpWrap">
<input type="checkbox" id="inviteClearExpires"> {{ _('invite_clear_expires') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('invite_note_label') }}</label>
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
</div>
<div class="form-group" id="inviteResetUsedWrap" style="display:none;">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="inviteResetUsed"> {{ _('invite_reset_used') }}
</label>
</div>
<div class="modal-footer" style="display:flex; gap:var(--space-sm); justify-content:flex-end;">
<button class="btn btn-secondary" onclick="closeModal('inviteModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="saveInvite()" id="inviteSaveBtn">
<span id="inviteSaveText">{{ _('save') }}</span>
<div class="spinner hidden" id="inviteSaveSpinner" style="width:14px;height:14px;"></div>
</button>
</div>
</div>
</div>
<script>
const invitesData = {{ invites | tojson }};
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
function inviteUrl(token) {
return `${window.location.origin}/invite/${token}`;
}
function copyInviteUrl(token) {
copyToClipboard(inviteUrl(token));
showToast(_('copied'), 'success');
}
function updateInviteProtoFields() {
const v = document.getElementById('inviteProtocol').value;
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
}
async function loadInviteInbounds(selected) {
const select = document.getElementById('inviteInboundId');
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
if (!xuiConfigured) return;
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 (selected && String(ib.id) === String(selected)) opt.selected = true;
select.appendChild(opt);
});
} catch (e) { /* ignore */ }
}
function parseProtocolValue() {
const raw = document.getElementById('inviteProtocol').value;
if (raw === 'xui') return { protocol: 'xui', server_id: 0 };
if (raw.includes('|')) {
const [protocol, sid] = raw.split('|');
return { protocol, server_id: parseInt(sid || '0') };
}
return { protocol: raw, server_id: 0 };
}
function setProtocolSelect(protocol, serverId) {
const sel = document.getElementById('inviteProtocol');
if (protocol === 'xui') {
sel.value = 'xui';
} else {
const want = `${protocol}|${serverId || 0}`;
if ([...sel.options].some(o => o.value === want)) sel.value = want;
else sel.value = 'xui';
}
updateInviteProtoFields();
}
function openCreateInvite() {
document.getElementById('inviteEditId').value = '';
document.getElementById('inviteModalTitle').textContent = _('invite_create');
document.getElementById('inviteName').value = '';
document.getElementById('inviteMaxUses').value = '1';
document.getElementById('inviteUserId').value = '';
document.getElementById('invitePassword').value = '';
document.getElementById('inviteExpires').value = '';
document.getElementById('inviteNote').value = '';
document.getElementById('inviteClearPwdWrap').style.display = 'none';
document.getElementById('inviteClearExpWrap').style.display = 'none';
document.getElementById('inviteResetUsedWrap').style.display = 'none';
setProtocolSelect('xui', 0);
loadInviteInbounds(0);
openModal('inviteModal');
}
function editInvite(id) {
const inv = invitesData.find(x => x.id === id);
if (!inv) return;
document.getElementById('inviteEditId').value = id;
document.getElementById('inviteModalTitle').textContent = _('invite_edit');
document.getElementById('inviteName').value = inv.name || '';
document.getElementById('inviteMaxUses').value = String(inv.max_uses ?? 1);
document.getElementById('inviteUserId').value = inv.user_id || '';
document.getElementById('invitePassword').value = '';
document.getElementById('inviteNote').value = inv.note || '';
document.getElementById('inviteClearPwdWrap').style.display = inv.has_password ? 'flex' : 'none';
document.getElementById('inviteClearPassword').checked = false;
document.getElementById('inviteClearExpWrap').style.display = inv.expires_at ? 'flex' : 'none';
document.getElementById('inviteClearExpires').checked = false;
document.getElementById('inviteResetUsedWrap').style.display = 'block';
document.getElementById('inviteResetUsed').checked = false;
if (inv.expires_at) {
try {
const d = new Date(inv.expires_at);
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
document.getElementById('inviteExpires').value = local.toISOString().slice(0, 16);
} catch (e) {
document.getElementById('inviteExpires').value = '';
}
} else {
document.getElementById('inviteExpires').value = '';
}
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
loadInviteInbounds(inv.xui_inbound_id || 0);
openModal('inviteModal');
}
async function saveInvite() {
const btn = document.getElementById('inviteSaveBtn');
const text = document.getElementById('inviteSaveText');
const spinner = document.getElementById('inviteSaveSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
try {
const editId = document.getElementById('inviteEditId').value;
const proto = parseProtocolValue();
const expiresVal = document.getElementById('inviteExpires').value;
const body = {
name: document.getElementById('inviteName').value || 'Invite',
max_uses: parseInt(document.getElementById('inviteMaxUses').value || '1'),
user_id: document.getElementById('inviteUserId').value || '',
protocol: proto.protocol,
server_id: proto.server_id,
xui_inbound_id: parseInt(document.getElementById('inviteInboundId').value || '0'),
note: document.getElementById('inviteNote').value || '',
};
const pwd = document.getElementById('invitePassword').value;
if (pwd) body.password = pwd;
if (expiresVal) body.expires_at = new Date(expiresVal).toISOString();
if (!body.user_id) throw new Error(_('invite_user_required'));
if (editId) {
body.clear_password = document.getElementById('inviteClearPassword').checked;
body.clear_expires = document.getElementById('inviteClearExpires').checked;
body.reset_used = document.getElementById('inviteResetUsed').checked;
if (!expiresVal && !body.clear_expires) {
// keep existing — don't send expires_at
delete body.expires_at;
}
await apiCall(`/api/invites/${editId}`, 'PUT', body);
showToast(_('invite_updated'), 'success');
} else {
const res = await apiCall('/api/invites', 'POST', body);
showToast(_('invite_created'), 'success');
if (res.url) copyToClipboard(window.location.origin + res.url);
}
closeModal('inviteModal');
setTimeout(() => window.location.reload(), 600);
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function toggleInvite(id, enabled) {
try {
await apiCall(`/api/invites/${id}`, 'PUT', { enabled });
showToast(_('invite_updated'), 'success');
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
}
}
async function deleteInvite(id) {
if (!confirm(_('invite_delete_confirm'))) return;
try {
await apiCall(`/api/invites/${id}`, 'DELETE');
showToast(_('invite_deleted'), 'success');
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
}
}
</script>
{% endblock %}