Add optional expiration that starts on first config use.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сервер",
|
||||
|
||||
Reference in New Issue
Block a user