diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 67c4b52b..b1043837 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -56,6 +56,8 @@ from ..schemas.miniapp import ( MiniAppAutoPromoGroupLevel, MiniAppConnectedServer, MiniAppDevice, + MiniAppDeviceRemovalRequest, + MiniAppDeviceRemovalResponse, MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, @@ -642,6 +644,7 @@ async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: devices: List[MiniAppDevice] = [] for device in devices_payload: + hwid = device.get("hwid") or device.get("deviceId") or device.get("id") 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") @@ -655,6 +658,7 @@ async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: devices.append( MiniAppDevice( + hwid=hwid, platform=platform, device_model=model, app_version=app_version, @@ -1444,6 +1448,96 @@ async def claim_promo_offer( await db.refresh(user) return MiniAppPromoOfferClaimResponse(success=True, code="discount_claimed") + + +@router.post( + "/devices/remove", + response_model=MiniAppDeviceRemovalResponse, +) +async def remove_connected_device( + payload: MiniAppDeviceRemovalRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppDeviceRemovalResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": str(error)}, + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "user_not_found", "message": "User not found"}, + ) + + remnawave_uuid = getattr(user, "remnawave_uuid", None) + if not remnawave_uuid: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={"code": "remnawave_unavailable", "message": "RemnaWave user is not linked"}, + ) + + hwid = (payload.hwid or "").strip() + if not hwid: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_hwid", "message": "Device identifier is required"}, + ) + + service = RemnaWaveService() + if not service.is_configured: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "service_unavailable", "message": "Device management is temporarily unavailable"}, + ) + + try: + async with service.get_api_client() as api: + success = await api.remove_device(remnawave_uuid, hwid) + except RemnaWaveConfigurationError as error: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "service_unavailable", "message": str(error)}, + ) from error + except Exception as error: # pragma: no cover - defensive + logger.warning( + "Failed to remove device %s for user %s: %s", + hwid, + telegram_id, + error, + ) + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"code": "remnawave_error", "message": "Failed to remove device"}, + ) from error + + if not success: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"code": "remnawave_error", "message": "Failed to remove device"}, + ) + + return MiniAppDeviceRemovalResponse(success=True) + + def _safe_int(value: Any) -> int: try: return int(value) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 60181a7c..8afed075 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -70,6 +70,7 @@ class MiniAppConnectedServer(BaseModel): class MiniAppDevice(BaseModel): + hwid: Optional[str] = None platform: Optional[str] = None device_model: Optional[str] = None app_version: Optional[str] = None @@ -77,6 +78,16 @@ class MiniAppDevice(BaseModel): last_ip: Optional[str] = None +class MiniAppDeviceRemovalRequest(BaseModel): + init_data: str = Field(..., alias="initData") + hwid: str + + +class MiniAppDeviceRemovalResponse(BaseModel): + success: bool = True + message: Optional[str] = None + + class MiniAppTransaction(BaseModel): id: int type: str diff --git a/miniapp/index.html b/miniapp/index.html index 9058cf88..2583a5dd 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -40,6 +40,7 @@ --success: #10b981; --warning: #f59e0b; --danger: #ef4444; + --danger-rgb: 239, 68, 68; --info: #3b82f6; } @@ -1706,7 +1707,7 @@ border-radius: var(--radius-lg); border: 2px solid var(--border-color); transition: all 0.3s ease; - cursor: pointer; + cursor: default; position: relative; overflow: hidden; } @@ -1736,8 +1737,9 @@ .device-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; + gap: 12px; margin-bottom: 12px; } @@ -1745,6 +1747,73 @@ font-size: 16px; font-weight: 600; color: var(--text-primary); + margin: 0; + flex: 1; + word-break: break-word; + } + + .device-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + .device-remove-button { + width: 36px; + height: 36px; + border-radius: var(--radius); + border: 2px solid rgba(var(--danger-rgb), 0.4); + background: rgba(var(--danger-rgb), 0.08); + color: var(--danger); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + } + + .device-remove-button:hover { + background: rgba(var(--danger-rgb), 0.16); + border-color: rgba(var(--danger-rgb), 0.6); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); + } + + .device-remove-button:active { + transform: scale(0.95); + } + + .device-remove-button:disabled, + .device-remove-button.is-removing { + opacity: 0.6; + cursor: default; + box-shadow: none; + transform: none; + } + + .device-remove-button span { + line-height: 1; + transition: opacity 0.2s ease; + } + + .device-remove-button.is-removing span { + opacity: 0; + } + + .device-remove-button.is-removing::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + animation: spin 0.8s linear infinite; } .device-type-badge { @@ -2420,6 +2489,18 @@ display: block; } + :root[data-theme="dark"] .device-remove-button { + background: rgba(var(--danger-rgb), 0.12); + border-color: rgba(var(--danger-rgb), 0.3); + color: #fca5a5; + } + + :root[data-theme="dark"] .device-remove-button:hover { + background: rgba(var(--danger-rgb), 0.2); + border-color: rgba(var(--danger-rgb), 0.5); + color: #fecaca; + } + :root[data-theme="light"] .theme-toggle .icon-moon { display: none; } @@ -3163,6 +3244,17 @@ 'referral.referrals.unknown': 'Referral', 'servers.empty': 'No servers connected yet', 'devices.empty': 'No devices connected yet', + 'devices.remove_button_label': 'Reset device', + 'devices.remove_confirm.title': 'Reset device', + 'devices.remove_confirm.message': 'Do you really want to reset the device “{device}”?', + 'devices.remove_confirm.confirm': 'Reset', + 'devices.remove_confirm.cancel': 'Cancel', + 'devices.remove_success.title': 'Device reset', + 'devices.remove_success': 'The device has been reset successfully.', + 'devices.remove_error.title': 'Unable to reset device', + 'devices.remove_error.generic': 'Failed to reset the device. Please try again later.', + 'devices.remove_error.network': 'Network error. Please try again later.', + 'devices.remove_error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram and try again.', 'promo_levels.total_spent': 'Total spent', 'promo_levels.threshold': 'from {amount}', 'promo_levels.badge.current': 'Current level', @@ -3334,6 +3426,17 @@ 'referral.referrals.unknown': 'Реферал', 'servers.empty': 'Подключённых серверов пока нет', 'devices.empty': 'Подключённых устройств пока нет', + 'devices.remove_button_label': 'Сбросить устройство', + 'devices.remove_confirm.title': 'Сброс устройства', + 'devices.remove_confirm.message': 'Сбросить устройство «{device}»?', + 'devices.remove_confirm.confirm': 'Сбросить', + 'devices.remove_confirm.cancel': 'Отмена', + 'devices.remove_success.title': 'Устройство сброшено', + 'devices.remove_success': 'Устройство успешно сброшено.', + 'devices.remove_error.title': 'Не удалось сбросить устройство', + 'devices.remove_error.generic': 'Не удалось сбросить устройство. Попробуйте позже.', + 'devices.remove_error.network': 'Ошибка сети. Попробуйте позже.', + 'devices.remove_error.unauthorized': 'Ошибка авторизации. Откройте мини-приложение из Telegram и повторите попытку.', 'promo_levels.total_spent': 'Всего потрачено', 'promo_levels.threshold': 'от {amount}', 'promo_levels.badge.current': 'Активно', @@ -5584,6 +5687,10 @@ } emptyState.classList.add('hidden'); + const removeLabelRaw = t('devices.remove_button_label'); + const removeLabel = (typeof removeLabelRaw === 'string' && removeLabelRaw !== 'devices.remove_button_label') + ? removeLabelRaw + : 'Reset device'; list.innerHTML = devices.map(device => { const platform = device?.platform ? String(device.platform) : ''; const model = device?.device_model ? String(device.device_model) : ''; @@ -5610,13 +5717,211 @@ ? `
` : ''; + const deviceHwid = device?.hwid ? String(device.hwid) : ''; + const safeTitle = escapeHtml(title); + const removeButtonHtml = deviceHwid + ? ` +