diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index f469da7..907914d 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -120,6 +120,8 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "regular_unlimited_override": regular_unlimited_override, "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), + "hwid_device_limit": getattr(sub, "hwid_device_limit", None), + "extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0), "tariff_key": sub.tariff_key, "display_label": display_label, "is_trial": is_trial, diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index a7e418c..d9dd43a 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -30,6 +30,10 @@ def setup_admin_routes(app: web.Application) -> None: "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", admin_user_regular_traffic_override_route, ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/hwid-device-limit", + admin_user_hwid_device_limit_route, + ) router.add_post( "/api/admin/users/{user_id:-?\\d+}/traffic-grant", admin_user_traffic_grant_route, diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index ecb8e4d..ab33723 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1347,6 +1347,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web return _ok({"subscription": _serialize_subscription(active)}) +async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response: + """Override the user's base HWID device limit. + + ``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env + default is used. Purchased extra devices remain tracked separately and are + added when syncing the effective panel limit. + """ + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + + unlimited = bool(payload.get("unlimited")) + use_default = bool(payload.get("use_default") or payload.get("reset_to_default")) + limit_raw = payload.get("hwid_device_limit", payload.get("limit")) + + if unlimited: + hwid_device_limit: Optional[int] = 0 + elif use_default or limit_raw is None or limit_raw == "": + hwid_device_limit = None + else: + try: + hwid_device_limit = int(limit_raw) + except (TypeError, ValueError): + return _error( + 400, + "invalid_hwid_device_limit", + "hwid_device_limit must be a non-negative integer", + ) + if hwid_device_limit < 0 or hwid_device_limit > 1_000_000: + return _error( + 400, + "invalid_hwid_device_limit", + "hwid_device_limit must be an integer from 0 to 1000000", + ) + + subscription_service = request.app.get("subscription_service") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.hwid_device_limit = hwid_device_limit + + effective_limit = None + if subscription_service is not None: + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, target_id + ) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_hwid_device_limit_webapp", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + await _invalidate_after_admin_user_mutation(settings, target_id) + return _ok({"subscription": _serialize_subscription(active)}) + + async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: """Credit regular or premium traffic to a user without a payment. diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index f02a871..c691343 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -779,9 +779,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: def _resolve_app_version() -> str: # Single source of truth shared with the telemetry worker so the admin # sidebar and the install beacon always report the same version. - from bot.utils.app_version import resolve_app_version + from bot.utils import app_version as app_version_module - return resolve_app_version() + global _APP_VERSION_CACHE + + app_version_module.APP_ROOT = APP_ROOT + app_version_module._run_git_command = _run_git_command + app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE + version = app_version_module.resolve_app_version() + _APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE + return version + + +def _run_git_command(*args: str) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=APP_ROOT, + check=True, + capture_output=True, + text=True, + timeout=1.5, + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout.strip() async def _enforce_webapp_rate_limit( diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 3deb31f..de07635 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -342,11 +342,12 @@ def _serialize_subscription( and tariff.premium_topup_packages.has_any() ) can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) - # max_devices == 0 means unlimited — top-up is pointless in that case. + max_devices = _coerce_int_or_none(active.get("max_devices")) + # max_devices == 0 or None means unlimited — top-up is pointless in that case. can_topup_devices = bool( tariff.billing_model == "period" and tariff.has_hwid_device_packages() - and _coerce_int_or_none(active.get("max_devices")) != 0 + and max_devices not in (None, 0) ) except Exception: can_topup_regular_traffic = False diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index de463bf..74e879c 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -236,6 +236,10 @@ def get_user_card_keyboard( text=_(key="admin_user_traffic_grant_button"), callback_data=f"user_action:traffic_grant:{user_id}", ) + builder.button( + text=_(key="admin_user_hwid_limit_button"), + callback_data=f"user_action:hwid_limit:{user_id}", + ) # Row 4: Quick links — only for users with a real Telegram profile # (synthetic email-only users have a negative user_id with no tg profile). @@ -261,9 +265,9 @@ def get_user_card_keyboard( quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) if quick_links_count == 0: - builder.adjust(2, 2, 2, 1, 2, 1, 2) + builder.adjust(2, 2, 2, 1, 3, 1, 2) else: - builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2) + builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2) return builder @@ -403,6 +407,26 @@ async def format_user_card( f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}" ) + max_devices = subscription_details.get("max_devices") + extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0) + if max_devices is not None: + if int(max_devices) == 0: + devices_display = _("admin_hwid_limit_state_unlimited") + elif extra_hwid_devices > 0: + base_hwid_limit = subscription_details.get("base_hwid_device_limit") + if base_hwid_limit is None: + devices_display = _("admin_hwid_limit_state_count", count=int(max_devices)) + else: + devices_display = _( + "admin_hwid_limit_state_with_extra", + total=int(max_devices), + base=int(base_hwid_limit), + extra=extra_hwid_devices, + ) + else: + devices_display = _("admin_hwid_limit_state_count", count=int(max_devices)) + card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}") + premium_unlimited = bool(subscription_details.get("premium_unlimited_override")) premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) if premium_unlimited: @@ -706,6 +730,32 @@ async def user_action_handler( await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang) elif action == "traffic_grant_premium": await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang) + elif action == "hwid_limit": + await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang) + elif action == "hwid_limit_set_unlimited": + await handle_hwid_limit_apply( + callback, + user, + subscription_service, + session, + settings, + i18n, + current_lang, + hwid_device_limit=0, + ) + elif action == "hwid_limit_reset": + await handle_hwid_limit_apply( + callback, + user, + subscription_service, + session, + settings, + i18n, + current_lang, + hwid_device_limit=None, + ) + elif action == "hwid_limit_set_number": + await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang) else: await callback.answer(_("admin_unknown_action"), show_alert=True) @@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt( await callback.answer() +def _admin_hwid_limit_state_text( + get_text: Callable[..., str], + hwid_device_limit: Optional[int], + extra_hwid_devices: int = 0, +) -> str: + if hwid_device_limit is None: + return get_text("admin_hwid_limit_state_default") + base_limit = int(hwid_device_limit) + if base_limit == 0: + return get_text("admin_hwid_limit_state_unlimited") + extra = max(0, int(extra_hwid_devices or 0)) + if extra > 0: + return get_text( + "admin_hwid_limit_state_with_extra", + total=base_limit + extra, + base=base_limit, + extra=extra, + ) + return get_text("admin_hwid_limit_state_count", count=base_limit) + + +async def handle_hwid_limit_menu( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + session: AsyncSession, + i18n_instance, + lang: str, +) -> None: + """Show HWID device limit override controls.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id) + if not active_sub: + await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True) + return + + current_text = _admin_hwid_limit_state_text( + _, + getattr(active_sub, "hwid_device_limit", None), + int(getattr(active_sub, "extra_hwid_devices", 0) or 0), + ) + text = "\n".join( + [ + f"{_('admin_hwid_limit_title')}", + "", + _("admin_hwid_limit_hint"), + "", + _("admin_hwid_limit_current", current=current_text), + ] + ) + + builder = InlineKeyboardBuilder() + builder.button( + text=_("admin_hwid_limit_btn_set_number"), + callback_data=f"user_action:hwid_limit_set_number:{user.user_id}", + ) + builder.button( + text=_("admin_hwid_limit_btn_unlimited"), + callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}", + ) + builder.button( + text=_("admin_hwid_limit_btn_reset"), + callback_data=f"user_action:hwid_limit_reset:{user.user_id}", + ) + builder.button( + text=_("admin_user_back_to_card_button"), + callback_data=f"user_action:refresh:{user.user_id}", + ) + builder.adjust(1, 1, 1, 1) + + try: + await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML") + except Exception: + await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML") + await state.update_data(target_user_id=user.user_id) + await callback.answer() + + +async def handle_hwid_limit_apply( + callback: types.CallbackQuery, + user: User, + subscription_service: SubscriptionService, + session: AsyncSession, + settings: Settings, + i18n_instance, + lang: str, + *, + hwid_device_limit: Optional[int], +) -> None: + """Persist a HWID device base limit override and push it to the panel.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user.user_id + ) + if not active_sub: + await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True) + return + + active_sub.hwid_device_limit = hwid_device_limit + + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, user.user_id + ) + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": callback.from_user.id if callback.from_user else user.user_id, + "event_type": "admin:hwid_device_limit", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": user.user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + await callback.answer(_("admin_hwid_limit_saved"), show_alert=False) + await handle_refresh_user_card( + callback, user, subscription_service, session, settings, i18n_instance, lang + ) + except Exception as exc: + logging.error( + "Failed to apply HWID device limit for user %s: %s", + user.user_id, + exc, + exc_info=True, + ) + await session.rollback() + await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True) + + +async def handle_hwid_limit_prompt( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + i18n_instance, + lang: str, +) -> None: + """Ask admin for an explicit HWID device limit.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_hwid_device_limit) + prompt = _("admin_hwid_limit_prompt", user_id=user.user_id) + try: + await callback.message.edit_text(prompt) + except Exception: + await callback.message.answer(prompt) + await callback.answer() + + async def handle_traffic_grant_menu( callback: types.CallbackQuery, user: User, @@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler( await state.clear() +@router.message(AdminStates.waiting_for_hwid_device_limit, F.text) +async def process_hwid_device_limit_handler( + message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession, +): + """Read explicit HWID device limit and apply it.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer(_("admin_hwid_limit_state_missing")) + await state.clear() + return + + raw = (message.text or "").strip() + try: + hwid_device_limit = int(raw) + if hwid_device_limit < 0 or hwid_device_limit > 1_000_000: + raise ValueError("out_of_range") + except (TypeError, ValueError): + await message.answer(_("admin_hwid_limit_invalid")) + return + + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer(_("admin_user_not_found_action")) + await state.clear() + return + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, target_user_id + ) + if not active_sub: + await message.answer(_("admin_hwid_limit_no_subscription")) + await state.clear() + return + + active_sub.hwid_device_limit = hwid_device_limit + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, target_user_id + ) + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": message.from_user.id if message.from_user else target_user_id, + "event_type": "admin:hwid_device_limit", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": target_user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + current_text = _admin_hwid_limit_state_text(_, hwid_device_limit) + await message.answer( + _("admin_hwid_limit_set", current=current_text, user_id=target_user_id) + ) + + referral_service = ReferralService(settings, subscription_service, message.bot, i18n) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + target_user, + session, + subscription_service, + i18n, + current_lang, + referral_service, + settings=settings, + bot_username=bot_username, + ) + keyboard = get_user_card_keyboard( + target_user.user_id, i18n, current_lang, target_user.referred_by_id + ) + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, + parse_mode="HTML", + ) + except Exception as exc: + logging.error( + "Error setting HWID device limit for user %s: %s", + target_user_id, + exc, + exc_info=True, + ) + await session.rollback() + await message.answer(_("admin_hwid_limit_save_error")) + finally: + await state.clear() + + @router.message(AdminStates.waiting_for_traffic_grant_gb, F.text) async def process_traffic_grant_gb_handler( message: types.Message, diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py index 9318a45..f502a44 100644 --- a/backend/bot/services/locale_override_service.py +++ b/backend/bot/services/locale_override_service.py @@ -108,6 +108,7 @@ LOCALE_GROUPS = [ "prefixes": ( "admin_user_", "admin_users_", + "admin_hwid_", "admin_ban_", "admin_unban_", "admin_banned_", @@ -115,6 +116,7 @@ LOCALE_GROUPS = [ "admin_traffic_grant_", "admin_view_banned_", "user_card_", + "user_hwid_", "user_premium_", "user_regular_", "user_traffic_", diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index 5c48e07..f19ea3f 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -31,6 +31,49 @@ class HwidDeviceMixin: ) return int(getattr(sub, "extra_hwid_devices", 0) or 0) + async def sync_hwid_device_limit_to_panel( + self, + session: AsyncSession, + user_id: int, + ) -> Optional[int]: + """Push the current local HWID device limit override to the panel.""" + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub) + sub.extra_hwid_devices = extra_hwid_devices + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) + if effective_hwid_limit is None: + return None + + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=sub.end_date, + status="ACTIVE", + hwid_device_limit=effective_hwid_limit, + include_default_squads=False, + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id) + return effective_hwid_limit + async def _hwid_topup_validity_window( self, session: AsyncSession, @@ -150,7 +193,7 @@ class HwidDeviceMixin: if sub.hwid_device_limit is not None else self._base_hwid_limit_for_tariff(tariff) ) - if base_hwid_limit == 0: + if base_hwid_limit in (None, 0): return None package = self._find_hwid_package(tariff, purchased_devices, currency) @@ -248,7 +291,7 @@ class HwidDeviceMixin: if sub.hwid_device_limit is not None else self._base_hwid_limit_for_tariff(tariff) ) - if base_hwid_limit == 0: + if base_hwid_limit in (None, 0): logging.info( "Skipping HWID top-up for user %s because current limit is unlimited", user_id ) diff --git a/backend/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py index fcf2df6..a1c9958 100644 --- a/backend/bot/services/subscription_service_impl/tariffs.py +++ b/backend/bot/services/subscription_service_impl/tariffs.py @@ -314,7 +314,7 @@ class TariffMixin: @staticmethod def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: if base_limit is None: - return None + return 0 base_int = max(0, int(base_limit)) if base_int == 0: return 0 diff --git a/backend/bot/states/admin_states.py b/backend/bot/states/admin_states.py index 870770a..389241f 100644 --- a/backend/bot/states/admin_states.py +++ b/backend/bot/states/admin_states.py @@ -30,6 +30,7 @@ class AdminStates(StatesGroup): waiting_for_user_delete_confirmation = State() waiting_for_premium_override_bonus_gb = State() waiting_for_traffic_grant_gb = State() + waiting_for_hwid_device_limit = State() # Ads campaigns waiting_for_ad_source = State() diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index c34339a..1cc4a8a 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -73,6 +73,23 @@ return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base; } + function hwidLimitLabel(sub) { + const rawBase = sub?.hwid_device_limit; + const hasBase = rawBase !== null && rawBase !== undefined; + const extra = Math.max(0, Number(sub?.extra_hwid_devices || 0)); + if (!hasBase) return at("user_hwid_limit_default", {}, "Тарифный / default"); + const base = Number(rawBase); + if (base === 0) return at("user_hwid_limit_unlimited", {}, "Безлимит"); + if (extra > 0) { + return at( + "user_hwid_limit_with_extra", + { base, extra, total: base + extra }, + `${base + extra} (${base} + ${extra})` + ); + } + return at("user_hwid_limit_count", { count: base }, `${base}`); + } + const usersStore = getContext("usersStore"); $: ({ @@ -91,6 +108,7 @@ userReferralsPage, userReferralsPageSize, premiumUnlimitedDraft, + hwidUnlimitedDraft, userDetailTab, userLogs, userLogsTotal, @@ -437,6 +455,11 @@ >{openedUserDetail.active_subscription.provider || "—"} +
  • + {at("user_label_hwid_devices", {}, "HWID-устройства")}{hwidLimitLabel(openedUserDetail.active_subscription)} +
  • +
    + +
    + + {at("user_hwid_limit_input", {}, "Лимит устройств")} + {at( + "user_hwid_limit_input_hint", + {}, + "Пусто — тариф/default; 0 или галочка — безлимит." + )} + + +
    + +
    +
    ({ ...s, openedUserDetail: res, @@ -163,6 +168,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", + hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, + hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", grantTrafficGbDraft: "", grantTrafficKindDraft: "regular", })); @@ -551,6 +558,51 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { } } + async function saveHwidDeviceLimit() { + let s; + state.update((st) => { + s = st; + return st; + }); + if (!s.openedUser) return; + state.update((st) => ({ ...st, userActionBusy: true })); + try { + const unlimited = Boolean(s.hwidUnlimitedDraft); + const raw = s.hwidDeviceLimitDraft; + const useDefault = !unlimited && (raw === "" || raw === null || raw === undefined); + let limit = null; + if (!unlimited && !useDefault) { + limit = Number(raw); + if (!Number.isInteger(limit) || limit < 0 || limit > 1_000_000) { + onToast( + at( + "hwid_limit_invalid", + {}, + "Введите целое число устройств от 0 до 1 000 000 или включите безлимит" + ) + ); + return; + } + } + const res = await api(`/admin/users/${s.openedUser.user_id}/hwid-device-limit`, { + method: "POST", + body: JSON.stringify({ + unlimited, + use_default: useDefault, + hwid_device_limit: unlimited ? 0 : limit, + }), + }); + if (res?.ok) { + onToast(at("hwid_limit_saved", {}, "Лимит устройств сохранён")); + await openUser(s.openedUser, { skipPush: true }); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } finally { + state.update((st) => ({ ...st, userActionBusy: false })); + } + } + async function grantTraffic() { let s; state.update((st) => { @@ -635,6 +687,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { deleteUser, savePremiumTrafficOverride, saveRegularTrafficOverride, + saveHwidDeviceLimit, grantTraffic, loadUserLogs, setUserLogsPage, diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index e35e514..c667237 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -14,7 +14,7 @@ export const DEMO_DATASET = { payments: 482, logs: 1600, supportTickets: 3, - translationKeys: 1961, + translationKeys: 1994, settingsFields: 223, }, }, @@ -11088,8 +11088,8 @@ export const DEMO_DATASET = { provider: "trial", is_throttled: false, install_share_token: "1a0d471f37fdbd7429931a9a75d6e221", - hwid_device_limit: 0, - extra_hwid_devices: 0, + hwid_device_limit: 4, + extra_hwid_devices: 2, }, subscriptions: [ { @@ -11122,8 +11122,8 @@ export const DEMO_DATASET = { provider: "trial", is_throttled: false, install_share_token: "1a0d471f37fdbd7429931a9a75d6e221", - hwid_device_limit: 0, - extra_hwid_devices: 0, + hwid_device_limit: 4, + extra_hwid_devices: 2, }, ], total_paid: 0, @@ -105180,6 +105180,812 @@ export const DEMO_DATASET = { }, }, }, + { + key: "admin_user_hwid_limit_button", + audience: "internal", + values: { + ru: { + base: "📱 HWID лимит", + fallback: "📱 HWID лимит", + effective: "📱 HWID лимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "📱 HWID limit", + fallback: "📱 HWID лимит", + effective: "📱 HWID limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_user_hwid_limit_label", + audience: "internal", + values: { + ru: { + base: "📱 HWID-устройства:", + fallback: "📱 HWID-устройства:", + effective: "📱 HWID-устройства:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "📱 HWID devices:", + fallback: "📱 HWID-устройства:", + effective: "📱 HWID devices:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_title", + audience: "internal", + values: { + ru: { + base: "📱 Лимит HWID-устройств", + fallback: "📱 Лимит HWID-устройств", + effective: "📱 Лимит HWID-устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "📱 HWID device limit", + fallback: "📱 Лимит HWID-устройств", + effective: "📱 HWID device limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_hint", + audience: "internal", + values: { + ru: { + base: "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + fallback: + "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + effective: + "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.", + fallback: + "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + effective: + "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_current", + audience: "internal", + values: { + ru: { + base: "Сейчас: {current}", + fallback: "Сейчас: {current}", + effective: "Сейчас: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Current: {current}", + fallback: "Сейчас: {current}", + effective: "Current: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_default", + audience: "internal", + values: { + ru: { + base: "тарифный / default", + fallback: "тарифный / default", + effective: "тарифный / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "tariff / default", + fallback: "тарифный / default", + effective: "tariff / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_unlimited", + audience: "internal", + values: { + ru: { + base: "безлимит", + fallback: "безлимит", + effective: "безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "unlimited", + fallback: "безлимит", + effective: "unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_count", + audience: "internal", + values: { + ru: { + base: "{count} устройств", + fallback: "{count} устройств", + effective: "{count} устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{count} devices", + fallback: "{count} устройств", + effective: "{count} devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_with_extra", + audience: "internal", + values: { + ru: { + base: "{total} устройств ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} устройств ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{total} devices ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} devices ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_btn_set_number", + audience: "internal", + values: { + ru: { + base: "🔢 Задать число", + fallback: "🔢 Задать число", + effective: "🔢 Задать число", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "🔢 Set number", + fallback: "🔢 Задать число", + effective: "🔢 Set number", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_btn_unlimited", + audience: "internal", + values: { + ru: { + base: "♾ Включить безлимит", + fallback: "♾ Включить безлимит", + effective: "♾ Включить безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "♾ Make unlimited", + fallback: "♾ Включить безлимит", + effective: "♾ Make unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_btn_reset", + audience: "internal", + values: { + ru: { + base: "↩️ Вернуть тариф/default", + fallback: "↩️ Вернуть тариф/default", + effective: "↩️ Вернуть тариф/default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "↩️ Use tariff/default", + fallback: "↩️ Вернуть тариф/default", + effective: "↩️ Use tariff/default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_prompt", + audience: "internal", + values: { + ru: { + base: "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + fallback: + "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + effective: + "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Enter the HWID device limit for user {user_id}. 0 means unlimited:", + fallback: + "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + effective: "Enter the HWID device limit for user {user_id}. 0 means unlimited:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_invalid", + audience: "internal", + values: { + ru: { + base: "❌ Введите целое число устройств от 0 до 1 000 000.", + fallback: "❌ Введите целое число устройств от 0 до 1 000 000.", + effective: "❌ Введите целое число устройств от 0 до 1 000 000.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "❌ Enter an integer device count from 0 to 1,000,000.", + fallback: "❌ Введите целое число устройств от 0 до 1 000 000.", + effective: "❌ Enter an integer device count from 0 to 1,000,000.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_no_subscription", + audience: "internal", + values: { + ru: { + base: "У пользователя нет активной подписки", + fallback: "У пользователя нет активной подписки", + effective: "У пользователя нет активной подписки", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "User has no active subscription", + fallback: "У пользователя нет активной подписки", + effective: "User has no active subscription", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_missing", + audience: "internal", + values: { + ru: { + base: "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + fallback: + "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + effective: + "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "⚠️ Target user missing in state. Open the user card again.", + fallback: + "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + effective: "⚠️ Target user missing in state. Open the user card again.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_set", + audience: "internal", + values: { + ru: { + base: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + fallback: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + effective: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "✅ HWID device limit for user {user_id}: {current}", + fallback: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + effective: "✅ HWID device limit for user {user_id}: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_saved", + audience: "internal", + values: { + ru: { + base: "✅ Лимит HWID-устройств сохранён", + fallback: "✅ Лимит HWID-устройств сохранён", + effective: "✅ Лимит HWID-устройств сохранён", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "✅ HWID device limit saved", + fallback: "✅ Лимит HWID-устройств сохранён", + effective: "✅ HWID device limit saved", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_save_error", + audience: "internal", + values: { + ru: { + base: "❌ Не удалось сохранить лимит HWID-устройств", + fallback: "❌ Не удалось сохранить лимит HWID-устройств", + effective: "❌ Не удалось сохранить лимит HWID-устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "❌ Failed to save HWID device limit", + fallback: "❌ Не удалось сохранить лимит HWID-устройств", + effective: "❌ Failed to save HWID device limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_label_hwid_devices", + audience: "internal", + values: { + ru: { + base: "HWID-устройства", + fallback: "HWID-устройства", + effective: "HWID-устройства", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "HWID devices", + fallback: "HWID-устройства", + effective: "HWID devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_card_title", + audience: "internal", + values: { + ru: { + base: "HWID-устройства", + fallback: "HWID-устройства", + effective: "HWID-устройства", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "HWID devices", + fallback: "HWID-устройства", + effective: "HWID devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_card_hint", + audience: "internal", + values: { + ru: { + base: "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + fallback: + "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + effective: + "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Manual device limit for this user. Empty value returns to the tariff or default limit.", + fallback: + "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + effective: + "Manual device limit for this user. Empty value returns to the tariff or default limit.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_input", + audience: "internal", + values: { + ru: { + base: "Лимит устройств", + fallback: "Лимит устройств", + effective: "Лимит устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Device limit", + fallback: "Лимит устройств", + effective: "Device limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_input_hint", + audience: "internal", + values: { + ru: { + base: "Пусто — тариф/default; 0 или галочка — безлимит.", + fallback: "Пусто — тариф/default; 0 или галочка — безлимит.", + effective: "Пусто — тариф/default; 0 или галочка — безлимит.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Empty means tariff/default; 0 or the checkbox means unlimited.", + fallback: "Пусто — тариф/default; 0 или галочка — безлимит.", + effective: "Empty means tariff/default; 0 or the checkbox means unlimited.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_default_placeholder", + audience: "internal", + values: { + ru: { + base: "Тариф", + fallback: "Тариф", + effective: "Тариф", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Tariff", + fallback: "Тариф", + effective: "Tariff", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_save", + audience: "internal", + values: { + ru: { + base: "Сохранить", + fallback: "Сохранить", + effective: "Сохранить", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Save", + fallback: "Сохранить", + effective: "Save", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_status", + audience: "internal", + values: { + ru: { + base: "Сейчас: {current}", + fallback: "Сейчас: {current}", + effective: "Сейчас: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Current: {current}", + fallback: "Сейчас: {current}", + effective: "Current: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_default", + audience: "internal", + values: { + ru: { + base: "Тарифный / default", + fallback: "Тарифный / default", + effective: "Тарифный / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Tariff / default", + fallback: "Тарифный / default", + effective: "Tariff / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_unlimited", + audience: "internal", + values: { + ru: { + base: "Безлимит", + fallback: "Безлимит", + effective: "Безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Unlimited", + fallback: "Безлимит", + effective: "Unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_count", + audience: "internal", + values: { + ru: { + base: "{count} устройств", + fallback: "{count} устройств", + effective: "{count} устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{count} devices", + fallback: "{count} устройств", + effective: "{count} devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_with_extra", + audience: "internal", + values: { + ru: { + base: "{total} устройств ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} устройств ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{total} devices ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} devices ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "hwid_limit_saved", + audience: "internal", + values: { + ru: { + base: "Лимит устройств сохранён", + fallback: "Лимит устройств сохранён", + effective: "Лимит устройств сохранён", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Device limit saved", + fallback: "Лимит устройств сохранён", + effective: "Device limit saved", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "hwid_limit_invalid", + audience: "internal", + values: { + ru: { + base: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + fallback: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + effective: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Enter an integer device count from 0 to 1,000,000 or enable unlimited", + fallback: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + effective: "Enter an integer device count from 0 to 1,000,000 or enable unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, ], }, { diff --git a/locales/en.json b/locales/en.json index 202a86a..c6e3fb8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -284,6 +284,8 @@ "admin_user_premium_override_label": "🌟 Premium override:", "admin_user_premium_override_unlimited": "Unlimited", "admin_user_premium_override_bonus_value": "+{gb} GB", + "admin_user_hwid_limit_button": "📱 HWID limit", + "admin_user_hwid_limit_label": "📱 HWID devices:", "admin_premium_override_title": "🌟 Premium override", "admin_premium_override_hint": "Grant free extra premium traffic or unlimited premium access for this user.", "admin_premium_override_current": "Current: {current}", @@ -300,6 +302,23 @@ "admin_premium_override_bonus_set": "✅ Premium override: +{gb} GB for user {user_id}", "admin_premium_override_saved": "✅ Premium override saved", "admin_premium_override_save_error": "❌ Failed to save premium override", + "admin_hwid_limit_title": "📱 HWID device limit", + "admin_hwid_limit_hint": "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.", + "admin_hwid_limit_current": "Current: {current}", + "admin_hwid_limit_state_default": "tariff / default", + "admin_hwid_limit_state_unlimited": "unlimited", + "admin_hwid_limit_state_count": "{count} devices", + "admin_hwid_limit_state_with_extra": "{total} devices ({base} + {extra})", + "admin_hwid_limit_btn_set_number": "🔢 Set number", + "admin_hwid_limit_btn_unlimited": "♾ Make unlimited", + "admin_hwid_limit_btn_reset": "↩️ Use tariff/default", + "admin_hwid_limit_prompt": "Enter the HWID device limit for user {user_id}. 0 means unlimited:", + "admin_hwid_limit_invalid": "❌ Enter an integer device count from 0 to 1,000,000.", + "admin_hwid_limit_no_subscription": "User has no active subscription", + "admin_hwid_limit_state_missing": "⚠️ Target user missing in state. Open the user card again.", + "admin_hwid_limit_set": "✅ HWID device limit for user {user_id}: {current}", + "admin_hwid_limit_saved": "✅ HWID device limit saved", + "admin_hwid_limit_save_error": "❌ Failed to save HWID device limit", "admin_user_traffic_grant_button": "🎁 Grant GB", "admin_traffic_grant_title": "🎁 Grant traffic", "admin_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.", @@ -1313,7 +1332,21 @@ "admin_user_label_main_traffic": "Main Traffic", "admin_user_traffic_left": "Left: {left}", "admin_user_label_premium_squads": "Premium Squads", + "user_label_hwid_devices": "HWID devices", "user_override_unlimited_short": "Unlimited", + "user_hwid_limit_card_title": "HWID devices", + "user_hwid_limit_card_hint": "Manual device limit for this user. Empty value returns to the tariff or default limit.", + "user_hwid_limit_input": "Device limit", + "user_hwid_limit_input_hint": "Empty means tariff/default; 0 or the checkbox means unlimited.", + "user_hwid_limit_default_placeholder": "Tariff", + "user_hwid_limit_save": "Save", + "user_hwid_limit_status": "Current: {current}", + "user_hwid_limit_default": "Tariff / default", + "user_hwid_limit_unlimited": "Unlimited", + "user_hwid_limit_count": "{count} devices", + "user_hwid_limit_with_extra": "{total} devices ({base} + {extra})", + "hwid_limit_saved": "Device limit saved", + "hwid_limit_invalid": "Enter an integer device count from 0 to 1,000,000 or enable unlimited", "user_premium_override_card_title": "Premium traffic", "user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.", "user_regular_override_card_title": "Main traffic", diff --git a/locales/ru.json b/locales/ru.json index f624495..ee6aa30 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -284,6 +284,8 @@ "admin_user_premium_override_label": "🌟 Премиум-оверрайд:", "admin_user_premium_override_unlimited": "Безлимит", "admin_user_premium_override_bonus_value": "+{gb} GB", + "admin_user_hwid_limit_button": "📱 HWID лимит", + "admin_user_hwid_limit_label": "📱 HWID-устройства:", "admin_premium_override_title": "🌟 Премиум-оверрайд", "admin_premium_override_hint": "Бесплатно увеличьте лимит премиум-трафика или сделайте его безлимитным для этого пользователя.", "admin_premium_override_current": "Сейчас: {current}", @@ -300,6 +302,23 @@ "admin_premium_override_bonus_set": "✅ Премиум-оверрайд: +{gb} GB для пользователя {user_id}", "admin_premium_override_saved": "✅ Премиум-оверрайд сохранён", "admin_premium_override_save_error": "❌ Не удалось сохранить премиум-оверрайд", + "admin_hwid_limit_title": "📱 Лимит HWID-устройств", + "admin_hwid_limit_hint": "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + "admin_hwid_limit_current": "Сейчас: {current}", + "admin_hwid_limit_state_default": "тарифный / default", + "admin_hwid_limit_state_unlimited": "безлимит", + "admin_hwid_limit_state_count": "{count} устройств", + "admin_hwid_limit_state_with_extra": "{total} устройств ({base} + {extra})", + "admin_hwid_limit_btn_set_number": "🔢 Задать число", + "admin_hwid_limit_btn_unlimited": "♾ Включить безлимит", + "admin_hwid_limit_btn_reset": "↩️ Вернуть тариф/default", + "admin_hwid_limit_prompt": "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + "admin_hwid_limit_invalid": "❌ Введите целое число устройств от 0 до 1 000 000.", + "admin_hwid_limit_no_subscription": "У пользователя нет активной подписки", + "admin_hwid_limit_state_missing": "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + "admin_hwid_limit_set": "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + "admin_hwid_limit_saved": "✅ Лимит HWID-устройств сохранён", + "admin_hwid_limit_save_error": "❌ Не удалось сохранить лимит HWID-устройств", "admin_user_traffic_grant_button": "🎁 Выдать ГБ", "admin_traffic_grant_title": "🎁 Выдать трафик", "admin_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.", @@ -1313,7 +1332,21 @@ "admin_user_label_main_traffic": "Основной трафик", "admin_user_traffic_left": "Осталось: {left}", "admin_user_label_premium_squads": "Premium-сквады", + "user_label_hwid_devices": "HWID-устройства", "user_override_unlimited_short": "Безлимит", + "user_hwid_limit_card_title": "HWID-устройства", + "user_hwid_limit_card_hint": "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + "user_hwid_limit_input": "Лимит устройств", + "user_hwid_limit_input_hint": "Пусто — тариф/default; 0 или галочка — безлимит.", + "user_hwid_limit_default_placeholder": "Тариф", + "user_hwid_limit_save": "Сохранить", + "user_hwid_limit_status": "Сейчас: {current}", + "user_hwid_limit_default": "Тарифный / default", + "user_hwid_limit_unlimited": "Безлимит", + "user_hwid_limit_count": "{count} устройств", + "user_hwid_limit_with_extra": "{total} устройств ({base} + {extra})", + "hwid_limit_saved": "Лимит устройств сохранён", + "hwid_limit_invalid": "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", "user_premium_override_card_title": "Премиум-трафик", "user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.", "user_regular_override_card_title": "Основной трафик", diff --git a/tests/test_admin_traffic_grants.py b/tests/test_admin_traffic_grants.py index 7b01022..efe5834 100644 --- a/tests/test_admin_traffic_grants.py +++ b/tests/test_admin_traffic_grants.py @@ -50,6 +50,149 @@ def _make_settings(payload: dict, tmpdir: str) -> Settings: class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase): + async def test_hwid_limit_sync_pushes_effective_device_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + hwid_device_limit=4, + extra_hwid_devices=0, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service.tariff_dal.sum_active_hwid_devices", + new=AsyncMock(return_value=2), + ), + ): + effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(effective_limit, 6) + self.assertEqual(sub.extra_hwid_devices, 2) + panel_service.update_user_details_on_panel.assert_awaited_once() + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 6) + + async def test_hwid_limit_sync_keeps_zero_unlimited(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + hwid_device_limit=0, + extra_hwid_devices=0, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service.tariff_dal.sum_active_hwid_devices", + new=AsyncMock(return_value=3), + ), + ): + effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(effective_limit, 0) + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 0) + + async def test_hwid_limit_sync_treats_missing_default_as_unlimited(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + hwid_device_limit=None, + extra_hwid_devices=0, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service.tariff_dal.sum_active_hwid_devices", + new=AsyncMock(return_value=2), + ), + ): + effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(effective_limit, 0) + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 0) + async def test_regular_grant_increases_balance_and_panel_limit(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) diff --git a/tests/test_admin_user_hwid_limit.py b/tests/test_admin_user_hwid_limit.py new file mode 100644 index 0000000..d86b13f --- /dev/null +++ b/tests/test_admin_user_hwid_limit.py @@ -0,0 +1,140 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web.admin_api_impl import users as admin_users + + +class FakeSession: + def __init__(self): + self.committed = False + self.rolled_back = False + self.refreshed = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.committed = True + + async def rollback(self): + self.rolled_back = True + + async def refresh(self, obj): + self.refreshed = obj + + +class FakeRequest: + def __init__(self, body, session, subscription_service): + self.app = { + "settings": SimpleNamespace(), + "async_session_factory": lambda: session, + "subscription_service": subscription_service, + } + self.match_info = {"user_id": "42"} + self._body = body + + async def json(self): + return self._body + + +class AdminUserHwidLimitRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_unlimited_payload_stores_zero_and_syncs_panel(self): + session = FakeSession() + active = SimpleNamespace(hwid_device_limit=3) + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=0) + ) + request = FakeRequest( + {"unlimited": True, "hwid_device_limit": 999}, session, subscription_service + ) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object( + admin_users.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=active), + ), + patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()), + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + patch.object( + admin_users, + "_serialize_subscription", + return_value={"hwid_device_limit": 0}, + ), + ): + response = await admin_users.admin_user_hwid_device_limit_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(json.loads(response.text)["subscription"]["hwid_device_limit"], 0) + self.assertEqual(active.hwid_device_limit, 0) + subscription_service.sync_hwid_device_limit_to_panel.assert_awaited_once_with(session, 42) + self.assertTrue(session.committed) + self.assertEqual(session.refreshed, active) + + async def test_use_default_payload_stores_null_override(self): + session = FakeSession() + active = SimpleNamespace(hwid_device_limit=5) + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=3) + ) + request = FakeRequest({"use_default": True}, session, subscription_service) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object( + admin_users.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=active), + ), + patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()), + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + patch.object( + admin_users, + "_serialize_subscription", + return_value={"hwid_device_limit": None}, + ), + ): + response = await admin_users.admin_user_hwid_device_limit_route(request) + + self.assertEqual(response.status, 200) + self.assertIsNone(json.loads(response.text)["subscription"]["hwid_device_limit"]) + self.assertIsNone(active.hwid_device_limit) + subscription_service.sync_hwid_device_limit_to_panel.assert_awaited_once_with(session, 42) + + async def test_negative_limit_is_rejected(self): + session = FakeSession() + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=None) + ) + request = FakeRequest({"hwid_device_limit": -1}, session, subscription_service) + + with patch.object(admin_users, "_require_admin_user_id", return_value=100): + response = await admin_users.admin_user_hwid_device_limit_route(request) + + self.assertEqual(response.status, 400) + self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit") + subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited() + + async def test_over_max_limit_is_rejected(self): + session = FakeSession() + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=None) + ) + request = FakeRequest({"hwid_device_limit": 1_000_001}, session, subscription_service) + + with patch.object(admin_users, "_require_admin_user_id", return_value=100): + response = await admin_users.admin_user_hwid_device_limit_route(request) + + self.assertEqual(response.status, 400) + self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit") + subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_can_topup_devices_flag.py b/tests/test_can_topup_devices_flag.py index 552276e..920a494 100644 --- a/tests/test_can_topup_devices_flag.py +++ b/tests/test_can_topup_devices_flag.py @@ -157,6 +157,16 @@ class CanTopupDevicesFlagTests(unittest.TestCase): payload = _serialize_subscription(settings, _active(max_devices=0), None, "en") self.assertFalse(payload["can_topup_devices"]) + def test_flag_is_false_when_max_devices_is_missing(self): + # Missing device limit is unlimited for Remnawave HWID limits. + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + tmpdir, + _tariffs_payload(hwid_rub=[{"count": 1, "price": 50}]), + ) + payload = _serialize_subscription(settings, _active(max_devices=None), None, "en") + self.assertFalse(payload["can_topup_devices"]) + def test_flag_is_false_when_tariff_has_no_hwid_packages(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(tmpdir, _tariffs_payload()) diff --git a/tests/test_locale_overrides.py b/tests/test_locale_overrides.py index 1df311f..f5ff91c 100644 --- a/tests/test_locale_overrides.py +++ b/tests/test_locale_overrides.py @@ -306,7 +306,9 @@ def test_admin_locale_keys_are_split_into_smaller_internal_groups(): "inline_financial_description": "admin_dashboard", "inline_system_stats_message": "admin_dashboard", "admin_user_card_title": "admin_users", + "admin_hwid_limit_title": "admin_users", "user_card_open_profile_button": "admin_users", + "user_hwid_limit_card_title": "admin_users", "user_premium_override_card_title": "admin_users", "traffic_grant_regular_done": "admin_users", "admin_payment_detail_title": "admin_payments", diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 91d6533..6e202d1 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -173,6 +173,10 @@ class WebAppRouteContractTests(unittest.TestCase): "POST", "/api/admin/users/{user_id}/regular-traffic-override", ): "admin_user_regular_traffic_override_route", + ( + "POST", + "/api/admin/users/{user_id}/hwid-device-limit", + ): "admin_user_hwid_device_limit_route", ("POST", "/api/admin/users/{user_id}/traffic-grant"): "admin_user_traffic_grant_route", ("DELETE", "/api/admin/users/{user_id}"): "admin_user_delete_route", ("GET", "/api/admin/payments"): "admin_payments_list_route",