Add invite links with admin-configurable config creation limits.
Admins can create shareable links that allow redeeming a VPN config a set number of times (or unlimited). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1879,6 +1879,39 @@ class GuestCreateRequest(BaseModel):
|
||||
name: str = 'Guest VPN'
|
||||
|
||||
|
||||
class InviteCreateRequest(BaseModel):
|
||||
name: str = 'Invite'
|
||||
max_uses: int = 1 # 0 = unlimited
|
||||
user_id: str = ''
|
||||
protocol: str = 'xui'
|
||||
server_id: int = 0
|
||||
xui_inbound_id: int = 0
|
||||
password: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
note: str = ''
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class InviteUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
max_uses: Optional[int] = None
|
||||
user_id: Optional[str] = None
|
||||
protocol: Optional[str] = None
|
||||
server_id: Optional[int] = None
|
||||
xui_inbound_id: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
clear_password: bool = False
|
||||
expires_at: Optional[str] = None
|
||||
clear_expires: bool = False
|
||||
note: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
reset_used: bool = False
|
||||
|
||||
|
||||
class InviteRedeemRequest(BaseModel):
|
||||
name: str = 'Invite VPN'
|
||||
|
||||
|
||||
class TunnelStartRequest(BaseModel):
|
||||
authtoken: Optional[str] = None
|
||||
|
||||
@@ -4207,6 +4240,363 @@ async def api_guest_regenerate_token(request: Request):
|
||||
return {'status': 'success', 'token': guest['token']}
|
||||
|
||||
|
||||
# ======================== Invite links (limited config creation) ========================
|
||||
|
||||
def _invite_public_view(link: dict) -> dict:
|
||||
max_uses = int(link.get('max_uses') or 0)
|
||||
used = int(link.get('used_count') or 0)
|
||||
remaining = None if max_uses <= 0 else max(0, max_uses - used)
|
||||
expired = False
|
||||
if link.get('expires_at'):
|
||||
try:
|
||||
exp = datetime.fromisoformat(str(link['expires_at']).replace('Z', '+00:00'))
|
||||
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
|
||||
expired = now > exp
|
||||
except Exception:
|
||||
expired = False
|
||||
exhausted = remaining is not None and remaining <= 0
|
||||
return {
|
||||
'id': link.get('id'),
|
||||
'name': link.get('name') or 'Invite',
|
||||
'token': link.get('token'),
|
||||
'enabled': bool(link.get('enabled', True)),
|
||||
'max_uses': max_uses,
|
||||
'used_count': used,
|
||||
'remaining': remaining,
|
||||
'unlimited': max_uses <= 0,
|
||||
'expired': expired,
|
||||
'exhausted': exhausted,
|
||||
'has_password': bool(link.get('password_hash')),
|
||||
'protocol': link.get('protocol') or 'xui',
|
||||
'server_id': int(link.get('server_id') or 0),
|
||||
'xui_inbound_id': int(link.get('xui_inbound_id') or 0),
|
||||
'user_id': link.get('user_id') or '',
|
||||
'note': link.get('note') or '',
|
||||
'expires_at': link.get('expires_at'),
|
||||
'created_at': link.get('created_at'),
|
||||
'available': bool(link.get('enabled', True)) and not expired and not exhausted,
|
||||
}
|
||||
|
||||
|
||||
def _find_invite(data: dict, token: str) -> Optional[dict]:
|
||||
return next((x for x in data.get('invite_links', []) if x.get('token') == token), None)
|
||||
|
||||
|
||||
def _invite_auth_ok(link: dict, request: Request) -> bool:
|
||||
if not link.get('password_hash'):
|
||||
return True
|
||||
return bool(request.session.get(f"invite_auth_{link.get('token')}"))
|
||||
|
||||
|
||||
async def _create_config_for_protocol(
|
||||
data: dict,
|
||||
*,
|
||||
protocol: str,
|
||||
name: str,
|
||||
server_id: int = 0,
|
||||
xui_inbound_id: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""Create VPN client; returns {client_id, config, protocol, server_id}."""
|
||||
protocol = protocol or 'xui'
|
||||
if protocol_base(protocol) == 'xui':
|
||||
from managers.xui_api import xui_create_vless_config
|
||||
created = await xui_create_vless_config(
|
||||
data.get('settings', {}),
|
||||
name=name,
|
||||
inbound_id=xui_inbound_id,
|
||||
)
|
||||
return {
|
||||
'client_id': created['client_id'],
|
||||
'config': created.get('config') or '',
|
||||
'protocol': 'xui',
|
||||
'server_id': 0,
|
||||
}
|
||||
|
||||
sid = int(server_id or 0)
|
||||
if sid >= len(data['servers']):
|
||||
raise RuntimeError('Server not found')
|
||||
server = data['servers'][sid]
|
||||
proto_info = server.get('protocols', {}).get(protocol, {})
|
||||
port = proto_info.get('port', '55424')
|
||||
ssh = get_ssh(server)
|
||||
await asyncio.to_thread(ssh.connect)
|
||||
try:
|
||||
manager = get_protocol_manager(ssh, protocol)
|
||||
if protocol_base(protocol) == 'wireguard':
|
||||
result = await asyncio.to_thread(manager.add_client, name, server['host'])
|
||||
else:
|
||||
result = await asyncio.to_thread(
|
||||
_manager_call, manager, 'add_client',
|
||||
protocol, name, server['host'], port,
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(ssh.disconnect)
|
||||
if not result.get('client_id'):
|
||||
raise RuntimeError('Failed to create config')
|
||||
return {
|
||||
'client_id': result['client_id'],
|
||||
'config': result.get('config') or '',
|
||||
'protocol': protocol,
|
||||
'server_id': sid,
|
||||
}
|
||||
|
||||
|
||||
@app.get('/invites', response_class=HTMLResponse, tags=["System Templates"])
|
||||
async def invites_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url='/login', status_code=302)
|
||||
if user['role'] not in ('admin', 'support'):
|
||||
return RedirectResponse(url='/my', status_code=302)
|
||||
data = load_data()
|
||||
return tpl(
|
||||
request,
|
||||
'invites.html',
|
||||
invites=[_invite_public_view(x) for x in data.get('invite_links', [])],
|
||||
users=data.get('users', []),
|
||||
servers=data.get('servers', []),
|
||||
xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')),
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/invites', tags=["Invites"])
|
||||
async def api_list_invites(request: Request):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
data = load_data()
|
||||
return {'invites': [_invite_public_view(x) for x in data.get('invite_links', [])]}
|
||||
|
||||
|
||||
@app.post('/api/invites', tags=["Invites"])
|
||||
async def api_create_invite(request: Request, req: InviteCreateRequest):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
if req.max_uses < 0:
|
||||
return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400)
|
||||
data = load_data()
|
||||
if req.user_id and not any(u['id'] == req.user_id for u in data['users']):
|
||||
return JSONResponse({'error': 'User not found'}, status_code=400)
|
||||
link = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'name': (req.name or 'Invite').strip() or 'Invite',
|
||||
'token': secrets.token_urlsafe(16),
|
||||
'enabled': bool(req.enabled),
|
||||
'max_uses': int(req.max_uses),
|
||||
'used_count': 0,
|
||||
'user_id': req.user_id or '',
|
||||
'protocol': req.protocol or 'xui',
|
||||
'server_id': int(req.server_id or 0),
|
||||
'xui_inbound_id': int(req.xui_inbound_id or 0),
|
||||
'password_hash': hash_password(req.password) if req.password else None,
|
||||
'expires_at': req.expires_at or None,
|
||||
'note': req.note or '',
|
||||
'created_at': datetime.now().isoformat(),
|
||||
}
|
||||
data.setdefault('invite_links', []).append(link)
|
||||
save_data(data)
|
||||
view = _invite_public_view(link)
|
||||
return {'status': 'success', 'invite': view, 'url': f"/invite/{link['token']}"}
|
||||
|
||||
|
||||
@app.put('/api/invites/{invite_id}', tags=["Invites"])
|
||||
async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateRequest):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
data = load_data()
|
||||
link = next((x for x in data.get('invite_links', []) if x.get('id') == invite_id), None)
|
||||
if not link:
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
if req.name is not None:
|
||||
link['name'] = req.name.strip() or link['name']
|
||||
if req.max_uses is not None:
|
||||
if req.max_uses < 0:
|
||||
return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400)
|
||||
link['max_uses'] = int(req.max_uses)
|
||||
if req.user_id is not None:
|
||||
if req.user_id and not any(u['id'] == req.user_id for u in data['users']):
|
||||
return JSONResponse({'error': 'User not found'}, status_code=400)
|
||||
link['user_id'] = req.user_id
|
||||
if req.protocol is not None:
|
||||
link['protocol'] = req.protocol
|
||||
if req.server_id is not None:
|
||||
link['server_id'] = int(req.server_id)
|
||||
if req.xui_inbound_id is not None:
|
||||
link['xui_inbound_id'] = int(req.xui_inbound_id)
|
||||
if req.clear_password:
|
||||
link['password_hash'] = None
|
||||
elif req.password:
|
||||
link['password_hash'] = hash_password(req.password)
|
||||
if req.clear_expires:
|
||||
link['expires_at'] = None
|
||||
elif req.expires_at is not None:
|
||||
link['expires_at'] = req.expires_at or None
|
||||
if req.note is not None:
|
||||
link['note'] = req.note
|
||||
if req.enabled is not None:
|
||||
link['enabled'] = bool(req.enabled)
|
||||
if req.reset_used:
|
||||
link['used_count'] = 0
|
||||
save_data(data)
|
||||
return {'status': 'success', 'invite': _invite_public_view(link)}
|
||||
|
||||
|
||||
@app.delete('/api/invites/{invite_id}', tags=["Invites"])
|
||||
async def api_delete_invite(request: Request, invite_id: str):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
data = load_data()
|
||||
before = len(data.get('invite_links', []))
|
||||
data['invite_links'] = [x for x in data.get('invite_links', []) if x.get('id') != invite_id]
|
||||
if len(data['invite_links']) == before:
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
save_data(data)
|
||||
return {'status': 'success'}
|
||||
|
||||
|
||||
@app.get('/invite/{token}', response_class=HTMLResponse, tags=["System Templates"])
|
||||
async def invite_public_page(token: str, request: Request):
|
||||
data = load_data()
|
||||
link = _find_invite(data, token)
|
||||
lang = request.cookies.get('lang', 'ru')
|
||||
if not link:
|
||||
return HTMLResponse(
|
||||
f"<h1>{_t('invite_not_found', lang)}</h1><p>{_t('invite_not_found_desc', lang)}</p>",
|
||||
status_code=404,
|
||||
)
|
||||
view = _invite_public_view(link)
|
||||
need_password = bool(link.get('password_hash')) and not _invite_auth_ok(link, request)
|
||||
return tpl(
|
||||
request,
|
||||
'invite.html',
|
||||
invite=view,
|
||||
need_password=need_password,
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/invite/{token}/auth', tags=["Invites"])
|
||||
async def api_invite_auth(token: str, req: ShareAuthRequest, request: Request):
|
||||
data = load_data()
|
||||
link = _find_invite(data, token)
|
||||
if not link or not link.get('enabled'):
|
||||
return JSONResponse({'error': 'Link expired or disabled'}, status_code=404)
|
||||
if not link.get('password_hash'):
|
||||
request.session[f'invite_auth_{token}'] = True
|
||||
return {'status': 'success'}
|
||||
if verify_password(req.password, link.get('password_hash') or ''):
|
||||
request.session[f'invite_auth_{token}'] = True
|
||||
return {'status': 'success'}
|
||||
lang = request.cookies.get('lang', 'ru')
|
||||
return JSONResponse({'error': _t('wrong_invite_password', lang)}, status_code=401)
|
||||
|
||||
|
||||
@app.get('/api/invite/{token}/info', tags=["Invites"])
|
||||
async def api_invite_info(token: str, request: Request):
|
||||
data = load_data()
|
||||
link = _find_invite(data, token)
|
||||
if not link:
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
if not _invite_auth_ok(link, request):
|
||||
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
||||
view = _invite_public_view(link)
|
||||
# Don't leak admin note / ids beyond what's needed
|
||||
return {
|
||||
'name': view['name'],
|
||||
'available': view['available'],
|
||||
'enabled': view['enabled'],
|
||||
'expired': view['expired'],
|
||||
'exhausted': view['exhausted'],
|
||||
'unlimited': view['unlimited'],
|
||||
'remaining': view['remaining'],
|
||||
'max_uses': view['max_uses'],
|
||||
'used_count': view['used_count'],
|
||||
'protocol': view['protocol'],
|
||||
}
|
||||
|
||||
|
||||
@app.post('/api/invite/{token}/create', tags=["Invites"])
|
||||
async def api_invite_create_config(token: str, req: InviteRedeemRequest, request: Request):
|
||||
data = load_data()
|
||||
link = _find_invite(data, token)
|
||||
if not link:
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
if not _invite_auth_ok(link, request):
|
||||
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
||||
|
||||
view = _invite_public_view(link)
|
||||
if not view['available']:
|
||||
if view['expired']:
|
||||
return JSONResponse({'error': 'Invite link expired'}, status_code=403)
|
||||
if view['exhausted']:
|
||||
return JSONResponse({'error': 'Invite link has no remaining uses'}, status_code=403)
|
||||
return JSONResponse({'error': 'Invite link is disabled'}, status_code=403)
|
||||
|
||||
holder_id = link.get('user_id') or ''
|
||||
if not holder_id or not any(u['id'] == holder_id for u in data['users']):
|
||||
return JSONResponse({'error': 'Invite holder user is not configured'}, status_code=400)
|
||||
|
||||
# Reserve a use slot under lock
|
||||
async with DATA_LOCK:
|
||||
data = load_data()
|
||||
link = _find_invite(data, token)
|
||||
if not link:
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
view = _invite_public_view(link)
|
||||
if not view['available']:
|
||||
return JSONResponse({'error': 'Invite link is no longer available'}, status_code=403)
|
||||
link['used_count'] = int(link.get('used_count') or 0) + 1
|
||||
save_data(data)
|
||||
|
||||
name = (req.name or 'Invite VPN').strip() or 'Invite VPN'
|
||||
name = f"{name}_{secrets.token_hex(3)}"
|
||||
try:
|
||||
data = load_data()
|
||||
created = await _create_config_for_protocol(
|
||||
data,
|
||||
protocol=link.get('protocol') or 'xui',
|
||||
name=name,
|
||||
server_id=int(link.get('server_id') or 0),
|
||||
xui_inbound_id=link.get('xui_inbound_id') or None,
|
||||
)
|
||||
conn = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'user_id': holder_id,
|
||||
'server_id': created['server_id'],
|
||||
'protocol': created['protocol'],
|
||||
'client_id': created['client_id'],
|
||||
'name': name,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
}
|
||||
async with DATA_LOCK:
|
||||
data = load_data()
|
||||
data.setdefault('user_connections', []).append(conn)
|
||||
save_data(data)
|
||||
config = created.get('config') or ''
|
||||
vpn_link = generate_vpn_link(config) if config else ''
|
||||
data = load_data()
|
||||
link = _find_invite(data, token) or link
|
||||
return {
|
||||
'status': 'success',
|
||||
'config': config,
|
||||
'vpn_link': vpn_link,
|
||||
'connection': conn,
|
||||
'invite': _invite_public_view(link),
|
||||
}
|
||||
except Exception as e:
|
||||
# Roll back reserved use
|
||||
try:
|
||||
async with DATA_LOCK:
|
||||
data = load_data()
|
||||
link = _find_invite(data, token)
|
||||
if link and int(link.get('used_count') or 0) > 0:
|
||||
link['used_count'] = int(link['used_count']) - 1
|
||||
save_data(data)
|
||||
except Exception:
|
||||
logger.exception('Failed to roll back invite use count')
|
||||
logger.exception('Error creating invite config')
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/my/connections/{connection_id}/config', tags=["Self-service"])
|
||||
async def api_my_connection_config(request: Request, connection_id: str):
|
||||
user = get_current_user(request)
|
||||
@@ -4703,6 +5093,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
|
||||
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
|
||||
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
|
||||
|
||||
backup_data.setdefault('user_connections', [])
|
||||
backup_data.setdefault('api_tokens', [])
|
||||
backup_data.setdefault('invite_links', [])
|
||||
backup_data.setdefault('settings', {})
|
||||
|
||||
# Save the new data
|
||||
async with DATA_LOCK:
|
||||
save_data(backup_data)
|
||||
|
||||
@@ -72,5 +72,24 @@ CREATE TABLE IF NOT EXISTS tunnel_state (
|
||||
data JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invite_links (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
user_id UUID,
|
||||
protocol TEXT NOT NULL DEFAULT 'xui',
|
||||
server_id INTEGER NOT NULL DEFAULT 0,
|
||||
xui_inbound_id INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_links_token ON invite_links(token);
|
||||
|
||||
INSERT INTO settings (id, data) VALUES (1, '{}'::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
+61
@@ -195,6 +195,25 @@ def _row_to_token(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _row_to_invite(row) -> dict:
|
||||
return {
|
||||
'id': str(row['id']),
|
||||
'name': row['name'] or '',
|
||||
'token': row['token'] or '',
|
||||
'enabled': bool(row['enabled']),
|
||||
'max_uses': int(row['max_uses'] or 0),
|
||||
'used_count': int(row['used_count'] or 0),
|
||||
'user_id': str(row['user_id']) if row['user_id'] else '',
|
||||
'protocol': row['protocol'] or 'xui',
|
||||
'server_id': int(row['server_id'] or 0),
|
||||
'xui_inbound_id': int(row['xui_inbound_id'] or 0),
|
||||
'password_hash': row['password_hash'],
|
||||
'expires_at': _ts_iso(row['expires_at']),
|
||||
'note': row['note'] or '',
|
||||
'created_at': _ts_iso(row['created_at']),
|
||||
}
|
||||
|
||||
|
||||
def load_data() -> dict:
|
||||
init_schema()
|
||||
pool = get_pool()
|
||||
@@ -230,6 +249,14 @@ def load_data() -> dict:
|
||||
)
|
||||
api_tokens = [_row_to_token(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
'SELECT id, name, token, enabled, max_uses, used_count, user_id, '
|
||||
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
||||
'note, created_at FROM invite_links '
|
||||
'ORDER BY created_at DESC NULLS LAST, name'
|
||||
)
|
||||
invite_links = [_row_to_invite(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute('SELECT data FROM settings WHERE id = 1')
|
||||
settings_row = cur.fetchone()
|
||||
settings = _merge_settings(settings_row['data'] if settings_row else None)
|
||||
@@ -239,6 +266,7 @@ def load_data() -> dict:
|
||||
'users': users,
|
||||
'user_connections': user_connections,
|
||||
'api_tokens': api_tokens,
|
||||
'invite_links': invite_links,
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
@@ -250,17 +278,23 @@ def save_data(data: dict) -> None:
|
||||
users = data.get('users') or []
|
||||
connections = data.get('user_connections') or []
|
||||
tokens = data.get('api_tokens') or []
|
||||
invite_links = data.get('invite_links') or []
|
||||
settings = _merge_settings(data.get('settings'))
|
||||
|
||||
# Keep only connections whose user still exists
|
||||
user_ids = {str(u.get('id')) for u in users if u.get('id')}
|
||||
connections = [c for c in connections if str(c.get('user_id')) in user_ids]
|
||||
tokens = [t for t in tokens if str(t.get('user_id')) in user_ids]
|
||||
# Clear holder if user was deleted
|
||||
for link in invite_links:
|
||||
if link.get('user_id') and str(link['user_id']) not in user_ids:
|
||||
link['user_id'] = ''
|
||||
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Order matters for FKs: children first on delete, parents first on insert
|
||||
cur.execute('DELETE FROM invite_links')
|
||||
cur.execute('DELETE FROM api_tokens')
|
||||
cur.execute('DELETE FROM user_connections')
|
||||
cur.execute('DELETE FROM users')
|
||||
@@ -352,6 +386,32 @@ def save_data(data: dict) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
for link in invite_links:
|
||||
uid = link.get('user_id') or None
|
||||
cur.execute(
|
||||
'INSERT INTO invite_links ('
|
||||
'id, name, token, enabled, max_uses, used_count, user_id, '
|
||||
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
||||
'note, created_at'
|
||||
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(
|
||||
_as_uuid(link['id']),
|
||||
link.get('name') or '',
|
||||
link.get('token') or '',
|
||||
bool(link.get('enabled', True)),
|
||||
int(link.get('max_uses') or 0),
|
||||
int(link.get('used_count') or 0),
|
||||
_as_uuid(uid) if uid else None,
|
||||
link.get('protocol') or 'xui',
|
||||
int(link.get('server_id') or 0),
|
||||
int(link.get('xui_inbound_id') or 0),
|
||||
link.get('password_hash'),
|
||||
_parse_ts(link.get('expires_at')),
|
||||
link.get('note') or '',
|
||||
_parse_ts(link.get('created_at')),
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
'INSERT INTO settings (id, data) VALUES (1, %s) '
|
||||
'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data',
|
||||
@@ -393,6 +453,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo
|
||||
data.setdefault('users', [])
|
||||
data.setdefault('user_connections', [])
|
||||
data.setdefault('api_tokens', [])
|
||||
data.setdefault('invite_links', [])
|
||||
data.setdefault('settings', {})
|
||||
|
||||
save_data(data)
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
{% if current_user.role in ['admin', 'support'] %}
|
||||
<a href="/" class="nav-link">{{ _('nav_servers') }}</a>
|
||||
<a href="/users" class="nav-link">{{ _('nav_users') }}</a>
|
||||
<a href="/invites" class="nav-link">{{ _('nav_invites') }}</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<a href="/settings" class="nav-link">{{ _('nav_settings') }}</a>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 560px; margin: 2rem auto;">
|
||||
<div class="card-header"
|
||||
style="justify-content: center; text-align: center; flex-direction: column; gap: var(--space-xs);">
|
||||
<h2 class="card-title">{{ invite.name }}</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('invite_public_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{% if need_password %}
|
||||
<div style="padding: var(--space-lg); text-align: center;">
|
||||
<div class="logo-icon" style="font-size: 3rem; margin-bottom: var(--space-md);">🔐</div>
|
||||
<p style="margin-bottom: var(--space-md);">{{ _('invite_protected_desc') }}</p>
|
||||
<form id="authForm" onsubmit="authInvite(event)"
|
||||
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
|
||||
<div class="form-group">
|
||||
<input type="password" id="invitePassword" class="form-input" placeholder="{{ _('password') }}" required autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="authBtn">
|
||||
<span id="authBtnText">{{ _('login') }}</span>
|
||||
<div class="spinner hidden" id="authSpinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: var(--space-lg); text-align: center;">
|
||||
<div id="statusBox" style="margin-bottom: var(--space-md); color: var(--text-muted); font-size: 0.95rem;">
|
||||
{% if invite.unlimited %}
|
||||
{{ _('invite_uses_left_unlimited').replace('{}', invite.used_count|string) }}
|
||||
{% elif invite.remaining is not none %}
|
||||
{{ _('invite_uses_left').replace('{}', invite.remaining|string) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if invite.available %}
|
||||
<button class="btn btn-primary" style="width:100%; max-width:320px;" onclick="createInviteConfig()" id="createBtn">
|
||||
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
||||
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
||||
</button>
|
||||
<div class="form-hint" style="margin-top: var(--space-sm);">{{ _('invite_get_config_hint') }}</div>
|
||||
{% elif invite.expired %}
|
||||
<p style="color:#ef4444;">{{ _('invite_expired') }}</p>
|
||||
{% elif invite.exhausted %}
|
||||
<p style="color:#ef4444;">{{ _('invite_exhausted') }}</p>
|
||||
{% else %}
|
||||
<p style="color:#ef4444;">{{ _('disabled') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<textarea class="config-text" id="configText" readonly rows="12"
|
||||
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config') }}</button>
|
||||
<a id="downloadBtn" class="btn btn-primary btn-sm"
|
||||
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center;">
|
||||
{{ _('download_conf') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-panel" id="panel-vpn">
|
||||
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
|
||||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" 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>
|
||||
|
||||
<script>
|
||||
const TOKEN = "{{ token }}";
|
||||
let currentConfig = '';
|
||||
let currentVpnLink = '';
|
||||
|
||||
async function authInvite(e) {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('invitePassword').value;
|
||||
const btn = document.getElementById('authBtn');
|
||||
const text = document.getElementById('authBtnText');
|
||||
const spinner = document.getElementById('authSpinner');
|
||||
btn.disabled = true;
|
||||
text.classList.add('hidden');
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch(`/api/invite/${TOKEN}/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') window.location.reload();
|
||||
else alert(data.error || _('login_error'));
|
||||
} catch (err) {
|
||||
alert(`${_('error')}: ` + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.classList.remove('hidden');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
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 openConfigModal(name, config, vpnLink) {
|
||||
currentConfig = config;
|
||||
currentVpnLink = vpnLink || '';
|
||||
document.getElementById('configText').value = config;
|
||||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||
document.getElementById('configModalTitle').textContent = name;
|
||||
document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.conf`);
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
qrContainer.innerHTML = '';
|
||||
new QRCode(qrContainer, {
|
||||
text: config, width: 256, height: 256,
|
||||
colorDark: '#000000', colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
});
|
||||
switchConfigTab('conf');
|
||||
openModal('configModal');
|
||||
}
|
||||
|
||||
function copyConfig() { copyToClipboard(currentConfig); }
|
||||
function copyVpnLink() { copyToClipboard(currentVpnLink); }
|
||||
|
||||
function updateStatus(invite) {
|
||||
const box = document.getElementById('statusBox');
|
||||
if (!box || !invite) return;
|
||||
if (invite.unlimited) {
|
||||
box.textContent = _('invite_uses_left_unlimited').replace('{}', String(invite.used_count || 0));
|
||||
} else if (invite.remaining != null) {
|
||||
box.textContent = _('invite_uses_left').replace('{}', String(invite.remaining));
|
||||
}
|
||||
if (!invite.available) {
|
||||
const btn = document.getElementById('createBtn');
|
||||
if (btn) btn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function createInviteConfig() {
|
||||
const btn = document.getElementById('createBtn');
|
||||
const text = document.getElementById('createBtnText');
|
||||
const spinner = document.getElementById('createSpinner');
|
||||
btn.disabled = true;
|
||||
text.classList.add('hidden');
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch(`/api/invite/${TOKEN}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Invite VPN' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
||||
if (data.config) openConfigModal(data.connection?.name || 'VPN', data.config, data.vpn_link || '');
|
||||
updateStatus(data.invite);
|
||||
} catch (err) {
|
||||
alert(`${_('error')}: ` + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.classList.remove('hidden');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.spinner {
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 3px solid var(--accent-color);
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,334 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title_extra %} — {{ _('invites_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;">
|
||||
<span class="icon">🔗</span>
|
||||
{{ _('invites_title') }}
|
||||
</h1>
|
||||
<button class="btn btn-primary" onclick="openCreateInvite()">
|
||||
<span>+</span> {{ _('invite_create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="form-hint" style="margin-bottom: var(--space-lg);">{{ _('invites_hint') }}</p>
|
||||
|
||||
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
||||
<div class="empty-icon">🔗</div>
|
||||
<div class="empty-title">{{ _('invites_empty') }}</div>
|
||||
<div class="empty-desc">{{ _('invites_empty_desc') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="clients-list" id="invitesGrid"
|
||||
style="display: grid; gap: var(--space-md); {% if not invites %}display:none;{% endif %}">
|
||||
{% for inv in invites %}
|
||||
<div class="client-item" id="invite-{{ inv.id }}" style="{% if not inv.enabled %}opacity:0.55;{% endif %}">
|
||||
<div class="client-info">
|
||||
<div class="client-avatar">🔗</div>
|
||||
<div>
|
||||
<div class="client-name">
|
||||
{{ inv.name }}
|
||||
{% if inv.available %}
|
||||
<span class="badge badge-success">{{ _('invite_active') }}</span>
|
||||
{% elif inv.expired %}
|
||||
<span class="badge badge-warn">{{ _('invite_expired') }}</span>
|
||||
{% elif inv.exhausted %}
|
||||
<span class="badge badge-warn">{{ _('invite_exhausted') }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">{{ _('disabled') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="client-meta">
|
||||
<span>{{ _('invite_uses') }}:
|
||||
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
|
||||
</span>
|
||||
<span>{{ inv.protocol if inv.protocol != 'xui' else '3x-ui VLESS' }}</span>
|
||||
{% if inv.has_password %}<span>🔐</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="client-actions" style="display:flex; gap:var(--space-xs); flex-wrap:wrap;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyInviteUrl('{{ inv.token }}')" title="{{ _('copy') }}">📋</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="editInvite('{{ inv.id }}')">✏️</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="toggleInvite('{{ inv.id }}', {{ 'false' if inv.enabled else 'true' }})">
|
||||
{% if inv.enabled %}⏸{% else %}▶️{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteInvite('{{ inv.id }}')">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Create / Edit Modal -->
|
||||
<div class="modal-backdrop" id="inviteModal">
|
||||
<div class="modal" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="inviteModalTitle">{{ _('invite_create') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('inviteModal')">×</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="inviteEditId" value="">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_name_label') }}</label>
|
||||
<input class="form-input" type="text" id="inviteName" placeholder="Promo / Friends">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_max_uses_label') }}</label>
|
||||
<input class="form-input" type="number" id="inviteMaxUses" min="0" value="1">
|
||||
<div class="form-hint">{{ _('invite_max_uses_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_user_label') }}</label>
|
||||
<select class="form-select" id="inviteUserId">
|
||||
<option value="">{{ _('guest_user_none') }}</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}">{{ u.username }} ({{ u.role }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-hint">{{ _('invite_user_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
|
||||
<option value="xui">3x-ui VLESS</option>
|
||||
{% for s in servers %}
|
||||
{% set server_idx = loop.index0 %}
|
||||
{% for key, info in (s.protocols or {}).items() %}
|
||||
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
|
||||
<option value="{{ key }}|{{ server_idx }}">{{ s.name or s.host }} — {{ key }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="inviteInboundGroup">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="inviteInboundId">
|
||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_password_label') }}</label>
|
||||
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
|
||||
<label style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;" id="inviteClearPwdWrap">
|
||||
<input type="checkbox" id="inviteClearPassword"> {{ _('guest_clear_password') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_expires_label') }}</label>
|
||||
<input class="form-input" type="datetime-local" id="inviteExpires">
|
||||
<label style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;" id="inviteClearExpWrap">
|
||||
<input type="checkbox" id="inviteClearExpires"> {{ _('invite_clear_expires') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_note_label') }}</label>
|
||||
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="inviteResetUsedWrap" style="display:none;">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="inviteResetUsed"> {{ _('invite_reset_used') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="display:flex; gap:var(--space-sm); justify-content:flex-end;">
|
||||
<button class="btn btn-secondary" onclick="closeModal('inviteModal')">{{ _('cancel') }}</button>
|
||||
<button class="btn btn-primary" onclick="saveInvite()" id="inviteSaveBtn">
|
||||
<span id="inviteSaveText">{{ _('save') }}</span>
|
||||
<div class="spinner hidden" id="inviteSaveSpinner" style="width:14px;height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const invitesData = {{ invites | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
|
||||
function inviteUrl(token) {
|
||||
return `${window.location.origin}/invite/${token}`;
|
||||
}
|
||||
|
||||
function copyInviteUrl(token) {
|
||||
copyToClipboard(inviteUrl(token));
|
||||
showToast(_('copied'), 'success');
|
||||
}
|
||||
|
||||
function updateInviteProtoFields() {
|
||||
const v = document.getElementById('inviteProtocol').value;
|
||||
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
|
||||
}
|
||||
|
||||
async function loadInviteInbounds(selected) {
|
||||
const select = document.getElementById('inviteInboundId');
|
||||
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
||||
if (!xuiConfigured) return;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/xui/inbounds');
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
if (selected && String(ib.id) === String(selected)) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function parseProtocolValue() {
|
||||
const raw = document.getElementById('inviteProtocol').value;
|
||||
if (raw === 'xui') return { protocol: 'xui', server_id: 0 };
|
||||
if (raw.includes('|')) {
|
||||
const [protocol, sid] = raw.split('|');
|
||||
return { protocol, server_id: parseInt(sid || '0') };
|
||||
}
|
||||
return { protocol: raw, server_id: 0 };
|
||||
}
|
||||
|
||||
function setProtocolSelect(protocol, serverId) {
|
||||
const sel = document.getElementById('inviteProtocol');
|
||||
if (protocol === 'xui') {
|
||||
sel.value = 'xui';
|
||||
} else {
|
||||
const want = `${protocol}|${serverId || 0}`;
|
||||
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
||||
else sel.value = 'xui';
|
||||
}
|
||||
updateInviteProtoFields();
|
||||
}
|
||||
|
||||
function openCreateInvite() {
|
||||
document.getElementById('inviteEditId').value = '';
|
||||
document.getElementById('inviteModalTitle').textContent = _('invite_create');
|
||||
document.getElementById('inviteName').value = '';
|
||||
document.getElementById('inviteMaxUses').value = '1';
|
||||
document.getElementById('inviteUserId').value = '';
|
||||
document.getElementById('invitePassword').value = '';
|
||||
document.getElementById('inviteExpires').value = '';
|
||||
document.getElementById('inviteNote').value = '';
|
||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||
document.getElementById('inviteClearExpWrap').style.display = 'none';
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||
setProtocolSelect('xui', 0);
|
||||
loadInviteInbounds(0);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
|
||||
function editInvite(id) {
|
||||
const inv = invitesData.find(x => x.id === id);
|
||||
if (!inv) return;
|
||||
document.getElementById('inviteEditId').value = id;
|
||||
document.getElementById('inviteModalTitle').textContent = _('invite_edit');
|
||||
document.getElementById('inviteName').value = inv.name || '';
|
||||
document.getElementById('inviteMaxUses').value = String(inv.max_uses ?? 1);
|
||||
document.getElementById('inviteUserId').value = inv.user_id || '';
|
||||
document.getElementById('invitePassword').value = '';
|
||||
document.getElementById('inviteNote').value = inv.note || '';
|
||||
document.getElementById('inviteClearPwdWrap').style.display = inv.has_password ? 'flex' : 'none';
|
||||
document.getElementById('inviteClearPassword').checked = false;
|
||||
document.getElementById('inviteClearExpWrap').style.display = inv.expires_at ? 'flex' : 'none';
|
||||
document.getElementById('inviteClearExpires').checked = false;
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
||||
document.getElementById('inviteResetUsed').checked = false;
|
||||
if (inv.expires_at) {
|
||||
try {
|
||||
const d = new Date(inv.expires_at);
|
||||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||
document.getElementById('inviteExpires').value = local.toISOString().slice(0, 16);
|
||||
} catch (e) {
|
||||
document.getElementById('inviteExpires').value = '';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('inviteExpires').value = '';
|
||||
}
|
||||
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
|
||||
loadInviteInbounds(inv.xui_inbound_id || 0);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
|
||||
async function saveInvite() {
|
||||
const btn = document.getElementById('inviteSaveBtn');
|
||||
const text = document.getElementById('inviteSaveText');
|
||||
const spinner = document.getElementById('inviteSaveSpinner');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const editId = document.getElementById('inviteEditId').value;
|
||||
const proto = parseProtocolValue();
|
||||
const expiresVal = document.getElementById('inviteExpires').value;
|
||||
const body = {
|
||||
name: document.getElementById('inviteName').value || 'Invite',
|
||||
max_uses: parseInt(document.getElementById('inviteMaxUses').value || '1'),
|
||||
user_id: document.getElementById('inviteUserId').value || '',
|
||||
protocol: proto.protocol,
|
||||
server_id: proto.server_id,
|
||||
xui_inbound_id: parseInt(document.getElementById('inviteInboundId').value || '0'),
|
||||
note: document.getElementById('inviteNote').value || '',
|
||||
};
|
||||
const pwd = document.getElementById('invitePassword').value;
|
||||
if (pwd) body.password = pwd;
|
||||
if (expiresVal) body.expires_at = new Date(expiresVal).toISOString();
|
||||
|
||||
if (!body.user_id) throw new Error(_('invite_user_required'));
|
||||
|
||||
if (editId) {
|
||||
body.clear_password = document.getElementById('inviteClearPassword').checked;
|
||||
body.clear_expires = document.getElementById('inviteClearExpires').checked;
|
||||
body.reset_used = document.getElementById('inviteResetUsed').checked;
|
||||
if (!expiresVal && !body.clear_expires) {
|
||||
// keep existing — don't send expires_at
|
||||
delete body.expires_at;
|
||||
}
|
||||
await apiCall(`/api/invites/${editId}`, 'PUT', body);
|
||||
showToast(_('invite_updated'), 'success');
|
||||
} else {
|
||||
const res = await apiCall('/api/invites', 'POST', body);
|
||||
showToast(_('invite_created'), 'success');
|
||||
if (res.url) copyToClipboard(window.location.origin + res.url);
|
||||
}
|
||||
closeModal('inviteModal');
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleInvite(id, enabled) {
|
||||
try {
|
||||
await apiCall(`/api/invites/${id}`, 'PUT', { enabled });
|
||||
showToast(_('invite_updated'), 'success');
|
||||
setTimeout(() => window.location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInvite(id) {
|
||||
if (!confirm(_('invite_delete_confirm'))) return;
|
||||
try {
|
||||
await apiCall(`/api/invites/${id}`, 'DELETE');
|
||||
showToast(_('invite_deleted'), 'success');
|
||||
setTimeout(() => window.location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav_servers": "Servers",
|
||||
"nav_users": "Users",
|
||||
"nav_invites": "Invite links",
|
||||
"nav_settings": "Settings",
|
||||
"nav_connections": "Connections",
|
||||
"nav_logout": "Logout",
|
||||
@@ -196,6 +197,41 @@
|
||||
"guest_not_found": "404 Not Found",
|
||||
"guest_not_found_desc": "Guest access is disabled or the link is invalid.",
|
||||
"wrong_guest_password": "Wrong password",
|
||||
"invites_title": "Invite links",
|
||||
"invites_hint": "Create shareable links. Anyone with the link can create a VPN config — you set how many times.",
|
||||
"invites_empty": "No invite links yet",
|
||||
"invites_empty_desc": "Create a link and share it — no registration needed.",
|
||||
"invite_create": "Create link",
|
||||
"invite_edit": "Edit link",
|
||||
"invite_active": "Active",
|
||||
"invite_expired": "Expired",
|
||||
"invite_exhausted": "No uses left",
|
||||
"invite_uses": "Uses",
|
||||
"invite_name_label": "Link name",
|
||||
"invite_max_uses_label": "Max configs",
|
||||
"invite_max_uses_hint": "How many configs can be created via this link. 0 = unlimited.",
|
||||
"invite_user_label": "Store configs under user",
|
||||
"invite_user_hint": "Created configs are attached to this panel user (for management).",
|
||||
"invite_user_required": "Select a user to store configs under",
|
||||
"invite_password_label": "Password (optional)",
|
||||
"invite_expires_label": "Expires at (optional)",
|
||||
"invite_clear_expires": "Remove expiration",
|
||||
"invite_note_label": "Note (admin only)",
|
||||
"invite_note_placeholder": "Internal comment",
|
||||
"invite_reset_used": "Reset used counter to 0",
|
||||
"invite_created": "Invite link created (URL copied)",
|
||||
"invite_updated": "Invite link updated",
|
||||
"invite_deleted": "Invite link deleted",
|
||||
"invite_delete_confirm": "Delete this invite link?",
|
||||
"invite_public_title": "Invite",
|
||||
"invite_public_subtitle": "Get a VPN config via invite link",
|
||||
"invite_protected_desc": "This invite link is password protected.",
|
||||
"invite_get_config_hint": "Each click creates one new config and counts as a use",
|
||||
"invite_uses_left": "Remaining uses: {}",
|
||||
"invite_uses_left_unlimited": "Created so far: {} (unlimited)",
|
||||
"invite_not_found": "404 Not Found",
|
||||
"invite_not_found_desc": "Invite link is invalid or was deleted.",
|
||||
"wrong_invite_password": "Wrong password",
|
||||
"public_link_label": "Public link",
|
||||
"disabled": "Disabled",
|
||||
"out_of": "Of",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav_servers": "سرورها",
|
||||
"nav_users": "کاربران",
|
||||
"nav_invites": "لینک دعوت",
|
||||
"nav_settings": "تنظیمات",
|
||||
"nav_connections": "اتصالها",
|
||||
"nav_logout": "خروج",
|
||||
@@ -194,6 +195,41 @@
|
||||
"guest_not_found": "404 یافت نشد",
|
||||
"guest_not_found_desc": "دسترسی مهمان غیرفعال است یا لینک نامعتبر است.",
|
||||
"wrong_guest_password": "رمز عبور اشتباه است",
|
||||
"invites_title": "لینکهای دعوت",
|
||||
"invites_hint": "لینک قابل اشتراک بسازید. با لینک میتوان کانفیگ VPN ساخت — تعداد را شما تعیین میکنید.",
|
||||
"invites_empty": "هنوز لینکی نیست",
|
||||
"invites_empty_desc": "لینک بسازید و به اشتراک بگذارید — بدون ثبتنام.",
|
||||
"invite_create": "ساخت لینک",
|
||||
"invite_edit": "ویرایش لینک",
|
||||
"invite_active": "فعال",
|
||||
"invite_expired": "منقضی",
|
||||
"invite_exhausted": "ظرفیت تمام شده",
|
||||
"invite_uses": "استفادهها",
|
||||
"invite_name_label": "نام لینک",
|
||||
"invite_max_uses_label": "حداکثر کانفیگ",
|
||||
"invite_max_uses_hint": "چند بار میتوان با این لینک کانفیگ ساخت. 0 = نامحدود.",
|
||||
"invite_user_label": "کاربر ذخیرهسازی",
|
||||
"invite_user_hint": "کانفیگهای ساختهشده به این کاربر پنل وصل میشوند.",
|
||||
"invite_user_required": "یک کاربر انتخاب کنید",
|
||||
"invite_password_label": "رمز عبور (اختیاری)",
|
||||
"invite_expires_label": "انقضا (اختیاری)",
|
||||
"invite_clear_expires": "حذف تاریخ انقضا",
|
||||
"invite_note_label": "یادداشت (فقط ادمین)",
|
||||
"invite_note_placeholder": "توضیح داخلی",
|
||||
"invite_reset_used": "ریست شمارنده استفاده",
|
||||
"invite_created": "لینک ساخته شد (URL کپی شد)",
|
||||
"invite_updated": "لینک بهروز شد",
|
||||
"invite_deleted": "لینک حذف شد",
|
||||
"invite_delete_confirm": "این لینک حذف شود؟",
|
||||
"invite_public_title": "دعوت",
|
||||
"invite_public_subtitle": "دریافت کانفیگ VPN با لینک دعوت",
|
||||
"invite_protected_desc": "این لینک با رمز محافظت شده است.",
|
||||
"invite_get_config_hint": "هر کلیک یک کانفیگ جدید میسازد و یک استفاده کم میکند",
|
||||
"invite_uses_left": "باقیمانده: {}",
|
||||
"invite_uses_left_unlimited": "ساختهشده: {} (نامحدود)",
|
||||
"invite_not_found": "404 یافت نشد",
|
||||
"invite_not_found_desc": "لینک نامعتبر است یا حذف شده.",
|
||||
"wrong_invite_password": "رمز عبور اشتباه است",
|
||||
"public_link_label": "لینک عمومی",
|
||||
"disabled": "غیرفعال",
|
||||
"out_of": "از",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav_servers": "Serveurs",
|
||||
"nav_users": "Utilisateurs",
|
||||
"nav_invites": "Liens d'invitation",
|
||||
"nav_settings": "Paramètres",
|
||||
"nav_connections": "Connexions",
|
||||
"nav_logout": "Déconnexion",
|
||||
@@ -194,6 +195,41 @@
|
||||
"guest_not_found": "404 Introuvable",
|
||||
"guest_not_found_desc": "Accès invité désactivé ou lien invalide.",
|
||||
"wrong_guest_password": "Mauvais mot de passe",
|
||||
"invites_title": "Liens d'invitation",
|
||||
"invites_hint": "Créez des liens partageables. Le destinataire peut créer une config VPN — vous fixez le nombre d'utilisations.",
|
||||
"invites_empty": "Aucun lien",
|
||||
"invites_empty_desc": "Créez un lien à partager — sans inscription.",
|
||||
"invite_create": "Créer un lien",
|
||||
"invite_edit": "Modifier le lien",
|
||||
"invite_active": "Actif",
|
||||
"invite_expired": "Expiré",
|
||||
"invite_exhausted": "Plus d'utilisations",
|
||||
"invite_uses": "Utilisations",
|
||||
"invite_name_label": "Nom du lien",
|
||||
"invite_max_uses_label": "Configs max",
|
||||
"invite_max_uses_hint": "Nombre de configs créables via ce lien. 0 = illimité.",
|
||||
"invite_user_label": "Utilisateur de stockage",
|
||||
"invite_user_hint": "Les configs créées sont attachées à cet utilisateur.",
|
||||
"invite_user_required": "Choisissez un utilisateur",
|
||||
"invite_password_label": "Mot de passe (optionnel)",
|
||||
"invite_expires_label": "Expiration (optionnel)",
|
||||
"invite_clear_expires": "Retirer l'expiration",
|
||||
"invite_note_label": "Note (admin)",
|
||||
"invite_note_placeholder": "Commentaire interne",
|
||||
"invite_reset_used": "Réinitialiser le compteur",
|
||||
"invite_created": "Lien créé (URL copiée)",
|
||||
"invite_updated": "Lien mis à jour",
|
||||
"invite_deleted": "Lien supprimé",
|
||||
"invite_delete_confirm": "Supprimer ce lien ?",
|
||||
"invite_public_title": "Invitation",
|
||||
"invite_public_subtitle": "Obtenir une config VPN via invitation",
|
||||
"invite_protected_desc": "Lien protégé par mot de passe.",
|
||||
"invite_get_config_hint": "Chaque clic crée une config et compte comme une utilisation",
|
||||
"invite_uses_left": "Restantes : {}",
|
||||
"invite_uses_left_unlimited": "Créées : {} (illimité)",
|
||||
"invite_not_found": "404 Introuvable",
|
||||
"invite_not_found_desc": "Lien invalide ou supprimé.",
|
||||
"wrong_invite_password": "Mauvais mot de passe",
|
||||
"public_link_label": "Lien public",
|
||||
"disabled": "Désactivé",
|
||||
"out_of": "sur",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav_servers": "Серверы",
|
||||
"nav_users": "Пользователи",
|
||||
"nav_invites": "Ссылки",
|
||||
"nav_settings": "Настройки",
|
||||
"nav_connections": "Подключения",
|
||||
"nav_logout": "Выход",
|
||||
@@ -196,6 +197,41 @@
|
||||
"guest_not_found": "404 Не найдено",
|
||||
"guest_not_found_desc": "Гостевой доступ отключён или ссылка неверна.",
|
||||
"wrong_guest_password": "Неверный пароль",
|
||||
"invites_title": "Ссылки для конфигов",
|
||||
"invites_hint": "Создавайте ссылки для раздачи. По ссылке можно создать VPN-конфиг — сколько раз, указываете вы.",
|
||||
"invites_empty": "Ссылок пока нет",
|
||||
"invites_empty_desc": "Создайте ссылку и отправьте её — регистрация не нужна.",
|
||||
"invite_create": "Создать ссылку",
|
||||
"invite_edit": "Редактировать ссылку",
|
||||
"invite_active": "Активна",
|
||||
"invite_expired": "Истекла",
|
||||
"invite_exhausted": "Лимит исчерпан",
|
||||
"invite_uses": "Использований",
|
||||
"invite_name_label": "Название ссылки",
|
||||
"invite_max_uses_label": "Сколько конфигов",
|
||||
"invite_max_uses_hint": "Сколько раз можно создать конфиг по этой ссылке. 0 = без лимита.",
|
||||
"invite_user_label": "Пользователь для хранения",
|
||||
"invite_user_hint": "Созданные конфиги привязываются к этому пользователю панели.",
|
||||
"invite_user_required": "Выберите пользователя для хранения конфигов",
|
||||
"invite_password_label": "Пароль (опционально)",
|
||||
"invite_expires_label": "Срок действия (опционально)",
|
||||
"invite_clear_expires": "Убрать срок действия",
|
||||
"invite_note_label": "Заметка (только админ)",
|
||||
"invite_note_placeholder": "Внутренний комментарий",
|
||||
"invite_reset_used": "Сбросить счётчик использований",
|
||||
"invite_created": "Ссылка создана (URL скопирован)",
|
||||
"invite_updated": "Ссылка обновлена",
|
||||
"invite_deleted": "Ссылка удалена",
|
||||
"invite_delete_confirm": "Удалить эту ссылку?",
|
||||
"invite_public_title": "Приглашение",
|
||||
"invite_public_subtitle": "Получите VPN-конфиг по ссылке",
|
||||
"invite_protected_desc": "Эта ссылка защищена паролем.",
|
||||
"invite_get_config_hint": "Каждое нажатие создаёт новый конфиг и тратит одно использование",
|
||||
"invite_uses_left": "Осталось использований: {}",
|
||||
"invite_uses_left_unlimited": "Уже создано: {} (без лимита)",
|
||||
"invite_not_found": "404 Не найдено",
|
||||
"invite_not_found_desc": "Ссылка неверна или была удалена.",
|
||||
"wrong_invite_password": "Неверный пароль",
|
||||
"public_link_label": "Публичная ссылка",
|
||||
"disabled": "Отключён",
|
||||
"out_of": "Из",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav_servers": "服务器",
|
||||
"nav_users": "用户列表",
|
||||
"nav_invites": "邀请链接",
|
||||
"nav_settings": "设置",
|
||||
"nav_connections": "连接管理",
|
||||
"nav_logout": "登出",
|
||||
@@ -194,6 +195,41 @@
|
||||
"guest_not_found": "404 未找到",
|
||||
"guest_not_found_desc": "访客访问已禁用或链接无效。",
|
||||
"wrong_guest_password": "密码错误",
|
||||
"invites_title": "邀请链接",
|
||||
"invites_hint": "创建可分享的链接。对方可创建 VPN 配置,次数由您设定。",
|
||||
"invites_empty": "暂无邀请链接",
|
||||
"invites_empty_desc": "创建链接并分享 — 无需注册。",
|
||||
"invite_create": "创建链接",
|
||||
"invite_edit": "编辑链接",
|
||||
"invite_active": "有效",
|
||||
"invite_expired": "已过期",
|
||||
"invite_exhausted": "次数用尽",
|
||||
"invite_uses": "使用次数",
|
||||
"invite_name_label": "链接名称",
|
||||
"invite_max_uses_label": "最多配置数",
|
||||
"invite_max_uses_hint": "此链接可创建配置的次数。0 = 不限。",
|
||||
"invite_user_label": "配置归属用户",
|
||||
"invite_user_hint": "创建的配置会挂到该面板用户下。",
|
||||
"invite_user_required": "请选择用户",
|
||||
"invite_password_label": "密码(可选)",
|
||||
"invite_expires_label": "过期时间(可选)",
|
||||
"invite_clear_expires": "清除过期时间",
|
||||
"invite_note_label": "备注(仅管理员)",
|
||||
"invite_note_placeholder": "内部备注",
|
||||
"invite_reset_used": "重置已用次数",
|
||||
"invite_created": "链接已创建(URL 已复制)",
|
||||
"invite_updated": "链接已更新",
|
||||
"invite_deleted": "链接已删除",
|
||||
"invite_delete_confirm": "删除此邀请链接?",
|
||||
"invite_public_title": "邀请",
|
||||
"invite_public_subtitle": "通过邀请链接获取 VPN 配置",
|
||||
"invite_protected_desc": "此链接受密码保护。",
|
||||
"invite_get_config_hint": "每次点击创建一份新配置并消耗一次额度",
|
||||
"invite_uses_left": "剩余次数:{}",
|
||||
"invite_uses_left_unlimited": "已创建:{}(不限)",
|
||||
"invite_not_found": "404 未找到",
|
||||
"invite_not_found_desc": "链接无效或已删除。",
|
||||
"wrong_invite_password": "密码错误",
|
||||
"public_link_label": "公开链接",
|
||||
"disabled": "已禁用",
|
||||
"out_of": "/",
|
||||
|
||||
Reference in New Issue
Block a user