Add optional expiration that starts on first config use.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 05:07:50 +03:00
co-authored by Cursor
parent 16d14a7256
commit 745b5e5d8c
9 changed files with 245 additions and 35 deletions
+69 -5
View File
@@ -1488,6 +1488,8 @@ class AddUserRequest(BaseModel):
protocol: Optional[str] = None
connection_name: Optional[str] = None
expiration_date: Optional[str] = None
expire_after_first_use: bool = False
expiration_days: int = 0
telemt_quota: Optional[str] = None
telemt_max_ips: Optional[int] = None
telemt_expiry: Optional[str] = None
@@ -1564,6 +1566,8 @@ class UpdateUserRequest(BaseModel):
traffic_limit: Optional[float] = 0
traffic_reset_strategy: Optional[str] = None
expiration_date: Optional[str] = None
expire_after_first_use: Optional[bool] = None
expiration_days: Optional[int] = None
password: Optional[str] = None
@@ -1703,6 +1707,10 @@ async def startup():
migrated = True
if 'expiration_date' not in u:
u['expiration_date'] = None
if 'expire_after_first_use' not in u:
u['expire_after_first_use'] = False
if 'expiration_days' not in u:
u['expiration_days'] = 0
migrated = True
if 'xui_email' not in u:
u['xui_email'] = None
@@ -3346,6 +3354,8 @@ async def api_list_users(request: Request, search: str = '', page: int = 1, size
'traffic_reset_strategy': u.get('traffic_reset_strategy', 'never'),
'last_reset_at': u.get('last_reset_at'),
"expiration_date": u.get("expiration_date"),
"expire_after_first_use": bool(u.get("expire_after_first_use")),
"expiration_days": int(u.get("expiration_days") or 0),
'share_enabled': u.get('share_enabled', False),
'share_token': u.get('share_token'),
'has_share_password': bool(u.get('share_password_hash')),
@@ -3389,7 +3399,9 @@ async def api_add_user(request: Request, req: AddUserRequest):
'traffic_used': 0,
'traffic_total': 0,
'last_reset_at': datetime.now().isoformat(),
'expiration_date': req.expiration_date,
'expiration_date': None if req.expire_after_first_use else req.expiration_date,
'expire_after_first_use': bool(req.expire_after_first_use),
'expiration_days': int(req.expiration_days or 0) if req.expire_after_first_use else 0,
'enabled': True,
'created_at': datetime.now().isoformat(),
'remnawave_uuid': None,
@@ -3398,6 +3410,8 @@ async def api_add_user(request: Request, req: AddUserRequest):
'share_token': secrets.token_urlsafe(16),
'share_password_hash': None,
}
if new_user['expire_after_first_use'] and new_user['expiration_days'] <= 0:
return JSONResponse({'error': 'expiration_days must be > 0 when start-after-first-use is enabled'}, status_code=400)
data['users'].append(new_user)
save_data(data)
@@ -3472,8 +3486,29 @@ async def api_update_user(request: Request, user_id: str, req: UpdateUserRequest
user['last_reset_at'] = datetime.now().isoformat()
req_fields = getattr(req, 'model_fields_set', getattr(req, '__fields_set__', set()))
if 'expiration_date' in req_fields:
if 'expire_after_first_use' in req_fields or 'expiration_days' in req_fields or 'expiration_date' in req_fields:
after_first = bool(req.expire_after_first_use) if req.expire_after_first_use is not None else bool(user.get('expire_after_first_use'))
days = int(req.expiration_days) if req.expiration_days is not None else int(user.get('expiration_days') or 0)
if after_first:
if days <= 0:
return JSONResponse({'error': 'expiration_days must be > 0 when start-after-first-use is enabled'}, status_code=400)
user['expire_after_first_use'] = True
user['expiration_days'] = days
# Keep existing absolute date if countdown already started; otherwise wait for first use
if not user.get('expiration_date'):
user['expiration_date'] = None
elif 'expiration_date' in req_fields and req.expiration_date:
# Admin can still override absolute end date after start
user['expiration_date'] = req.expiration_date or None
else:
user['expire_after_first_use'] = False
user['expiration_days'] = 0
if 'expiration_date' in req_fields:
user['expiration_date'] = req.expiration_date or None
elif 'expiration_date' in req_fields:
user['expiration_date'] = req.expiration_date or None
user['expire_after_first_use'] = False
user['expiration_days'] = 0
if req.password:
user['password_hash'] = hash_password(req.password)
@@ -3735,6 +3770,12 @@ async def api_share_config(token: str, connection_id: str, request: Request):
return JSONResponse({'error': 'Not found'}, status_code=404)
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(user):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if maybe_start_user_expiration(data, user['id']):
save_data(data)
if protocol_base(conn.get('protocol', '')) == 'xui':
return JSONResponse({'error': '3x-ui support removed'}, status_code=410)
@@ -3749,7 +3790,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')}
except Exception as e:
logger.exception("Error getting shared config")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -3873,6 +3914,12 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
if not conn:
return JSONResponse({'error': 'Not found'}, status_code=404)
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(holder):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if maybe_start_user_expiration(data, holder['id']):
save_data(data)
if protocol_base(conn.get('protocol', '')) == 'xui':
return JSONResponse({'error': '3x-ui support removed'}, status_code=410)
@@ -3886,7 +3933,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
except Exception as e:
logger.exception("Error getting guest config")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -3909,6 +3956,10 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
name = f"{name}_{secrets.token_hex(3)}"
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(holder):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
sid = int(guest.get('create_server_id') or 0)
if sid >= len(data['servers']):
return JSONResponse({'error': 'Guest server not found'}, status_code=400)
@@ -3944,6 +3995,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
async with DATA_LOCK:
data = load_data()
data['user_connections'].append(conn)
maybe_start_user_expiration(data, holder['id'])
save_data(data)
config = result.get('config') or ''
@@ -3955,6 +4007,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
'config': config,
'subscription_url': subscription_url,
'vpn_link': vpn_link,
'expires_at': next((u.get('expiration_date') for u in data.get('users', []) if u.get('id') == holder['id']), None),
}
except Exception as e:
logger.exception("Error creating guest config")
@@ -4323,7 +4376,14 @@ async def api_my_connection_config(request: Request, connection_id: str):
if not user:
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
data = load_data()
panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None)
if panel_user and user_is_expired(panel_user):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if panel_user and maybe_start_user_expiration(data, user['id']):
save_data(data)
conn = next(
(c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']),
None
@@ -4347,7 +4407,11 @@ async def api_my_connection_config(request: Request, connection_id: str):
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
expires_at = None
panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None)
if panel_user:
expires_at = panel_user.get('expiration_date')
return {'config': config, 'vpn_link': vpn_link, 'expires_at': expires_at}
except Exception as e:
logger.exception("Error getting my connection config")
return JSONResponse({'error': str(e)}, status_code=500)
+6
View File
@@ -93,6 +93,12 @@ def init_schema():
cur.execute(
"ALTER TABLE user_connections ADD COLUMN IF NOT EXISTS xui_panel_id TEXT NOT NULL DEFAULT ''"
)
cur.execute(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS expire_after_first_use BOOLEAN NOT NULL DEFAULT FALSE"
)
cur.execute(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS expiration_days INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
_schema_ready = True
logger.info('PostgreSQL schema ready')
+2
View File
@@ -28,6 +28,8 @@ CREATE TABLE IF NOT EXISTS users (
traffic_reset_strategy TEXT NOT NULL DEFAULT 'never',
last_reset_at TIMESTAMPTZ,
expiration_date TIMESTAMPTZ,
expire_after_first_use BOOLEAN NOT NULL DEFAULT FALSE,
expiration_days INTEGER NOT NULL DEFAULT 0,
remnawave_uuid TEXT,
xui_email TEXT,
share_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+8 -3
View File
@@ -151,6 +151,8 @@ def _row_to_user(row) -> dict:
'traffic_reset_strategy': row['traffic_reset_strategy'] or 'never',
'last_reset_at': _ts_iso(row['last_reset_at']),
'expiration_date': _ts_iso(row['expiration_date']),
'expire_after_first_use': bool(row.get('expire_after_first_use') if hasattr(row, 'get') else row['expire_after_first_use']),
'expiration_days': int((row.get('expiration_days') if hasattr(row, 'get') else row['expiration_days']) or 0),
'remnawave_uuid': row['remnawave_uuid'],
'xui_email': row.get('xui_email'),
'share_enabled': bool(row['share_enabled']),
@@ -222,7 +224,8 @@ def load_data() -> dict:
'SELECT id, username, password_hash, role, enabled, created_at, '
'telegram_id, email, description, traffic_limit, traffic_used, '
'traffic_total, traffic_reset_strategy, last_reset_at, '
'expiration_date, remnawave_uuid, xui_email, share_enabled, share_token, '
'expiration_date, expire_after_first_use, expiration_days, '
'remnawave_uuid, xui_email, share_enabled, share_token, '
'share_password_hash FROM users ORDER BY created_at NULLS LAST, username'
)
users = [_row_to_user(r) for r in cur.fetchall()]
@@ -316,10 +319,10 @@ def save_data(data: dict) -> None:
'id, username, password_hash, role, enabled, created_at, '
'telegram_id, email, description, traffic_limit, traffic_used, '
'traffic_total, traffic_reset_strategy, last_reset_at, '
'expiration_date, remnawave_uuid, xui_email, share_enabled, share_token, '
'expiration_date, expire_after_first_use, expiration_days, remnawave_uuid, xui_email, share_enabled, share_token, '
'share_password_hash'
') VALUES ('
'%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s'
'%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s'
')',
(
_as_uuid(user['id']),
@@ -337,6 +340,8 @@ def save_data(data: dict) -> None:
user.get('traffic_reset_strategy') or 'never',
_parse_ts(user.get('last_reset_at')),
_parse_ts(user.get('expiration_date')),
bool(user.get('expire_after_first_use', False)),
int(user.get('expiration_days') or 0),
user.get('remnawave_uuid'),
user.get('xui_email'),
bool(user.get('share_enabled', False)),
+42
View File
@@ -0,0 +1,42 @@
"""User subscription expiration helpers."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
def user_is_expired(user: dict, *, now: Optional[datetime] = None) -> bool:
"""True when absolute expiration_date is in the past."""
exp_str = user.get('expiration_date')
if not exp_str:
return False
try:
exp_date = datetime.fromisoformat(str(exp_str))
except Exception:
return False
return (now or datetime.now()) > exp_date
def maybe_start_user_expiration(data: dict, user_id: str, *, now: Optional[datetime] = None) -> Optional[str]:
"""If user waits for first config use, start the countdown.
Returns the new expiration_date ISO string when activated, else None.
"""
if not user_id:
return None
user = next((u for u in data.get('users', []) if u.get('id') == user_id), None)
if not user:
return None
if not user.get('expire_after_first_use'):
return None
if user.get('expiration_date'):
return None
days = int(user.get('expiration_days') or 0)
if days <= 0:
return None
stamp = now or datetime.now()
expires = stamp + timedelta(days=days)
iso = expires.isoformat()
user['expiration_date'] = iso
return iso
+14 -2
View File
@@ -499,7 +499,7 @@ async def _handle_refresh(api: TelegramAPI, chat_id: int, message_id: int, callb
await api.edit_message(chat_id, message_id, f"<b>Your connections</b> ({len(conns)}) — tap to get config:", reply_markup=kb)
async def _handle_get_config(api: TelegramAPI, chat_id: int, message_id: int, callback_id: str, conn_id: str, tg_id: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
async def _handle_get_config(api: TelegramAPI, chat_id: int, message_id: int, callback_id: str, conn_id: str, tg_id: str, load_data_fn: Callable, generate_vpn_link_fn: Callable, save_data_fn: Optional[Callable] = None):
await api.answer_callback(callback_id, "Fetching config...")
panel_user = _find_user(load_data_fn, tg_id)
@@ -508,6 +508,18 @@ async def _handle_get_config(api: TelegramAPI, chat_id: int, message_id: int, ca
return
data = load_data_fn()
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
owner = next((u for u in data.get("users", []) if u.get("id") == panel_user.get("id")), panel_user)
if user_is_expired(owner):
await api.send_message(chat_id, "❌ Subscription expired.")
return
if save_data_fn and maybe_start_user_expiration(data, owner.get("id")):
save_data_fn(data)
data = load_data_fn()
except Exception:
logger.exception("Bot: failed to start expiration on first use")
conn = next((c for c in data.get("user_connections", []) if c.get("id") == conn_id and (_is_admin(panel_user) or c.get("user_id") == panel_user.get("id"))), None)
if not conn:
await api.send_message(chat_id, "❌ Connection not found.")
@@ -1072,7 +1084,7 @@ async def _dispatch(api: TelegramAPI, update: dict, load_data_fn: Callable, gene
await _handle_refresh(api, chat_id, message_id, callback_id, tg_id, load_data_fn)
return
if data_str.startswith("cfg:"):
await _handle_get_config(api, chat_id, message_id, callback_id, data_str[4:], tg_id, load_data_fn, generate_vpn_link_fn)
await _handle_get_config(api, chat_id, message_id, callback_id, data_str[4:], tg_id, load_data_fn, generate_vpn_link_fn, save_data_fn)
return
panel_user = _require_admin(load_data_fn, tg_id)
+90 -25
View File
@@ -109,10 +109,21 @@
<div class="form-group">
<label class="form-label">{{ _('expiration_date_label') }}</label>
<div class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
<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')">Без срока</button>
<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>
@@ -224,10 +235,21 @@
</div>
<div class="form-group">
<label class="form-label">{{ _('expiration_date_label') }}</label>
<div class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
<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')">Без срока</button>
<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">
@@ -501,7 +523,7 @@
${u.created_at ? `<span>📅 ${u.created_at.substring(0, 10)}</span>` : ''}
${u.telegramId ? `<span>💬 ${u.telegramId}</span>` : ''}
${u.email ? `<span>📧 ${u.email}</span>` : ''}
${u.expiration_date ? `<span style="color: ${new Date(u.expiration_date) < new Date() ? 'var(--danger)' : 'var(--text-muted)'}">⌛ ${_('expiration_date_label')}: ${new Date(u.expiration_date).toLocaleString()}</span>` : ''}
${formatExpirationBadge(u)}
</div>
${u.description ? `<div class="client-meta" style="font-size: 0.75rem; opacity: 0.8; margin-top: 2px;">📝 ${u.description}</div>` : ''}
${(() => {
@@ -545,7 +567,7 @@
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 || ''}"})'
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 || ''}"
@@ -665,8 +687,15 @@
description: document.getElementById('newDescription').value || null,
traffic_limit: parseFloat(document.getElementById('newTrafficLimit').value || '0'),
traffic_reset_strategy: document.getElementById('newTrafficResetStrategy').value,
expiration_date: getDateTimeValue('newExpirationDate', 'newExpirationTime'),
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);
@@ -981,6 +1010,27 @@
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;
@@ -989,7 +1039,17 @@
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');
}
@@ -997,34 +1057,39 @@
async function saveEditUser() {
const uid = document.getElementById('editUserId').value;
const btn = document.getElementById('saveEditBtn');
const text = document.getElementById('saveEditBtnText');
const spinner = document.getElementById('saveEditSpinner');
const payload = {
telegramId: document.getElementById('editUserTG').value,
email: document.getElementById('editUserEmail').value,
description: document.getElementById('editUserDesc').value,
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,
expiration_date: getDateTimeValue('editUserExpiration', 'editUserExpirationTime'),
password: document.getElementById('editUserPass').value || null
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;
text.textContent = _('saving');
spinner.classList.remove('hidden');
try {
await apiCall(`/api/users/${uid}/update`, 'POST', payload);
await apiCall(`/api/users/${uid}/update`, 'POST', body);
showToast(_('success'), 'success');
closeModal('editUserModal');
refreshUsersList();
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
btn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
}
}
+7
View File
@@ -141,6 +141,13 @@
"email_label": "Email (opt.)",
"traffic_limit_label": "Traffic limit (GB) (opt., 0 = ∞)",
"expiration_date_label": "Expiration date",
"no_expiration": "No expiry",
"expire_after_first_use": "Start after first use",
"expire_after_first_use_hint": "The day countdown starts when the user first gets a config (panel, share, or Telegram).",
"expire_days_required": "Enter a number of days greater than 0",
"expire_pending_label": "{} days after first use",
"expire_started_suffix": "countdown started",
"expire_already_started_hint": "Countdown already started. Valid until: {}. Uncheck to set a fixed date.",
"description_label": "Description (opt.)",
"auto_conn_title": "Automatic connection creation (optional)",
"server_label": "Server",
+7
View File
@@ -141,6 +141,13 @@
"email_label": "Email (опц.)",
"traffic_limit_label": "Лимит трафика (ГБ) (опц., 0 = ∞)",
"expiration_date_label": "Срок действия (до)",
"no_expiration": "Без срока",
"expire_after_first_use": "Старт после первого использования",
"expire_after_first_use_hint": "Отсчёт дней начнётся, когда пользователь впервые получит конфиг (панель, share или Telegram).",
"expire_days_required": "Укажите число дней больше 0",
"expire_pending_label": "{} дн. после первого использования",
"expire_started_suffix": "отсчёт уже начат",
"expire_already_started_hint": "Отсчёт уже начат. Срок до: {}. Снимите галочку, чтобы задать фиксированную дату.",
"description_label": "Описание (опц.)",
"auto_conn_title": "Автоматическое создание подключения (опционально)",
"server_label": "Сервер",