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:
orohi
2026-07-26 01:23:16 +03:00
co-authored by Cursor
parent 5386c4d40e
commit 7b3d8aac34
11 changed files with 1197 additions and 0 deletions
+395
View File
@@ -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)
+19
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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>
+207
View File
@@ -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 %}
+334
View File
@@ -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 %}
+36
View File
@@ -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",
+36
View File
@@ -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": "از",
+36
View File
@@ -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",
+36
View File
@@ -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": "Из",
+36
View File
@@ -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": "/",