Files
Amnezia-Web-Panel-main/templates/settings.html
T

1036 lines
54 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 %} — {{ _('nav_settings') }}{% endblock %}
{% block content %}
<div class="section-title">
<span class="icon">⚙️</span>
{{ _('settings_title') }}
</div>
<div class="grid"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: var(--space-lg); align-items: start;">
<!-- LEFT COLUMN -->
<div style="display: flex; flex-direction: column; gap: var(--space-lg);">
<!-- BLOCK 1: Appearance -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('appearance') }}</h3>
<form id="appearanceForm">
<div class="form-group">
<label class="form-label">{{ _('title_label') }}</label>
<input type="text" class="form-input" name="title" value="{{ settings.appearance.title }}" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('logo_label') }}</label>
<input type="text" class="form-input" name="logo" value="{{ settings.appearance.logo }}" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('subtitle_label') }}</label>
<input type="text" class="form-input" name="subtitle" value="{{ settings.appearance.subtitle }}"
required>
</div>
</form>
</div>
<!-- BLOCK Captcha -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('captcha_title') }}</h3>
<form id="captchaForm">
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="captcha_enabled" {% if captcha_settings.enabled %}checked{% endif %}>
{{ _('enable_captcha') }}
</label>
</div>
</form>
</div>
<!-- BLOCK Telegram Bot -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('telegram_bot_title') }}</h3>
<form id="telegramForm">
<div class="form-group">
<label class="form-label">{{ _('bot_token_label') }}</label>
<input type="text" class="form-input" id="tg_token" value="{{ telegram_settings.token or '' }}"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
</div>
<div style="display: flex; align-items: center; gap: var(--space-md); margin-top: var(--space-md);">
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<span style="font-size: 0.85rem; color: var(--text-muted);">{{ _('bot_status') }}:</span>
{% if bot_running %}
<span class="badge badge-success" id="botStatusBadge">{{ _('bot_running') }}</span>
{% else %}
<span class="badge badge-secondary" id="botStatusBadge">{{ _('bot_stopped') }}</span>
{% endif %}
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleBot()" id="toggleBotBtn">
<span id="toggleBotBtnText">{% if bot_running %}{{ _('bot_stop_btn') }}{% else %}{{
_('bot_start_btn') }}{% endif %}</span>
<div class="spinner hidden" id="toggleBotSpinner" style="width:14px;height:14px;"></div>
</button>
</div>
<p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('bot_hint') }}
</p>
</form>
</div>
<!-- BLOCK 4: API Docs -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('api_docs_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<a href="/docs" target="_blank" class="btn btn-secondary"
style="justify-content: flex-start; text-decoration: none;">
<span style="font-size: 1.2rem; margin-right: 10px;">📖</span>
<div style="text-align: left;">
<div style="font-weight: 600;">Swagger UI</div>
<div style="font-size: 0.75rem; opacity: 0.7;">{{ _('api_docs_hint') }}</div>
</div>
</a>
<a href="/redoc" target="_blank" class="btn btn-secondary"
style="justify-content: flex-start; text-decoration: none;">
<span style="font-size: 1.2rem; margin-right: 10px;">📑</span>
<div style="text-align: left;">
<div style="font-weight: 600;">ReDoc</div>
<div style="font-size: 0.75rem; opacity: 0.7;">{{ _('api_docs_hint') }}</div>
</div>
</a>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('api_docs_hint') }}</p>
</div>
<!-- BLOCK Tunnels -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span>
</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div>
</div>
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>Cloudflare Quick Tunnel</strong>
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>ngrok Tunnel + Authtoken</strong>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="form-group" style="margin-bottom: var(--space-sm);">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
</div>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>Cloudflare WARP</strong>
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
</div>
<!-- BLOCK SSL/HTTPS -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🔒 {{ _('ssl_title') }}</h3>
<form id="sslForm">
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="ssl_enabled" {% if settings.get('ssl', {}).get('enabled') %}checked{% endif %}>
{{ _('enable_https') }}
</label>
</div>
<div class="form-group" style="margin-top: var(--space-md);">
<label class="form-label">{{ _('panel_port_label') }}</label>
<input type="number" class="form-input" id="panel_port" value="{{ settings.get('ssl', {}).get('panel_port', 5000) }}" placeholder="5000">
</div>
<div id="sslFields" style="{% if not settings.get('ssl', {}).get('enabled') %}display:none;{% endif %} margin-top: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('domain_label') }}</label>
<input type="text" class="form-input" id="ssl_domain" value="{{ settings.get('ssl', {}).get('domain', '') }}" placeholder="vpn.example.com">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('cert_path_label') }}</label>
<input type="text" class="form-input" id="ssl_cert_path" value="{{ settings.get('ssl', {}).get('cert_path', '') }}" placeholder="/etc/amnezia/cert.pem">
</div>
<div class="form-group">
<label class="form-label">{{ _('key_path_label') }}</label>
<input type="text" class="form-input" id="ssl_key_path" value="{{ settings.get('ssl', {}).get('key_path', '') }}" placeholder="/etc/amnezia/key.pem">
</div>
</div>
<div class="divider" style="margin: var(--space-md) 0;">{{ _('or_paste_text') }}</div>
<div class="form-group">
<label class="form-label">{{ _('cert_text_label') }}</label>
<textarea class="form-input" id="ssl_cert_text" rows="4" placeholder="-----BEGIN CERTIFICATE----- ...">{{ settings.get('ssl', {}).get('cert_text', '') }}</textarea>
</div>
<div class="form-group">
<label class="form-label">{{ _('key_text_label') }}</label>
<textarea class="form-input" id="ssl_key_text" rows="4" placeholder="-----BEGIN PRIVATE KEY----- ...">{{ settings.get('ssl', {}).get('key_text', '') }}</textarea>
</div>
<p class="form-hint">{{ _('ssl_hint') }}</p>
</div>
</form>
</div>
</div>
<!-- RIGHT COLUMN -->
<div style="display: flex; flex-direction: column; gap: var(--space-lg);">
<!-- BLOCK 3: Import Users -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('import_users_title') }}</h3>
<form id="syncForm">
<div class="form-group" style="margin-bottom: var(--space-lg);">
<label class="form-label">{{ _('import_source_label') }}</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="remnawave_sync" {% if settings.sync.remnawave_sync %}checked{%
endif %}> Remnawave
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Marzban
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> 3x-ui
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Hiddify
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> LDAP
</label>
</div>
</div>
<div id="remnawaveFields"
style="{% if not settings.sync.remnawave_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('remnawave_url_label') }}</label>
<input type="url" class="form-input" name="remnawave_url"
value="{{ settings.sync.remnawave_url }}" placeholder="https://vpn.example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('api_key_label') }}</label>
<input type="password" class="form-input" name="remnawave_api_key"
value="{{ settings.sync.remnawave_api_key }}" placeholder="Your secret key">
</div>
<div class="form-group">
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="checkbox" name="remnawave_sync_users" {% if settings.sync.remnawave_sync_users
%}checked{% endif %}>
{{ _('enable_sync') }}
</label>
<div class="form-hint"
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
{{ _('sync_hint') }}
</div>
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
<button type="button" class="btn btn-secondary btn-sm" onclick="syncRemnawaveNow()"
id="syncNowBtn">
<span id="syncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
<div class="spinner hidden" id="syncNowSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncRemnawave()"
id="syncDelBtn">
<span id="syncDelBtnText">🗑 {{ _('delete_sync_btn') }}</span>
<div class="spinner hidden" id="syncDelSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="remnawave_create_conns" id="syncCreateConns" {% if
settings.sync.remnawave_create_conns %}checked{% endif %}>
{{ _('auto_create_conns') }}
</label>
</div>
<div id="autoConnFields"
style="{% if not settings.sync.remnawave_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('sync_server_label') }}</label>
<select class="form-select" name="remnawave_server_id" onchange="updateProtocolsForSync()">
{% for s in servers %}
<option value="{{ loop.index0 }}" {% if settings.sync.remnawave_server_id==loop.index0
%}selected{% endif %}>{{ s.name or s.host }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" name="remnawave_protocol" id="syncProtocolSelect">
<option value="awg" {% if settings.sync.remnawave_protocol=='awg' %}selected{% endif %}>
AmneziaWG</option>
<option value="awg2" {% if settings.sync.remnawave_protocol=='awg2' %}selected{% endif %}>
AmneziaWG 2.0</option>
<option value="awg_legacy" {% if settings.sync.remnawave_protocol=='awg_legacy' %}selected{% endif %}>
AWG Legacy</option>
<option value="xray" {% if settings.sync.remnawave_protocol=='xray' %}selected{% endif %}>
Xray</option>
</select>
</div>
</div>
</div>
</form>
</div>
<!-- BLOCK: Simple Backup -->
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm);">
<a href="/api/settings/backup/download" class="btn btn-secondary"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>⬇️</span> {{ _('download_backup') }}
</a>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
<input type="file" id="backupFile" accept=".json" style="display: none;"
onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
style="gap:var(--space-sm);">
<span>⬆️</span> {{ _('restore_backup') }}
</button>
</div>
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('restore_confirm') }}
</p>
</div>
<!-- BLOCK: API Tokens -->
<div class="card" style="margin-top: var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
<h3 class="card-title" style="margin:0;">🔑 {{ _('api_tokens_title') }}</h3>
<button type="button" class="btn btn-primary btn-sm" onclick="openCreateTokenModal()">
<span></span> {{ _('api_tokens_create') }}
</button>
</div>
<p class="form-hint" style="margin-bottom: var(--space-md);">
{{ _('api_tokens_hint') }}
</p>
<div id="tokensList">
<div class="form-hint" id="tokensEmptyState">{{ _('api_tokens_empty') }}</div>
</div>
</div>
<!-- BLOCK: About & Updates -->
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">️ {{ _('about_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-sm) 0; border-bottom: 1px solid var(--border-color);">
<span style="font-weight: 500;">{{ _('current_version') }}</span>
<span class="badge badge-info" id="currentVersionBadge" style="font-size: 0.95rem;">{{ current_version }}</span>
</div>
<div id="updateStatus" class="hidden" style="padding: var(--space-md); border-radius: var(--radius-md); background: var(--bg-primary); text-align: center;">
<!-- Updated via JS -->
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;">
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<a href="https://github.com/PRVTPRO/Amnezia-Web-Panel/releases" target="_blank" class="btn btn-primary hidden" id="downloadUpdateBtn" style="flex:1; text-decoration: none; justify-content: center;">
⬇️ {{ _('download_update') }}
</a>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top: var(--space-xl); text-align: right;">
<button class="btn btn-primary btn-lg" onclick="saveSettings()" id="saveBtn">
<span>{{ _('save_changes') }}</span>
<div class="spinner hidden" id="saveSpinner"></div>
</button>
</div>
<!-- ===== Create API Token Modal ===== -->
<div class="modal-backdrop" id="createTokenModal">
<div class="modal" style="max-width: 460px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('api_tokens_create_title') }}</h2>
<button class="modal-close" onclick="closeModal('createTokenModal')">×</button>
</div>
<div class="form-group">
<label class="form-label">{{ _('api_tokens_name_label') }}</label>
<input class="form-input" type="text" id="createTokenName"
placeholder="{{ _('api_tokens_name_placeholder') }}" maxlength="100">
<div class="form-hint">{{ _('api_tokens_name_hint') }}</div>
</div>
<div id="createTokenError" style="color: var(--danger, #ef4444); font-size: 0.85rem; margin-bottom: var(--space-sm);"></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('createTokenModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="submitCreateToken()" id="createTokenBtn">
{{ _('api_tokens_create') }}
</button>
</div>
</div>
</div>
<!-- ===== Newly-Created Token Modal (one-time display) ===== -->
<div class="modal-backdrop" id="newTokenModal">
<div class="modal" style="max-width: 560px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('api_tokens_created_title') }}</h2>
<button class="modal-close" onclick="closeModal('newTokenModal')">×</button>
</div>
<div class="form-group">
<label class="form-label">{{ _('api_tokens_name_label') }}</label>
<div id="newTokenName" style="font-weight: 600;"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('api_tokens_value_label') }}</label>
<input class="form-input" type="text" id="newTokenValue" readonly
style="font-family: 'SF Mono', monospace; font-size: 0.85rem;">
</div>
<div style="background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.4); border-radius: var(--radius-md); padding: var(--space-md); margin-bottom: var(--space-md);">
⚠️ <strong>{{ _('api_tokens_warning_title') }}</strong>
<div class="form-hint" style="margin-top: 4px;">{{ _('api_tokens_warning_body') }}</div>
</div>
<div class="form-hint" style="margin-bottom: var(--space-sm); font-family: 'SF Mono', monospace; font-size: 0.78rem; padding: var(--space-sm); background: var(--bg-primary); border-radius: var(--radius-sm);">
curl -H "Authorization: Bearer &lt;your_token&gt;" {{ request.url.scheme }}://{{ request.url.netloc }}/api/users
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="copyNewToken()">📋 {{ _('copy') }}</button>
<button class="btn btn-primary" onclick="closeModal('newTokenModal')">{{ _('done') }}</button>
</div>
</div>
</div>
<script>
const serversData = {{ servers | tojson }};
const botCurrentlyRunning = {{ 'true' if bot_running else 'false' }};
function updateProtocolsForSync() {
const serverIdx = document.querySelector('[name="remnawave_server_id"]').value;
const protoSelect = document.getElementById('syncProtocolSelect');
protoSelect.innerHTML = '';
if (serverIdx === '' || !serversData[serverIdx]) return;
const protocols = serversData[serverIdx].protocols || {};
let count = 0;
for (const [key, info] of Object.entries(protocols)) {
if (info.installed) {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
if (key === "{{ settings.sync.remnawave_protocol }}") opt.selected = true;
protoSelect.appendChild(opt);
count++;
}
}
if (count === 0) {
const opt = document.createElement('option');
opt.textContent = _('no_protocols');
opt.disabled = true;
protoSelect.appendChild(opt);
}
}
document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => {
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
});
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForSync();
});
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
});
let tunnelStatusTimer = null;
function copyElementText(id) {
const el = document.getElementById(id);
if (el) copyToClipboard(el.textContent.trim());
}
function renderTunnelUrls(provider, urls) {
const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return;
if (!urls || !urls.length) {
el.textContent = _('tunnel_no_public_url');
return;
}
el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div>
`).join('');
}
function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
badge.textContent = status.installed ? _('installed') : _('not_installed');
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = !!status.running;
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
}
async function loadTunnelStatus() {
try {
const data = await apiCall('/api/settings/tunnels/status');
if (data.local_server) document.getElementById('localServerUrl').textContent = data.local_server;
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
function updateWarpStatus(status) {
const badge = document.getElementById('warpInstallBadge');
const statusText = document.getElementById('warpStatusText');
const hintText = document.getElementById('warpHintText');
const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
hintText.textContent = status.last_error || (installed ? _('warp_hint') : (status.install_hint || _('warp_install_hint')));
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
}
async function connectWarp() {
const btn = document.getElementById('warpConnectBtn');
const spinner = document.getElementById('warpConnectSpinner');
const text = document.getElementById('warpConnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/warp/connect', 'POST');
updateWarpStatus(status);
showToast(_('warp_connected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
spinner.classList.add('hidden');
}
}
async function disconnectWarp() {
const btn = document.getElementById('warpDisconnectBtn');
const spinner = document.getElementById('warpDisconnectSpinner');
const text = document.getElementById('warpDisconnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/warp/disconnect', 'POST');
updateWarpStatus(status);
showToast(_('warp_disconnected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('warp_disconnect');
spinner.classList.add('hidden');
}
}
async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`);
const text = document.getElementById(`${provider}TunnelBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const current = await apiCall('/api/settings/tunnels/status');
const providerStatus = current[provider] || {};
if (!providerStatus.installed) {
text.textContent = _('installing');
await apiCall(`/api/settings/tunnels/${provider}/install`, 'POST');
}
text.textContent = _('tunnel_starting');
const body = provider === 'ngrok' ? { authtoken: document.getElementById('ngrokAuthtoken').value.trim() } : {};
const status = await apiCall(`/api/settings/tunnels/${provider}/start`, 'POST', body);
updateTunnelProvider(provider, status);
showToast(status.public_url ? _('tunnel_started') : _('tunnel_waiting_url'), 'success');
if (!tunnelStatusTimer) tunnelStatusTimer = setInterval(loadTunnelStatus, 5000);
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function stopTunnel(provider) {
const btn = document.getElementById(`${provider}StopBtn`);
const spinner = document.getElementById(`${provider}StopSpinner`);
const text = document.getElementById(`${provider}StopBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('tunnel_stopping');
try {
const status = await apiCall(`/api/settings/tunnels/${provider}/stop`, 'POST');
updateTunnelProvider(provider, status);
showToast(_('tunnel_stopped'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('tunnel_stop');
spinner.classList.add('hidden');
}
}
async function deleteTunnel(provider) {
if (!confirm(_('tunnel_delete_confirm'))) return;
const btn = document.getElementById(`${provider}DeleteBtn`);
const spinner = document.getElementById(`${provider}DeleteSpinner`);
const text = document.getElementById(`${provider}DeleteBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('deleting');
try {
const status = await apiCall(`/api/settings/tunnels/${provider}`, 'DELETE');
updateTunnelProvider(provider, status);
showToast(_('tunnel_deleted'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('tunnel_delete');
spinner.classList.add('hidden');
}
}
async function syncRemnawaveNow() {
const btn = document.getElementById('syncNowBtn');
const text = document.getElementById('syncNowBtnText');
const spinner = document.getElementById('syncNowSpinner');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/sync_now', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_success').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
throw new Error(data.message || 'Error occurred');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🔄 ${_('sync_now_btn')}`;
spinner.classList.add('hidden');
}
}
async function deleteSyncRemnawave() {
if (!confirm(_('delete_sync_confirm'))) return;
const btn = document.getElementById('syncDelBtn');
const text = document.getElementById('syncDelBtnText');
const spinner = document.getElementById('syncDelSpinner');
btn.disabled = true;
text.textContent = _('deleting');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/sync_delete', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_deleted').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🗑 ${_('delete_sync_btn')}`;
spinner.classList.add('hidden');
}
}
async function saveSettings() {
const btn = document.getElementById('saveBtn');
const spinner = document.getElementById('saveSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
const appForm = document.getElementById('appearanceForm');
const syncForm = document.getElementById('syncForm');
const appearance = {
title: appForm.title.value,
logo: appForm.logo.value,
subtitle: appForm.subtitle.value
};
const sync = {
remnawave_url: syncForm.remnawave_url.value,
remnawave_api_key: syncForm.remnawave_api_key.value,
remnawave_sync: syncForm.remnawave_sync.checked,
remnawave_sync_users: syncForm.remnawave_sync_users.checked,
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
remnawave_protocol: syncForm.remnawave_protocol.value
};
const captcha = {
enabled: document.getElementById('captcha_enabled').checked
};
const telegram = {
token: document.getElementById('tg_token').value.trim(),
enabled: botCurrentlyRunning
};
const ssl = {
enabled: document.getElementById('ssl_enabled').checked,
domain: document.getElementById('ssl_domain').value.trim(),
cert_path: document.getElementById('ssl_cert_path').value.trim(),
key_path: document.getElementById('ssl_key_path').value.trim(),
cert_text: document.getElementById('ssl_cert_text').value.trim(),
key_text: document.getElementById('ssl_key_text').value.trim(),
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
};
try {
const res = await fetch('/api/settings/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl })
});
if (!res.ok) throw new Error(_('error'));
showToast(_('settings_saved'), 'success');
setTimeout(() => window.location.reload(), 1000);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function toggleBot() {
const btn = document.getElementById('toggleBotBtn');
const text = document.getElementById('toggleBotBtnText');
const spinner = document.getElementById('toggleBotSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/telegram/toggle', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || _('error'));
const running = data.bot_running;
document.getElementById('botStatusBadge').className = running ? 'badge badge-success' : 'badge badge-secondary';
document.getElementById('botStatusBadge').textContent = running ? _('bot_running') : _('bot_stopped');
text.textContent = running ? _('bot_stop_btn') : _('bot_start_btn');
showToast(running ? _('bot_started') : _('bot_stopped_msg'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function handleRestore(e) {
const file = e.target.files[0];
if (!file) return;
if (!confirm(_('restore_confirm'))) {
e.target.value = '';
return;
}
const btn = document.getElementById('restoreBtn');
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('loading')}`;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/settings/backup/restore', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.status === 'success') {
showToast(_('restore_success'), 'success');
setTimeout(() => window.location.reload(), 2000);
} else {
showToast(data.error || _('invalid_backup_file'), 'error');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
e.target.value = '';
}
}
// Init protocols on load
if (document.getElementById('syncCreateConns').checked) {
updateProtocolsForSync();
}
async function checkForUpdates() {
const btn = document.getElementById('checkUpdateBtn');
const text = document.getElementById('checkUpdateBtnText');
const spinner = document.getElementById('checkUpdateSpinner');
const statusDiv = document.getElementById('updateStatus');
const downloadBtn = document.getElementById('downloadUpdateBtn');
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
statusDiv.classList.add('hidden');
downloadBtn.classList.add('hidden');
try {
const response = await fetch('https://api.github.com/repos/PRVTPRO/Amnezia-Web-Panel/releases/latest');
if (!response.ok) throw new Error('API Error');
const data = await response.json();
const latestVersion = data.tag_name;
const currentVersion = '{{ current_version }}';
statusDiv.classList.remove('hidden');
if (latestVersion !== currentVersion && latestVersion) {
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url;
downloadBtn.classList.remove('hidden');
} else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)';
}
} catch (err) {
showToast('Failed to check for updates: ' + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
spinner.classList.add('hidden');
}
}
/* ========== API Tokens ========== */
function fmtTokenDate(iso) {
if (!iso) return _('api_tokens_never');
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
async function loadTokens() {
try {
const data = await apiCall('/api/settings/tokens');
renderTokens(data.tokens || []);
} catch (err) {
showToast(err.message, 'error');
}
}
function renderTokens(tokens) {
const container = document.getElementById('tokensList');
if (!container) return;
if (!tokens.length) {
container.innerHTML = `<div class="form-hint" id="tokensEmptyState">${_('api_tokens_empty')}</div>`;
return;
}
container.innerHTML = tokens.map(t => `
<div class="token-row" style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); padding:var(--space-sm) 0; border-bottom:1px solid var(--border-color);">
<div style="min-width:0; flex:1;">
<div style="font-weight:600;">${escapeHTML(t.name)}</div>
<div class="text-muted" style="font-size:0.78rem; font-family: 'SF Mono', monospace;">
<span>${escapeHTML(t.token_prefix || '')}…</span>
<span style="margin-left:var(--space-md);">${_('api_tokens_created')}: ${fmtTokenDate(t.created_at)}</span>
<span style="margin-left:var(--space-md);">${_('api_tokens_last_used')}: ${fmtTokenDate(t.last_used_at)}</span>
${t.owner ? `<span style="margin-left:var(--space-md);">${_('api_tokens_owner')}: ${escapeHTML(t.owner)}</span>` : ''}
</div>
</div>
<button class="btn btn-danger btn-sm" onclick="revokeToken('${t.id}', '${escapeHTML(t.name)}')">
${_('api_tokens_revoke')}
</button>
</div>
`).join('');
}
function escapeHTML(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function openCreateTokenModal() {
document.getElementById('createTokenName').value = '';
document.getElementById('createTokenError').textContent = '';
openModal('createTokenModal');
setTimeout(() => document.getElementById('createTokenName').focus(), 50);
}
async function submitCreateToken() {
const name = document.getElementById('createTokenName').value.trim();
const errEl = document.getElementById('createTokenError');
if (!name) {
errEl.textContent = _('api_tokens_name_required');
return;
}
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;
try {
const res = await apiCall('/api/settings/tokens', 'POST', { name });
closeModal('createTokenModal');
// Show the raw token exactly once. The user must copy it now —
// we cannot retrieve it after this point.
document.getElementById('newTokenValue').value = res.token || '';
document.getElementById('newTokenName').textContent = res.name || '';
openModal('newTokenModal');
loadTokens();
} catch (err) {
errEl.textContent = err.message;
} finally {
btn.disabled = false;
}
}
function copyNewToken() {
const input = document.getElementById('newTokenValue');
input.select();
copyToClipboard(input.value);
}
async function revokeToken(id, name) {
if (!confirm(_('api_tokens_revoke_confirm').replace('{}', name))) return;
try {
await apiCall(`/api/settings/tokens/${id}`, 'DELETE');
showToast(_('api_tokens_revoked'), 'success');
loadTokens();
} catch (err) {
showToast(err.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTokens();
loadTunnelStatus();
tunnelStatusTimer = setInterval(loadTunnelStatus, 10000);
});
</script>
{% endblock %}