diff --git a/app.py b/app.py index c06cf76..3a97084 100644 --- a/app.py +++ b/app.py @@ -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"

{_t('invite_not_found', lang)}

{_t('invite_not_found_desc', lang)}

", + 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) diff --git a/db/schema.sql b/db/schema.sql index 853a582..bc899a8 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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; diff --git a/db/store.py b/db/store.py index 4e7c515..77e881f 100644 --- a/db/store.py +++ b/db/store.py @@ -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) diff --git a/templates/base.html b/templates/base.html index 8f5e7f4..e256479 100644 --- a/templates/base.html +++ b/templates/base.html @@ -45,6 +45,7 @@ {% if current_user.role in ['admin', 'support'] %} {{ _('nav_servers') }} {{ _('nav_users') }} + {{ _('nav_invites') }} {% endif %} {% if current_user.role == 'admin' %} {{ _('nav_settings') }} diff --git a/templates/invite.html b/templates/invite.html new file mode 100644 index 0000000..b00d4c6 --- /dev/null +++ b/templates/invite.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %} + +{% block content %} +
+
+

{{ invite.name }}

+

{{ _('invite_public_subtitle') }}

+
+ + {% if need_password %} +
+
🔐
+

{{ _('invite_protected_desc') }}

+
+
+ +
+ +
+
+ {% else %} +
+
+ {% 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 %} +
+ + {% if invite.available %} + +
{{ _('invite_get_config_hint') }}
+ {% elif invite.expired %} +

{{ _('invite_expired') }}

+ {% elif invite.exhausted %} +

{{ _('invite_exhausted') }}

+ {% else %} +

{{ _('disabled') }}

+ {% endif %} +
+ {% endif %} +
+ + + + + + +{% endblock %} diff --git a/templates/invites.html b/templates/invites.html new file mode 100644 index 0000000..56f1c8f --- /dev/null +++ b/templates/invites.html @@ -0,0 +1,334 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('invites_title') }}{% endblock %} + +{% block content %} +
+
+

+ 🔗 + {{ _('invites_title') }} +

+ +
+ +

{{ _('invites_hint') }}

+ +
+
🔗
+
{{ _('invites_empty') }}
+
{{ _('invites_empty_desc') }}
+
+ +
+ {% for inv in invites %} +
+
+
🔗
+
+
+ {{ inv.name }} + {% if inv.available %} + {{ _('invite_active') }} + {% elif inv.expired %} + {{ _('invite_expired') }} + {% elif inv.exhausted %} + {{ _('invite_exhausted') }} + {% else %} + {{ _('disabled') }} + {% endif %} +
+
+ {{ _('invite_uses') }}: + {% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %} + + {{ inv.protocol if inv.protocol != 'xui' else '3x-ui VLESS' }} + {% if inv.has_password %}🔐{% endif %} +
+
+
+
+ + + + +
+
+ {% endfor %} +
+
+ + + + + +{% endblock %} diff --git a/translations/en.json b/translations/en.json index 963216e..aca4269 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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", diff --git a/translations/fa.json b/translations/fa.json index 1917fc1..fcb4c67 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -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": "از", diff --git a/translations/fr.json b/translations/fr.json index 1ae0c0b..a74e8bb 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -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", diff --git a/translations/ru.json b/translations/ru.json index c84b98f..d6a5429 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -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": "Из", diff --git a/translations/zh.json b/translations/zh.json index c0204d7..2ed933e 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -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": "/",