Files
Amnezia-Web-Panel-main/templates/settings.html
T
orohi 77c6f0dab7 Add NordVPN outbound control in Tunnels (v2.5.0).
Connect and disconnect via official nordvpn CLI from Settings with optional country/city, alongside Cloudflare WARP.
2026-07-28 09:34:10 +03:00

1787 lines
95 KiB
HTML
Raw Permalink 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" %}
{% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ _('nav_settings') }}{% endblock %}
{% block content %}
<div class="section-title">
<span class="icon">{{ icon('settings') }}</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 Guest Access -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('guest_settings_title') }}</h3>
<form id="guestForm">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="guest_enabled" {% if settings.guest.enabled %}checked{% endif %}
onchange="document.getElementById('guestFields').style.display = this.checked ? 'block' : 'none'">
{{ _('guest_enable') }}
</label>
<div class="form-hint">{{ _('guest_enable_hint') }}</div>
</div>
<div id="guestFields" style="{% if not settings.guest.enabled %}display:none;{% endif %}">
<div class="form-group">
<label class="form-label">{{ _('guest_link_label') }}</label>
<div style="display:flex; gap: var(--space-sm);">
<input type="text" class="form-input" id="guest_link_display" readonly
value="{% if settings.guest.token %}{{ request.url.scheme }}://{{ request.url.netloc }}/guest/{{ settings.guest.token }}{% endif %}"
placeholder="{{ _('guest_link_placeholder') }}">
<button type="button" class="btn btn-secondary btn-sm btn-icon" onclick="copyGuestLink()" title="{{ _('copy') }}">{{ icon('copy') }}</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="regenGuestToken()" title="{{ _('guest_regen_token') }}">🔄</button>
</div>
<input type="hidden" id="guest_token" value="{{ settings.guest.token or '' }}">
</div>
<div class="form-group">
<label class="form-label">{{ _('guest_user_label') }}</label>
<select class="form-select" id="guest_user_id">
<option value="">{{ _('guest_user_none') }}</option>
{% for u in users %}
<option value="{{ u.id }}" {% if settings.guest.user_id == u.id %}selected{% endif %}>
{{ u.username }} ({{ u.role }})
</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('guest_user_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('guest_password_label') }}</label>
<input type="password" class="form-input" id="guest_password"
placeholder="{% if settings.guest.password_hash %}{{ _('guest_password_keep') }}{% else %}{{ _('share_password_hint') }}{% endif %}"
autocomplete="new-password">
{% if settings.guest.password_hash %}
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;">
<input type="checkbox" id="guest_clear_password">
{{ _('guest_clear_password') }}
</label>
{% endif %}
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="guest_allow_create" {% if settings.guest.allow_create %}checked{% endif %}
onchange="document.getElementById('guestCreateFields').style.display = this.checked ? 'block' : 'none'">
{{ _('guest_allow_create') }}
</label>
<div class="form-hint">{{ _('guest_allow_create_hint') }}</div>
</div>
<div id="guestCreateFields"
style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);">
<div class="form-group">
<label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="guest_create_server" onchange="fillGuestProtocols()">
{% if xui_servers %}
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui</option>
{% endif %}
{% for s in servers %}
<option value="{{ loop.index0 }}"
{% if settings.guest.create_protocol != 'xui' and settings.guest.create_server_id == loop.index0 %}selected{% endif %}>
{{ s.name or s.host }} ({{ s.host }})
</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
<!-- filled by JS -->
</select>
<input type="hidden" id="guest_create_protocol_pref" value="{{ settings.guest.create_protocol or '' }}">
</div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}" {% if settings.guest.create_xui_panel_id == s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="guest_create_inbound_id">
<option value="0">{{ _('invite_inbound_loading') }}</option>
</select>
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
</div>
</div>
</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 class="tunnel-row" data-provider="nordvpn" 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>NordVPN</strong>
<span class="badge badge-secondary" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="nordvpnStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_status_unknown') }}</div>
<div id="nordvpnServerText" class="form-hint hidden" style="margin-bottom: var(--space-sm);"></div>
<div id="nordvpnHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_hint') }}</div>
<div class="grid" style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" 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: pointer;">
<input type="checkbox" name="xui_sync" {% if settings.sync.xui_sync %}checked{% endif %}>
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">{{ icon('trash') }} {{ _('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>
<div id="xuiFields"
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md);">
{{ _('xui_servers_manage_hint') }}
</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="xui_sync_users" {% if settings.sync.xui_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);">
{{ _('xui_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="syncXuiNow()" id="xuiSyncNowBtn">
<span id="xuiSyncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
<div class="spinner hidden" id="xuiSyncNowSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncXui()" id="xuiSyncDelBtn">
<span id="xuiSyncDelBtnText">{{ icon('trash') }} {{ _('delete_sync_btn') }}</span>
<div class="spinner hidden" id="xuiSyncDelSpinner" 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="xui_create_conns" id="xuiSyncCreateConns" {% if
settings.sync.xui_create_conns %}checked{% endif %}>
{{ _('auto_create_conns') }}
</label>
</div>
<div id="xuiAutoConnFields"
style="{% if not settings.sync.xui_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="xui_server_id" onchange="updateProtocolsForXuiSync()">
{% for s in servers %}
<option value="{{ loop.index0 }}" {% if settings.sync.xui_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="xui_protocol" id="xuiSyncProtocolSelect">
<option value="xray" {% if settings.sync.xui_protocol=='xray' %}selected{% endif %}>Xray</option>
<option value="awg" {% if settings.sync.xui_protocol=='awg' %}selected{% endif %}>AmneziaWG</option>
<option value="awg2" {% if settings.sync.xui_protocol=='awg2' %}selected{% endif %}>AmneziaWG 2.0</option>
</select>
</div>
</div>
<input type="hidden" name="xui_url" value="{{ settings.sync.xui_url or '' }}">
<input type="hidden" name="xui_sub_url" value="{{ settings.sync.xui_sub_url or '' }}">
<input type="hidden" name="xui_api_token" value="{{ settings.sync.xui_api_token or '' }}">
<input type="hidden" name="xui_username" value="{{ settings.sync.xui_username or '' }}">
<input type="hidden" name="xui_password" value="{{ settings.sync.xui_password or '' }}">
<input type="hidden" name="xui_inbound_id" value="{{ settings.sync.xui_inbound_id or 0 }}">
</div>
</form>
</div>
<!-- BLOCK: 3x-ui servers (multi) -->
<div class="card" style="margin-top: var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-lg);">
<h3 class="card-title" style="margin:0;">{{ _('xui_servers_title') }}</h3>
<button type="button" class="btn btn-primary btn-sm" onclick="openXuiServerModal()">+ {{ _('xui_server_add') }}</button>
</div>
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('xui_servers_hint') }}</div>
<div id="xuiServersList">
{% if xui_servers %}
{% for s in xui_servers %}
<div class="client-item" style="margin-bottom:var(--space-sm);" data-xui-id="{{ s.id }}">
<div class="client-info" style="flex:1; min-width:0;">
<div class="client-avatar">🌐</div>
<div style="min-width:0;">
<div class="client-name">{{ s.name }}</div>
<div class="client-meta" style="word-break:break-all;">
<span>{{ s.url }}</span>
{% if s.sub_url %}<span>· sub: {{ s.sub_url }}</span>{% endif %}
</div>
</div>
</div>
<div class="client-actions">
<button class="btn btn-secondary btn-sm" type="button" onclick='editXuiServer({{ s | tojson }})'>{{ _('edit') }}</button>
<button class="btn btn-danger btn-sm" type="button" onclick="deleteXuiServer('{{ s.id }}')">{{ _('delete') }}</button>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center; padding:var(--space-lg); color:var(--text-muted);">{{ _('xui_servers_empty') }}</div>
{% endif %}
</div>
</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()">
{{ icon('plus') }} {{ _('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); flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;">
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-primary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
<span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span>
<div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-secondary hidden" id="downloadUpdateBtn" style="flex:1; min-width:140px; text-decoration: none; justify-content: center;">
⬇️ {{ _('download_update') }}
</a>
</div>
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
</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>
</div>
<!-- ===== 3x-ui Server Modal ===== -->
<div class="modal-backdrop" id="xuiServerModal">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 class="modal-title" id="xuiServerModalTitle">{{ _('xui_server_add') }}</h2>
<button class="modal-close" onclick="closeModal('xuiServerModal')">×</button>
</div>
<input type="hidden" id="xuiServerEditId" value="">
<div class="form-group">
<label class="form-label">{{ _('xui_server_name_label') }}</label>
<input class="form-input" type="text" id="xuiServerName" placeholder="US / NL / Home">
<div class="form-hint">{{ _('xui_server_name_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_url_label') }}</label>
<input class="form-input" type="url" id="xuiServerUrl" placeholder="https://panel.example.com:2053/path">
<div class="form-hint">{{ _('xui_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
<input class="form-input" type="url" id="xuiServerSubUrl" placeholder="https://sub.example.com:2096/sub">
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_api_token_label') }}</label>
<input class="form-input" type="password" id="xuiServerToken" placeholder="{{ _('xui_api_token_placeholder') }}" autocomplete="off">
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('xui_username_label') }}</label>
<input class="form-input" type="text" id="xuiServerUser" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_password_label') }}</label>
<input class="form-input" type="password" id="xuiServerPass" autocomplete="off">
</div>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="xuiServerEnabled" checked> {{ _('enabled') if False else 'Enabled' }}
</label>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="xuiServerSyncUsers"> {{ _('enable_sync') }}
</label>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('xuiServerModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="saveXuiServer()" id="xuiServerSaveBtn">
<span id="xuiServerSaveText">{{ _('save') }}</span>
<div class="spinner hidden" id="xuiServerSaveSpinner" style="width:14px;height:14px;"></div>
</button>
</div>
</div>
</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()">{{ icon('copy') }} {{ _('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.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
});
function updateProtocolsForXuiSync() {
const serverIdx = document.querySelector('[name="xui_server_id"]').value;
const protoSelect = document.getElementById('xuiSyncProtocolSelect');
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.xui_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);
}
}
function openXuiServerModal(server) {
document.getElementById('xuiServerEditId').value = server?.id || '';
document.getElementById('xuiServerModalTitle').textContent = server?.id ? (_('edit') + ' — 3x-ui') : _('xui_server_add');
document.getElementById('xuiServerName').value = server?.name || '';
document.getElementById('xuiServerUrl').value = server?.url || '';
document.getElementById('xuiServerSubUrl').value = server?.sub_url || '';
document.getElementById('xuiServerToken').value = '';
document.getElementById('xuiServerUser').value = server?.username || '';
document.getElementById('xuiServerPass').value = '';
document.getElementById('xuiServerEnabled').checked = server ? !!server.enabled : true;
document.getElementById('xuiServerSyncUsers').checked = !!(server && server.sync_users);
openModal('xuiServerModal');
}
function editXuiServer(server) { openXuiServerModal(server); }
async function saveXuiServer() {
const id = document.getElementById('xuiServerEditId').value;
const body = {
name: document.getElementById('xuiServerName').value.trim(),
url: document.getElementById('xuiServerUrl').value.trim(),
sub_url: document.getElementById('xuiServerSubUrl').value.trim(),
api_token: document.getElementById('xuiServerToken').value,
username: document.getElementById('xuiServerUser').value.trim(),
password: document.getElementById('xuiServerPass').value,
enabled: document.getElementById('xuiServerEnabled').checked,
sync_users: document.getElementById('xuiServerSyncUsers').checked,
default_inbound_id: 0,
};
if (!body.url) { showToast(_('error') + ': URL', 'error'); return; }
const btn = document.getElementById('xuiServerSaveBtn');
const text = document.getElementById('xuiServerSaveText');
const spinner = document.getElementById('xuiServerSaveSpinner');
btn.disabled = true; text.textContent = _('saving') || 'Saving...'; spinner.classList.remove('hidden');
try {
if (id) await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'PUT', body);
else await apiCall('/api/settings/xui/servers', 'POST', body);
showToast(_('saved') || 'OK', 'success');
closeModal('xuiServerModal');
setTimeout(() => location.reload(), 400);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false; text.textContent = _('save'); spinner.classList.add('hidden');
}
}
async function deleteXuiServer(id) {
if (!confirm(_('xui_server_delete_confirm') || 'Delete this 3x-ui server?')) return;
try {
await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'DELETE');
showToast(_('deleted') || 'OK', 'success');
setTimeout(() => location.reload(), 400);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function loadGuestInbounds() {
const select = document.getElementById('guest_create_inbound_id');
if (!select) return;
const preferred = {{ settings.guest.create_inbound_id | default(0) | int }};
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
try {
const panelSel = document.getElementById('guest_create_xui_panel');
const panelId = panelSel ? panelSel.value : '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const res = await fetch('/api/settings/xui/inbounds' + q);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
if (preferred && String(ib.id) === String(preferred)) opt.selected = true;
else if (!preferred && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (err) {
console.warn('loadGuestInbounds:', err);
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
}
}
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForSync();
});
document.getElementById('xuiSyncCreateConns').addEventListener('change', (e) => {
document.getElementById('xuiAutoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForXuiSync();
});
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 || {});
updateNordvpnStatus(data.nordvpn || {});
} 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');
}
}
function updateNordvpnStatus(status) {
const badge = document.getElementById('nordvpnInstallBadge');
const statusText = document.getElementById('nordvpnStatusText');
const serverText = document.getElementById('nordvpnServerText');
const hintText = document.getElementById('nordvpnHintText');
const connectBtn = document.getElementById('nordvpnConnectBtn');
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
const connectText = document.getElementById('nordvpnConnectBtnText');
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 ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed'));
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
if (serverText) {
const serverLine = status.server || '';
serverText.textContent = serverLine;
serverText.classList.toggle('hidden', !serverLine);
}
hintText.textContent = status.last_error || (installed ? _('nordvpn_hint') : (status.install_hint || _('nordvpn_install_hint')));
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
}
async function connectNordvpn() {
const btn = document.getElementById('nordvpnConnectBtn');
const spinner = document.getElementById('nordvpnConnectSpinner');
const text = document.getElementById('nordvpnConnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const country = (document.getElementById('nordvpnCountry') || {}).value || '';
const city = (document.getElementById('nordvpnCity') || {}).value || '';
const status = await apiCall('/api/settings/nordvpn/connect', 'POST', {
country: country.trim(),
city: city.trim(),
});
updateNordvpnStatus(status);
showToast(_('nordvpn_connected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
spinner.classList.add('hidden');
}
}
async function disconnectNordvpn() {
const btn = document.getElementById('nordvpnDisconnectBtn');
const spinner = document.getElementById('nordvpnDisconnectSpinner');
const text = document.getElementById('nordvpnDisconnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/nordvpn/disconnect', 'POST');
updateNordvpnStatus(status);
showToast(_('nordvpn_disconnected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('nordvpn_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.innerHTML = `${uiIcon('trash')} ${_('delete_sync_btn')}`;
spinner.classList.add('hidden');
}
}
async function syncXuiNow() {
const btn = document.getElementById('xuiSyncNowBtn');
const text = document.getElementById('xuiSyncNowBtnText');
const spinner = document.getElementById('xuiSyncNowSpinner');
const syncForm = document.getElementById('syncForm');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const payload = {
xui_sync: true,
xui_sync_users: true,
xui_url: syncForm.xui_url.value.trim(),
xui_username: syncForm.xui_username.value.trim(),
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value.trim(),
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
};
const res = await fetch('/api/settings/xui_sync_now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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 || data.error || 'Error occurred');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🔄 ${_('sync_now_btn')}`;
spinner.classList.add('hidden');
}
}
async function deleteSyncXui() {
if (!confirm(_('delete_sync_confirm'))) return;
const btn = document.getElementById('xuiSyncDelBtn');
const text = document.getElementById('xuiSyncDelBtnText');
const spinner = document.getElementById('xuiSyncDelSpinner');
btn.disabled = true;
text.textContent = _('deleting');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/xui_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.innerHTML = `${uiIcon('trash')} ${_('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,
xui_sync: syncForm.xui_sync.checked,
xui_url: syncForm.xui_url.value,
xui_username: syncForm.xui_username.value,
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value,
xui_sync_users: syncForm.xui_sync_users.checked,
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
};
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')
};
let createProtocol = 'xui';
let createServerId = 0;
const guestServerVal = document.getElementById('guest_create_server')?.value || 'xui';
const guestProtoVal = document.getElementById('guest_create_protocol')?.value || '';
if (guestServerVal === 'xui' || guestProtoVal === 'xui') {
createProtocol = 'xui';
createServerId = 0;
} else {
createProtocol = guestProtoVal;
createServerId = parseInt(guestServerVal || '0', 10) || 0;
}
const guest = {
enabled: document.getElementById('guest_enabled').checked,
token: document.getElementById('guest_token').value || '',
password: document.getElementById('guest_password').value || '',
clear_password: !!(document.getElementById('guest_clear_password') && document.getElementById('guest_clear_password').checked),
user_id: document.getElementById('guest_user_id').value || '',
allow_create: document.getElementById('guest_allow_create').checked,
create_protocol: createProtocol,
create_server_id: createServerId,
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
};
try {
const res = await fetch('/api/settings/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest })
});
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');
}
}
const guestServersData = {{ servers | default([]) | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
const raw = String(key || '');
const base = raw.split('__')[0];
const title = PROTO_TITLES[base] || base.toUpperCase();
const m = raw.match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
}
function fillGuestProtocols() {
const serverSel = document.getElementById('guest_create_server');
const protoSel = document.getElementById('guest_create_protocol');
if (!serverSel || !protoSel) return;
const prefer = document.getElementById('guest_create_protocol_pref')?.value || protoSel.value || '';
const serverVal = serverSel.value;
protoSel.innerHTML = '';
if (serverVal === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = PROTO_TITLES.xui;
protoSel.appendChild(opt);
updateGuestCreateFields();
return;
}
const server = guestServersData[parseInt(serverVal, 10)];
const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(k => {
const info = protocols[k] || {};
return !!info.installed && VPN_PROTO_ORDER.includes(k.split('__')[0]);
}).sort((a, b) => {
const ia = VPN_PROTO_ORDER.indexOf(a.split('__')[0]);
const ib = VPN_PROTO_ORDER.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
if (!installed.length) {
const opt = document.createElement('option');
opt.value = '';
opt.disabled = true;
opt.textContent = _('no_protocols');
protoSel.appendChild(opt);
} else {
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = protoTitle(key);
if (prefer && prefer === key) opt.selected = true;
protoSel.appendChild(opt);
});
}
updateGuestCreateFields();
}
function updateGuestCreateFields() {
const v = document.getElementById('guest_create_protocol')?.value;
const group = document.getElementById('guestInboundGroup');
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
if (v === 'xui') loadGuestInbounds();
}
fillGuestProtocols();
if (document.getElementById('guest_create_protocol')?.value === 'xui') {
loadGuestInbounds();
}
function copyGuestLink() {
const el = document.getElementById('guest_link_display');
if (el && el.value) copyToClipboard(el.value);
}
async function regenGuestToken() {
if (!confirm(_('guest_regen_confirm'))) return;
try {
const res = await fetch('/api/settings/guest/regenerate_token', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || _('error'));
document.getElementById('guest_token').value = data.token;
const link = `${window.location.origin}/guest/${data.token}`;
document.getElementById('guest_link_display').value = link;
showToast(_('guest_token_updated'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
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();
}
let latestUpdateInfo = null;
async function checkForUpdates(silent) {
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');
const applyBtn = document.getElementById('applyUpdateBtn');
const modeHint = document.getElementById('updateModeHint');
if (!silent) {
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
}
statusDiv.classList.add('hidden');
downloadBtn.classList.add('hidden');
applyBtn.classList.add('hidden');
try {
const data = await apiCall('/api/settings/check_updates');
latestUpdateInfo = data;
const latestVersion = data.latest_version || '';
statusDiv.classList.remove('hidden');
if (modeHint) {
const mode = data.update_mode || (data.mode && data.mode.mode) || '';
if (data.can_auto_update) {
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
} else if (mode === 'docker') {
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker install: rebuild/pull image, or use a git checkout for one-click update.') }}";
} else {
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a git clone. You can still download a release.') }}";
}
}
if (data.update_available && 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 || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
downloadBtn.classList.remove('hidden');
if (data.can_auto_update) {
applyBtn.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) {
if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
} finally {
if (!silent) {
btn.disabled = false;
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
spinner.classList.add('hidden');
}
}
}
async function applyPanelUpdate() {
const applyBtn = document.getElementById('applyUpdateBtn');
const applyText = document.getElementById('applyUpdateBtnText');
const applySpinner = document.getElementById('applyUpdateSpinner');
const target = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
if (!target) {
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
return;
}
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git and restart the panel?') }} " + target)) {
return;
}
applyBtn.disabled = true;
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
applySpinner.classList.remove('hidden');
try {
const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target,
allow_dirty: false,
});
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
applyBtn.disabled = false;
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
applySpinner.classList.add('hidden');
}
}
// Auto-check once on Settings load
setTimeout(() => checkForUpdates(true), 800);
/* ========== 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 %}