Add an icons sprite and helpers so nav, actions, and protocol cards use consistent stroke icons. Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
14 KiB
HTML
281 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% from "macros/icons.html" import icon %}
|
||
|
||
{% block title_extra %} — {{ _('invites_title') }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<section>
|
||
<div style="display:flex; align-items:flex-end; justify-content:space-between; gap:var(--space-md); flex-wrap:wrap; margin-bottom:var(--space-lg);">
|
||
<div>
|
||
<h1 class="section-title" style="margin-bottom:var(--space-xs);">
|
||
<span class="icon">{{ icon('link') }}</span>
|
||
{{ _('invites_title') }}
|
||
</h1>
|
||
<p style="color:var(--text-muted); margin:0; max-width:42rem; line-height:1.45;">{{ _('invites_hint') }}</p>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="openCreateInvite()">
|
||
{{ icon('plus') }} {{ _('invite_create') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
||
<div class="empty-icon">{{ icon('link') }}</div>
|
||
<div class="empty-title">{{ _('invites_empty') }}</div>
|
||
<div class="empty-desc">{{ _('invites_empty_desc') }}</div>
|
||
</div>
|
||
|
||
<div id="invitesGrid" style="display:{% if invites %}grid{% else %}none{% endif %}; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-md);">
|
||
{% for inv in invites %}
|
||
<div class="card invite-card" id="invite-{{ inv.id }}" style="padding:var(--space-md); {% if not inv.enabled %}opacity:0.55;{% endif %}">
|
||
<div style="display:flex; justify-content:space-between; gap:var(--space-sm); align-items:flex-start; margin-bottom:var(--space-sm);">
|
||
<div>
|
||
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
|
||
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
|
||
{{ inv.protocol }}
|
||
</div>
|
||
</div>
|
||
{% if inv.available %}
|
||
<span class="badge badge-success">{{ _('invite_active') }}</span>
|
||
{% elif inv.exhausted %}
|
||
<span class="badge badge-warn">{{ _('invite_exhausted') }}</span>
|
||
{% else %}
|
||
<span class="badge badge-secondary">{{ _('disabled') }}</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-md);">
|
||
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-sm);">
|
||
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em;">{{ _('invite_uses') }}</div>
|
||
<div style="font-weight:700; margin-top:2px;">
|
||
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
|
||
</div>
|
||
</div>
|
||
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-sm);">
|
||
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em;">{{ _('invite_duration_short') }}</div>
|
||
<div style="font-weight:700; margin-top:2px;">
|
||
{% if inv.duration_days %}{{ inv.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; gap:var(--space-xs); flex-wrap:wrap;">
|
||
<button class="btn btn-primary btn-sm" onclick="copyInviteUrl('{{ inv.token }}')">{{ icon('copy') }} {{ _('copy') }}</button>
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="editInvite('{{ inv.id }}')" title="{{ _('edit') }}">{{ icon('pencil') }}</button>
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="toggleInvite('{{ inv.id }}', {{ 'false' if inv.enabled else 'true' }})" title="{% if inv.enabled %}{{ _('disabled') }}{% else %}{{ _('invite_active') }}{% endif %}">
|
||
{% if inv.enabled %}{{ icon('pause') }}{% else %}{{ icon('play') }}{% endif %}
|
||
</button>
|
||
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteInvite('{{ inv.id }}')" title="{{ _('delete') }}">{{ icon('trash') }}</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</section>
|
||
|
||
<div class="modal-backdrop" id="inviteModal">
|
||
<div class="modal" style="max-width: 560px;">
|
||
<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 style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md);">
|
||
<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_duration_label') }}</label>
|
||
<input class="form-input" type="number" id="inviteDurationDays" min="0" value="0">
|
||
<div class="form-hint">{{ _('invite_duration_hint') }}</div>
|
||
</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">
|
||
{% 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">
|
||
<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_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 }};
|
||
|
||
function inviteUrl(token) {
|
||
return `${window.location.origin}/invite/${token}`;
|
||
}
|
||
|
||
function copyInviteUrl(token) {
|
||
copyToClipboard(inviteUrl(token));
|
||
showToast(_('copied'), 'success');
|
||
}
|
||
|
||
function parseProtocolValue() {
|
||
const raw = document.getElementById('inviteProtocol').value;
|
||
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');
|
||
const want = `${protocol}|${serverId || 0}`;
|
||
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
||
else if (sel.options.length) sel.value = sel.options[0].value;
|
||
}
|
||
|
||
function openCreateInvite() {
|
||
document.getElementById('inviteEditId').value = '';
|
||
document.getElementById('inviteModalTitle').textContent = _('invite_create');
|
||
document.getElementById('inviteName').value = '';
|
||
document.getElementById('inviteMaxUses').value = '1';
|
||
document.getElementById('inviteDurationDays').value = '0';
|
||
document.getElementById('inviteUserId').value = '';
|
||
document.getElementById('invitePassword').value = '';
|
||
document.getElementById('inviteNote').value = '';
|
||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||
setProtocolSelect('awg', 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('inviteDurationDays').value = String(inv.duration_days ?? 0);
|
||
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('inviteResetUsedWrap').style.display = 'block';
|
||
document.getElementById('inviteResetUsed').checked = false;
|
||
setProtocolSelect(inv.protocol || 'awg', inv.server_id || 0);
|
||
openModal('inviteModal');
|
||
}
|
||
|
||
async function saveInvite() {
|
||
const btn = document.getElementById('inviteSaveBtn');
|
||
const spinner = document.getElementById('inviteSaveSpinner');
|
||
btn.disabled = true;
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const editId = document.getElementById('inviteEditId').value;
|
||
const proto = parseProtocolValue();
|
||
|
||
const body = {
|
||
name: document.getElementById('inviteName').value || 'Invite',
|
||
max_uses: parseInt(document.getElementById('inviteMaxUses').value || '1'),
|
||
duration_days: parseInt(document.getElementById('inviteDurationDays').value || '0'),
|
||
user_id: document.getElementById('inviteUserId').value || '',
|
||
protocol: proto.protocol,
|
||
server_id: proto.server_id,
|
||
note: document.getElementById('inviteNote').value || '',
|
||
};
|
||
const pwd = document.getElementById('invitePassword').value;
|
||
if (pwd) body.password = pwd;
|
||
if (!body.user_id) throw new Error(_('invite_user_required'));
|
||
|
||
if (editId) {
|
||
body.clear_password = document.getElementById('inviteClearPassword').checked;
|
||
body.reset_used = document.getElementById('inviteResetUsed').checked;
|
||
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 %}
|