Files
Amnezia-Web-Panel-main/templates/invites.html
T
orohiandCursor 7b3d8aac34 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>
2026-07-26 01:23:16 +03:00

335 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}