Files
Amnezia-Web-Panel-main/templates/invite.html
T
orohiandCursor 7d7094bc38 Issue invite configs as subscription URLs with redeem-based lifetime.
Admin picks inbound once, sets duration in days from Get config, and configures the 3x-ui /sub base URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:34:40 +03:00

225 lines
11 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 %} — {{ _('invite_public_title') }}{% endblock %}
{% block content %}
<div class="card" style="max-width: 520px; margin: 2.5rem auto; overflow:hidden;">
<div style="padding: var(--space-xl) var(--space-lg) var(--space-md); text-align:center; background: linear-gradient(180deg, rgba(59,130,246,0.12), transparent);">
<div style="width:56px;height:56px;border-radius:16px;margin:0 auto var(--space-md);display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:1.6rem;">🔗</div>
<h2 class="card-title" style="margin-bottom:6px;">{{ invite.name }}</h2>
<p style="color: var(--text-muted); font-size: 0.92rem; margin:0;">{{ _('invite_public_subtitle') }}</p>
</div>
{% if need_password %}
<div style="padding: var(--space-lg); text-align: center;">
<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);">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-lg);">
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
<div style="font-size:0.72rem; color:var(--text-muted); text-transform:uppercase;">{{ _('invite_uses') }}</div>
<div id="usesValue" style="font-weight:700; font-size:1.15rem; margin-top:4px;">
{% if invite.unlimited %}∞{% else %}{{ invite.remaining }}{% endif %}
</div>
</div>
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
<div style="font-size:0.72rem; color:var(--text-muted); text-transform:uppercase;">{{ _('invite_duration_short') }}</div>
<div style="font-weight:700; font-size:1.15rem; margin-top:4px;">
{% if invite.duration_days %}{{ invite.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
</div>
</div>
</div>
<p id="statusHint" style="text-align:center; color:var(--text-muted); font-size:0.88rem; margin:0 0 var(--space-md);">
{% if invite.duration_days %}
{{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }}
{% else %}
{{ _('invite_get_config_hint') }}
{% endif %}
</p>
{% if invite.available %}
<button class="btn btn-primary" style="width:100%;" onclick="createInviteConfig()" id="createBtn">
<span id="createBtnText">{{ _('guest_get_config') }}</span>
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
</button>
{% elif invite.exhausted %}
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('invite_exhausted') }}</p>
{% else %}
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('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 id="expiresBanner" class="hidden" style="margin:0 var(--space-md) var(--space-sm); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); background:rgba(59,130,246,0.1); color:var(--text-muted); font-size:0.85rem;"></div>
<div class="config-tabs">
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('invite_sub_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="8"
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, expiresAt) {
currentConfig = config;
currentVpnLink = vpnLink || config || '';
document.getElementById('configText').value = config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('configModalTitle').textContent = name;
const banner = document.getElementById('expiresBanner');
if (expiresAt) {
banner.textContent = _('invite_config_expires_at').replace('{}', new Date(expiresAt).toLocaleString());
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.txt`);
const qrContainer = document.getElementById('qrcode');
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: currentVpnLink || 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 uses = document.getElementById('usesValue');
if (uses && invite) {
uses.textContent = invite.unlimited ? '∞' : String(invite.remaining ?? 0);
}
if (invite && !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'));
const share = data.subscription_url || data.config || '';
if (share) openConfigModal(data.connection?.name || 'VPN', share, data.vpn_link || share, data.expires_at);
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 %}