Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{ _('site_description') }}">
|
||||
<title>{{ site_settings.title or 'Amnezia Panel' }} {% block title_extra %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t) }()</script>
|
||||
<script src="/static/js/qrcode.min.js"></script>
|
||||
{% block head_extra %}{% endblock %}
|
||||
<style>
|
||||
.lang-check {
|
||||
color: var(--success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[dir="rtl"] .lang-check {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[dir="ltr"] .lang-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<a href="/" class="app-logo" style="text-decoration:none">
|
||||
<div class="logo-icon">{{ site_settings.logo }}</div>
|
||||
<div>
|
||||
<div class="logo-text">{{ site_settings.title }}</div>
|
||||
<div class="logo-subtitle">{{ site_settings.subtitle }}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% if current_user %}
|
||||
<nav class="header-nav">
|
||||
{% if current_user.role in ['admin', 'support'] %}
|
||||
<a href="/" class="nav-link">{{ _('nav_servers') }}</a>
|
||||
<a href="/users" class="nav-link">{{ _('nav_users') }}</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<a href="/settings" class="nav-link">{{ _('nav_settings') }}</a>
|
||||
{% endif %}
|
||||
<a href="/my" class="nav-link">{{ _('nav_connections') }}</a>
|
||||
</nav>
|
||||
|
||||
<div class="nav-user">
|
||||
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
|
||||
style="margin-right: var(--space-sm);">
|
||||
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
|
||||
%}🇮🇷{% else %}🇺🇸{% endif %}
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
|
||||
id="themeToggle">🌙</button>
|
||||
<span class="nav-username">{{ current_user.username }}</span>
|
||||
<span
|
||||
class="badge badge-{{ 'success' if current_user.role == 'admin' else ('info' if current_user.role == 'support' else 'warn') }}"
|
||||
style="font-size:0.65rem;">
|
||||
{{ current_user.role | upper }}
|
||||
</span>
|
||||
<a href="/logout" class="btn btn-secondary btn-sm">{{ _('nav_logout') }}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="nav-user">
|
||||
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
|
||||
style="margin-right: var(--space-sm);">
|
||||
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
|
||||
%}🇮🇷{% else %}🇺🇸{% endif %}
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
|
||||
id="themeToggle">🌙</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ===== Language Modal ===== -->
|
||||
<div class="modal-backdrop" id="langModal">
|
||||
<div class="modal" style="max-width: 320px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('select_lang') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('langModal')">×</button>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||||
{% set langs = [
|
||||
('en', '🇺🇸', 'lang_en'),
|
||||
('ru', '🇷🇺', 'lang_ru'),
|
||||
('fr', '🇫🇷', 'lang_fr'),
|
||||
('zh', '🇨🇳', 'lang_zh'),
|
||||
('fa', '🇮🇷', 'lang_fa')
|
||||
] %}
|
||||
{% for l_code, l_flag, l_key in langs %}
|
||||
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
|
||||
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
|
||||
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
|
||||
<span style="font-weight: 500;">{{ _(l_key) }}</span>
|
||||
{% if lang == l_code %}
|
||||
<span class="lang-check">✓</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.I18N = {{ translations_json | safe }};
|
||||
window._ = function (key) { return window.I18N[key] || key; };
|
||||
|
||||
/* ===== Theme Toggle ===== */
|
||||
(function () {
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
|
||||
const updateUI = () => {
|
||||
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||
document.querySelectorAll('.theme-toggle').forEach(btn => {
|
||||
const isLang = btn.getAttribute('onclick').includes('langModal');
|
||||
if (!isLang) {
|
||||
btn.textContent = isLight ? '☀️' : '🌙';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateUI);
|
||||
} else {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
window.toggleTheme = function () {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateUI();
|
||||
};
|
||||
})();
|
||||
|
||||
/* ===== Active Nav Link ===== */
|
||||
(function () {
|
||||
const path = window.location.pathname;
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href === '/' && path === '/') link.classList.add('active');
|
||||
else if (href !== '/' && path.startsWith(href)) link.classList.add('active');
|
||||
});
|
||||
})();
|
||||
|
||||
/* ===== Toast Notifications ===== */
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const icons = { success: '✓', error: '✕', info: 'ℹ' };
|
||||
toast.innerHTML = `<span>${icons[type] || 'ℹ'}</span> <span>${message}</span>`;
|
||||
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
toast.style.transition = 'all 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/* ===== Modal Management ===== */
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* Close modal on backdrop click */
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')) {
|
||||
e.target.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
/* Close modal on Escape */
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
|
||||
m.classList.remove('active');
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== API Helper ===== */
|
||||
async function apiCall(url, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
window.location.href = '/login';
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ===== Copy to clipboard ===== */
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(_('copied_to_clipboard'), 'success');
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
showToast(_('copied_to_clipboard'), 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Download file ===== */
|
||||
function downloadFile(content, filename) {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,453 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<div class="flex items-center justify-between" style="margin-bottom: var(--space-lg);">
|
||||
<h1 class="section-title" style="margin-bottom:0;">
|
||||
<span class="icon">🖥</span>
|
||||
{{ _('nav_servers') }}
|
||||
</h1>
|
||||
<button class="btn btn-primary" onclick="openModal('addServerModal')">
|
||||
<span>+</span> {{ _('add_server') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if servers %}
|
||||
<div class="server-grid" id="serverGrid">
|
||||
{% for server in servers %}
|
||||
<div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}"
|
||||
data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}"
|
||||
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
|
||||
draggable="true">
|
||||
<div class="server-meta">
|
||||
<div class="server-icon">🖥</div>
|
||||
<div style="min-width: 0; flex: 1;">
|
||||
<div class="server-name">
|
||||
<span class="ping-dot ping-pending" id="ping-{{ loop.index0 }}"
|
||||
title="{{ _('ping_checking') }}"></span>
|
||||
<span>{{ server.name }}</span>
|
||||
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
|
||||
</div>
|
||||
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-protocols" id="server-protocols-{{ loop.index0 }}">
|
||||
{% if server.protocols %}
|
||||
{% for proto_key, proto_val in server.protocols.items() %}
|
||||
{% if proto_val.get('installed') %}
|
||||
<span class="badge badge-success">
|
||||
<span class="badge-dot"></span>
|
||||
{{ 'AWG' if proto_key == 'awg' else ('AWG-Legacy' if proto_key == 'awg_legacy' else ('Xray' if
|
||||
proto_key == 'xray' else proto_key | upper)) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="badge badge-warn">
|
||||
<span class="badge-dot"></span>
|
||||
{{ _('no_protocols') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="server-actions">
|
||||
<a href="/server/{{ loop.index0 }}" class="btn btn-secondary btn-sm" style="flex:1">
|
||||
{{ _('manage') }}
|
||||
</a>
|
||||
<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditServer(this)"
|
||||
title="{{ _('edit') }}">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteServer({{ loop.index0 }})"
|
||||
title="{{ _('delete') }}">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🛡</div>
|
||||
<div class="empty-title">{{ _('no_servers') }}</div>
|
||||
<div class="empty-desc">
|
||||
{{ _('add_server_desc') }}
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('addServerModal')">
|
||||
<span>+</span> {{ _('add_server') }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ===== Edit Server Modal ===== -->
|
||||
<div class="modal-backdrop" id="editServerModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('edit_server_title') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('editServerModal')">×</button>
|
||||
</div>
|
||||
|
||||
<form id="editServerForm" onsubmit="return submitEditServer(event)">
|
||||
<input type="hidden" id="editServerIdx">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('name') }}</label>
|
||||
<input class="form-input" type="text" id="editServerName">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_host') }}</label>
|
||||
<input class="form-input" type="text" id="editServerHost" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_port') }}</label>
|
||||
<input class="form-input" type="number" id="editServerPort" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_user') }}</label>
|
||||
<input class="form-input" type="text" id="editServerUser" required>
|
||||
</div>
|
||||
|
||||
<div class="tabs" style="margin-top: var(--space-md)">
|
||||
<button type="button" class="tab active" onclick="switchEditAuthTab('password', this)">{{ _('ssh_password') }}</button>
|
||||
<button type="button" class="tab" onclick="switchEditAuthTab('key', this)">{{ _('ssh_key') }}</button>
|
||||
</div>
|
||||
|
||||
<div id="editAuthPassword">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_password') }}</label>
|
||||
<input class="form-input" type="password" id="editServerPassword"
|
||||
placeholder="{{ _('edit_keep_credential') }}">
|
||||
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editAuthKey" class="hidden">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_key') }}</label>
|
||||
<textarea class="form-textarea" id="editServerKey"
|
||||
placeholder="{{ _('edit_keep_credential') }}"></textarea>
|
||||
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary" id="editServerBtn">
|
||||
<span id="editServerBtnText">{{ _('save') }}</span>
|
||||
<div class="spinner hidden" id="editServerSpinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Add Server Modal ===== -->
|
||||
<div class="modal-backdrop" id="addServerModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('add_server') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('addServerModal')">×</button>
|
||||
</div>
|
||||
|
||||
<form id="addServerForm" onsubmit="return addServer(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('name') }}</label>
|
||||
<input class="form-input" type="text" id="serverName" placeholder="My VPN Server">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_host') }}</label>
|
||||
<input class="form-input" type="text" id="serverHost" placeholder="192.168.1.1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_port') }}</label>
|
||||
<input class="form-input" type="number" id="serverPort" value="22" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_user') }}</label>
|
||||
<input class="form-input" type="text" id="serverUser" placeholder="root" required>
|
||||
</div>
|
||||
|
||||
<!-- Tabs for auth method -->
|
||||
<div class="tabs" style="margin-top: var(--space-md)">
|
||||
<button type="button" class="tab active" onclick="switchAuthTab('password', this)">{{ _('ssh_password')
|
||||
}}</button>
|
||||
<button type="button" class="tab" onclick="switchAuthTab('key', this)">{{ _('ssh_key') }}</button>
|
||||
</div>
|
||||
|
||||
<div id="authPassword">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_password') }}</label>
|
||||
<input class="form-input" type="password" id="serverPassword" placeholder="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authKey" class="hidden">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('ssh_key') }}</label>
|
||||
<textarea class="form-textarea" id="serverKey"
|
||||
placeholder="{{ _('ssh_key_placeholder') }}"></textarea>
|
||||
<div class="form-hint">{{ _('ssh_key_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary" id="addServerBtn">
|
||||
<span id="addServerBtnText">{{ _('connect') }}</span>
|
||||
<div class="spinner hidden" id="addServerSpinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function switchAuthTab(tab, btn) {
|
||||
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
document.getElementById('authPassword').classList.toggle('hidden', tab !== 'password');
|
||||
document.getElementById('authKey').classList.toggle('hidden', tab !== 'key');
|
||||
}
|
||||
|
||||
async function addServer(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = document.getElementById('addServerBtn');
|
||||
const btnText = document.getElementById('addServerBtnText');
|
||||
const spinner = document.getElementById('addServerSpinner');
|
||||
|
||||
btn.disabled = true;
|
||||
btnText.textContent = _('connecting');
|
||||
spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name: document.getElementById('serverName').value,
|
||||
host: document.getElementById('serverHost').value,
|
||||
ssh_port: parseInt(document.getElementById('serverPort').value) || 22,
|
||||
username: document.getElementById('serverUser').value,
|
||||
password: document.getElementById('serverPassword').value,
|
||||
private_key: document.getElementById('serverKey').value,
|
||||
};
|
||||
|
||||
const result = await apiCall('/api/servers/add', 'POST', data);
|
||||
showToast(_('server_added'), 'success');
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.textContent = 'Подключить';
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(serverId) {
|
||||
if (!confirm(_('delete_confirm'))) return;
|
||||
|
||||
try {
|
||||
await apiCall(`/api/servers/${serverId}/delete`, 'POST');
|
||||
showToast(_('server_deleted'), 'success');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Edit server ========== */
|
||||
|
||||
function switchEditAuthTab(tab, btn) {
|
||||
const tabs = btn.parentElement.querySelectorAll('.tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('editAuthPassword').classList.toggle('hidden', tab !== 'password');
|
||||
document.getElementById('editAuthKey').classList.toggle('hidden', tab !== 'key');
|
||||
}
|
||||
|
||||
function openEditServer(btn) {
|
||||
// Prefill from data-* on the parent server-card so we don't have to round-trip.
|
||||
const card = btn.closest('.server-card');
|
||||
if (!card) return;
|
||||
document.getElementById('editServerIdx').value = card.dataset.idx;
|
||||
document.getElementById('editServerName').value = card.dataset.name || '';
|
||||
document.getElementById('editServerHost').value = card.dataset.host || '';
|
||||
document.getElementById('editServerPort').value = card.dataset.port || '22';
|
||||
document.getElementById('editServerUser').value = card.dataset.username || '';
|
||||
document.getElementById('editServerPassword').value = '';
|
||||
document.getElementById('editServerKey').value = '';
|
||||
|
||||
// Pre-select the tab matching the server's current auth method.
|
||||
const authIsKey = card.dataset.auth === 'key';
|
||||
const tabsEl = document.querySelector('#editServerForm .tabs');
|
||||
const passTab = tabsEl.children[0];
|
||||
const keyTab = tabsEl.children[1];
|
||||
switchEditAuthTab(authIsKey ? 'key' : 'password', authIsKey ? keyTab : passTab);
|
||||
|
||||
openModal('editServerModal');
|
||||
}
|
||||
|
||||
async function submitEditServer(e) {
|
||||
e.preventDefault();
|
||||
const idx = document.getElementById('editServerIdx').value;
|
||||
const btn = document.getElementById('editServerBtn');
|
||||
const btnText = document.getElementById('editServerBtnText');
|
||||
const spinner = document.getElementById('editServerSpinner');
|
||||
btn.disabled = true;
|
||||
btnText.textContent = _('saving') || 'Saving...';
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const body = {
|
||||
name: document.getElementById('editServerName').value,
|
||||
host: document.getElementById('editServerHost').value,
|
||||
ssh_port: parseInt(document.getElementById('editServerPort').value) || 22,
|
||||
username: document.getElementById('editServerUser').value,
|
||||
// Empty -> null means "leave unchanged" on the backend.
|
||||
password: document.getElementById('editServerPassword').value || null,
|
||||
private_key: document.getElementById('editServerKey').value || null,
|
||||
};
|
||||
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
|
||||
showToast(_('server_saved'), 'success');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.textContent = _('save');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ========== Ping indicators ========== */
|
||||
|
||||
async function pingServer(idx) {
|
||||
const dot = document.getElementById(`ping-${idx}`);
|
||||
const ms = document.getElementById(`ping-ms-${idx}`);
|
||||
if (!dot) return;
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${idx}/ping`, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
dot.classList.remove('ping-pending');
|
||||
if (data.alive) {
|
||||
dot.classList.add('ping-ok');
|
||||
dot.title = `${data.ms} ms`;
|
||||
if (ms) ms.textContent = `${data.ms} ms`;
|
||||
} else {
|
||||
dot.classList.add('ping-fail');
|
||||
dot.title = data.error || _('offline');
|
||||
if (ms) ms.textContent = _('offline');
|
||||
}
|
||||
} catch {
|
||||
dot.classList.remove('ping-pending');
|
||||
dot.classList.add('ping-fail');
|
||||
if (ms) ms.textContent = _('offline');
|
||||
}
|
||||
}
|
||||
|
||||
function startAllPings() {
|
||||
// Browsers will fan these out concurrently; the page never blocks waiting
|
||||
// for results because each card updates independently.
|
||||
document.querySelectorAll('.server-card').forEach(card => {
|
||||
const idx = card.dataset.idx;
|
||||
if (idx !== undefined) pingServer(idx);
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== Drag-and-drop reorder ========== */
|
||||
|
||||
let draggedCard = null;
|
||||
|
||||
function setupDragAndDrop() {
|
||||
const grid = document.getElementById('serverGrid');
|
||||
if (!grid) return;
|
||||
|
||||
grid.addEventListener('dragstart', e => {
|
||||
const card = e.target.closest('.server-card');
|
||||
if (!card) return;
|
||||
// Don't initiate drag from a button/link inside the card — those have
|
||||
// their own click semantics (Edit/Delete/Manage).
|
||||
if (e.target.closest('button, a')) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggedCard = card;
|
||||
card.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Some browsers require this for dragstart to fire with text payload.
|
||||
try { e.dataTransfer.setData('text/plain', card.dataset.idx); } catch {}
|
||||
});
|
||||
|
||||
grid.addEventListener('dragend', e => {
|
||||
const card = e.target.closest('.server-card');
|
||||
if (card) card.classList.remove('dragging');
|
||||
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
|
||||
draggedCard = null;
|
||||
});
|
||||
|
||||
grid.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const card = e.target.closest('.server-card');
|
||||
if (!card || card === draggedCard) return;
|
||||
grid.querySelectorAll('.server-card.drag-over').forEach(c => {
|
||||
if (c !== card) c.classList.remove('drag-over');
|
||||
});
|
||||
card.classList.add('drag-over');
|
||||
});
|
||||
|
||||
grid.addEventListener('dragleave', e => {
|
||||
const card = e.target.closest('.server-card');
|
||||
if (card && !card.contains(e.relatedTarget)) card.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
grid.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
const target = e.target.closest('.server-card');
|
||||
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
|
||||
if (!target || !draggedCard || target === draggedCard) return;
|
||||
|
||||
// Insert before/after target depending on cursor position relative
|
||||
// to the target's vertical centre, which feels natural in a grid.
|
||||
const rect = target.getBoundingClientRect();
|
||||
const insertAfter = (e.clientY - rect.top) > rect.height / 2;
|
||||
grid.insertBefore(draggedCard, insertAfter ? target.nextSibling : target);
|
||||
|
||||
const newOrder = Array.from(grid.querySelectorAll('.server-card'))
|
||||
.map(c => parseInt(c.dataset.idx, 10));
|
||||
|
||||
try {
|
||||
await apiCall('/api/servers/reorder', 'POST', { order: newOrder });
|
||||
showToast(_('servers_reordered'), 'success');
|
||||
// Reload so onclick indices and per-card data-idx attrs realign with
|
||||
// the new server array — simpler and safer than rewriting every handler.
|
||||
setTimeout(() => window.location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
// Rollback the visual move — backend rejected.
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
startAllPings();
|
||||
setupDragAndDrop();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,285 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site_settings.title or 'Amnezia Panel' }} — {{ _('login') }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t) }()</script>
|
||||
<style>
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.login-logo .logo-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 2rem;
|
||||
margin: 0 auto var(--space-md);
|
||||
}
|
||||
|
||||
.login-logo .logo-text {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-logo .logo-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-md);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-error.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lang-check {
|
||||
color: var(--success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[dir="rtl"] .lang-check {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[dir="ltr"] .lang-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="openModal('langModal')" id="langToggle"
|
||||
style="position:fixed; top:20px; {% if lang == 'fa' %}left:65px;{% else %}right:65px;{% endif %} z-index:10;"
|
||||
title="{{ _('select_lang') }}">
|
||||
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa' %}🇮🇷{%
|
||||
else %}🇺🇸{% endif %}
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle"
|
||||
style="position:fixed; top:20px; {% if lang == 'fa' %}left:20px;{% else %}right:20px;{% endif %} z-index:10;"
|
||||
title="{{ _('toggle_theme') }}">🌙</button>
|
||||
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<div class="logo-icon">{{ site_settings.logo or '🛡' }}</div>
|
||||
<div class="logo-text">{{ site_settings.title or 'Amnezia Panel' }}</div>
|
||||
<div class="logo-subtitle">{{ site_settings.subtitle or 'Web Panel' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:1.2rem; font-weight:600; margin-bottom:var(--space-lg); text-align:center;">{{
|
||||
_('login_title') }}</h2>
|
||||
|
||||
<div class="login-error" id="loginError"></div>
|
||||
|
||||
<form id="loginForm" onsubmit="return doLogin(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('username') }}</label>
|
||||
<input class="form-input" type="text" id="username" placeholder="admin" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('password') }}</label>
|
||||
<input class="form-input" type="password" id="password" placeholder="••••••••" required>
|
||||
</div>
|
||||
|
||||
{% if captcha_settings.enabled %}
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('captcha') }}</label>
|
||||
<div
|
||||
style="display: flex; margin-bottom: var(--space-xs); border-radius: var(--radius-md); overflow: hidden; border: 1px solid var(--border-color);">
|
||||
<img id="captchaImage" src="/api/auth/captcha" alt="Captcha"
|
||||
style="width: 100%; height: 65px; object-fit: cover; object-position: center; cursor: pointer; background: #fff;"
|
||||
onclick="refreshCaptcha()" title="{{ _('captcha_hint') }}">
|
||||
</div>
|
||||
<input class="form-input" type="text" id="captcha" placeholder="{{ _('captcha_placeholder') }}"
|
||||
required>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="loginBtn"
|
||||
style="width:100%; margin-top:var(--space-md);">
|
||||
<span id="loginBtnText">{{ _('login') }}</span>
|
||||
<div class="spinner hidden" id="loginSpinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Language Modal ===== -->
|
||||
<div class="modal-backdrop" id="langModal">
|
||||
<div class="modal" style="max-width: 320px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('select_lang') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('langModal')">×</button>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||||
{% set langs = [
|
||||
('en', '🇺🇸', 'lang_en'),
|
||||
('ru', '🇷🇺', 'lang_ru'),
|
||||
('fr', '🇫🇷', 'lang_fr'),
|
||||
('zh', '🇨🇳', 'lang_zh'),
|
||||
('fa', '🇮🇷', 'lang_fa')
|
||||
] %}
|
||||
{% for l_code, l_flag, l_key in langs %}
|
||||
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
|
||||
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
|
||||
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
|
||||
<span style="font-weight: 500;">{{ _(l_key) }}</span>
|
||||
{% if lang == l_code %}
|
||||
<span class="lang-check">✓</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.I18N = {{ translations_json | safe }};
|
||||
window._ = function (key) { return window.I18N[key] || key; };
|
||||
|
||||
/* Theme toggle */
|
||||
(function () {
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
|
||||
const updateUI = () => {
|
||||
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||
document.querySelectorAll('.theme-toggle').forEach(btn => {
|
||||
if (btn.id === 'themeToggle') {
|
||||
btn.textContent = isLight ? '☀️' : '🌙';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateUI);
|
||||
} else {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
window.toggleTheme = function () {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateUI();
|
||||
};
|
||||
})();
|
||||
|
||||
/* ===== Modal Management ===== */
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* Close modal on backdrop click */
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')) {
|
||||
e.target.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
/* Close modal on Escape */
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
|
||||
m.classList.remove('active');
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
function refreshCaptcha() {
|
||||
const img = document.getElementById('captchaImage');
|
||||
if (img) {
|
||||
img.src = '/api/auth/captcha?' + new Date().getTime();
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('loginBtn');
|
||||
const text = document.getElementById('loginBtnText');
|
||||
const spinner = document.getElementById('loginSpinner');
|
||||
const errEl = document.getElementById('loginError');
|
||||
|
||||
btn.disabled = true;
|
||||
text.textContent = _('logging_in');
|
||||
spinner.classList.remove('hidden');
|
||||
errEl.classList.remove('visible');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
captcha: document.getElementById('captcha') ? document.getElementById('captcha').value : null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || _('login_error'));
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.add('visible');
|
||||
refreshCaptcha();
|
||||
if (document.getElementById('captcha')) {
|
||||
document.getElementById('captcha').value = '';
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = _('login');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title_extra %} — {{ _('my_connections_title') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<h1 class="section-title">
|
||||
<span class="icon">🔗</span>
|
||||
{{ _('my_connections_title') }}
|
||||
</h1>
|
||||
|
||||
{% if connections %}
|
||||
<div class="clients-list" id="myConnectionsList">
|
||||
{% for c in connections %}
|
||||
<div class="client-item">
|
||||
<div class="client-info">
|
||||
<div class="client-avatar">🔗</div>
|
||||
<div>
|
||||
<div class="client-name">{{ c.name or 'VPN Connection' }}</div>
|
||||
<div class="client-meta">
|
||||
<span>🖥 {{ c.server_name }}</span>
|
||||
<span class="badge badge-info" style="font-size:0.65rem;">
|
||||
{{ 'AmneziaWG' if c.protocol == 'awg' else ('AmneziaWG 2.0' if c.protocol == 'awg2' else ('AWG Legacy' if c.protocol == 'awg_legacy' else
|
||||
('Xray' if c.protocol == 'xray' else c.protocol | upper))) }}
|
||||
</span>
|
||||
{% if c.created_at %}
|
||||
<span>📅 {{ c.created_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="client-actions">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="showMyConfig('{{ c.id }}', '{{ c.name or 'Connection' }}', '{{ c.protocol }}')">
|
||||
{{ _('show_config') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔗</div>
|
||||
<div class="empty-title">{{ _('no_connections') }}</div>
|
||||
<div class="empty-desc">{{ _('no_connections_user_desc') }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ===== Config Modal ===== -->
|
||||
<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">
|
||||
<div class="config-text" id="configText"></div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
|
||||
{{ _('copy_config') }}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="downloadFile(currentConfig, currentConfigName + '.conf')" style="flex:1">
|
||||
{{ _('download_conf') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-panel" id="panel-vpn">
|
||||
<div class="vpn-link-box" id="vpnLinkText"></div>
|
||||
<div class="config-actions" style="margin-top: var(--space-sm);">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentVpnLink)" style="flex:1">
|
||||
{{ _('copy_key') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top: var(--space-sm);">
|
||||
{{ _('vpn_link_hint') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-panel" id="panel-qr">
|
||||
<div class="qr-container">
|
||||
<div id="qrCode"></div>
|
||||
<div class="qr-hint">{{ _('qr_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentConfig = '';
|
||||
let currentVpnLink = '';
|
||||
let currentConfigName = 'connection';
|
||||
|
||||
async function showMyConfig(connectionId, name, proto) {
|
||||
try {
|
||||
showToast(_('loading_connections'), 'info');
|
||||
const result = await apiCall(`/api/my/connections/${connectionId}/config`, 'POST');
|
||||
|
||||
if (result.config) {
|
||||
currentConfig = result.config;
|
||||
currentVpnLink = result.vpn_link || '';
|
||||
currentConfigName = name.replace(/\s+/g, '_');
|
||||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${name}`;
|
||||
document.getElementById('configText').textContent = result.config;
|
||||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||
|
||||
// Telemt (MTProxy) doesn't have a "VPN Link" (vpn://) format in Amnezia
|
||||
const vpnTab = document.querySelectorAll('.config-tab')[1];
|
||||
const vpnPanel = document.getElementById('panel-vpn');
|
||||
if (proto === 'telemt') {
|
||||
vpnTab.style.display = 'none';
|
||||
} else {
|
||||
vpnTab.style.display = '';
|
||||
}
|
||||
|
||||
switchConfigTab('conf');
|
||||
openModal('configModal');
|
||||
generateQR(result.config);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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 generateQR(text) {
|
||||
const container = document.getElementById('qrCode');
|
||||
container.innerHTML = '';
|
||||
try {
|
||||
new QRCode(container, {
|
||||
text: text, width: 280, height: 280,
|
||||
colorDark: '#000000', colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${_('qr_error')}</div>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title_extra %} — {{ _('share_title') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 600px; 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">{{ _('vpn_access') }}</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('user_label_share') }}: <strong>{{
|
||||
share_user.username }}</strong>
|
||||
</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);">{{ _('share_protected_desc') }}
|
||||
</p>
|
||||
|
||||
<form id="authForm" onsubmit="authShare(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="sharePassword" 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 id="connectionsList" style="padding: var(--space-md);">
|
||||
<div style="text-align: center; padding: 2rem;" id="loadingState">
|
||||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto;"></div>
|
||||
<p style="margin-top: 1rem; color: var(--text-muted);">{{ _('loading_share_conns') }}</p>
|
||||
</div>
|
||||
<div id="connectionsGrid" class="hidden" style="display: grid; gap: var(--space-md);">
|
||||
<!-- Connections will be here -->
|
||||
</div>
|
||||
<div id="emptyState" class="hidden" style="text-align: center; padding: 2rem;">
|
||||
<p style="color: var(--text-muted);">{{ _('no_active_conns') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Modal for Config -->
|
||||
<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 }}";
|
||||
|
||||
async function authShare(e) {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('sharePassword').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/share/${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');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
if (document.getElementById('loadingState') === null) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/share/${TOKEN}/connections`);
|
||||
if (res.status === 401) return; // Wait for auth
|
||||
|
||||
const data = await res.json();
|
||||
document.getElementById('loadingState').classList.add('hidden');
|
||||
|
||||
if (!data.connections || data.connections.length === 0) {
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = document.getElementById('connectionsGrid');
|
||||
grid.classList.remove('hidden');
|
||||
grid.innerHTML = data.connections.map(c => `
|
||||
<div class="card" style="padding: var(--space-md); border: 1px solid var(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-sm);">
|
||||
<div style="text-align: initial;">
|
||||
<div style="font-weight: 600; font-size: 1.1rem;">${c.name}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted);">${c.protocol.toUpperCase()} • ${c.server_name}</div>
|
||||
</div>
|
||||
<span class="badge badge-success">${_('active')}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm w-full" onclick="showConfig('${c.id}', '${c.name}')">
|
||||
${_('show_settings_btn')}
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
let currentConfig = '';
|
||||
let currentVpnLink = '';
|
||||
|
||||
async function showConfig(connId, name) {
|
||||
try {
|
||||
const res = await fetch(`/api/share/${TOKEN}/config/${connId}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentConfig = data.config;
|
||||
currentVpnLink = data.vpn_link || '';
|
||||
|
||||
document.getElementById('configText').value = data.config;
|
||||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||
document.getElementById('configModalTitle').textContent = name;
|
||||
|
||||
const dl = document.getElementById('downloadBtn');
|
||||
dl.onclick = () => downloadFile(data.config, `${name}.conf`);
|
||||
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
qrContainer.innerHTML = '';
|
||||
new QRCode(qrContainer, {
|
||||
text: data.config,
|
||||
width: 256,
|
||||
height: 256,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
});
|
||||
|
||||
switchConfigTab('conf');
|
||||
openModal('configModal');
|
||||
} catch (err) {
|
||||
alert(`${_('error')}: ` + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
copyToClipboard(currentConfig);
|
||||
}
|
||||
|
||||
function copyVpnLink() {
|
||||
copyToClipboard(currentVpnLink);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConnections);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user