Files
Amnezia-Web-Panel-main/templates/invite.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

208 lines
9.2 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: 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 %}