diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index f37c9aa..8f786a0 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -18,6 +18,7 @@ Mail, RefreshCw, Send, + Smartphone, TriangleAlert, Settings as SettingsIcon, Shield, @@ -57,6 +58,7 @@ const APP_SECTION_PATHS = { home: "/home", invite: "/invite", + devices: "/devices", settings: "/settings", }; @@ -101,6 +103,46 @@ traffic_limit: "100 GB", traffic_used_bytes: 19756849561, traffic_limit_bytes: 107374182400, + max_devices: 5, + }, + devices: { + ok: true, + enabled: true, + current_devices: 3, + max_devices: 5, + max_devices_label: "5", + devices: [ + { + index: 1, + display_name: "iPhone 15 Pro", + platform_label: "iOS 18.4", + user_agent: "Streisand/1.6 CFNetwork", + created_at_text: "28.04.2026 16:12", + hwid_short: "A1B2C3D4...98FA01", + token: "preview-device-1", + can_disconnect: true, + }, + { + index: 2, + display_name: "MacBook Air", + platform_label: "macOS 15.4", + user_agent: "Happ/3.1.0", + created_at_text: "29.04.2026 09:40", + hwid_short: "F0E1D2C3...44AB22", + token: "preview-device-2", + can_disconnect: true, + }, + { + index: 3, + display_name: "Android Phone", + platform_label: "Android 15", + user_agent: "v2rayNG/1.9.35", + created_at_text: "30.04.2026 07:55", + hwid_short: "778899AA...BCDD10", + token: "preview-device-3", + can_disconnect: true, + }, + ], }, plans: [ { months: 1, price: 290, currency: "RUB", title: "1 месяц" }, @@ -132,6 +174,8 @@ settings: { support_url: "https://t.me/support", traffic_mode: false, + my_devices_enabled: false, + user_hwid_device_limit: 5, trial_enabled: true, trial_available: true, trial_duration_days: 5, @@ -182,6 +226,14 @@ let promoStatus = ""; let promoIsError = false; let promoFieldError = ""; + let devicesData = DEV_MOCK.data.devices; + let devicesLoaded = false; + let devicesBusy = false; + let devicesStatus = ""; + let devicesIsError = false; + let deviceConfirmOpen = false; + let deviceToDisconnect = null; + let deviceDisconnectBusy = false; let toastText = ""; let toastTimer = null; let authStatus = ""; @@ -228,6 +280,13 @@ { months: 100, traffic_gb: 100, price: 1390, currency: "RUB", title: "100 GB", sale_mode: "traffic" }, { months: 300, traffic_gb: 300, price: 3490, currency: "RUB", title: "300 GB", sale_mode: "traffic" }, ]; + } else if (mode === "devices") { + DEV_MOCK.data.settings.my_devices_enabled = true; + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + max_devices: 5, + }; } else if (mode === "trial") { DEV_MOCK.data.settings.traffic_mode = false; DEV_MOCK.data.settings.trial_enabled = true; @@ -257,6 +316,7 @@ $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; $: trafficMode = Boolean(appSettings?.traffic_mode); + $: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: subscription = data?.subscription || DEV_MOCK.data.subscription; $: user = data?.user || {}; $: referral = data?.referral || DEV_MOCK.data.referral; @@ -307,8 +367,10 @@ const onPopState = () => { const section = sectionFromPath(window.location.pathname); if (mode === "app") { - activeTab = section; - screen = section; + const nextSection = section === "devices" && !devicesEnabled ? "home" : section; + activeTab = nextSection; + screen = nextSection; + if (nextSection === "devices") loadDevices(); } }; window.addEventListener("popstate", onPopState); @@ -413,7 +475,7 @@ function normalizeSection(value) { const section = String(value || "").trim().toLowerCase(); - return section === "invite" || section === "settings" ? section : "home"; + return section === "invite" || section === "devices" || section === "settings" ? section : "home"; } function sectionFromPath(pathname) { @@ -484,11 +546,15 @@ data = payload; selectedPlan = payload.plans?.[Math.min(1, payload.plans.length - 1)] || payload.plans?.[0] || null; selectedMethod = payload.payment_methods?.[0]?.id || ""; - const section = sectionFromPath(window.location.pathname); + let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname); + if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; activeTab = section; screen = section; mode = "app"; syncSectionPath(section, true); + if (section === "devices" && payload.settings?.my_devices_enabled) { + await loadDevices(); + } } function showLogin() { @@ -539,6 +605,16 @@ return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; } if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; + if (path === "/devices") return structuredCloneSafe(DEV_MOCK.data.devices); + if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") { + let payload = {}; + try { + payload = options?.body ? JSON.parse(String(options.body)) : {}; + } catch {} + DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter((device) => device.token !== payload.token); + DEV_MOCK.data.devices.current_devices = DEV_MOCK.data.devices.devices.length; + return { ok: true }; + } if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") { DEV_MOCK.data.subscription = { ...DEV_MOCK.data.subscription, @@ -947,7 +1023,7 @@ if (!response?.ok) throw response; linkEmailPending = normalized; linkEmailCode = ""; - setLinkEmailStatus(t("wa_email_sent_to", { email: normalized })); + setLinkEmailStatus(""); startCooldownTimer("link_email", 60); } catch (error) { setLinkEmailStatus(emailError(error, t("wa_auth_send_code_failed")), true); @@ -1179,6 +1255,58 @@ } } + async function loadDevices(force = false) { + if (!devicesEnabled || devicesBusy || (devicesLoaded && !force)) return; + devicesBusy = true; + devicesStatus = ""; + devicesIsError = false; + try { + const response = await api("/devices"); + if (!response?.ok) throw response; + devicesData = response; + devicesLoaded = true; + } catch (error) { + devicesStatus = error?.message || t("wa_devices_load_failed"); + devicesIsError = true; + devicesLoaded = true; + } finally { + devicesBusy = false; + } + } + + function openDeviceDisconnectDialog(device) { + deviceToDisconnect = device; + deviceConfirmOpen = true; + } + + function closeDeviceDisconnectDialog() { + if (deviceDisconnectBusy) return; + deviceConfirmOpen = false; + deviceToDisconnect = null; + } + + async function disconnectDevice() { + const token = String(deviceToDisconnect?.token || "").trim(); + if (!token || deviceDisconnectBusy) return; + deviceDisconnectBusy = true; + try { + const response = await api("/devices/disconnect", { + method: "POST", + body: JSON.stringify({ token }), + }); + if (!response?.ok) throw response; + showToast(t("wa_device_disconnected")); + deviceConfirmOpen = false; + deviceToDisconnect = null; + devicesLoaded = false; + await loadDevices(true); + } catch (error) { + showToast(error?.message || t("wa_device_disconnect_failed")); + } finally { + deviceDisconnectBusy = false; + } + } + async function logout() { markManualLogout(); clearToken(); @@ -1210,6 +1338,15 @@ syncSectionPath("invite"); } + function goDevices() { + if (!devicesEnabled) return; + paymentModalOpen = false; + activeTab = "devices"; + screen = "devices"; + syncSectionPath("devices"); + loadDevices(); + } + function goSettings() { paymentModalOpen = false; activeTab = "settings"; @@ -1355,6 +1492,24 @@ return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic"); } + function devicesLimitLabel(value = devicesData?.max_devices) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited"); + return String(Math.trunc(numeric)); + } + + function devicesCountLabel() { + const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0); + return t("wa_devices_count", { current, max: devicesLimitLabel() }); + } + + function devicesPercent() { + const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0); + const max = Number(devicesData?.max_devices || 0); + if (!max || max <= 0) return 100; + return Math.max(0, Math.min(100, Math.round((current / max) * 100))); + } + function activeSubscriptionTermLabel(sub) { const forever = isForeverSubscription(sub); if (forever) return t("wa_sub_term_forever"); @@ -1652,7 +1807,7 @@ {:else}
- {#if screen === "invite" || screen === "settings"} + {#if screen === "invite" || screen === "devices" || screen === "settings"}
@@ -1822,6 +1977,78 @@ {/if} + {:else if screen === "devices"} +
+ +
+ + + {t("wa_devices_title")} + {devicesCountLabel()} + + +
+
+ +
+
+ + {#if devicesBusy && !devicesLoaded} + {t("wa_devices_loading")} + {:else if devicesStatus} + +

{devicesStatus}

+
+ {:else if !devicesData?.devices?.length} + + + {t("wa_devices_empty")} + {t("wa_devices_empty_hint", { max: devicesLimitLabel() })} + + {:else} +
+ {#each devicesData.devices as device (device.token || device.index)} + +
+
+ + {device.display_name || t("wa_device_fallback_name", { index: device.index })} + {device.platform_label || t("wa_devices_platform_unknown")} + +
+
+ {#if device.created_at_text} +
+ {t("wa_devices_connected_at")} + {device.created_at_text} +
+ {/if} + {#if device.hwid_short} +
+ HWID + {device.hwid_short} +
+ {/if} + {#if device.user_agent} +
+ User Agent + {device.user_agent} +
+ {/if} +
+ {#if device.can_disconnect} + + {/if} +
+ {/each} +
+ {/if} +
{:else if screen === "settings"}
@@ -1946,8 +2173,8 @@
{/if} - {#if screen === "home" || screen === "invite" || screen === "settings"} -
+ +
+ + +
+
+
{#if !linkEmailPending} diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index d6e509d..5657e77 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -391,6 +391,124 @@ a { line-height: 1.28; } +.devices-summary-card { + display: grid; + gap: 10px; +} + +.devices-summary-head, +.device-card-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 11px; +} + +.devices-summary-head > svg { + color: var(--accent); +} + +.devices-summary-head span, +.device-card-head span { + display: grid; + min-width: 0; + gap: 3px; +} + +.devices-summary-head strong, +.device-card-head strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.devices-summary-head small, +.device-card-head small, +.device-meta span, +.device-meta small, +.devices-empty-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.3; +} + +.devices-progress { + margin-top: 0; +} + +.devices-list { + display: grid; + gap: 8px; +} + +.device-card { + display: grid; + gap: 12px; +} + +.device-card-head { + grid-template-columns: 38px minmax(0, 1fr); +} + +.device-icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03)); + color: var(--accent); +} + +.device-meta { + display: grid; + gap: 7px; +} + +.device-meta div { + display: grid; + gap: 2px; +} + +.device-meta strong, +.device-meta code { + overflow: hidden; + color: var(--text); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-meta code { + font-family: var(--font-mono); +} + +.device-user-agent small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-disconnect-button, +.device-danger-button { + border-color: color-mix(in srgb, var(--danger) 62%, var(--border)); + color: #ffb8b8; +} + +.devices-empty-card { + display: grid; + justify-items: center; + gap: 7px; + text-align: center; +} + +.devices-empty-card svg { + color: var(--accent); +} + .action-stack { display: grid; gap: 8px; @@ -1092,6 +1210,10 @@ a { backdrop-filter: blur(16px); } +.bottom-nav-devices { + grid-template-columns: repeat(4, 1fr); +} + .bottom-nav.static { position: fixed; } @@ -1411,11 +1533,16 @@ a { .link-email-dialog-card { display: grid; grid-template-rows: auto minmax(0, 1fr); - height: 100%; + height: min(100%, 560px); max-height: 100%; overflow: hidden; } +.link-email-dialog-card .dialog-head { + position: relative; + z-index: 2; +} + .link-email-dialog-card .payment-dialog-body { min-height: 0; } diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index f7862b6..45d161b 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -111,6 +111,12 @@ class WebAppLanguagePayload(BaseModel): language: constr(min_length=2, max_length=16) + +class WebAppDeviceDisconnectPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: constr(min_length=8, max_length=128) + _SHARED_HTTP_SESSION: Optional[ClientSession] = None _SHARED_HTTP_SESSION_LOCK = asyncio.Lock() @@ -167,6 +173,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/", index_route) app.router.add_get("/home", index_route) app.router.add_get("/invite", index_route) + app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) app.router.add_get("/health", health_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) @@ -185,6 +192,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/account/telegram/link", account_telegram_link_route) app.router.add_post("/api/promo/apply", apply_promo_route) app.router.add_post("/api/trial/activate", activate_trial_route) + app.router.add_get("/api/devices", devices_route) + app.router.add_post("/api/devices/disconnect", disconnect_device_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) @@ -1487,6 +1496,108 @@ async def activate_trial_route(request: web.Request) -> web.Response: ) +async def devices_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + + active = await subscription_service.get_active_subscription_details(session, user_id) + panel_user_uuid = active.get("user_id") if active else None + if not panel_user_uuid: + return _json_error(400, "subscription_not_active", "Subscription is not active") + + panel_service = getattr(subscription_service, "panel_service", None) + if not panel_service: + return _json_error(503, "panel_unavailable", "Panel service unavailable") + + try: + devices_response = await panel_service.get_user_devices(panel_user_uuid) + except Exception: + logger.exception("Failed to load WebApp devices for user %s", user_id) + return _json_error(502, "devices_load_failed", "Failed to load devices") + + devices = _normalize_devices_response(devices_response) + max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None + return web.json_response( + { + "ok": True, + "enabled": True, + "current_devices": len(devices), + "max_devices": max_devices, + "max_devices_label": _format_devices_limit(max_devices), + "devices": [_serialize_device(device, index) for index, device in enumerate(devices, start=1)], + } + ) + + +async def disconnect_device_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + rate_limit_response = await _enforce_webapp_rate_limit( + request, + user_id=user_id, + action="devices_disconnect", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + + payload = await _read_json(request) + disconnect_payload, validation_error = _validate_model_payload(WebAppDeviceDisconnectPayload, payload) + if validation_error: + return validation_error + token = str(disconnect_payload.token or "").strip() + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + + active = await subscription_service.get_active_subscription_details(session, user_id) + panel_user_uuid = active.get("user_id") if active else None + if not panel_user_uuid: + return _json_error(400, "subscription_not_active", "Subscription is not active") + + panel_service = getattr(subscription_service, "panel_service", None) + if not panel_service: + return _json_error(503, "panel_unavailable", "Panel service unavailable") + + try: + devices_response = await panel_service.get_user_devices(panel_user_uuid) + except Exception: + logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id) + return _json_error(502, "devices_load_failed", "Failed to load devices") + + target_hwid = None + for device in _normalize_devices_response(devices_response): + hwid = str(device.get("hwid") or "").strip() + if hwid and hmac.compare_digest(_device_hwid_token(hwid), token): + target_hwid = hwid + break + + if not target_hwid: + return _json_error(404, "device_not_found", "Device not found") + + success = await panel_service.disconnect_device(panel_user_uuid, target_hwid) + if not success: + return _json_error(502, "device_disconnect_failed", "Failed to disconnect device") + await session.commit() + + return web.json_response({"ok": True}) + + async def payment_status_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) try: @@ -2133,6 +2244,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), + "my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED), + "user_hwid_device_limit": ( + int(settings.USER_HWID_DEVICE_LIMIT) + if settings.USER_HWID_DEVICE_LIMIT is not None + else None + ), "trial_enabled": bool(settings.TRIAL_ENABLED), "trial_available": trial_available, "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), @@ -2224,6 +2341,7 @@ def _serialize_subscription( "traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")), "traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")), "traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""), + "max_devices": _coerce_int_or_none(active.get("max_devices")), "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), "provider": getattr(local_sub, "provider", None), } @@ -2817,6 +2935,67 @@ def _format_bytes(value: Optional[Any]) -> str: return f"{size:.2f} {units[index]}" +def _device_hwid_token(hwid: str) -> str: + return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32] + + +def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str: + value = str(hwid or "").strip() + if len(value) <= max_length: + return value + return f"{value[:8]}...{value[-6:]}" + + +def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]: + if isinstance(devices_response, dict): + devices = devices_response.get("devices") or [] + else: + devices = devices_response or [] + if not isinstance(devices, list): + return [] + return [device for device in devices if isinstance(device, dict)] + + +def _format_devices_limit(max_devices: Optional[int]) -> str: + if max_devices in (None, 0): + return "Unlimited" + return str(max_devices) + + +def _format_device_datetime(value: Any) -> str: + if not value: + return "" + text = str(value) + try: + normalized = datetime.fromisoformat(text.replace("Z", "+00:00")) + return normalized.strftime("%d.%m.%Y %H:%M") + except Exception: + return text + + +def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]: + hwid = str(device.get("hwid") or "").strip() + model = str(device.get("deviceModel") or "").strip() + platform = str(device.get("platform") or "").strip() + os_version = str(device.get("osVersion") or "").strip() + user_agent = str(device.get("userAgent") or "").strip() + display_name = model or platform or f"Device {index}" + platform_label = " ".join(part for part in (platform, os_version) if part).strip() + return { + "index": index, + "display_name": display_name, + "platform": platform, + "os_version": os_version, + "platform_label": platform_label, + "user_agent": user_agent, + "created_at": device.get("createdAt"), + "created_at_text": _format_device_datetime(device.get("createdAt")), + "hwid_short": _shorten_hwid_for_display(hwid), + "token": _device_hwid_token(hwid) if hwid else "", + "can_disconnect": bool(hwid), + } + + def _format_months_title(months: int, lang: str) -> str: if lang == "en": if months == 1: diff --git a/locales/en.json b/locales/en.json index 897932a..b4280ad 100644 --- a/locales/en.json +++ b/locales/en.json @@ -672,6 +672,25 @@ "wa_navigation": "Navigation", "wa_nav_home": "Home", "wa_nav_settings": "Settings", + "wa_nav_devices": "Devices", + "wa_devices_title": "My devices", + "wa_devices_count": "{current} of {max}", + "wa_devices_unlimited": "Unlimited", + "wa_devices_refresh": "Refresh devices", + "wa_devices_loading": "Loading devices...", + "wa_devices_load_failed": "Failed to load devices", + "wa_devices_empty": "No devices yet", + "wa_devices_empty_hint": "You can connect up to {max} devices from your subscription app.", + "wa_device_fallback_name": "Device {index}", + "wa_devices_platform_unknown": "Platform unknown", + "wa_devices_connected_at": "Connected", + "wa_devices_disconnect": "Disconnect device", + "wa_devices_disconnect_title": "Disconnect device", + "wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.", + "wa_devices_disconnect_confirm": "Disconnect", + "wa_device_disconnected": "Device disconnected", + "wa_device_disconnect_failed": "Failed to disconnect device", + "wa_cancel": "Cancel", "wa_payment_created": "Payment created", "wa_payment_create_failed": "Failed to create payment", "wa_connect_link_unavailable": "Connection link is not available yet", diff --git a/locales/ru.json b/locales/ru.json index 08146e7..e1ed54b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -672,6 +672,25 @@ "wa_navigation": "Навигация", "wa_nav_home": "Главная", "wa_nav_settings": "Настройки", + "wa_nav_devices": "Устройства", + "wa_devices_title": "Мои устройства", + "wa_devices_count": "{current} из {max}", + "wa_devices_unlimited": "Без ограничений", + "wa_devices_refresh": "Обновить устройства", + "wa_devices_loading": "Загружаем устройства...", + "wa_devices_load_failed": "Не удалось загрузить устройства", + "wa_devices_empty": "Устройств пока нет", + "wa_devices_empty_hint": "Можно подключить до {max} устройств через приложение с подпиской.", + "wa_device_fallback_name": "Устройство {index}", + "wa_devices_platform_unknown": "Платформа неизвестна", + "wa_devices_connected_at": "Подключено", + "wa_devices_disconnect": "Отключить устройство", + "wa_devices_disconnect_title": "Отключить устройство", + "wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.", + "wa_devices_disconnect_confirm": "Отключить", + "wa_device_disconnected": "Устройство отключено", + "wa_device_disconnect_failed": "Не удалось отключить устройство", + "wa_cancel": "Отмена", "wa_payment_created": "Платеж создан", "wa_payment_create_failed": "Не удалось создать платеж", "wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",