Template
1273 lines
64 KiB
HTML
1273 lines
64 KiB
HTML
{% extends "base.html" %}
|
||
{% from "macros/icons.html" import icon %}
|
||
|
||
{% block title_extra %} — {{ _('users_title') }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<section>
|
||
<div class="flex items-center justify-between"
|
||
style="margin-bottom: var(--space-lg); gap: var(--space-md); flex-wrap: wrap;">
|
||
<h1 class="section-title" style="margin-bottom:0; flex-shrink: 0;">
|
||
<span class="icon">{{ icon('users') }}</span>
|
||
{{ _('users_title') }}
|
||
</h1>
|
||
|
||
<div style="flex: 1; min-width: 250px; max-width: 500px; position: relative;">
|
||
<input type="text" class="form-input" id="userSearch" placeholder="{{ _('search_placeholder') }}"
|
||
oninput="handleSearch(this.value)" style="padding-left: 40px;">
|
||
<span style="position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--text-muted); display:flex;">{{ icon('search') }}</span>
|
||
</div>
|
||
|
||
{% if current_user.role == 'admin' %}
|
||
<button class="btn btn-primary" onclick="openModal('addUserModal')" style="flex-shrink: 0;">
|
||
{{ icon('plus') }} {{ _('add_user') }}
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div id="usersContainer" class="hidden">
|
||
<div class="clients-list" id="usersGrid"
|
||
style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
|
||
<!-- Users will be loaded here via JS -->
|
||
</div>
|
||
|
||
<div id="pagination"
|
||
style="display: flex; justify-content: center; align-items: center; gap: var(--space-md); margin-top: var(--space-xl);">
|
||
<button class="btn btn-secondary btn-sm" id="prevPage" onclick="changePage(-1)">{{ _('prev_page')
|
||
}}</button>
|
||
<span id="pageInfo" style="font-weight: 600; font-size: 0.9rem;"></span>
|
||
<button class="btn btn-secondary btn-sm" id="nextPage" onclick="changePage(1)">{{ _('next_page') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="usersLoading" style="text-align: center; padding: 4rem;">
|
||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto;"></div>
|
||
<p style="margin-top: 1rem; color: var(--text-muted);">{{ _('loading_users') }}</p>
|
||
</div>
|
||
|
||
<div id="usersEmpty" class="empty-state hidden">
|
||
<div class="empty-icon">{{ icon('user') }}</div>
|
||
<div class="empty-title">{{ _('nothing_found') }}</div>
|
||
<div class="empty-desc">{{ _('search_empty_desc') }}</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== Add User Modal ===== -->
|
||
<div class="modal-backdrop" id="addUserModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('add_user') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('addUserModal')">×</button>
|
||
</div>
|
||
|
||
<form id="addUserForm" onsubmit="return addUser(event)">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('username_label') }}</label>
|
||
<input class="form-input" type="text" id="newUsername" placeholder="username" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('password_label') }}</label>
|
||
<input class="form-input" type="password" id="newPassword" placeholder="••••••••" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('role_label') }}</label>
|
||
<select class="form-select" id="newRole">
|
||
<option value="user">{{ _('role_user_desc') }}</option>
|
||
<option value="support">{{ _('role_support_desc') }}</option>
|
||
<option value="admin">{{ _('role_admin_desc') }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('tg_id_label') }}</label>
|
||
<input class="form-input" type="text" id="newTelegramId" placeholder="@username or ID">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('email_label') }}</label>
|
||
<input class="form-input" type="email" id="newEmail" placeholder="user@example.com">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('traffic_limit_label') }}</label>
|
||
<input class="form-input" type="number" step="0.01" min="0" id="newTrafficLimit" placeholder="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('traffic_reset_strategy_label') }}</label>
|
||
<select class="form-select" id="newTrafficResetStrategy">
|
||
<option value="never">{{ _('traffic_reset_never') }}</option>
|
||
<option value="daily">{{ _('traffic_reset_daily') }}</option>
|
||
<option value="weekly">{{ _('traffic_reset_weekly') }}</option>
|
||
<option value="monthly">{{ _('traffic_reset_monthly') }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('expiration_date_label') }}</label>
|
||
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom:var(--space-sm); cursor:pointer;">
|
||
<input type="checkbox" id="newExpireAfterFirstUse" onchange="toggleExpireAfterFirstUse('new')">
|
||
{{ _('expire_after_first_use') }}
|
||
</label>
|
||
<div id="newExpireAbsoluteWrap" class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
|
||
<input class="form-input" type="date" id="newExpirationDate" aria-label="Expiration date">
|
||
<select class="form-select" id="newExpirationTime" aria-label="Expiration time"></select>
|
||
<button type="button" class="btn btn-secondary btn-sm" onclick="clearDateTimeFields('newExpirationDate', 'newExpirationTime')">{{ _('no_expiration') }}</button>
|
||
</div>
|
||
<div id="newExpireDaysWrap" style="display:none;">
|
||
<div style="display:flex; gap:var(--space-sm); align-items:center;">
|
||
<input class="form-input" type="number" id="newExpirationDays" min="1" value="30" style="max-width:120px;">
|
||
<span style="color:var(--text-muted);">{{ _('days_short') }}</span>
|
||
</div>
|
||
<div class="form-hint">{{ _('expire_after_first_use_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('description_label') }}</label>
|
||
<textarea class="form-input" id="newDescription" rows="2"
|
||
placeholder="{{ _('description_label') }}"></textarea>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<div style="color:var(--text-muted); font-size:0.85rem; margin-bottom:var(--space-md);">
|
||
{{ _('auto_conn_title') }}
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('server_label') }}</label>
|
||
<select class="form-select" id="newUserServer">
|
||
<option value="">{{ _('no_create_conn') }}</option>
|
||
{% for s in servers %}
|
||
<option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="newUserProtoGroup" style="display:none;">
|
||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||
<select class="form-select" id="newUserProtocol">
|
||
<!-- Dynamic -->
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="newUserConnNameGroup" style="display:none;">
|
||
<label class="form-label">{{ _('connection_name') }}</label>
|
||
<input class="form-input" type="text" id="newUserConnName"
|
||
placeholder="{{ _('connection_name_placeholder') }}">
|
||
</div>
|
||
|
||
<div id="newUserTelemtOptions" style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">Secret (Hex 32 chars)</label>
|
||
<input class="form-input" type="text" id="newUserTelemtSecret" placeholder="Optional">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">AD Tag (Hex 32 chars)</label>
|
||
<input class="form-input" type="text" id="newUserTelemtAdTag" placeholder="Optional">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max TCP Conns</label>
|
||
<input class="form-input" type="number" id="newUserTelemtMaxConns" placeholder="Optional" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Quota (Bytes)</label>
|
||
<input class="form-input" type="number" id="newUserTelemtQuota" placeholder="Optional" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max Unique IPs</label>
|
||
<input class="form-input" type="number" id="newUserTelemtMaxIps" placeholder="Optional" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('expiration_date_label') }}</label>
|
||
<input class="form-input" type="datetime-local" id="newUserTelemtExpiry">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" onclick="closeModal('addUserModal')">{{ _('cancel')
|
||
}}</button>
|
||
<button type="submit" class="btn btn-primary" id="addUserBtn">
|
||
<span id="addUserBtnText">{{ _('create') }}</span>
|
||
<div class="spinner hidden" id="addUserSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Edit User Modal ===== -->
|
||
<div class="modal-backdrop" id="editUserModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('edit_user_title').replace('{}', '<span id="editUserTitle"></span>') | safe }}
|
||
</h2>
|
||
<button class="modal-close" onclick="closeModal('editUserModal')">×</button>
|
||
</div>
|
||
<form id="editUserForm" onsubmit="event.preventDefault(); saveEditUser();">
|
||
<input type="hidden" id="editUserId">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('edit_user_tg') }}</label>
|
||
<input type="text" class="form-input" id="editUserTG" placeholder="@username or ID">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('edit_user_email') }}</label>
|
||
<input type="email" class="form-input" id="editUserEmail" placeholder="user@example.com">
|
||
</div>
|
||
<div class="grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('edit_user_limit') }}</label>
|
||
<input type="number" step="0.01" min="0" class="form-input" id="editUserLimit" placeholder="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('traffic_reset_strategy_label') }}</label>
|
||
<select class="form-select" id="editUserTrafficResetStrategy">
|
||
<option value="never">{{ _('traffic_reset_never') }}</option>
|
||
<option value="daily">{{ _('traffic_reset_daily') }}</option>
|
||
<option value="weekly">{{ _('traffic_reset_weekly') }}</option>
|
||
<option value="monthly">{{ _('traffic_reset_monthly') }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('expiration_date_label') }}</label>
|
||
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom:var(--space-sm); cursor:pointer;">
|
||
<input type="checkbox" id="editExpireAfterFirstUse" onchange="toggleExpireAfterFirstUse('edit')">
|
||
{{ _('expire_after_first_use') }}
|
||
</label>
|
||
<div id="editExpireAbsoluteWrap" class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
|
||
<input class="form-input" type="date" id="editUserExpiration" aria-label="Expiration date">
|
||
<select class="form-select" id="editUserExpirationTime" aria-label="Expiration time"></select>
|
||
<button type="button" class="btn btn-secondary btn-sm" onclick="clearDateTimeFields('editUserExpiration', 'editUserExpirationTime')">{{ _('no_expiration') }}</button>
|
||
</div>
|
||
<div id="editExpireDaysWrap" style="display:none;">
|
||
<div style="display:flex; gap:var(--space-sm); align-items:center;">
|
||
<input class="form-input" type="number" id="editExpirationDays" min="1" value="30" style="max-width:120px;">
|
||
<span style="color:var(--text-muted);">{{ _('days_short') }}</span>
|
||
</div>
|
||
<div class="form-hint" id="editExpireDaysHint">{{ _('expire_after_first_use_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('description_label') }}</label>
|
||
<textarea class="form-input" id="editUserDesc" rows="2"
|
||
placeholder="{{ _('description_label') }}"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('new_password_hint') }}</label>
|
||
<input type="password" class="form-input" id="editUserPass" placeholder="••••••••">
|
||
</div>
|
||
<div class="flex justify-end" style="margin-top: var(--space-lg);">
|
||
<button type="button" class="btn btn-secondary" onclick="closeModal('editUserModal')"
|
||
style="margin-right: var(--space-sm);">{{ _('cancel') }}</button>
|
||
<button type="submit" class="btn btn-primary" id="saveEditBtn">
|
||
<div id="saveEditSpinner" class="spinner hidden"
|
||
style="width: 14px; height: 14px; border-width: 2px;"></div>
|
||
<span id="saveEditBtnText">{{ _('save') }}</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Add Connection for User Modal ===== -->
|
||
<div class="modal-backdrop" id="addUserConnModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="addUserConnTitle">{{ _('add_connection') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('addUserConnModal')">×</button>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('op_type_label') }}</label>
|
||
<select class="form-select" id="ucMode" onchange="toggleUCMode()">
|
||
<option value="new">{{ _('create_new_conn') }}</option>
|
||
<option value="existing">{{ _('link_existing_conn') }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="ucServerGroup">
|
||
<label class="form-label">{{ _('server_label') }}</label>
|
||
<select class="form-select" id="ucServer">
|
||
{% if xui_servers %}
|
||
<option value="xui">3x-ui</option>
|
||
{% endif %}
|
||
{% for s in servers %}
|
||
<option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="ucProtoGroup">
|
||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||
<select class="form-select" id="ucProtocol">
|
||
<!-- Dynamic -->
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
|
||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||
<select class="form-select" id="ucXuiPanel" onchange="loadXuiInbounds()">
|
||
{% for s in xui_servers %}
|
||
<option value="{{ s.id }}" {% if s.id == xui_default_panel_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="ucXuiInbound">
|
||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
||
</select>
|
||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="ucExistingClientGroup" style="display:none;">
|
||
<label class="form-label">{{ _('select_existing_conn') }}</label>
|
||
<select class="form-select" id="ucExistingClient">
|
||
<option value="">{{ _('loading') }}</option>
|
||
</select>
|
||
<div class="form-hint">{{ _('existing_conn_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="ucNameGroup">
|
||
<label class="form-label">{{ _('conn_name_panel') }}</label>
|
||
<input class="form-input" type="text" id="ucName" placeholder="{{ _('connection_name_placeholder') }}">
|
||
</div>
|
||
|
||
<div id="ucTelemtOptions" style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">Secret (Hex 32 chars)</label>
|
||
<input class="form-input" type="text" id="ucTelemtSecret" placeholder="Optional">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">AD Tag (Hex 32 chars)</label>
|
||
<input class="form-input" type="text" id="ucTelemtAdTag" placeholder="Optional">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max TCP Conns</label>
|
||
<input class="form-input" type="number" id="ucTelemtMaxConns" placeholder="Optional" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Quota (Bytes)</label>
|
||
<input class="form-input" type="number" id="ucTelemtQuota" placeholder="Optional" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max Unique IPs</label>
|
||
<input class="form-input" type="number" id="ucTelemtMaxIps" placeholder="Optional" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Expiration Date (ISO 8601)</label>
|
||
<input class="form-input" type="text" id="ucTelemtExpiry" placeholder="e.g. 2026-12-31T23:59:59Z">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('addUserConnModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="doAddUserConnection()" id="ucAddBtn">
|
||
<span id="ucAddBtnText">{{ _('add') }}</span>
|
||
<div class="spinner hidden" id="ucAddSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== User Connections Modal ===== -->
|
||
<div class="modal-backdrop" id="userConnsModal">
|
||
<div class="modal" style="max-width:600px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="userConnsTitle">{{ _('user_conns_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('userConnsModal')">×</button>
|
||
</div>
|
||
<div id="userConnsList" style="max-height:400px; overflow-y:auto;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Config Modal ===== -->
|
||
<div class="modal-backdrop" id="configModal">
|
||
<div class="modal" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
||
</div>
|
||
<div class="config-tabs">
|
||
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
|
||
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
||
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
||
</div>
|
||
<div class="config-panel active" id="panel-conf">
|
||
<div class="config-display">
|
||
<div class="config-text" id="configText"></div>
|
||
<div class="config-actions">
|
||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">{{
|
||
_('copy_config') }}</button>
|
||
<button class="btn btn-primary btn-sm"
|
||
onclick="downloadFile(currentConfig, currentConfigName+'.conf')" style="flex:1">{{
|
||
_('download_conf') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="config-panel" id="panel-vpn">
|
||
<div class="vpn-link-box" id="vpnLinkText"></div>
|
||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentVpnLink)" style="flex:1">{{
|
||
_('copy_key') }}</button>
|
||
</div>
|
||
</div>
|
||
<div class="config-panel" id="panel-qr">
|
||
<div class="qr-container">
|
||
<div id="qrCode"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
let currentUserId = '';
|
||
let currentConfig = '';
|
||
let currentVpnLink = '';
|
||
let currentConfigName = 'connection';
|
||
|
||
let currentPage = 1;
|
||
let pageSize = 10;
|
||
let searchQuery = '';
|
||
let searchTimeout = null;
|
||
|
||
const serversData = {{ servers | tojson }};
|
||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
|
||
const xuiServers = {{ xui_servers | default([]) | tojson }};
|
||
|
||
function populateTimeSelect(selectId) {
|
||
const select = document.getElementById(selectId);
|
||
if (!select || select.options.length) return;
|
||
for (let hour = 0; hour < 24; hour++) {
|
||
for (const minute of [0, 15, 30, 45]) {
|
||
const value = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
select.add(new Option(value, value));
|
||
}
|
||
}
|
||
select.add(new Option('23:59', '23:59'));
|
||
select.value = '23:59';
|
||
}
|
||
|
||
function setupDateTimeFields() {
|
||
populateTimeSelect('newExpirationTime');
|
||
populateTimeSelect('editUserExpirationTime');
|
||
}
|
||
|
||
function clearDateTimeFields(dateId, timeId) {
|
||
const dateEl = document.getElementById(dateId);
|
||
const timeEl = document.getElementById(timeId);
|
||
if (dateEl) dateEl.value = '';
|
||
if (timeEl) timeEl.value = '23:59';
|
||
}
|
||
|
||
function getDateTimeValue(dateId, timeId) {
|
||
const date = document.getElementById(dateId).value;
|
||
if (!date) return null;
|
||
const time = document.getElementById(timeId).value || '23:59';
|
||
return `${date}T${time}`;
|
||
}
|
||
|
||
function setDateTimeValue(dateId, timeId, value) {
|
||
const dateEl = document.getElementById(dateId);
|
||
const timeEl = document.getElementById(timeId);
|
||
const localValue = toDateTimeLocalValue(value);
|
||
if (!localValue) {
|
||
clearDateTimeFields(dateId, timeId);
|
||
return;
|
||
}
|
||
dateEl.value = localValue.slice(0, 10);
|
||
const timeValue = localValue.slice(11, 16);
|
||
if (![...timeEl.options].some(option => option.value === timeValue)) {
|
||
timeEl.add(new Option(timeValue, timeValue));
|
||
}
|
||
timeEl.value = timeValue;
|
||
}
|
||
|
||
function handleSearch(val) {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
searchQuery = val;
|
||
currentPage = 1;
|
||
refreshUsersList();
|
||
}, 300);
|
||
}
|
||
|
||
function changePage(delta) {
|
||
currentPage += delta;
|
||
refreshUsersList();
|
||
}
|
||
|
||
async function refreshUsersList() {
|
||
const loading = document.getElementById('usersLoading');
|
||
const container = document.getElementById('usersContainer');
|
||
const empty = document.getElementById('usersEmpty');
|
||
const grid = document.getElementById('usersGrid');
|
||
|
||
loading.classList.remove('hidden');
|
||
container.classList.add('hidden');
|
||
empty.classList.add('hidden');
|
||
|
||
try {
|
||
const data = await apiCall(`/api/users?search=${encodeURIComponent(searchQuery)}&page=${currentPage}&size=${pageSize}`);
|
||
|
||
if (data.users.length === 0) {
|
||
loading.classList.add('hidden');
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = data.users.map(u => `
|
||
<div class="client-item" id="user-${u.id}" style="${u.enabled ? '' : 'opacity:0.5;'}">
|
||
<div class="client-info">
|
||
<div class="client-avatar">${u.username[0].toUpperCase()}</div>
|
||
<div>
|
||
<div class="client-name">
|
||
${u.username}
|
||
<span class="badge badge-${u.role === 'admin' ? 'success' : (u.role === 'support' ? 'info' : 'warn')}"
|
||
style="font-size:0.65rem; margin-left: var(--space-xs);">
|
||
${u.role.toUpperCase()}
|
||
</span>
|
||
<span class="badge badge-secondary" style="font-size:0.65rem; opacity: 0.7;">
|
||
${u.source}
|
||
</span>
|
||
${u.enabled ? '' : `<span class="badge badge-danger" style="font-size:0.65rem;">${_('disabled')}</span>`}
|
||
</div>
|
||
<div class="client-meta">
|
||
<span style="cursor:pointer; color:var(--primary); text-decoration: underline; display:inline-flex; align-items:center; gap:4px;" onclick="viewUserConnections('${u.id}', '${u.username.replace(/'/g, "\\'")}')">${uiIcon('link')} ${u.connections_count} ${_('connections')}</span>
|
||
${u.created_at ? `<span>📅 ${u.created_at.substring(0, 10)}</span>` : ''}
|
||
${u.telegramId ? `<span>💬 ${u.telegramId}</span>` : ''}
|
||
${u.email ? `<span>📧 ${u.email}</span>` : ''}
|
||
${formatExpirationBadge(u)}
|
||
</div>
|
||
${u.description ? `<div class="client-meta" style="font-size: 0.75rem; opacity: 0.8; margin-top: 2px;">📝 ${u.description}</div>` : ''}
|
||
${(() => {
|
||
let limitHtml = '';
|
||
if (u.traffic_limit && u.traffic_limit > 0) {
|
||
let pct = (u.traffic_used / u.traffic_limit) * 100;
|
||
if (pct > 100) pct = 100;
|
||
let colorClass = pct > 90 ? '#ef4444' : (pct > 70 ? '#f59e0b' : '#3b82f6');
|
||
limitHtml = `
|
||
<div style="margin-top: 8px; font-size: 0.75rem;">
|
||
<div style="display:flex; justify-content:space-between; color:var(--text-muted); margin-bottom: 2px;">
|
||
<span>${_('traffic')}: ${(u.traffic_used / Math.pow(1024, 3)).toFixed(2)} ${_('gb')}</span>
|
||
<span>${_('out_of')} ${(u.traffic_limit / Math.pow(1024, 3)).toFixed(2)} ${_('gb')}</span>
|
||
</div>
|
||
<div style="width: 100%; height: 4px; background: var(--bg-tertiary); border-radius: 2px; overflow: hidden;">
|
||
<div style="background-color: ${colorClass}; height: 100%; width: ${pct}%; transition: width 0.3s ease;"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (u.traffic_used > 0) {
|
||
limitHtml = `
|
||
<div style="margin-top: 8px; font-size: 0.75rem; color:var(--text-muted);">
|
||
<span>${_('traffic')}: ${(u.traffic_used / Math.pow(1024, 3)).toFixed(2)} ${_('gb')}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
return limitHtml;
|
||
})()}
|
||
${(() => {
|
||
if (u.traffic_total > 0) {
|
||
return `<div class="client-meta" style="font-size: 0.7rem; opacity: 0.6; margin-top: 2px;">📈 ${_('traffic_used_total')}: ${(u.traffic_total / Math.pow(1024, 3)).toFixed(2)} ${_('gb')}</div>`;
|
||
}
|
||
return '';
|
||
})()}
|
||
${u.traffic_reset_strategy !== 'never' ? `<div class="client-meta" style="font-size: 0.7rem; opacity: 0.6;">🔄 ${_('traffic_reset_strategy_label')}: ${_('traffic_reset_' + u.traffic_reset_strategy)}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="client-actions" style="flex-wrap: wrap; justify-content: flex-end; max-width: 140px;">
|
||
{% if current_user.role == 'admin' %}
|
||
<button class="btn btn-secondary btn-sm btn-icon"
|
||
onclick="openAddConnectionForUser('${u.id}', '${u.username}')"
|
||
title="${_('add_connection')}">${uiIcon('link')}</button>
|
||
<button class="btn btn-secondary btn-sm btn-icon"
|
||
onclick='openEditUser({"id": "${u.id}", "username": "${u.username}", "tg": "${u.telegramId || ""}", "email": "${u.email || ""}", "desc": "${u.description || ""}", "limit": "${u.traffic_limit ? (u.traffic_limit / Math.pow(1024, 3)).toFixed(2) : 0}", "strategy": "${u.traffic_reset_strategy || 'never'}", "expiration": "${u.expiration_date || ''}", "expire_after_first_use": ${u.expire_after_first_use ? 'true' : 'false'}, "expiration_days": ${u.expiration_days || 0}})'
|
||
title="${_('edit')}">${uiIcon('pencil')}</button>
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="openShareModal(this.dataset)"
|
||
data-id="${u.id}" data-username="${u.username}" data-token="${u.share_token || ''}"
|
||
data-enabled="${u.share_enabled ? 'true' : 'false'}"
|
||
data-haspassword="${u.has_share_password ? 'true' : 'false'}"
|
||
title="${_('share_access')}">${uiIcon('share')}</button>
|
||
${u.id !== "{{ current_user.id }}" ? `
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="toggleUser(this.dataset)"
|
||
data-id="${u.id}" data-enable="${u.enabled ? 'false' : 'true'}"
|
||
title="${u.enabled ? _('stop_btn') : _('start_btn')}">
|
||
${u.enabled ? uiIcon('pause') : uiIcon('play')}
|
||
</button>
|
||
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteUser('${u.id}')" title="${_('delete')}">${uiIcon('trash')}</button>
|
||
` : ''}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Pagination UI
|
||
document.getElementById('pageInfo').textContent = _('page_info').replace('{}', data.page).replace('{}', data.pages || 1);
|
||
document.getElementById('prevPage').disabled = (data.page <= 1);
|
||
document.getElementById('nextPage').disabled = (data.page >= data.pages);
|
||
|
||
loading.classList.add('hidden');
|
||
container.classList.remove('hidden');
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ` + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Call on load
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
setupDateTimeFields();
|
||
refreshUsersList();
|
||
});
|
||
|
||
function updateProtocols(serverId, selectId, groupId = null) {
|
||
const select = document.getElementById(selectId);
|
||
const group = groupId ? document.getElementById(groupId) : null;
|
||
select.innerHTML = '';
|
||
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||
const titles = {
|
||
awg2: 'AmneziaWG 2.0',
|
||
awg: 'AmneziaWG',
|
||
awg_legacy: 'AWG Legacy',
|
||
wireguard: 'WireGuard',
|
||
xray: 'Xray (VLESS-Reality)',
|
||
telemt: 'Telemt',
|
||
hysteria: 'Hysteria 2',
|
||
naiveproxy: 'NaiveProxy',
|
||
mieru: 'Mieru',
|
||
xui: '3x-ui VLESS',
|
||
};
|
||
const protoTitle = (key) => {
|
||
const base = String(key || '').split('__')[0];
|
||
const title = titles[base] || base.toUpperCase();
|
||
const m = String(key || '').match(/__(\d+)$/);
|
||
return m ? `${title} #${m[1]}` : title;
|
||
};
|
||
let count = 0;
|
||
|
||
// Special source: 3x-ui (server picker value "xui")
|
||
if (String(serverId) === 'xui') {
|
||
const opt = document.createElement('option');
|
||
opt.value = 'xui';
|
||
opt.textContent = titles.xui;
|
||
select.appendChild(opt);
|
||
count = 1;
|
||
if (group) group.style.display = '';
|
||
if (selectId === 'ucProtocol') updateXuiInboundVisibility();
|
||
return;
|
||
}
|
||
|
||
const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null;
|
||
const protocols = (server && server.protocols) || {};
|
||
const installed = Object.keys(protocols).filter(key => {
|
||
const info = protocols[key] || {};
|
||
if (!info.installed) return false;
|
||
return vpnOrder.includes(key.split('__')[0]);
|
||
}).sort((a, b) => {
|
||
const ia = vpnOrder.indexOf(a.split('__')[0]);
|
||
const ib = vpnOrder.indexOf(b.split('__')[0]);
|
||
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
|
||
});
|
||
|
||
installed.forEach(key => {
|
||
const opt = document.createElement('option');
|
||
opt.value = key;
|
||
opt.textContent = protoTitle(key);
|
||
select.appendChild(opt);
|
||
count++;
|
||
});
|
||
|
||
if (count > 0) {
|
||
if (group) group.style.display = '';
|
||
} else {
|
||
const opt = document.createElement('option');
|
||
opt.textContent = _('no_protocols');
|
||
opt.disabled = true;
|
||
select.appendChild(opt);
|
||
if (group) group.style.display = '';
|
||
}
|
||
if (selectId === 'ucProtocol') {
|
||
updateXuiInboundVisibility();
|
||
}
|
||
}
|
||
|
||
async function loadXuiInbounds() {
|
||
const select = document.getElementById('ucXuiInbound');
|
||
if (!select) return;
|
||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
||
try {
|
||
const panelId = document.getElementById('ucXuiPanel')?.value || '';
|
||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
||
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 (xuiDefaultInboundId && String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true;
|
||
else if (!xuiDefaultInboundId && idx === 0) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (err) {
|
||
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
|
||
}
|
||
}
|
||
|
||
function updateXuiInboundVisibility() {
|
||
const proto = document.getElementById('ucProtocol')?.value;
|
||
const group = document.getElementById('ucXuiInboundGroup');
|
||
if (!group) return;
|
||
if (proto === 'xui') {
|
||
group.style.display = 'block';
|
||
loadXuiInbounds();
|
||
} else {
|
||
group.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Show/hide connection fields
|
||
document.getElementById('newUserServer').addEventListener('change', (e) => {
|
||
const show = e.target.value !== '';
|
||
updateProtocols(e.target.value, 'newUserProtocol', 'newUserProtoGroup');
|
||
document.getElementById('newUserConnNameGroup').style.display = show ? '' : 'none';
|
||
updateTelemtOptions('newUserProtocol', 'newUserTelemtOptions');
|
||
});
|
||
|
||
document.getElementById('newUserProtocol').addEventListener('change', (e) => {
|
||
updateTelemtOptions('newUserProtocol', 'newUserTelemtOptions');
|
||
});
|
||
|
||
function updateTelemtOptions(protoSelectId, optionsId) {
|
||
const proto = document.getElementById(protoSelectId).value;
|
||
const options = document.getElementById(optionsId);
|
||
if (options) options.style.display = (proto === 'telemt') ? 'block' : 'none';
|
||
}
|
||
|
||
document.getElementById('ucServer').addEventListener('change', (e) => {
|
||
updateProtocols(e.target.value, 'ucProtocol');
|
||
const mode = document.getElementById('ucMode').value;
|
||
if (mode === 'existing' && e.target.value !== 'xui') {
|
||
fetchExistingClients();
|
||
}
|
||
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
||
updateXuiInboundVisibility();
|
||
});
|
||
|
||
document.getElementById('ucProtocol').addEventListener('change', (e) => {
|
||
if (document.getElementById('ucMode').value === 'existing') {
|
||
fetchExistingClients();
|
||
}
|
||
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
||
updateXuiInboundVisibility();
|
||
});
|
||
|
||
async function addUser(e) {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('addUserBtn');
|
||
const text = document.getElementById('addUserBtnText');
|
||
const spinner = document.getElementById('addUserSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('creating');
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const body = {
|
||
username: document.getElementById('newUsername').value,
|
||
password: document.getElementById('newPassword').value,
|
||
role: document.getElementById('newRole').value,
|
||
telegramId: document.getElementById('newTelegramId').value || null,
|
||
email: document.getElementById('newEmail').value || null,
|
||
description: document.getElementById('newDescription').value || null,
|
||
traffic_limit: parseFloat(document.getElementById('newTrafficLimit').value || '0'),
|
||
traffic_reset_strategy: document.getElementById('newTrafficResetStrategy').value,
|
||
expire_after_first_use: document.getElementById('newExpireAfterFirstUse').checked,
|
||
expiration_days: parseInt(document.getElementById('newExpirationDays').value || '0'),
|
||
expiration_date: document.getElementById('newExpireAfterFirstUse').checked
|
||
? null
|
||
: getDateTimeValue('newExpirationDate', 'newExpirationTime'),
|
||
};
|
||
if (body.expire_after_first_use && (!body.expiration_days || body.expiration_days < 1)) {
|
||
throw new Error(_('expire_days_required'));
|
||
}
|
||
const serverId = document.getElementById('newUserServer').value;
|
||
if (serverId !== '') {
|
||
body.server_id = parseInt(serverId);
|
||
body.protocol = document.getElementById('newUserProtocol').value;
|
||
body.connection_name = document.getElementById('newUserConnName').value || null;
|
||
if (body.protocol === 'telemt') {
|
||
body.telemt_quota = document.getElementById('newUserTelemtQuota').value || null;
|
||
body.telemt_max_ips = parseInt(document.getElementById('newUserTelemtMaxIps').value) || null;
|
||
body.telemt_expiry = document.getElementById('newUserTelemtExpiry').value || null;
|
||
body.telemt_secret = document.getElementById('newUserTelemtSecret').value || null;
|
||
body.telemt_ad_tag = document.getElementById('newUserTelemtAdTag').value || null;
|
||
body.telemt_max_conns = parseInt(document.getElementById('newUserTelemtMaxConns').value) || null;
|
||
}
|
||
}
|
||
const result = await apiCall('/api/users/add', 'POST', body);
|
||
showToast(_('success'), 'success');
|
||
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = body.username;
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${body.username}`;
|
||
document.getElementById('configText').textContent = result.config;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
switchConfigTab('conf');
|
||
closeModal('addUserModal');
|
||
openModal('configModal');
|
||
generateQR(result.config);
|
||
} else {
|
||
setTimeout(() => window.location.reload(), 500);
|
||
}
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ` + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('create');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async function deleteUser(userId) {
|
||
if (!confirm(_('delete_user_confirm'))) return;
|
||
try {
|
||
await apiCall(`/api/users/${userId}/delete`, 'POST');
|
||
showToast(_('success'), 'success');
|
||
setTimeout(() => window.location.reload(), 500);
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ` + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function toggleUser(dataset) {
|
||
try {
|
||
const userId = dataset.id;
|
||
const enable = dataset.enable === 'true';
|
||
const action = enable ? _('enabling') : _('disabling');
|
||
showToast(`${action}...`, 'info');
|
||
await apiCall(`/api/users/${userId}/toggle`, 'POST', { enabled: enable });
|
||
showToast(enable ? _('user_enabled') : _('user_disabled'), 'success');
|
||
setTimeout(() => window.location.reload(), 500);
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ` + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function openAddConnectionForUser(userId, username) {
|
||
currentUserId = userId;
|
||
document.getElementById('addUserConnTitle').textContent = `${_('add_connection')} — ${username}`;
|
||
document.getElementById('ucName').value = `${username}_vpn`;
|
||
document.getElementById('ucMode').value = 'new';
|
||
toggleUCMode();
|
||
updateProtocols(document.getElementById('ucServer').value, 'ucProtocol');
|
||
openModal('addUserConnModal');
|
||
}
|
||
|
||
function toggleUCMode() {
|
||
const mode = document.getElementById('ucMode').value;
|
||
const existingGroup = document.getElementById('ucExistingClientGroup');
|
||
const ucAddBtnText = document.getElementById('ucAddBtnText');
|
||
|
||
if (mode === 'existing') {
|
||
existingGroup.style.display = 'block';
|
||
ucAddBtnText.textContent = _('linking');
|
||
fetchExistingClients();
|
||
} else {
|
||
existingGroup.style.display = 'none';
|
||
ucAddBtnText.textContent = _('add');
|
||
}
|
||
}
|
||
|
||
async function fetchExistingClients() {
|
||
const serverId = document.getElementById('ucServer').value;
|
||
const protocol = document.getElementById('ucProtocol').value;
|
||
const select = document.getElementById('ucExistingClient');
|
||
select.innerHTML = `<option value="">${_('loading')}</option>`;
|
||
|
||
if (!protocol) {
|
||
select.innerHTML = `<option value="">${_('no_protocols')}</option>`;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await apiCall(`/api/servers/${serverId}/${encodeURIComponent(protocol)}/clients`);
|
||
if (!data.clients || data.clients.length === 0) {
|
||
select.innerHTML = `<option value="">${_('no_free_conns')}</option>`;
|
||
return;
|
||
}
|
||
select.innerHTML = '<option value="">' + _('select_existing_conn') + '</option>';
|
||
data.clients.forEach(c => {
|
||
const id = c.id || '';
|
||
const short = id ? (id.length > 8 ? id.substring(0, 8) + '...' : id) : '';
|
||
const label = `${c.name || 'Unnamed'}${short ? ' (' + short + ')' : ''}`;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = label;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (err) {
|
||
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
|
||
}
|
||
}
|
||
|
||
|
||
async function doAddUserConnection() {
|
||
const btn = document.getElementById('ucAddBtn');
|
||
const text = document.getElementById('ucAddBtnText');
|
||
const spinner = document.getElementById('ucAddSpinner');
|
||
btn.disabled = true;
|
||
|
||
const mode = document.getElementById('ucMode').value;
|
||
text.textContent = mode === 'existing' ? _('linking') : _('creating');
|
||
spinner.classList.remove('hidden');
|
||
|
||
try {
|
||
const serverRaw = document.getElementById('ucServer').value;
|
||
const protocol = document.getElementById('ucProtocol').value;
|
||
const body = {
|
||
server_id: serverRaw === 'xui' ? 0 : (parseInt(serverRaw, 10) || 0),
|
||
protocol: protocol,
|
||
name: document.getElementById('ucName').value || 'VPN Connection',
|
||
};
|
||
|
||
if (mode === 'existing') {
|
||
if (serverRaw === 'xui' || protocol === 'xui') {
|
||
throw new Error(_('link_existing_xui_unsupported') || 'Link existing is only for Amnezia server protocols');
|
||
}
|
||
const clientId = document.getElementById('ucExistingClient').value;
|
||
if (!clientId) throw new Error(_('select_connection'));
|
||
body.client_id = clientId;
|
||
} else if (body.protocol === 'xui') {
|
||
const inbound = parseInt(document.getElementById('ucXuiInbound').value || '0');
|
||
if (!inbound) throw new Error(_('invite_inbound_required'));
|
||
body.xui_inbound_id = inbound;
|
||
body.xui_panel_id = document.getElementById('ucXuiPanel')?.value || '';
|
||
} else if (body.protocol === 'telemt') {
|
||
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
||
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
||
body.telemt_expiry = document.getElementById('ucTelemtExpiry').value || null;
|
||
body.telemt_secret = document.getElementById('ucTelemtSecret').value || null;
|
||
body.telemt_ad_tag = document.getElementById('ucTelemtAdTag').value || null;
|
||
body.telemt_max_conns = parseInt(document.getElementById('ucTelemtMaxConns').value) || null;
|
||
}
|
||
|
||
const result = await apiCall(`/api/users/${currentUserId}/connections/add`, 'POST', body);
|
||
showToast(mode === 'existing' ? _('conn_linked') : _('connection_created').replace('{}', body.name), 'success');
|
||
closeModal('addUserConnModal');
|
||
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = document.getElementById('ucName').value.replace(/\s+/g, '_');
|
||
document.getElementById('configModalTitle').textContent = _('config');
|
||
document.getElementById('configText').textContent = result.config;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
generateQR(result.config);
|
||
}
|
||
setTimeout(() => window.location.reload(), 1000);
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ` + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('create');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async function viewUserConnections(userId, username) {
|
||
document.getElementById('userConnsTitle').textContent = `${_('connections')} — ${username}`;
|
||
const listEl = document.getElementById('userConnsList');
|
||
listEl.innerHTML = `<div style="text-align:center;padding:var(--space-lg);color:var(--text-muted);">${_('loading')}</div>`;
|
||
openModal('userConnsModal');
|
||
try {
|
||
const data = await apiCall(`/api/users/${userId}/connections`);
|
||
if (!data.connections || data.connections.length === 0) {
|
||
listEl.innerHTML = `<div style="text-align:center;padding:var(--space-lg);color:var(--text-muted);">${_('no_connections')}</div>`;
|
||
return;
|
||
}
|
||
listEl.innerHTML = '';
|
||
data.connections.forEach(c => {
|
||
listEl.innerHTML += `
|
||
<div class="client-item">
|
||
<div class="client-info">
|
||
<div class="client-avatar">${uiIcon('link')}</div>
|
||
<div>
|
||
<div class="client-name">${c.name || 'Connection'}</div>
|
||
<div class="client-meta">
|
||
<span style="display:inline-flex;align-items:center;gap:4px;">${uiIcon('server')} ${c.server_name || ''}</span>
|
||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : (c.protocol === 'xui' ? '3x-ui VLESS' : c.protocol.toUpperCase()))))}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="client-actions">
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="showUserConnectionConfig(${c.server_id}, '${c.client_id}', '${c.protocol}', '${(c.name || 'VPN').replace(/'/g, "\\'")}')" title="${_('config')}">${uiIcon('file')}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
} catch (err) {
|
||
listEl.innerHTML = `<div style="color:#ef4444;padding:var(--space-md);">${_('error')}: ${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// Config tabs
|
||
function switchConfigTab(tab) {
|
||
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
|
||
const tabs = document.querySelectorAll('.config-tab');
|
||
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
|
||
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
|
||
tabs[tabIdx[tab]].classList.add('active');
|
||
document.getElementById(panels[tab]).classList.add('active');
|
||
}
|
||
|
||
function generateQR(text) {
|
||
const container = document.getElementById('qrCode');
|
||
container.innerHTML = '';
|
||
try {
|
||
new QRCode(container, { text, width: 280, height: 280, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.L });
|
||
} catch (e) {
|
||
container.innerHTML = `<div style="color:var(--text-muted);">${_('qr_error')}</div>`;
|
||
}
|
||
}
|
||
async function showUserConnectionConfig(serverId, clientId, protocol, connName) {
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const result = await apiCall(`/api/servers/${serverId}/connections/config`, 'POST', {
|
||
protocol: protocol, client_id: clientId,
|
||
});
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||
document.getElementById('configText').textContent = result.config;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
generateQR(result.config);
|
||
}
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ` + err.message, 'error');
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!-- ===== Share User Modal ===== -->
|
||
<div class="modal-backdrop" id="shareUserModal">
|
||
<div class="modal" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="shareModalTitle">{{ _('share_access') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('shareUserModal')">×</button>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||
<input type="checkbox" id="shareEnabled" onchange="updateShareSetup()"> {{ _('enable_public_access') }}
|
||
</label>
|
||
</div>
|
||
|
||
<div id="shareSetupFields" style="display:none;">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('share_password_label') }}</label>
|
||
<input class="form-input" type="password" id="sharePassword" placeholder="{{ _('new_password_hint') }}">
|
||
<div class="form-hint">{{ _('share_password_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('public_link_label') }}</label>
|
||
<div class="flex gap-sm">
|
||
<input class="form-input" type="text" id="shareLink" readonly
|
||
style="background: var(--bg-primary);">
|
||
<button class="btn btn-secondary btn-icon" onclick="copyShareLink()" title="{{ _('copy') }}">{{ icon('copy') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<div style="display: flex; gap: var(--space-md); justify-content: flex-end;">
|
||
<button class="btn btn-secondary" onclick="closeModal('shareUserModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="saveShareSetup()" id="saveShareBtn">
|
||
<span id="saveShareBtnText">{{ _('save') }}</span>
|
||
<div class="spinner hidden" id="saveShareSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentSharedUser = null;
|
||
|
||
function toDateTimeLocalValue(value) {
|
||
if (!value) return '';
|
||
const raw = String(value).trim();
|
||
if (!raw) return '';
|
||
const match = raw.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||
if (match) return match[1];
|
||
const parsed = new Date(raw);
|
||
if (Number.isNaN(parsed.getTime())) return '';
|
||
const offsetMs = parsed.getTimezoneOffset() * 60000;
|
||
return new Date(parsed.getTime() - offsetMs).toISOString().slice(0, 16);
|
||
}
|
||
|
||
function toggleExpireAfterFirstUse(prefix) {
|
||
const checked = document.getElementById(prefix === 'new' ? 'newExpireAfterFirstUse' : 'editExpireAfterFirstUse').checked;
|
||
const abs = document.getElementById(prefix === 'new' ? 'newExpireAbsoluteWrap' : 'editExpireAbsoluteWrap');
|
||
const days = document.getElementById(prefix === 'new' ? 'newExpireDaysWrap' : 'editExpireDaysWrap');
|
||
if (abs) abs.style.display = checked ? 'none' : 'grid';
|
||
if (days) days.style.display = checked ? '' : 'none';
|
||
}
|
||
|
||
function formatExpirationBadge(u) {
|
||
if (u.expire_after_first_use && !u.expiration_date) {
|
||
const days = u.expiration_days || 0;
|
||
return `<span style="color:var(--text-muted)">⌛ ${_('expire_pending_label').replace('{}', String(days))}</span>`;
|
||
}
|
||
if (u.expiration_date) {
|
||
const expired = new Date(u.expiration_date) < new Date();
|
||
const suffix = u.expire_after_first_use ? ` (${_('expire_started_suffix')})` : '';
|
||
return `<span style="color: ${expired ? 'var(--danger)' : 'var(--text-muted)'}">⌛ ${_('expiration_date_label')}: ${new Date(u.expiration_date).toLocaleString()}${suffix}</span>`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function openEditUser(data) {
|
||
document.getElementById('editUserId').value = data.id;
|
||
document.getElementById('editUserTitle').textContent = data.username;
|
||
document.getElementById('editUserTG').value = data.tg || '';
|
||
document.getElementById('editUserEmail').value = data.email || '';
|
||
document.getElementById('editUserDesc').value = data.desc || '';
|
||
document.getElementById('editUserLimit').value = data.limit || '0';
|
||
document.getElementById('editUserTrafficResetStrategy').value = data.strategy || 'never';
|
||
const afterFirst = !!data.expire_after_first_use;
|
||
document.getElementById('editExpireAfterFirstUse').checked = afterFirst;
|
||
document.getElementById('editExpirationDays').value = String(data.expiration_days || 30);
|
||
setDateTimeValue('editUserExpiration', 'editUserExpirationTime', data.expiration);
|
||
toggleExpireAfterFirstUse('edit');
|
||
const hint = document.getElementById('editExpireDaysHint');
|
||
if (hint) {
|
||
hint.textContent = (afterFirst && data.expiration)
|
||
? _('expire_already_started_hint').replace('{}', new Date(data.expiration).toLocaleString())
|
||
: _('expire_after_first_use_hint');
|
||
}
|
||
document.getElementById('editUserPass').value = '';
|
||
openModal('editUserModal');
|
||
}
|
||
|
||
async function saveEditUser() {
|
||
const uid = document.getElementById('editUserId').value;
|
||
const btn = document.getElementById('saveEditBtn');
|
||
const afterFirst = document.getElementById('editExpireAfterFirstUse').checked;
|
||
const days = parseInt(document.getElementById('editExpirationDays').value || '0');
|
||
if (afterFirst && days < 1) {
|
||
showToast(_('expire_days_required'), 'error');
|
||
return;
|
||
}
|
||
const body = {
|
||
telegramId: document.getElementById('editUserTG').value || null,
|
||
email: document.getElementById('editUserEmail').value || null,
|
||
description: document.getElementById('editUserDesc').value || null,
|
||
traffic_limit: parseFloat(document.getElementById('editUserLimit').value || '0'),
|
||
traffic_reset_strategy: document.getElementById('editUserTrafficResetStrategy').value,
|
||
expire_after_first_use: afterFirst,
|
||
expiration_days: afterFirst ? days : 0,
|
||
expiration_date: afterFirst ? null : getDateTimeValue('editUserExpiration', 'editUserExpirationTime'),
|
||
};
|
||
// If countdown already started, keep absolute date unless admin cleared first-use mode
|
||
if (afterFirst) {
|
||
const existing = getDateTimeValue('editUserExpiration', 'editUserExpirationTime');
|
||
if (existing) body.expiration_date = existing;
|
||
}
|
||
const pwd = document.getElementById('editUserPass').value;
|
||
if (pwd) body.password = pwd;
|
||
btn.disabled = true;
|
||
try {
|
||
await apiCall(`/api/users/${uid}/update`, 'POST', body);
|
||
showToast(_('success'), 'success');
|
||
closeModal('editUserModal');
|
||
setTimeout(() => window.location.reload(), 400);
|
||
} catch (err) {
|
||
showToast(`${_('error')}: ${err.message}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openShareModal(dataset) {
|
||
currentSharedUser = dataset.id;
|
||
document.getElementById('shareModalTitle').textContent = `${_('share_access')} — ${dataset.username}`;
|
||
document.getElementById('shareEnabled').checked = dataset.enabled === 'true';
|
||
document.getElementById('sharePassword').value = '';
|
||
|
||
let token = dataset.token;
|
||
if (!token || token === '') {
|
||
token = Math.random().toString(36).substring(2, 15);
|
||
}
|
||
const link = window.location.origin + '/share/' + token;
|
||
document.getElementById('shareLink').value = link;
|
||
|
||
updateShareSetup();
|
||
openModal('shareUserModal');
|
||
}
|
||
|
||
function updateShareSetup() {
|
||
const enabled = document.getElementById('shareEnabled').checked;
|
||
document.getElementById('shareSetupFields').style.display = enabled ? '' : 'none';
|
||
}
|
||
|
||
async function saveShareSetup() {
|
||
const btn = document.getElementById('saveShareBtn');
|
||
const text = document.getElementById('saveShareBtnText');
|
||
const spinner = document.getElementById('saveShareSpinner');
|
||
|
||
btn.disabled = true;
|
||
text.textContent = _('saving');
|
||
spinner.classList.remove('hidden');
|
||
|
||
try {
|
||
const body = {
|
||
enabled: document.getElementById('shareEnabled').checked,
|
||
password: document.getElementById('sharePassword').value
|
||
};
|
||
const result = await apiCall(`/api/users/${currentSharedUser}/share/setup`, 'POST', body);
|
||
if (result.share_token) {
|
||
const link = window.location.origin + '/share/' + result.share_token;
|
||
document.getElementById('shareLink').value = link;
|
||
}
|
||
showToast(_('success'), 'success');
|
||
setTimeout(() => window.location.reload(), 1000);
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('save');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function copyShareLink() {
|
||
const input = document.getElementById('shareLink');
|
||
if (!input.value) return;
|
||
input.select();
|
||
document.execCommand('copy');
|
||
showToast(_('copied'), 'success');
|
||
}
|
||
</script>
|
||
|
||
{% endblock %}
|
||
|