diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 1cb0fb4d..244c6d2e 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,15 +1,20 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription, Transaction +from app.database.models import Subscription, Transaction, User +from app.services.remnawave_service import ( + RemnaWaveConfigurationError, + RemnaWaveService, +) from app.services.subscription_service import SubscriptionService from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( @@ -19,6 +24,9 @@ from app.utils.telegram_webapp import ( from ..dependencies import get_db_session from ..schemas.miniapp import ( + MiniAppConnectedServer, + MiniAppDevice, + MiniAppPromoGroup, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -71,6 +79,122 @@ def _status_label(status: str) -> str: return mapping.get(status, status.title()) +def _parse_datetime_string(value: Optional[str]) -> Optional[str]: + if not value: + return None + + try: + cleaned = value.strip() + if cleaned.endswith("Z"): + cleaned = f"{cleaned[:-1]}+00:00" + # Normalize duplicated timezone suffixes like +00:00+00:00 + if "+00:00+00:00" in cleaned: + cleaned = cleaned.replace("+00:00+00:00", "+00:00") + + datetime.fromisoformat(cleaned) + return cleaned + except Exception: # pragma: no cover - defensive + return value + + +async def _resolve_connected_servers( + db: AsyncSession, + squad_uuids: List[str], +) -> List[MiniAppConnectedServer]: + if not squad_uuids: + return [] + + resolved: Dict[str, str] = {} + missing: List[str] = [] + + for squad_uuid in squad_uuids: + if squad_uuid in resolved: + continue + server = await get_server_squad_by_uuid(db, squad_uuid) + if server and server.display_name: + resolved[squad_uuid] = server.display_name + else: + missing.append(squad_uuid) + + if missing: + try: + service = RemnaWaveService() + if service.is_configured: + squads = await service.get_all_squads() + for squad in squads: + uuid = squad.get("uuid") + name = squad.get("name") + if uuid in missing and name: + resolved[uuid] = name + except RemnaWaveConfigurationError: + logger.debug("RemnaWave is not configured; skipping server name enrichment") + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to resolve server names from RemnaWave: %s", error) + + connected_servers: List[MiniAppConnectedServer] = [] + for squad_uuid in squad_uuids: + name = resolved.get(squad_uuid, squad_uuid) + connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name)) + + return connected_servers + + +async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: + remnawave_uuid = getattr(user, "remnawave_uuid", None) + if not remnawave_uuid: + return 0, [] + + try: + service = RemnaWaveService() + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to initialise RemnaWave service: %s", error) + return 0, [] + + if not service.is_configured: + return 0, [] + + try: + async with service.get_api_client() as api: + response = await api.get_user_devices(remnawave_uuid) + except RemnaWaveConfigurationError: + logger.debug("RemnaWave configuration missing while loading devices") + return 0, [] + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load devices from RemnaWave: %s", error) + return 0, [] + + total_devices = int(response.get("total") or 0) + devices_payload = response.get("devices") or [] + + devices: List[MiniAppDevice] = [] + for device in devices_payload: + platform = device.get("platform") or device.get("platformType") + model = device.get("deviceModel") or device.get("model") or device.get("name") + app_version = device.get("appVersion") or device.get("version") + last_seen_raw = ( + device.get("updatedAt") + or device.get("lastSeen") + or device.get("lastActiveAt") + or device.get("createdAt") + ) + last_ip = device.get("ip") or device.get("ipAddress") + + devices.append( + MiniAppDevice( + platform=platform, + device_model=model, + app_version=app_version, + last_seen=_parse_datetime_string(last_seen_raw), + last_ip=last_ip, + ) + ) + + if total_devices == 0: + total_devices = len(devices) + + return total_devices, devices + + def _resolve_display_name(user_data: Dict[str, Any]) -> str: username = user_data.get("username") if username: @@ -186,6 +310,8 @@ async def get_subscription_details( happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) connected_squads: List[str] = list(subscription.connected_squads or []) + connected_servers = await _resolve_connected_servers(db, connected_squads) + devices_count, devices = await _load_devices_info(user) links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} @@ -202,6 +328,8 @@ async def get_subscription_details( if isinstance(balance_currency, str): balance_currency = balance_currency.upper() + promo_group = getattr(user, "promo_group", None) + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -239,6 +367,9 @@ async def get_subscription_details( links=links, ss_conf_links=ss_conf_links, connected_squads=connected_squads, + connected_servers=connected_servers, + connected_devices_count=devices_count, + connected_devices=devices, happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), @@ -247,5 +378,10 @@ async def get_subscription_details( balance_rubles=round(user.balance_rubles, 2), balance_currency=balance_currency, transactions=[_serialize_transaction(tx) for tx in transactions], + promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name) + if promo_group + else None, + subscription_type="trial" if subscription.is_trial else "paid", + autopay_enabled=bool(subscription.autopay_enabled), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 7ce2b375..4c42e230 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -31,6 +31,24 @@ class MiniAppSubscriptionUser(BaseModel): has_active_subscription: bool = False +class MiniAppPromoGroup(BaseModel): + id: int + name: str + + +class MiniAppConnectedServer(BaseModel): + uuid: str + name: str + + +class MiniAppDevice(BaseModel): + platform: Optional[str] = None + device_model: Optional[str] = None + app_version: Optional[str] = None + last_seen: Optional[str] = None + last_ip: Optional[str] = None + + class MiniAppTransaction(BaseModel): id: int type: str @@ -54,6 +72,9 @@ class MiniAppSubscriptionResponse(BaseModel): links: List[str] = Field(default_factory=list) ss_conf_links: Dict[str, str] = Field(default_factory=dict) connected_squads: List[str] = Field(default_factory=list) + connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list) + connected_devices_count: int = 0 + connected_devices: List[MiniAppDevice] = Field(default_factory=list) happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None @@ -62,4 +83,7 @@ class MiniAppSubscriptionResponse(BaseModel): balance_rubles: float = 0.0 balance_currency: Optional[str] = None transactions: List[MiniAppTransaction] = Field(default_factory=list) + promo_group: Optional[MiniAppPromoGroup] = None + subscription_type: str + autopay_enabled: bool = False diff --git a/miniapp/index.html b/miniapp/index.html index 7af22803..7858021c 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -230,6 +230,39 @@ margin-bottom: 0; } + .device-list { + list-style: none; + padding: 0; + margin: 0; + } + + .device-item { + padding: 12px; + background: white; + border-radius: 10px; + font-size: 13px; + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--border-color); + margin-bottom: 8px; + } + + .device-item:last-child { + margin-bottom: 0; + } + + .device-title { + font-weight: 600; + margin-bottom: 4px; + } + + .device-meta { + font-size: 12px; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 8px; + } + /* User Info */ .user-header { display: flex; @@ -302,7 +335,7 @@ /* Stats Grid */ .stats-grid { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 12px; } @@ -606,6 +639,10 @@
-
Servers
+
+
-
+
Devices
+
@@ -620,6 +657,22 @@ Traffic Limit -
+
+ Subscription Type + - +
+
+ Promo Group + - +
+
+ Device Limit + - +
+
+ Auto-Pay + - +
@@ -660,6 +713,12 @@ +
+
Connected Devices
+ + +
+
@@ -716,15 +775,21 @@ 'error.default.message': 'Please contact support to activate your subscription.', 'stats.days_left': 'Days left', 'stats.servers': 'Servers', + 'stats.devices': 'Devices', 'info.expires': 'Expires', 'info.traffic_used': 'Traffic used', 'info.traffic_limit': 'Traffic limit', + 'info.subscription_type': 'Subscription type', + 'info.promo_group': 'Promo group', + 'info.device_limit': 'Device limit', + 'info.autopay': 'Auto-pay', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', + 'card.devices.title': 'Connected Devices', 'apps.title': 'Installation guide', 'apps.no_data': 'No installation guide available for this platform yet.', 'apps.featured': 'Recommended', @@ -740,6 +805,7 @@ 'history.type.refund': 'Refund', 'history.type.referral_reward': 'Referral reward', 'servers.empty': 'No servers connected yet', + 'devices.empty': 'No devices connected yet', 'language.ariaLabel': 'Select interface language', 'notifications.copy.success': 'Subscription link copied to clipboard.', 'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.', @@ -750,12 +816,17 @@ 'status.expired': 'Expired', 'status.disabled': 'Disabled', 'status.unknown': 'Unknown', + 'subscription.type.trial': 'Trial', + 'subscription.type.paid': 'Paid', + 'autopay.enabled': 'Enabled', + 'autopay.disabled': 'Disabled', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'PC', 'platform.tv': 'TV', 'units.gb': 'GB', - 'values.unlimited': 'Unlimited' + 'values.unlimited': 'Unlimited', + 'values.not_available': 'Not available' }, ru: { 'app.title': 'Подписка VPN', @@ -766,15 +837,21 @@ 'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.', 'stats.days_left': 'Осталось дней', 'stats.servers': 'Серверы', + 'stats.devices': 'Устройства', 'info.expires': 'Действует до', 'info.traffic_used': 'Использовано трафика', 'info.traffic_limit': 'Лимит трафика', + 'info.subscription_type': 'Тип подписки', + 'info.promo_group': 'Промогруппа', + 'info.device_limit': 'Лимит устройств', + 'info.autopay': 'Автоплатеж', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', 'card.balance.title': 'Баланс', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', + 'card.devices.title': 'Подключенные устройства', 'apps.title': 'Инструкция по установке', 'apps.no_data': 'Для этой платформы инструкция пока недоступна.', 'apps.featured': 'Рекомендуем', @@ -790,6 +867,7 @@ 'history.type.refund': 'Возврат', 'history.type.referral_reward': 'Реферальное вознаграждение', 'servers.empty': 'Подключённых серверов пока нет', + 'devices.empty': 'Подключённых устройств пока нет', 'language.ariaLabel': 'Выберите язык интерфейса', 'notifications.copy.success': 'Ссылка подписки скопирована.', 'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.', @@ -800,12 +878,17 @@ 'status.expired': 'Истекла', 'status.disabled': 'Отключена', 'status.unknown': 'Неизвестно', + 'subscription.type.trial': 'Триал', + 'subscription.type.paid': 'Платная', + 'autopay.enabled': 'Включен', + 'autopay.disabled': 'Выключен', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'ПК', - 'platform.tv': 'TV', + 'platform.tv': 'ТВ', 'units.gb': 'ГБ', - 'values.unlimited': 'Безлимит' + 'values.unlimited': 'Безлимит', + 'values.not_available': 'Недоступно' } }; @@ -1087,19 +1170,74 @@ const serversCount = Array.isArray(userData.connected_squads) ? userData.connected_squads.length - : Array.isArray(userData.links) - ? userData.links.length - : 0; + : Array.isArray(userData.connected_servers) + ? userData.connected_servers.length + : Array.isArray(userData.links) + ? userData.links.length + : 0; document.getElementById('serversCount').textContent = serversCount; + const devicesCountRaw = Number(userData?.connected_devices_count); + const devicesCount = Number.isFinite(devicesCountRaw) + ? devicesCountRaw + : Array.isArray(userData?.connected_devices) + ? userData.connected_devices.length + : 0; + const devicesCountElement = document.getElementById('devicesCount'); + if (devicesCountElement) { + devicesCountElement.textContent = devicesCount; + } + document.getElementById('trafficUsed').textContent = user.traffic_used_label || formatTraffic(user.traffic_used_gb); document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + const deviceLimitElement = document.getElementById('deviceLimit'); + if (deviceLimitElement) { + const limitValue = typeof user.device_limit === 'number' + ? user.device_limit + : Number.parseInt(user.device_limit ?? '', 10); + deviceLimitElement.textContent = Number.isFinite(limitValue) + ? String(limitValue) + : t('values.not_available'); + } + + const subscriptionTypeElement = document.getElementById('subscriptionType'); + if (subscriptionTypeElement) { + const fallbackSubscriptionType = (user?.subscription_status || '').toLowerCase() === 'trial' + ? 'trial' + : 'paid'; + const subscriptionTypeRaw = String( + userData?.subscription_type + || fallbackSubscriptionType + ).toLowerCase(); + const subscriptionTypeKey = `subscription.type.${subscriptionTypeRaw}`; + const subscriptionTypeLabel = t(subscriptionTypeKey); + subscriptionTypeElement.textContent = subscriptionTypeLabel === subscriptionTypeKey + ? subscriptionTypeRaw + : subscriptionTypeLabel; + } + + const promoGroupElement = document.getElementById('promoGroup'); + if (promoGroupElement) { + const promoGroupName = userData?.promo_group?.name || user?.promo_group?.name; + promoGroupElement.textContent = promoGroupName || t('values.not_available'); + } + + const autopayElement = document.getElementById('autopayStatus'); + if (autopayElement) { + const autopayKey = userData?.autopay_enabled ? 'autopay.enabled' : 'autopay.disabled'; + const autopayLabel = t(autopayKey); + autopayElement.textContent = autopayLabel === autopayKey + ? (userData?.autopay_enabled ? 'On' : 'Off') + : autopayLabel; + } + renderBalanceSection(); renderTransactionHistory(); renderServersList(); + renderDevicesList(); updateConnectButtonLabel(); updateActionButtons(); } @@ -1432,7 +1570,16 @@ return; } - const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : []; + let servers = []; + if (Array.isArray(userData?.connected_servers) && userData.connected_servers.length) { + servers = userData.connected_servers.map(server => ({ + uuid: server?.uuid || '', + name: server?.name || server?.uuid || '', + })); + } else if (Array.isArray(userData?.connected_squads)) { + servers = userData.connected_squads.map(uuid => ({ uuid, name: uuid })); + } + if (!servers.length) { list.innerHTML = ''; emptyState.textContent = t('servers.empty'); @@ -1441,7 +1588,63 @@ } emptyState.classList.add('hidden'); - list.innerHTML = servers.map(server => `
  • ${escapeHtml(server)}
  • `).join(''); + list.innerHTML = servers + .map(server => `
  • ${escapeHtml(server.name || server.uuid || '')}
  • `) + .join(''); + } + + function renderDevicesList() { + const list = document.getElementById('devicesList'); + const emptyState = document.getElementById('devicesEmpty'); + if (!list || !emptyState) { + return; + } + + const devices = Array.isArray(userData?.connected_devices) + ? userData.connected_devices + : []; + + if (!devices.length) { + list.innerHTML = ''; + emptyState.textContent = t('devices.empty'); + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + list.innerHTML = devices.map(device => { + const platform = device?.platform ? String(device.platform) : ''; + const model = device?.device_model ? String(device.device_model) : ''; + const titleParts = [platform, model].filter(Boolean); + const title = titleParts.length + ? titleParts.join(' — ') + : t('values.not_available'); + + const metaParts = []; + if (device?.app_version) { + metaParts.push(String(device.app_version)); + } + if (device?.last_seen) { + const formatted = formatDateTime(device.last_seen); + if (formatted) { + metaParts.push(formatted); + } + } + if (device?.last_ip) { + metaParts.push(String(device.last_ip)); + } + + const metaHtml = metaParts.length + ? `
    ${metaParts.map(part => `${escapeHtml(part)}`).join('')}
    ` + : ''; + + return ` +
  • +
    ${escapeHtml(title)}
    + ${metaHtml} +
  • + `; + }).join(''); } function getCurrentSubscriptionUrl() {