1447 lines
76 KiB
HTML
1447 lines
76 KiB
HTML
{% 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 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" onclick="copyGuestLink()">📋</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">{{ _('protocol_label') }}</label>
|
||
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
|
||
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
|
||
{% for s in servers %}
|
||
{% set server_idx = loop.index0 %}
|
||
{% for key, info in (s.protocols or {}).items() %}
|
||
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt','hysteria'] %}
|
||
<option value="{{ key }}|{{ server_idx }}"
|
||
{% if settings.guest.create_protocol == key and settings.guest.create_server_id == server_idx %}selected{% endif %}>
|
||
{{ s.name or s.host }} — {{ key }}
|
||
</option>
|
||
{% endif %}
|
||
{% endfor %}
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
|
||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||
<select class="form-select" id="guest_create_inbound_id">
|
||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||
</select>
|
||
</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>
|
||
<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">🗑 {{ _('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-group">
|
||
<label class="form-label">{{ _('xui_url_label') }}</label>
|
||
<input type="url" class="form-input" name="xui_url"
|
||
value="{{ settings.sync.xui_url }}" 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 type="url" class="form-input" name="xui_sub_url"
|
||
value="{{ settings.sync.xui_sub_url or '' }}" 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 type="password" class="form-input" name="xui_api_token"
|
||
value="{{ settings.sync.xui_api_token }}" placeholder="{{ _('xui_api_token_placeholder') }}">
|
||
<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 type="text" class="form-input" name="xui_username"
|
||
value="{{ settings.sync.xui_username }}" autocomplete="off">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('xui_password_label') }}</label>
|
||
<input type="password" class="form-input" name="xui_password"
|
||
value="{{ settings.sync.xui_password }}" autocomplete="off">
|
||
</div>
|
||
</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">🗑 {{ _('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 class="form-group">
|
||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||
<select class="form-select" name="xui_inbound_id" id="xuiInboundSelect">
|
||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||
</select>
|
||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||
</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>
|
||
</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 <your_token>" {{ 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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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';
|
||
if (e.target.checked) loadXuiInbounds();
|
||
});
|
||
|
||
const xuiDefaultInboundId = {{ settings.sync.xui_inbound_id | default(0) | int }};
|
||
|
||
async function loadXuiInbounds() {
|
||
const select = document.getElementById('xuiInboundSelect');
|
||
if (!select) return;
|
||
const prev = select.value || String(xuiDefaultInboundId || '0');
|
||
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||
try {
|
||
const res = await fetch('/api/settings/xui/inbounds');
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
|
||
(data.inbounds || []).forEach(ib => {
|
||
const opt = document.createElement('option');
|
||
opt.value = ib.id;
|
||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||
select.appendChild(opt);
|
||
});
|
||
if ([...select.options].some(o => o.value === String(prev))) {
|
||
select.value = String(prev);
|
||
}
|
||
} catch (err) {
|
||
// Keep auto option; credentials may not be saved yet
|
||
console.warn('loadXuiInbounds:', err);
|
||
}
|
||
}
|
||
|
||
if (document.querySelector('[name="xui_sync"]').checked) {
|
||
loadXuiInbounds();
|
||
}
|
||
|
||
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 || {});
|
||
} 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 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.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,
|
||
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 guestProtoRaw = document.getElementById('guest_create_protocol').value;
|
||
if (guestProtoRaw === 'xui') {
|
||
createProtocol = 'xui';
|
||
} else if (guestProtoRaw.includes('|')) {
|
||
const parts = guestProtoRaw.split('|');
|
||
createProtocol = parts[0];
|
||
createServerId = parseInt(parts[1] || '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')
|
||
};
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
function updateGuestCreateFields() {
|
||
const v = document.getElementById('guest_create_protocol')?.value;
|
||
const group = document.getElementById('guestInboundGroup');
|
||
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
|
||
}
|
||
|
||
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 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="0">${_('xui_inbound_auto')}</option>`;
|
||
try {
|
||
const res = await fetch('/api/settings/xui/inbounds');
|
||
const data = await res.json();
|
||
if (!res.ok) return;
|
||
(data.inbounds || []).forEach(ib => {
|
||
const opt = document.createElement('option');
|
||
opt.value = ib.id;
|
||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||
if (String(ib.id) === String(preferred)) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
if (document.getElementById('guest_allow_create')?.checked) {
|
||
loadGuestInbounds();
|
||
}
|
||
document.getElementById('guest_allow_create')?.addEventListener('change', (e) => {
|
||
if (e.target.checked) loadGuestInbounds();
|
||
});
|
||
|
||
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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[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 %}
|