forked from test2/Amnezia-Web-Panel-main
Issue invite configs as subscription URLs with redeem-based lifetime.
Admin picks inbound once, sets duration in days from Get config, and configures the 3x-ui /sub base URL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1793,6 +1793,7 @@ class SyncSettings(BaseModel):
|
|||||||
xui_server_id: int = 0
|
xui_server_id: int = 0
|
||||||
xui_protocol: str = 'xray'
|
xui_protocol: str = 'xray'
|
||||||
xui_inbound_id: int = 0
|
xui_inbound_id: int = 0
|
||||||
|
xui_sub_url: str = ''
|
||||||
|
|
||||||
class CaptchaSettings(BaseModel):
|
class CaptchaSettings(BaseModel):
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
@@ -1887,7 +1888,7 @@ class InviteCreateRequest(BaseModel):
|
|||||||
server_id: int = 0
|
server_id: int = 0
|
||||||
xui_inbound_id: int = 0
|
xui_inbound_id: int = 0
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
expires_at: Optional[str] = None
|
duration_days: int = 0 # client lifetime after redeem; 0 = no expiry
|
||||||
note: str = ''
|
note: str = ''
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
@@ -1901,8 +1902,7 @@ class InviteUpdateRequest(BaseModel):
|
|||||||
xui_inbound_id: Optional[int] = None
|
xui_inbound_id: Optional[int] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
clear_password: bool = False
|
clear_password: bool = False
|
||||||
expires_at: Optional[str] = None
|
duration_days: Optional[int] = None
|
||||||
clear_expires: bool = False
|
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
reset_used: bool = False
|
reset_used: bool = False
|
||||||
@@ -4246,15 +4246,8 @@ def _invite_public_view(link: dict) -> dict:
|
|||||||
max_uses = int(link.get('max_uses') or 0)
|
max_uses = int(link.get('max_uses') or 0)
|
||||||
used = int(link.get('used_count') or 0)
|
used = int(link.get('used_count') or 0)
|
||||||
remaining = None if max_uses <= 0 else max(0, max_uses - used)
|
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
|
exhausted = remaining is not None and remaining <= 0
|
||||||
|
duration_days = int(link.get('duration_days') or 0)
|
||||||
return {
|
return {
|
||||||
'id': link.get('id'),
|
'id': link.get('id'),
|
||||||
'name': link.get('name') or 'Invite',
|
'name': link.get('name') or 'Invite',
|
||||||
@@ -4264,17 +4257,17 @@ def _invite_public_view(link: dict) -> dict:
|
|||||||
'used_count': used,
|
'used_count': used,
|
||||||
'remaining': remaining,
|
'remaining': remaining,
|
||||||
'unlimited': max_uses <= 0,
|
'unlimited': max_uses <= 0,
|
||||||
'expired': expired,
|
'expired': False,
|
||||||
'exhausted': exhausted,
|
'exhausted': exhausted,
|
||||||
'has_password': bool(link.get('password_hash')),
|
'has_password': bool(link.get('password_hash')),
|
||||||
'protocol': link.get('protocol') or 'xui',
|
'protocol': link.get('protocol') or 'xui',
|
||||||
'server_id': int(link.get('server_id') or 0),
|
'server_id': int(link.get('server_id') or 0),
|
||||||
'xui_inbound_id': int(link.get('xui_inbound_id') or 0),
|
'xui_inbound_id': int(link.get('xui_inbound_id') or 0),
|
||||||
|
'duration_days': duration_days,
|
||||||
'user_id': link.get('user_id') or '',
|
'user_id': link.get('user_id') or '',
|
||||||
'note': link.get('note') or '',
|
'note': link.get('note') or '',
|
||||||
'expires_at': link.get('expires_at'),
|
|
||||||
'created_at': link.get('created_at'),
|
'created_at': link.get('created_at'),
|
||||||
'available': bool(link.get('enabled', True)) and not expired and not exhausted,
|
'available': bool(link.get('enabled', True)) and not exhausted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4288,6 +4281,14 @@ def _invite_auth_ok(link: dict, request: Request) -> bool:
|
|||||||
return bool(request.session.get(f"invite_auth_{link.get('token')}"))
|
return bool(request.session.get(f"invite_auth_{link.get('token')}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _expiry_ms_from_duration_days(duration_days: int) -> int:
|
||||||
|
"""3x-ui expiryTime is unix ms; 0 means no expiry. Starts now (on redeem)."""
|
||||||
|
days = int(duration_days or 0)
|
||||||
|
if days <= 0:
|
||||||
|
return 0
|
||||||
|
return int((datetime.now().timestamp() + days * 86400) * 1000)
|
||||||
|
|
||||||
|
|
||||||
async def _create_config_for_protocol(
|
async def _create_config_for_protocol(
|
||||||
data: dict,
|
data: dict,
|
||||||
*,
|
*,
|
||||||
@@ -4295,21 +4296,32 @@ async def _create_config_for_protocol(
|
|||||||
name: str,
|
name: str,
|
||||||
server_id: int = 0,
|
server_id: int = 0,
|
||||||
xui_inbound_id: Optional[int] = None,
|
xui_inbound_id: Optional[int] = None,
|
||||||
|
duration_days: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create VPN client; returns {client_id, config, protocol, server_id}."""
|
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
|
||||||
protocol = protocol or 'xui'
|
protocol = protocol or 'xui'
|
||||||
if protocol_base(protocol) == 'xui':
|
if protocol_base(protocol) == 'xui':
|
||||||
|
if not xui_inbound_id:
|
||||||
|
raise RuntimeError('Select a VLESS inbound for this invite link')
|
||||||
from managers.xui_api import xui_create_vless_config
|
from managers.xui_api import xui_create_vless_config
|
||||||
|
expiry_ms = _expiry_ms_from_duration_days(duration_days)
|
||||||
created = await xui_create_vless_config(
|
created = await xui_create_vless_config(
|
||||||
data.get('settings', {}),
|
data.get('settings', {}),
|
||||||
name=name,
|
name=name,
|
||||||
inbound_id=xui_inbound_id,
|
inbound_id=xui_inbound_id,
|
||||||
|
expiry_time=expiry_ms,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
'client_id': created['client_id'],
|
'client_id': created['client_id'],
|
||||||
'config': created.get('config') or '',
|
'config': created.get('config') or '',
|
||||||
|
'subscription_url': created.get('subscription_url') or '',
|
||||||
|
'sub_id': created.get('sub_id') or '',
|
||||||
'protocol': 'xui',
|
'protocol': 'xui',
|
||||||
'server_id': 0,
|
'server_id': 0,
|
||||||
|
'expires_at': (
|
||||||
|
datetime.fromtimestamp(expiry_ms / 1000).isoformat()
|
||||||
|
if expiry_ms > 0 else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
sid = int(server_id or 0)
|
sid = int(server_id or 0)
|
||||||
@@ -4336,8 +4348,10 @@ async def _create_config_for_protocol(
|
|||||||
return {
|
return {
|
||||||
'client_id': result['client_id'],
|
'client_id': result['client_id'],
|
||||||
'config': result.get('config') or '',
|
'config': result.get('config') or '',
|
||||||
|
'subscription_url': '',
|
||||||
'protocol': protocol,
|
'protocol': protocol,
|
||||||
'server_id': sid,
|
'server_id': sid,
|
||||||
|
'expires_at': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4356,6 +4370,8 @@ async def invites_page(request: Request):
|
|||||||
users=data.get('users', []),
|
users=data.get('users', []),
|
||||||
servers=data.get('servers', []),
|
servers=data.get('servers', []),
|
||||||
xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')),
|
xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')),
|
||||||
|
xui_sub_url=(((data.get('settings') or {}).get('sync') or {}).get('xui_sub_url') or ''),
|
||||||
|
xui_default_inbound=int((((data.get('settings') or {}).get('sync') or {}).get('xui_inbound_id') or 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4373,9 +4389,15 @@ async def api_create_invite(request: Request, req: InviteCreateRequest):
|
|||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
if req.max_uses < 0:
|
if req.max_uses < 0:
|
||||||
return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400)
|
return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400)
|
||||||
|
if req.duration_days < 0:
|
||||||
|
return JSONResponse({'error': 'duration_days must be >= 0'}, status_code=400)
|
||||||
data = load_data()
|
data = load_data()
|
||||||
if req.user_id and not any(u['id'] == req.user_id for u in data['users']):
|
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)
|
return JSONResponse({'error': 'User not found'}, status_code=400)
|
||||||
|
protocol = req.protocol or 'xui'
|
||||||
|
inbound_id = int(req.xui_inbound_id or 0)
|
||||||
|
if protocol_base(protocol) == 'xui' and not inbound_id:
|
||||||
|
return JSONResponse({'error': 'Select a VLESS inbound'}, status_code=400)
|
||||||
link = {
|
link = {
|
||||||
'id': str(uuid.uuid4()),
|
'id': str(uuid.uuid4()),
|
||||||
'name': (req.name or 'Invite').strip() or 'Invite',
|
'name': (req.name or 'Invite').strip() or 'Invite',
|
||||||
@@ -4384,11 +4406,12 @@ async def api_create_invite(request: Request, req: InviteCreateRequest):
|
|||||||
'max_uses': int(req.max_uses),
|
'max_uses': int(req.max_uses),
|
||||||
'used_count': 0,
|
'used_count': 0,
|
||||||
'user_id': req.user_id or '',
|
'user_id': req.user_id or '',
|
||||||
'protocol': req.protocol or 'xui',
|
'protocol': protocol,
|
||||||
'server_id': int(req.server_id or 0),
|
'server_id': int(req.server_id or 0),
|
||||||
'xui_inbound_id': int(req.xui_inbound_id or 0),
|
'xui_inbound_id': inbound_id,
|
||||||
'password_hash': hash_password(req.password) if req.password else None,
|
'password_hash': hash_password(req.password) if req.password else None,
|
||||||
'expires_at': req.expires_at or None,
|
'expires_at': None,
|
||||||
|
'duration_days': int(req.duration_days or 0),
|
||||||
'note': req.note or '',
|
'note': req.note or '',
|
||||||
'created_at': datetime.now().isoformat(),
|
'created_at': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
@@ -4422,20 +4445,23 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR
|
|||||||
link['server_id'] = int(req.server_id)
|
link['server_id'] = int(req.server_id)
|
||||||
if req.xui_inbound_id is not None:
|
if req.xui_inbound_id is not None:
|
||||||
link['xui_inbound_id'] = int(req.xui_inbound_id)
|
link['xui_inbound_id'] = int(req.xui_inbound_id)
|
||||||
|
if req.duration_days is not None:
|
||||||
|
if req.duration_days < 0:
|
||||||
|
return JSONResponse({'error': 'duration_days must be >= 0'}, status_code=400)
|
||||||
|
link['duration_days'] = int(req.duration_days)
|
||||||
if req.clear_password:
|
if req.clear_password:
|
||||||
link['password_hash'] = None
|
link['password_hash'] = None
|
||||||
elif req.password:
|
elif req.password:
|
||||||
link['password_hash'] = hash_password(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:
|
if req.note is not None:
|
||||||
link['note'] = req.note
|
link['note'] = req.note
|
||||||
if req.enabled is not None:
|
if req.enabled is not None:
|
||||||
link['enabled'] = bool(req.enabled)
|
link['enabled'] = bool(req.enabled)
|
||||||
if req.reset_used:
|
if req.reset_used:
|
||||||
link['used_count'] = 0
|
link['used_count'] = 0
|
||||||
|
# Validate inbound for xui
|
||||||
|
if protocol_base(link.get('protocol') or 'xui') == 'xui' and not int(link.get('xui_inbound_id') or 0):
|
||||||
|
return JSONResponse({'error': 'Select a VLESS inbound'}, status_code=400)
|
||||||
save_data(data)
|
save_data(data)
|
||||||
return {'status': 'success', 'invite': _invite_public_view(link)}
|
return {'status': 'success', 'invite': _invite_public_view(link)}
|
||||||
|
|
||||||
@@ -4556,7 +4582,8 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
|
|||||||
protocol=link.get('protocol') or 'xui',
|
protocol=link.get('protocol') or 'xui',
|
||||||
name=name,
|
name=name,
|
||||||
server_id=int(link.get('server_id') or 0),
|
server_id=int(link.get('server_id') or 0),
|
||||||
xui_inbound_id=link.get('xui_inbound_id') or None,
|
xui_inbound_id=int(link.get('xui_inbound_id') or 0) or None,
|
||||||
|
duration_days=int(link.get('duration_days') or 0),
|
||||||
)
|
)
|
||||||
conn = {
|
conn = {
|
||||||
'id': str(uuid.uuid4()),
|
'id': str(uuid.uuid4()),
|
||||||
@@ -4572,13 +4599,17 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
|
|||||||
data.setdefault('user_connections', []).append(conn)
|
data.setdefault('user_connections', []).append(conn)
|
||||||
save_data(data)
|
save_data(data)
|
||||||
config = created.get('config') or ''
|
config = created.get('config') or ''
|
||||||
vpn_link = generate_vpn_link(config) if config else ''
|
subscription_url = created.get('subscription_url') or ''
|
||||||
|
# For subscription URLs, vpn_link is the same shareable string
|
||||||
|
vpn_link = subscription_url or (generate_vpn_link(config) if config else '')
|
||||||
data = load_data()
|
data = load_data()
|
||||||
link = _find_invite(data, token) or link
|
link = _find_invite(data, token) or link
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'config': config,
|
'config': config,
|
||||||
|
'subscription_url': subscription_url,
|
||||||
'vpn_link': vpn_link,
|
'vpn_link': vpn_link,
|
||||||
|
'expires_at': created.get('expires_at'),
|
||||||
'connection': conn,
|
'connection': conn,
|
||||||
'invite': _invite_public_view(link),
|
'invite': _invite_public_view(link),
|
||||||
}
|
}
|
||||||
@@ -4872,7 +4903,7 @@ async def api_xui_sync_now(request: Request):
|
|||||||
for key in (
|
for key in (
|
||||||
'xui_url', 'xui_username', 'xui_password', 'xui_api_token',
|
'xui_url', 'xui_username', 'xui_password', 'xui_api_token',
|
||||||
'xui_create_conns', 'xui_server_id', 'xui_protocol', 'xui_sync_users', 'xui_sync',
|
'xui_create_conns', 'xui_server_id', 'xui_protocol', 'xui_sync_users', 'xui_sync',
|
||||||
'xui_inbound_id',
|
'xui_inbound_id', 'xui_sub_url',
|
||||||
):
|
):
|
||||||
if key in body and body[key] is not None:
|
if key in body and body[key] is not None:
|
||||||
sync_cfg[key] = body[key]
|
sync_cfg[key] = body[key]
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ def init_schema():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_users_xui_email ON users(xui_email)"
|
"CREATE INDEX IF NOT EXISTS idx_users_xui_email ON users(xui_email)"
|
||||||
)
|
)
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE invite_links ADD COLUMN IF NOT EXISTS duration_days INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_schema_ready = True
|
_schema_ready = True
|
||||||
logger.info('PostgreSQL schema ready')
|
logger.info('PostgreSQL schema ready')
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS invite_links (
|
|||||||
xui_inbound_id INTEGER NOT NULL DEFAULT 0,
|
xui_inbound_id INTEGER NOT NULL DEFAULT 0,
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
expires_at TIMESTAMPTZ,
|
expires_at TIMESTAMPTZ,
|
||||||
|
duration_days INTEGER NOT NULL DEFAULT 0,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ
|
created_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-3
@@ -45,6 +45,7 @@ DEFAULT_SETTINGS = {
|
|||||||
'xui_server_id': 0,
|
'xui_server_id': 0,
|
||||||
'xui_protocol': 'xray',
|
'xui_protocol': 'xray',
|
||||||
'xui_inbound_id': 0,
|
'xui_inbound_id': 0,
|
||||||
|
'xui_sub_url': '',
|
||||||
},
|
},
|
||||||
'guest': {
|
'guest': {
|
||||||
'enabled': False,
|
'enabled': False,
|
||||||
@@ -209,6 +210,7 @@ def _row_to_invite(row) -> dict:
|
|||||||
'xui_inbound_id': int(row['xui_inbound_id'] or 0),
|
'xui_inbound_id': int(row['xui_inbound_id'] or 0),
|
||||||
'password_hash': row['password_hash'],
|
'password_hash': row['password_hash'],
|
||||||
'expires_at': _ts_iso(row['expires_at']),
|
'expires_at': _ts_iso(row['expires_at']),
|
||||||
|
'duration_days': int(row.get('duration_days') or 0),
|
||||||
'note': row['note'] or '',
|
'note': row['note'] or '',
|
||||||
'created_at': _ts_iso(row['created_at']),
|
'created_at': _ts_iso(row['created_at']),
|
||||||
}
|
}
|
||||||
@@ -252,7 +254,7 @@ def load_data() -> dict:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
'SELECT id, name, token, enabled, max_uses, used_count, user_id, '
|
'SELECT id, name, token, enabled, max_uses, used_count, user_id, '
|
||||||
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
||||||
'note, created_at FROM invite_links '
|
'duration_days, note, created_at FROM invite_links '
|
||||||
'ORDER BY created_at DESC NULLS LAST, name'
|
'ORDER BY created_at DESC NULLS LAST, name'
|
||||||
)
|
)
|
||||||
invite_links = [_row_to_invite(r) for r in cur.fetchall()]
|
invite_links = [_row_to_invite(r) for r in cur.fetchall()]
|
||||||
@@ -392,8 +394,8 @@ def save_data(data: dict) -> None:
|
|||||||
'INSERT INTO invite_links ('
|
'INSERT INTO invite_links ('
|
||||||
'id, name, token, enabled, max_uses, used_count, user_id, '
|
'id, name, token, enabled, max_uses, used_count, user_id, '
|
||||||
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
'protocol, server_id, xui_inbound_id, password_hash, expires_at, '
|
||||||
'note, created_at'
|
'duration_days, note, created_at'
|
||||||
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||||
(
|
(
|
||||||
_as_uuid(link['id']),
|
_as_uuid(link['id']),
|
||||||
link.get('name') or '',
|
link.get('name') or '',
|
||||||
@@ -407,6 +409,7 @@ def save_data(data: dict) -> None:
|
|||||||
int(link.get('xui_inbound_id') or 0),
|
int(link.get('xui_inbound_id') or 0),
|
||||||
link.get('password_hash'),
|
link.get('password_hash'),
|
||||||
_parse_ts(link.get('expires_at')),
|
_parse_ts(link.get('expires_at')),
|
||||||
|
int(link.get('duration_days') or 0),
|
||||||
link.get('note') or '',
|
link.get('note') or '',
|
||||||
_parse_ts(link.get('created_at')),
|
_parse_ts(link.get('created_at')),
|
||||||
),
|
),
|
||||||
|
|||||||
+46
-4
@@ -32,9 +32,21 @@ def _settings_creds(settings: dict) -> dict:
|
|||||||
'username': (sync.get('xui_username') or '').strip(),
|
'username': (sync.get('xui_username') or '').strip(),
|
||||||
'password': sync.get('xui_password') or '',
|
'password': sync.get('xui_password') or '',
|
||||||
'inbound_id': sync.get('xui_inbound_id'),
|
'inbound_id': sync.get('xui_inbound_id'),
|
||||||
|
'sub_url': (sync.get('xui_sub_url') or '').strip().rstrip('/'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_subscription_url(settings: dict, sub_id: str) -> str:
|
||||||
|
"""Build public subscription URL from panel settings + client subId."""
|
||||||
|
sub_id = (sub_id or '').strip()
|
||||||
|
if not sub_id:
|
||||||
|
return ''
|
||||||
|
base = _settings_creds(settings).get('sub_url') or ''
|
||||||
|
if not base:
|
||||||
|
return ''
|
||||||
|
return f"{base}/{sub_id}"
|
||||||
|
|
||||||
|
|
||||||
class XuiApiError(RuntimeError):
|
class XuiApiError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -211,6 +223,7 @@ class XuiApi:
|
|||||||
'email': email,
|
'email': email,
|
||||||
'config': vless or '',
|
'config': vless or '',
|
||||||
'links': links,
|
'links': links,
|
||||||
|
'expiry_time': expiry_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_client_links(self, email: str) -> list:
|
async def get_client_links(self, email: str) -> list:
|
||||||
@@ -292,8 +305,14 @@ class XuiApi:
|
|||||||
raise XuiApiError(f'Failed to toggle 3x-ui client {email}')
|
raise XuiApiError(f'Failed to toggle 3x-ui client {email}')
|
||||||
|
|
||||||
|
|
||||||
async def xui_create_vless_config(settings: dict, *, name: str, inbound_id: Optional[int] = None) -> dict:
|
async def xui_create_vless_config(
|
||||||
"""Create a VLESS client on 3x-ui and return {client_id, config, links}."""
|
settings: dict,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
inbound_id: Optional[int] = None,
|
||||||
|
expiry_time: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a VLESS client on 3x-ui and return {client_id, config, subscription_url, links}."""
|
||||||
creds = _settings_creds(settings)
|
creds = _settings_creds(settings)
|
||||||
inbound = inbound_id if inbound_id is not None else creds.get('inbound_id')
|
inbound = inbound_id if inbound_id is not None else creds.get('inbound_id')
|
||||||
try:
|
try:
|
||||||
@@ -312,16 +331,39 @@ async def xui_create_vless_config(settings: dict, *, name: str, inbound_id: Opti
|
|||||||
base = ''.join(ch if ch.isalnum() or ch in '._-+@' else '_' for ch in (name or 'user').strip())
|
base = ''.join(ch if ch.isalnum() or ch in '._-+@' else '_' for ch in (name or 'user').strip())
|
||||||
base = base[:48] or f'user_{secrets.token_hex(4)}'
|
base = base[:48] or f'user_{secrets.token_hex(4)}'
|
||||||
email = base
|
email = base
|
||||||
|
created = None
|
||||||
# Avoid collisions
|
# Avoid collisions
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
try:
|
try:
|
||||||
return await api.add_vless_client(email=email, inbound_id=inbound, comment=name or email)
|
created = await api.add_vless_client(
|
||||||
|
email=email,
|
||||||
|
inbound_id=inbound,
|
||||||
|
comment=name or email,
|
||||||
|
expiry_time=int(expiry_time or 0),
|
||||||
|
)
|
||||||
|
break
|
||||||
except XuiApiError as e:
|
except XuiApiError as e:
|
||||||
if 'exist' in str(e).lower() or 'duplicate' in str(e).lower() or 'already' in str(e).lower():
|
if 'exist' in str(e).lower() or 'duplicate' in str(e).lower() or 'already' in str(e).lower():
|
||||||
email = f'{base}_{secrets.token_hex(3)}'
|
email = f'{base}_{secrets.token_hex(3)}'
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
return await api.add_vless_client(email=email, inbound_id=inbound, comment=name or email)
|
if created is None:
|
||||||
|
created = await api.add_vless_client(
|
||||||
|
email=email,
|
||||||
|
inbound_id=inbound,
|
||||||
|
comment=name or email,
|
||||||
|
expiry_time=int(expiry_time or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_url = build_subscription_url(settings, created.get('sub_id') or '')
|
||||||
|
# Prefer subscription URL as the main share string when configured
|
||||||
|
if sub_url:
|
||||||
|
created['subscription_url'] = sub_url
|
||||||
|
created['config'] = sub_url
|
||||||
|
else:
|
||||||
|
created['subscription_url'] = ''
|
||||||
|
created['inbound_id'] = inbound
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
async def xui_get_config(settings: dict, email: str) -> str:
|
async def xui_get_config(settings: dict, email: str) -> str:
|
||||||
|
|||||||
+50
-33
@@ -3,16 +3,15 @@
|
|||||||
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
|
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card" style="max-width: 560px; margin: 2rem auto;">
|
<div class="card" style="max-width: 520px; margin: 2.5rem auto; overflow:hidden;">
|
||||||
<div class="card-header"
|
<div style="padding: var(--space-xl) var(--space-lg) var(--space-md); text-align:center; background: linear-gradient(180deg, rgba(59,130,246,0.12), transparent);">
|
||||||
style="justify-content: center; text-align: center; flex-direction: column; gap: var(--space-xs);">
|
<div style="width:56px;height:56px;border-radius:16px;margin:0 auto var(--space-md);display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:1.6rem;">🔗</div>
|
||||||
<h2 class="card-title">{{ invite.name }}</h2>
|
<h2 class="card-title" style="margin-bottom:6px;">{{ invite.name }}</h2>
|
||||||
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('invite_public_subtitle') }}</p>
|
<p style="color: var(--text-muted); font-size: 0.92rem; margin:0;">{{ _('invite_public_subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if need_password %}
|
{% if need_password %}
|
||||||
<div style="padding: var(--space-lg); text-align: center;">
|
<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>
|
<p style="margin-bottom: var(--space-md);">{{ _('invite_protected_desc') }}</p>
|
||||||
<form id="authForm" onsubmit="authInvite(event)"
|
<form id="authForm" onsubmit="authInvite(event)"
|
||||||
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
|
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
|
||||||
@@ -26,27 +25,39 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="padding: var(--space-lg); text-align: center;">
|
<div style="padding: var(--space-lg);">
|
||||||
<div id="statusBox" style="margin-bottom: var(--space-md); color: var(--text-muted); font-size: 0.95rem;">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-lg);">
|
||||||
{% if invite.unlimited %}
|
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
|
||||||
{{ _('invite_uses_left_unlimited').replace('{}', invite.used_count|string) }}
|
<div style="font-size:0.72rem; color:var(--text-muted); text-transform:uppercase;">{{ _('invite_uses') }}</div>
|
||||||
{% elif invite.remaining is not none %}
|
<div id="usesValue" style="font-weight:700; font-size:1.15rem; margin-top:4px;">
|
||||||
{{ _('invite_uses_left').replace('{}', invite.remaining|string) }}
|
{% if invite.unlimited %}∞{% else %}{{ invite.remaining }}{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
|
||||||
|
<div style="font-size:0.72rem; color:var(--text-muted); text-transform:uppercase;">{{ _('invite_duration_short') }}</div>
|
||||||
|
<div style="font-weight:700; font-size:1.15rem; margin-top:4px;">
|
||||||
|
{% if invite.duration_days %}{{ invite.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p id="statusHint" style="text-align:center; color:var(--text-muted); font-size:0.88rem; margin:0 0 var(--space-md);">
|
||||||
|
{% if invite.duration_days %}
|
||||||
|
{{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }}
|
||||||
|
{% else %}
|
||||||
|
{{ _('invite_get_config_hint') }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if invite.available %}
|
{% if invite.available %}
|
||||||
<button class="btn btn-primary" style="width:100%; max-width:320px;" onclick="createInviteConfig()" id="createBtn">
|
<button class="btn btn-primary" style="width:100%;" onclick="createInviteConfig()" id="createBtn">
|
||||||
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
||||||
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
||||||
</button>
|
</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 %}
|
{% elif invite.exhausted %}
|
||||||
<p style="color:#ef4444;">{{ _('invite_exhausted') }}</p>
|
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('invite_exhausted') }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="color:#ef4444;">{{ _('disabled') }}</p>
|
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('disabled') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -58,14 +69,15 @@
|
|||||||
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
||||||
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="expiresBanner" class="hidden" style="margin:0 var(--space-md) var(--space-sm); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); background:rgba(59,130,246,0.1); color:var(--text-muted); font-size:0.85rem;"></div>
|
||||||
<div class="config-tabs">
|
<div class="config-tabs">
|
||||||
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
|
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('invite_sub_tab') }}</button>
|
||||||
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
||||||
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-panel active" id="panel-conf">
|
<div class="config-panel active" id="panel-conf">
|
||||||
<div class="config-display">
|
<div class="config-display">
|
||||||
<textarea class="config-text" id="configText" readonly rows="12"
|
<textarea class="config-text" id="configText" readonly rows="8"
|
||||||
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
|
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
|
||||||
<div class="config-actions">
|
<div class="config-actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config') }}</button>
|
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config') }}</button>
|
||||||
@@ -130,17 +142,24 @@
|
|||||||
document.getElementById(panels[tab]).classList.add('active');
|
document.getElementById(panels[tab]).classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openConfigModal(name, config, vpnLink) {
|
function openConfigModal(name, config, vpnLink, expiresAt) {
|
||||||
currentConfig = config;
|
currentConfig = config;
|
||||||
currentVpnLink = vpnLink || '';
|
currentVpnLink = vpnLink || config || '';
|
||||||
document.getElementById('configText').value = config;
|
document.getElementById('configText').value = config;
|
||||||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||||
document.getElementById('configModalTitle').textContent = name;
|
document.getElementById('configModalTitle').textContent = name;
|
||||||
document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.conf`);
|
const banner = document.getElementById('expiresBanner');
|
||||||
|
if (expiresAt) {
|
||||||
|
banner.textContent = _('invite_config_expires_at').replace('{}', new Date(expiresAt).toLocaleString());
|
||||||
|
banner.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
banner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.txt`);
|
||||||
const qrContainer = document.getElementById('qrcode');
|
const qrContainer = document.getElementById('qrcode');
|
||||||
qrContainer.innerHTML = '';
|
qrContainer.innerHTML = '';
|
||||||
new QRCode(qrContainer, {
|
new QRCode(qrContainer, {
|
||||||
text: config, width: 256, height: 256,
|
text: currentVpnLink || config, width: 256, height: 256,
|
||||||
colorDark: '#000000', colorLight: '#ffffff',
|
colorDark: '#000000', colorLight: '#ffffff',
|
||||||
correctLevel: QRCode.CorrectLevel.L
|
correctLevel: QRCode.CorrectLevel.L
|
||||||
});
|
});
|
||||||
@@ -152,14 +171,11 @@
|
|||||||
function copyVpnLink() { copyToClipboard(currentVpnLink); }
|
function copyVpnLink() { copyToClipboard(currentVpnLink); }
|
||||||
|
|
||||||
function updateStatus(invite) {
|
function updateStatus(invite) {
|
||||||
const box = document.getElementById('statusBox');
|
const uses = document.getElementById('usesValue');
|
||||||
if (!box || !invite) return;
|
if (uses && invite) {
|
||||||
if (invite.unlimited) {
|
uses.textContent = invite.unlimited ? '∞' : String(invite.remaining ?? 0);
|
||||||
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) {
|
if (invite && !invite.available) {
|
||||||
const btn = document.getElementById('createBtn');
|
const btn = document.getElementById('createBtn');
|
||||||
if (btn) btn.style.display = 'none';
|
if (btn) btn.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -180,7 +196,8 @@
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
||||||
if (data.config) openConfigModal(data.connection?.name || 'VPN', data.config, data.vpn_link || '');
|
const share = data.subscription_url || data.config || '';
|
||||||
|
if (share) openConfigModal(data.connection?.name || 'VPN', share, data.vpn_link || share, data.expires_at);
|
||||||
updateStatus(data.invite);
|
updateStatus(data.invite);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`${_('error')}: ` + err.message);
|
alert(`${_('error')}: ` + err.message);
|
||||||
|
|||||||
+95
-81
@@ -4,18 +4,30 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between"
|
<div style="display:flex; align-items:flex-end; justify-content:space-between; gap:var(--space-md); flex-wrap:wrap; margin-bottom:var(--space-lg);">
|
||||||
style="margin-bottom: var(--space-lg); gap: var(--space-md); flex-wrap: wrap;">
|
<div>
|
||||||
<h1 class="section-title" style="margin-bottom:0;">
|
<h1 class="section-title" style="margin-bottom:var(--space-xs);">
|
||||||
<span class="icon">🔗</span>
|
<span class="icon">🔗</span>
|
||||||
{{ _('invites_title') }}
|
{{ _('invites_title') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<p style="color:var(--text-muted); margin:0; max-width:42rem; line-height:1.45;">{{ _('invites_hint') }}</p>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" onclick="openCreateInvite()">
|
<button class="btn btn-primary" onclick="openCreateInvite()">
|
||||||
<span>+</span> {{ _('invite_create') }}
|
<span>+</span> {{ _('invite_create') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="form-hint" style="margin-bottom: var(--space-lg);">{{ _('invites_hint') }}</p>
|
{% if not xui_sub_url and xui_configured %}
|
||||||
|
<div class="card" style="margin-bottom:var(--space-lg); border-color: rgba(245, 158, 11, 0.35); background: rgba(245, 158, 11, 0.06);">
|
||||||
|
<div style="display:flex; gap:var(--space-md); align-items:flex-start;">
|
||||||
|
<div style="font-size:1.4rem;">⚠️</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; margin-bottom:4px;">{{ _('invite_sub_url_missing_title') }}</div>
|
||||||
|
<div class="form-hint" style="margin:0;">{{ _('invite_sub_url_missing_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
||||||
<div class="empty-icon">🔗</div>
|
<div class="empty-icon">🔗</div>
|
||||||
@@ -23,36 +35,43 @@
|
|||||||
<div class="empty-desc">{{ _('invites_empty_desc') }}</div>
|
<div class="empty-desc">{{ _('invites_empty_desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clients-list" id="invitesGrid"
|
<div id="invitesGrid" style="display:{% if invites %}grid{% else %}none{% endif %}; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-md);">
|
||||||
style="display: grid; gap: var(--space-md); {% if not invites %}display:none;{% endif %}">
|
|
||||||
{% for inv in invites %}
|
{% for inv in invites %}
|
||||||
<div class="client-item" id="invite-{{ inv.id }}" style="{% if not inv.enabled %}opacity:0.55;{% endif %}">
|
<div class="card invite-card" id="invite-{{ inv.id }}" style="padding:var(--space-md); {% if not inv.enabled %}opacity:0.55;{% endif %}">
|
||||||
<div class="client-info">
|
<div style="display:flex; justify-content:space-between; gap:var(--space-sm); align-items:flex-start; margin-bottom:var(--space-sm);">
|
||||||
<div class="client-avatar">🔗</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="client-name">
|
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
|
||||||
{{ inv.name }}
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
|
||||||
{% if inv.available %}
|
{% if inv.protocol == 'xui' %}3x-ui VLESS{% else %}{{ inv.protocol }}{% endif %}
|
||||||
<span class="badge badge-success">{{ _('invite_active') }}</span>
|
· inbound #{{ inv.xui_inbound_id or '—' }}
|
||||||
{% 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>
|
||||||
<div class="client-meta">
|
</div>
|
||||||
<span>{{ _('invite_uses') }}:
|
{% if inv.available %}
|
||||||
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
|
<span class="badge badge-success">{{ _('invite_active') }}</span>
|
||||||
</span>
|
{% elif inv.exhausted %}
|
||||||
<span>{{ inv.protocol if inv.protocol != 'xui' else '3x-ui VLESS' }}</span>
|
<span class="badge badge-warn">{{ _('invite_exhausted') }}</span>
|
||||||
{% if inv.has_password %}<span>🔐</span>{% endif %}
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">{{ _('disabled') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-md);">
|
||||||
|
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-sm);">
|
||||||
|
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em;">{{ _('invite_uses') }}</div>
|
||||||
|
<div style="font-weight:700; margin-top:2px;">
|
||||||
|
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-sm);">
|
||||||
|
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em;">{{ _('invite_duration_short') }}</div>
|
||||||
|
<div style="font-weight:700; margin-top:2px;">
|
||||||
|
{% if inv.duration_days %}{{ inv.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<div style="display:flex; gap:var(--space-xs); flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="copyInviteUrl('{{ inv.token }}')">📋 {{ _('copy') }}</button>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editInvite('{{ inv.id }}')">✏️</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' }})">
|
<button class="btn btn-secondary btn-sm" onclick="toggleInvite('{{ inv.id }}', {{ 'false' if inv.enabled else 'true' }})">
|
||||||
{% if inv.enabled %}⏸{% else %}▶️{% endif %}
|
{% if inv.enabled %}⏸{% else %}▶️{% endif %}
|
||||||
@@ -64,9 +83,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Create / Edit Modal -->
|
|
||||||
<div class="modal-backdrop" id="inviteModal">
|
<div class="modal-backdrop" id="inviteModal">
|
||||||
<div class="modal" style="max-width: 520px;">
|
<div class="modal" style="max-width: 560px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title" id="inviteModalTitle">{{ _('invite_create') }}</h2>
|
<h2 class="modal-title" id="inviteModalTitle">{{ _('invite_create') }}</h2>
|
||||||
<button class="modal-close" onclick="closeModal('inviteModal')">×</button>
|
<button class="modal-close" onclick="closeModal('inviteModal')">×</button>
|
||||||
@@ -79,10 +97,17 @@
|
|||||||
<input class="form-input" type="text" id="inviteName" placeholder="Promo / Friends">
|
<input class="form-input" type="text" id="inviteName" placeholder="Promo / Friends">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md);">
|
||||||
<label class="form-label">{{ _('invite_max_uses_label') }}</label>
|
<div class="form-group">
|
||||||
<input class="form-input" type="number" id="inviteMaxUses" min="0" value="1">
|
<label class="form-label">{{ _('invite_max_uses_label') }}</label>
|
||||||
<div class="form-hint">{{ _('invite_max_uses_hint') }}</div>
|
<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_duration_label') }}</label>
|
||||||
|
<input class="form-input" type="number" id="inviteDurationDays" min="0" value="0">
|
||||||
|
<div class="form-hint">{{ _('invite_duration_hint') }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -114,8 +139,9 @@
|
|||||||
<div class="form-group" id="inviteInboundGroup">
|
<div class="form-group" id="inviteInboundGroup">
|
||||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||||
<select class="form-select" id="inviteInboundId">
|
<select class="form-select" id="inviteInboundId">
|
||||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
<option value="">{{ _('invite_inbound_loading') }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="form-hint">{{ _('invite_inbound_hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -126,14 +152,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ _('invite_note_label') }}</label>
|
<label class="form-label">{{ _('invite_note_label') }}</label>
|
||||||
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
|
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
|
||||||
@@ -158,6 +176,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const invitesData = {{ invites | tojson }};
|
const invitesData = {{ invites | tojson }};
|
||||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||||
|
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||||
|
|
||||||
function inviteUrl(token) {
|
function inviteUrl(token) {
|
||||||
return `${window.location.origin}/invite/${token}`;
|
return `${window.location.origin}/invite/${token}`;
|
||||||
@@ -171,22 +190,36 @@
|
|||||||
function updateInviteProtoFields() {
|
function updateInviteProtoFields() {
|
||||||
const v = document.getElementById('inviteProtocol').value;
|
const v = document.getElementById('inviteProtocol').value;
|
||||||
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
|
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
|
||||||
|
if (v === 'xui') loadInviteInbounds(document.getElementById('inviteInboundId').value || xuiDefaultInbound);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInviteInbounds(selected) {
|
async function loadInviteInbounds(selected) {
|
||||||
const select = document.getElementById('inviteInboundId');
|
const select = document.getElementById('inviteInboundId');
|
||||||
select.innerHTML = `<option value="0">${_('xui_inbound_auto')}</option>`;
|
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
||||||
if (!xuiConfigured) return;
|
if (!xuiConfigured) {
|
||||||
|
select.innerHTML = `<option value="">${_('invite_inbound_need_xui')}</option>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await apiCall('/api/settings/xui/inbounds');
|
const data = await apiCall('/api/settings/xui/inbounds');
|
||||||
(data.inbounds || []).forEach(ib => {
|
const list = data.inbounds || [];
|
||||||
|
if (!list.length) {
|
||||||
|
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.innerHTML = '';
|
||||||
|
list.forEach((ib, idx) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = ib.id;
|
opt.value = ib.id;
|
||||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||||
if (selected && String(ib.id) === String(selected)) opt.selected = true;
|
const prefer = selected || xuiDefaultInbound;
|
||||||
|
if (prefer && String(ib.id) === String(prefer)) opt.selected = true;
|
||||||
|
else if (!prefer && idx === 0) opt.selected = true;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) {
|
||||||
|
select.innerHTML = `<option value="">${_('error')}: ${e.message}</option>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProtocolValue() {
|
function parseProtocolValue() {
|
||||||
@@ -201,9 +234,8 @@
|
|||||||
|
|
||||||
function setProtocolSelect(protocol, serverId) {
|
function setProtocolSelect(protocol, serverId) {
|
||||||
const sel = document.getElementById('inviteProtocol');
|
const sel = document.getElementById('inviteProtocol');
|
||||||
if (protocol === 'xui') {
|
if (protocol === 'xui') sel.value = 'xui';
|
||||||
sel.value = 'xui';
|
else {
|
||||||
} else {
|
|
||||||
const want = `${protocol}|${serverId || 0}`;
|
const want = `${protocol}|${serverId || 0}`;
|
||||||
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
||||||
else sel.value = 'xui';
|
else sel.value = 'xui';
|
||||||
@@ -216,15 +248,14 @@
|
|||||||
document.getElementById('inviteModalTitle').textContent = _('invite_create');
|
document.getElementById('inviteModalTitle').textContent = _('invite_create');
|
||||||
document.getElementById('inviteName').value = '';
|
document.getElementById('inviteName').value = '';
|
||||||
document.getElementById('inviteMaxUses').value = '1';
|
document.getElementById('inviteMaxUses').value = '1';
|
||||||
|
document.getElementById('inviteDurationDays').value = '0';
|
||||||
document.getElementById('inviteUserId').value = '';
|
document.getElementById('inviteUserId').value = '';
|
||||||
document.getElementById('invitePassword').value = '';
|
document.getElementById('invitePassword').value = '';
|
||||||
document.getElementById('inviteExpires').value = '';
|
|
||||||
document.getElementById('inviteNote').value = '';
|
document.getElementById('inviteNote').value = '';
|
||||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||||
document.getElementById('inviteClearExpWrap').style.display = 'none';
|
|
||||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||||
setProtocolSelect('xui', 0);
|
setProtocolSelect('xui', 0);
|
||||||
loadInviteInbounds(0);
|
loadInviteInbounds(xuiDefaultInbound);
|
||||||
openModal('inviteModal');
|
openModal('inviteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,64 +266,47 @@
|
|||||||
document.getElementById('inviteModalTitle').textContent = _('invite_edit');
|
document.getElementById('inviteModalTitle').textContent = _('invite_edit');
|
||||||
document.getElementById('inviteName').value = inv.name || '';
|
document.getElementById('inviteName').value = inv.name || '';
|
||||||
document.getElementById('inviteMaxUses').value = String(inv.max_uses ?? 1);
|
document.getElementById('inviteMaxUses').value = String(inv.max_uses ?? 1);
|
||||||
|
document.getElementById('inviteDurationDays').value = String(inv.duration_days ?? 0);
|
||||||
document.getElementById('inviteUserId').value = inv.user_id || '';
|
document.getElementById('inviteUserId').value = inv.user_id || '';
|
||||||
document.getElementById('invitePassword').value = '';
|
document.getElementById('invitePassword').value = '';
|
||||||
document.getElementById('inviteNote').value = inv.note || '';
|
document.getElementById('inviteNote').value = inv.note || '';
|
||||||
document.getElementById('inviteClearPwdWrap').style.display = inv.has_password ? 'flex' : 'none';
|
document.getElementById('inviteClearPwdWrap').style.display = inv.has_password ? 'flex' : 'none';
|
||||||
document.getElementById('inviteClearPassword').checked = false;
|
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('inviteResetUsedWrap').style.display = 'block';
|
||||||
document.getElementById('inviteResetUsed').checked = false;
|
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);
|
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
|
||||||
loadInviteInbounds(inv.xui_inbound_id || 0);
|
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
|
||||||
openModal('inviteModal');
|
openModal('inviteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveInvite() {
|
async function saveInvite() {
|
||||||
const btn = document.getElementById('inviteSaveBtn');
|
const btn = document.getElementById('inviteSaveBtn');
|
||||||
const text = document.getElementById('inviteSaveText');
|
|
||||||
const spinner = document.getElementById('inviteSaveSpinner');
|
const spinner = document.getElementById('inviteSaveSpinner');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
spinner.classList.remove('hidden');
|
spinner.classList.remove('hidden');
|
||||||
try {
|
try {
|
||||||
const editId = document.getElementById('inviteEditId').value;
|
const editId = document.getElementById('inviteEditId').value;
|
||||||
const proto = parseProtocolValue();
|
const proto = parseProtocolValue();
|
||||||
const expiresVal = document.getElementById('inviteExpires').value;
|
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
|
||||||
|
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
name: document.getElementById('inviteName').value || 'Invite',
|
name: document.getElementById('inviteName').value || 'Invite',
|
||||||
max_uses: parseInt(document.getElementById('inviteMaxUses').value || '1'),
|
max_uses: parseInt(document.getElementById('inviteMaxUses').value || '1'),
|
||||||
|
duration_days: parseInt(document.getElementById('inviteDurationDays').value || '0'),
|
||||||
user_id: document.getElementById('inviteUserId').value || '',
|
user_id: document.getElementById('inviteUserId').value || '',
|
||||||
protocol: proto.protocol,
|
protocol: proto.protocol,
|
||||||
server_id: proto.server_id,
|
server_id: proto.server_id,
|
||||||
xui_inbound_id: parseInt(document.getElementById('inviteInboundId').value || '0'),
|
xui_inbound_id: inbound,
|
||||||
note: document.getElementById('inviteNote').value || '',
|
note: document.getElementById('inviteNote').value || '',
|
||||||
};
|
};
|
||||||
const pwd = document.getElementById('invitePassword').value;
|
const pwd = document.getElementById('invitePassword').value;
|
||||||
if (pwd) body.password = pwd;
|
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 (!body.user_id) throw new Error(_('invite_user_required'));
|
||||||
|
|
||||||
if (editId) {
|
if (editId) {
|
||||||
body.clear_password = document.getElementById('inviteClearPassword').checked;
|
body.clear_password = document.getElementById('inviteClearPassword').checked;
|
||||||
body.clear_expires = document.getElementById('inviteClearExpires').checked;
|
|
||||||
body.reset_used = document.getElementById('inviteResetUsed').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);
|
await apiCall(`/api/invites/${editId}`, 'PUT', body);
|
||||||
showToast(_('invite_updated'), 'success');
|
showToast(_('invite_updated'), 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+10
-2
@@ -436,6 +436,12 @@
|
|||||||
value="{{ settings.sync.xui_url }}" placeholder="https://panel.example.com:2053/path">
|
value="{{ settings.sync.xui_url }}" placeholder="https://panel.example.com:2053/path">
|
||||||
<div class="form-hint">{{ _('xui_url_hint') }}</div>
|
<div class="form-hint">{{ _('xui_url_hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
|
||||||
|
<input type="url" class="form-input" name="xui_sub_url"
|
||||||
|
value="{{ settings.sync.xui_sub_url or '' }}" placeholder="https://sub.example.com:2096/sub">
|
||||||
|
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ _('xui_api_token_label') }}</label>
|
<label class="form-label">{{ _('xui_api_token_label') }}</label>
|
||||||
<input type="password" class="form-input" name="xui_api_token"
|
<input type="password" class="form-input" name="xui_api_token"
|
||||||
@@ -1028,7 +1034,8 @@
|
|||||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
||||||
xui_protocol: syncForm.xui_protocol.value,
|
xui_protocol: syncForm.xui_protocol.value,
|
||||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0')
|
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
|
||||||
|
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
|
||||||
};
|
};
|
||||||
const res = await fetch('/api/settings/xui_sync_now', {
|
const res = await fetch('/api/settings/xui_sync_now', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1110,7 +1117,8 @@
|
|||||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
||||||
xui_protocol: syncForm.xui_protocol.value,
|
xui_protocol: syncForm.xui_protocol.value,
|
||||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0')
|
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
|
||||||
|
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,20 @@
|
|||||||
"invite_user_required": "Select a user to store configs under",
|
"invite_user_required": "Select a user to store configs under",
|
||||||
"invite_password_label": "Password (optional)",
|
"invite_password_label": "Password (optional)",
|
||||||
"invite_expires_label": "Expires at (optional)",
|
"invite_expires_label": "Expires at (optional)",
|
||||||
|
"invite_duration_label": "Config lifetime (days)",
|
||||||
|
"invite_duration_hint": "Starts when the user clicks Get config. 0 = no expiry.",
|
||||||
|
"invite_duration_short": "Lifetime",
|
||||||
|
"invite_duration_starts_hint": "After you get a config it will work for {} day(s)",
|
||||||
|
"invite_config_expires_at": "Config valid until: {}",
|
||||||
|
"invite_inbound_hint": "Choose the VLESS inbound once — all configs from this link use it",
|
||||||
|
"invite_inbound_loading": "Loading inbounds…",
|
||||||
|
"invite_inbound_empty": "No VLESS inbounds found",
|
||||||
|
"invite_inbound_need_xui": "Configure 3x-ui in Settings first",
|
||||||
|
"invite_inbound_required": "Select a VLESS inbound",
|
||||||
|
"invite_sub_url_missing_title": "Subscription URL is not set",
|
||||||
|
"invite_sub_url_missing_hint": "In Settings → 3x-ui set Subscription URL base so invite links issue /sub/… configs.",
|
||||||
|
"invite_sub_tab": "Subscription",
|
||||||
|
"days_short": "d",
|
||||||
"invite_clear_expires": "Remove expiration",
|
"invite_clear_expires": "Remove expiration",
|
||||||
"invite_note_label": "Note (admin only)",
|
"invite_note_label": "Note (admin only)",
|
||||||
"invite_note_placeholder": "Internal comment",
|
"invite_note_placeholder": "Internal comment",
|
||||||
@@ -277,6 +291,8 @@
|
|||||||
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
|
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
|
||||||
"xui_url_label": "3x-ui URL",
|
"xui_url_label": "3x-ui URL",
|
||||||
"xui_url_hint": "Include scheme, port and webBasePath if configured (example: https://host:2053/secret)",
|
"xui_url_hint": "Include scheme, port and webBasePath if configured (example: https://host:2053/secret)",
|
||||||
|
"xui_sub_url_label": "Subscription URL base",
|
||||||
|
"xui_sub_url_hint": "Public /sub base without trailing slash (example: https://host:2096/sub). Invite configs are issued as subscription links.",
|
||||||
"xui_api_token_label": "API Token (preferred)",
|
"xui_api_token_label": "API Token (preferred)",
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||||
"xui_api_token_hint": "If set, username/password login is skipped",
|
"xui_api_token_hint": "If set, username/password login is skipped",
|
||||||
|
|||||||
@@ -213,6 +213,20 @@
|
|||||||
"invite_user_required": "یک کاربر انتخاب کنید",
|
"invite_user_required": "یک کاربر انتخاب کنید",
|
||||||
"invite_password_label": "رمز عبور (اختیاری)",
|
"invite_password_label": "رمز عبور (اختیاری)",
|
||||||
"invite_expires_label": "انقضا (اختیاری)",
|
"invite_expires_label": "انقضا (اختیاری)",
|
||||||
|
"invite_duration_label": "مدت کانفیگ (روز)",
|
||||||
|
"invite_duration_hint": "از لحظه کلیک روی دریافت کانفیگ شروع میشود. 0 = بدون انقضا.",
|
||||||
|
"invite_duration_short": "مدت",
|
||||||
|
"invite_duration_starts_hint": "پس از دریافت، کانفیگ {} روز معتبر است",
|
||||||
|
"invite_config_expires_at": "اعتبار تا: {}",
|
||||||
|
"invite_inbound_hint": "یکبار inbound مورد نیاز VLESS را انتخاب کنید",
|
||||||
|
"invite_inbound_loading": "در حال بارگذاری…",
|
||||||
|
"invite_inbound_empty": "inbound VLESS یافت نشد",
|
||||||
|
"invite_inbound_need_xui": "ابتدا 3x-ui را در تنظیمات پیکربندی کنید",
|
||||||
|
"invite_inbound_required": "یک inbound VLESS انتخاب کنید",
|
||||||
|
"invite_sub_url_missing_title": "آدرس اشتراک تنظیم نشده",
|
||||||
|
"invite_sub_url_missing_hint": "در تنظیمات → 3x-ui پایه /sub را وارد کنید.",
|
||||||
|
"invite_sub_tab": "اشتراک",
|
||||||
|
"days_short": "روز",
|
||||||
"invite_clear_expires": "حذف تاریخ انقضا",
|
"invite_clear_expires": "حذف تاریخ انقضا",
|
||||||
"invite_note_label": "یادداشت (فقط ادمین)",
|
"invite_note_label": "یادداشت (فقط ادمین)",
|
||||||
"invite_note_placeholder": "توضیح داخلی",
|
"invite_note_placeholder": "توضیح داخلی",
|
||||||
@@ -275,6 +289,8 @@
|
|||||||
"sync_hint": "کاربران طبق دادههای Remnawave بهطور خودکار مدیریت میشوند",
|
"sync_hint": "کاربران طبق دادههای Remnawave بهطور خودکار مدیریت میشوند",
|
||||||
"xui_url_label": "آدرس 3x-ui",
|
"xui_url_label": "آدرس 3x-ui",
|
||||||
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
|
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
|
||||||
|
"xui_sub_url_label": "آدرس پایه اشتراک",
|
||||||
|
"xui_sub_url_hint": "پایه عمومی /sub بدون اسلش پایانی (مثال: https://host:2096/sub).",
|
||||||
"xui_api_token_label": "توکن API (ترجیحی)",
|
"xui_api_token_label": "توکن API (ترجیحی)",
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||||
"xui_api_token_hint": "در صورت تنظیم، ورود با نام کاربری/رمز رد میشود",
|
"xui_api_token_hint": "در صورت تنظیم، ورود با نام کاربری/رمز رد میشود",
|
||||||
|
|||||||
@@ -213,6 +213,20 @@
|
|||||||
"invite_user_required": "Choisissez un utilisateur",
|
"invite_user_required": "Choisissez un utilisateur",
|
||||||
"invite_password_label": "Mot de passe (optionnel)",
|
"invite_password_label": "Mot de passe (optionnel)",
|
||||||
"invite_expires_label": "Expiration (optionnel)",
|
"invite_expires_label": "Expiration (optionnel)",
|
||||||
|
"invite_duration_label": "Durée du config (jours)",
|
||||||
|
"invite_duration_hint": "Commence quand l'utilisateur clique Obtenir. 0 = illimité.",
|
||||||
|
"invite_duration_short": "Durée",
|
||||||
|
"invite_duration_starts_hint": "Après obtention, valable {} jour(s)",
|
||||||
|
"invite_config_expires_at": "Valable jusqu'au : {}",
|
||||||
|
"invite_inbound_hint": "Choisissez l'inbound VLESS une fois",
|
||||||
|
"invite_inbound_loading": "Chargement…",
|
||||||
|
"invite_inbound_empty": "Aucun inbound VLESS",
|
||||||
|
"invite_inbound_need_xui": "Configurez 3x-ui d'abord",
|
||||||
|
"invite_inbound_required": "Sélectionnez un inbound VLESS",
|
||||||
|
"invite_sub_url_missing_title": "URL d'abonnement manquante",
|
||||||
|
"invite_sub_url_missing_hint": "Dans Paramètres → 3x-ui, définissez l'URL de base /sub.",
|
||||||
|
"invite_sub_tab": "Abonnement",
|
||||||
|
"days_short": "j",
|
||||||
"invite_clear_expires": "Retirer l'expiration",
|
"invite_clear_expires": "Retirer l'expiration",
|
||||||
"invite_note_label": "Note (admin)",
|
"invite_note_label": "Note (admin)",
|
||||||
"invite_note_placeholder": "Commentaire interne",
|
"invite_note_placeholder": "Commentaire interne",
|
||||||
@@ -275,6 +289,8 @@
|
|||||||
"sync_hint": "Synchro automatique avec Remnawave",
|
"sync_hint": "Synchro automatique avec Remnawave",
|
||||||
"xui_url_label": "URL 3x-ui",
|
"xui_url_label": "URL 3x-ui",
|
||||||
"xui_url_hint": "Inclure le schéma, le port et webBasePath si configuré",
|
"xui_url_hint": "Inclure le schéma, le port et webBasePath si configuré",
|
||||||
|
"xui_sub_url_label": "URL de base d'abonnement",
|
||||||
|
"xui_sub_url_hint": "Base /sub publique sans slash final (ex: https://host:2096/sub).",
|
||||||
"xui_api_token_label": "Jeton API (préféré)",
|
"xui_api_token_label": "Jeton API (préféré)",
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||||
"xui_api_token_hint": "Si défini, le login/mot de passe est ignoré",
|
"xui_api_token_hint": "Si défini, le login/mot de passe est ignoré",
|
||||||
|
|||||||
@@ -215,6 +215,20 @@
|
|||||||
"invite_user_required": "Выберите пользователя для хранения конфигов",
|
"invite_user_required": "Выберите пользователя для хранения конфигов",
|
||||||
"invite_password_label": "Пароль (опционально)",
|
"invite_password_label": "Пароль (опционально)",
|
||||||
"invite_expires_label": "Срок действия (опционально)",
|
"invite_expires_label": "Срок действия (опционально)",
|
||||||
|
"invite_duration_label": "Срок конфига (дни)",
|
||||||
|
"invite_duration_hint": "Отсчёт начинается, когда пользователь нажал «Получить конфиг». 0 = без срока.",
|
||||||
|
"invite_duration_short": "Срок",
|
||||||
|
"invite_duration_starts_hint": "После получения конфиг будет действовать {} дн.",
|
||||||
|
"invite_config_expires_at": "Конфиг действует до: {}",
|
||||||
|
"invite_inbound_hint": "Укажите нужный VLESS inbound один раз — все конфиги с этой ссылки пойдут в него",
|
||||||
|
"invite_inbound_loading": "Загрузка inbound…",
|
||||||
|
"invite_inbound_empty": "VLESS inbound не найдены",
|
||||||
|
"invite_inbound_need_xui": "Сначала настройте 3x-ui в Настройках",
|
||||||
|
"invite_inbound_required": "Выберите VLESS inbound",
|
||||||
|
"invite_sub_url_missing_title": "Не задан URL подписки",
|
||||||
|
"invite_sub_url_missing_hint": "В Настройки → 3x-ui укажите «Базовый URL подписки», чтобы ссылки выдавали /sub/… конфиги.",
|
||||||
|
"invite_sub_tab": "Подписка",
|
||||||
|
"days_short": "дн",
|
||||||
"invite_clear_expires": "Убрать срок действия",
|
"invite_clear_expires": "Убрать срок действия",
|
||||||
"invite_note_label": "Заметка (только админ)",
|
"invite_note_label": "Заметка (только админ)",
|
||||||
"invite_note_placeholder": "Внутренний комментарий",
|
"invite_note_placeholder": "Внутренний комментарий",
|
||||||
@@ -277,6 +291,8 @@
|
|||||||
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
|
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
|
||||||
"xui_url_label": "URL 3x-ui",
|
"xui_url_label": "URL 3x-ui",
|
||||||
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
|
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
|
||||||
|
"xui_sub_url_label": "Базовый URL подписки",
|
||||||
|
"xui_sub_url_hint": "Публичный /sub без слэша в конце (пример: https://host:2096/sub). Конфиги по ссылкам выдаются как subscription URL.",
|
||||||
"xui_api_token_label": "API Token (предпочтительно)",
|
"xui_api_token_label": "API Token (предпочтительно)",
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||||
"xui_api_token_hint": "Если задан, вход по логину/паролю не используется",
|
"xui_api_token_hint": "Если задан, вход по логину/паролю не используется",
|
||||||
|
|||||||
@@ -213,6 +213,20 @@
|
|||||||
"invite_user_required": "请选择用户",
|
"invite_user_required": "请选择用户",
|
||||||
"invite_password_label": "密码(可选)",
|
"invite_password_label": "密码(可选)",
|
||||||
"invite_expires_label": "过期时间(可选)",
|
"invite_expires_label": "过期时间(可选)",
|
||||||
|
"invite_duration_label": "配置有效期(天)",
|
||||||
|
"invite_duration_hint": "从用户点击获取配置时开始计时。0 = 不过期。",
|
||||||
|
"invite_duration_short": "有效期",
|
||||||
|
"invite_duration_starts_hint": "获取后配置将有效 {} 天",
|
||||||
|
"invite_config_expires_at": "配置有效至:{}",
|
||||||
|
"invite_inbound_hint": "一次性选择所需的 VLESS 入站",
|
||||||
|
"invite_inbound_loading": "加载入站中…",
|
||||||
|
"invite_inbound_empty": "未找到 VLESS 入站",
|
||||||
|
"invite_inbound_need_xui": "请先在设置中配置 3x-ui",
|
||||||
|
"invite_inbound_required": "请选择 VLESS 入站",
|
||||||
|
"invite_sub_url_missing_title": "未设置订阅 URL",
|
||||||
|
"invite_sub_url_missing_hint": "在 设置 → 3x-ui 中填写订阅基础 URL,以便发放 /sub/… 配置。",
|
||||||
|
"invite_sub_tab": "订阅",
|
||||||
|
"days_short": "天",
|
||||||
"invite_clear_expires": "清除过期时间",
|
"invite_clear_expires": "清除过期时间",
|
||||||
"invite_note_label": "备注(仅管理员)",
|
"invite_note_label": "备注(仅管理员)",
|
||||||
"invite_note_placeholder": "内部备注",
|
"invite_note_placeholder": "内部备注",
|
||||||
@@ -275,6 +289,8 @@
|
|||||||
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
|
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
|
||||||
"xui_url_label": "3x-ui 地址",
|
"xui_url_label": "3x-ui 地址",
|
||||||
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
|
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
|
||||||
|
"xui_sub_url_label": "订阅基础 URL",
|
||||||
|
"xui_sub_url_hint": "公共 /sub 基础地址,末尾不要斜杠(如 https://host:2096/sub)。",
|
||||||
"xui_api_token_label": "API Token(推荐)",
|
"xui_api_token_label": "API Token(推荐)",
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||||
"xui_api_token_hint": "若已设置,将跳过用户名/密码登录",
|
"xui_api_token_hint": "若已设置,将跳过用户名/密码登录",
|
||||||
|
|||||||
Reference in New Issue
Block a user