Merge pull request #21 from 3252a8/feature/ui-improvements
Improve admin UX, trial reset behavior, and channel subscription checks
This commit is contained in:
@@ -1172,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if panel_service is None or subscription_service is None:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
@@ -1183,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if active:
|
||||
await session.delete(active)
|
||||
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
|
||||
if reset_at is None:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_reset_trial_webapp",
|
||||
"content": f"Reset trial for user_id={target_id}",
|
||||
"content": f"Reset trial eligibility for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
let attempted = false;
|
||||
let pageLeft = false;
|
||||
let state = "opening";
|
||||
let closeAttemptTimer = null;
|
||||
const CLOSE_ATTEMPT_DELAY_MS = 2500;
|
||||
|
||||
function hasControlChars(value) {
|
||||
return Array.from(String(value || "")).some((char) => {
|
||||
@@ -167,7 +169,10 @@
|
||||
function markDone() {
|
||||
if (state === "done" || isUnsafe) return;
|
||||
render("done");
|
||||
window.setTimeout(tryCloseWindow, 120);
|
||||
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
|
||||
closeAttemptTimer = window.setTimeout(() => {
|
||||
if (pageLeft || document.hidden) tryCloseWindow();
|
||||
}, CLOSE_ATTEMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
@@ -201,7 +206,6 @@
|
||||
render("done");
|
||||
});
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
window.addEventListener("blur", notePageLeft);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!attempted) return;
|
||||
if (document.hidden) {
|
||||
|
||||
@@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
|
||||
@@ -932,8 +932,7 @@ async def handle_reset_trial(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
# Delete all user subscriptions to reset trial eligibility
|
||||
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
|
||||
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
|
||||
|
||||
@@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
)
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
@@ -45,12 +49,12 @@ async def should_show_trial_button(
|
||||
if not settings.TRIAL_ENABLED:
|
||||
return False
|
||||
|
||||
if hasattr(subscription_service, "has_had_any_subscription") and callable(
|
||||
getattr(subscription_service, "has_had_any_subscription")
|
||||
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
|
||||
getattr(subscription_service, "has_trial_blocking_subscription")
|
||||
):
|
||||
return not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
return not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
|
||||
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
|
||||
logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
|
||||
return False
|
||||
|
||||
|
||||
@@ -215,7 +219,7 @@ async def ensure_required_channel_subscription(
|
||||
Verify that the user is a member of the required channel (if configured).
|
||||
Returns True when access can proceed, False when user must subscribe first.
|
||||
"""
|
||||
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||
required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
|
||||
if not required_channel_id:
|
||||
return True
|
||||
|
||||
@@ -279,6 +283,29 @@ async def ensure_required_channel_subscription(
|
||||
if status_value in allowed_statuses:
|
||||
is_member = True
|
||||
except TelegramBadRequest as bad_request:
|
||||
if is_required_channel_access_error(bad_request):
|
||||
logging.error(
|
||||
"Required channel check failed due to channel access/configuration error "
|
||||
"(configured=%s, normalized=%s): %s",
|
||||
settings.REQUIRED_CHANNEL_ID,
|
||||
required_channel_id,
|
||||
bad_request,
|
||||
)
|
||||
error_text = translate("channel_subscription_check_failed")
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
|
||||
logging.info(
|
||||
"Required channel check: user %s not subscribed (details: %s)",
|
||||
user_id,
|
||||
|
||||
@@ -46,7 +46,7 @@ async def request_trial_confirmation_handler(
|
||||
return
|
||||
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if not await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
pass
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
@@ -60,7 +60,7 @@ async def request_trial_confirmation_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
await callback.message.edit_text(
|
||||
_("trial_already_had_subscription_or_trial"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
|
||||
@@ -147,8 +147,9 @@ async def request_trial_confirmation_handler(
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
@@ -218,7 +219,7 @@ async def confirm_activate_trial_handler(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
try:
|
||||
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
|
||||
except Exception:
|
||||
@@ -283,8 +284,9 @@ async def confirm_activate_trial_handler(
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.channel_subscription import normalize_required_channel_id
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
@@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||
required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
|
||||
if not required_channel_id:
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ class PaymentContextMixin:
|
||||
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||
|
||||
async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id)
|
||||
|
||||
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
"""Return True if user currently has an active subscription (end_date in future)."""
|
||||
try:
|
||||
|
||||
@@ -22,7 +22,7 @@ class TrialSubscriptionMixin:
|
||||
"message_key": "user_not_found_for_trial",
|
||||
}
|
||||
|
||||
if await self.has_had_any_subscription(session, user_id):
|
||||
if await self.has_trial_blocking_subscription(session, user_id):
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def normalize_required_channel_id(value: object) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
channel_id = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if channel_id == 0:
|
||||
return None
|
||||
|
||||
if channel_id > 0:
|
||||
return int(f"-100{channel_id}")
|
||||
|
||||
raw_abs = str(abs(channel_id))
|
||||
if raw.startswith("-100"):
|
||||
return channel_id
|
||||
if abs(channel_id) < 1_000_000_000:
|
||||
return channel_id
|
||||
return -int(f"100{raw_abs}")
|
||||
|
||||
|
||||
def is_required_channel_access_error(error: BaseException) -> bool:
|
||||
message = str(error).lower()
|
||||
configuration_markers = (
|
||||
"chat not found",
|
||||
"bot is not a member",
|
||||
"not enough rights",
|
||||
"have no rights",
|
||||
"kicked",
|
||||
)
|
||||
return any(marker in message for marker in configuration_markers)
|
||||
@@ -1018,6 +1018,7 @@ class Settings(BaseSettings):
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
"REQUIRED_CHANNEL_ID",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
|
||||
@@ -4,12 +4,12 @@ import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import delete, func, or_, update
|
||||
from sqlalchemy import and_, delete, func, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Subscription, SubscriptionNotification
|
||||
from db.models import Subscription, SubscriptionNotification, User
|
||||
|
||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||
|
||||
@@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int)
|
||||
|
||||
|
||||
async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int:
|
||||
"""Completely delete all user subscriptions (for trial reset)"""
|
||||
"""Completely delete all user subscriptions."""
|
||||
stmt = delete(Subscription).where(Subscription.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
@@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) ->
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
reset_at = (
|
||||
select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery()
|
||||
)
|
||||
subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date)
|
||||
stmt = (
|
||||
select(Subscription.subscription_id)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
or_(
|
||||
reset_at.is_(None),
|
||||
and_(Subscription.is_active == True, Subscription.end_date > now_utc),
|
||||
subscription_anchor > reset_at,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def get_subscriptions_near_expiration(
|
||||
session: AsyncSession, days_threshold: int
|
||||
) -> List[Subscription]:
|
||||
|
||||
@@ -261,6 +261,20 @@ async def create_email_user(
|
||||
)
|
||||
|
||||
|
||||
async def mark_trial_eligibility_reset(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
reset_at: Optional[datetime] = None,
|
||||
) -> Optional[datetime]:
|
||||
reset_at = reset_at or datetime.now(timezone.utc)
|
||||
stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount <= 0:
|
||||
return None
|
||||
return reset_at
|
||||
|
||||
|
||||
async def _has_active_panel_subscription(
|
||||
session: AsyncSession, user_id: int, panel_user_uuid: str
|
||||
) -> bool:
|
||||
|
||||
@@ -1061,6 +1061,15 @@ def _migration_0032_add_telegram_notification_status(connection: Connection) ->
|
||||
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
|
||||
|
||||
|
||||
def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "trial_eligibility_reset_at" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ")
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track whether the bot can message Telegram-linked users",
|
||||
upgrade=_migration_0032_add_telegram_notification_status,
|
||||
),
|
||||
Migration(
|
||||
id="0033_add_trial_eligibility_reset_marker",
|
||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class User(Base):
|
||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True)
|
||||
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
||||
|
||||
+19
-2
@@ -183,6 +183,7 @@
|
||||
let languageClickGuardArmed = false;
|
||||
let languageClickGuardTimer = null;
|
||||
let languageClickGuardArmTimer = null;
|
||||
let guestLanguage = "";
|
||||
let emailAvatarUrl = "";
|
||||
let avatarHashToken = "";
|
||||
let token = MOCK ? "local-preview" : "";
|
||||
@@ -213,12 +214,13 @@
|
||||
const i18n = createI18n({
|
||||
messages: I18N,
|
||||
defaultLang: "ru",
|
||||
getLang: () => user?.language_code || CFG.language || "ru",
|
||||
getLang: () => user?.language_code || guestLanguage || CFG.language || "ru",
|
||||
});
|
||||
const normalizeLangCode = i18n.normalizeLangCode;
|
||||
const t = i18n.t;
|
||||
const termUnitLabel = i18n.termUnitLabel;
|
||||
const languageName = i18n.languageName;
|
||||
guestLanguage = normalizeLangCode(CFG.language || "ru");
|
||||
const apiClient = createApiClient({
|
||||
apiBase: CFG.apiBase,
|
||||
csrfCookieName: CSRF_COOKIE_NAME,
|
||||
@@ -460,7 +462,7 @@
|
||||
activeTab = "settings";
|
||||
}
|
||||
$: referral = data?.referral || MOCK_SOURCE.data.referral;
|
||||
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru");
|
||||
$: currentLang = normalizeLangCode(user?.language_code || guestLanguage || CFG.language || "ru");
|
||||
$: languageCodes = uniqueLanguageCodes(
|
||||
WEBAPP_LANGUAGE_ORDER,
|
||||
CFG.languages,
|
||||
@@ -939,6 +941,13 @@
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function updateGuestLanguage(nextValue) {
|
||||
const language = normalizeLangCode(nextValue);
|
||||
setLanguageMenuOpen(false);
|
||||
if (!language || language === currentLang) return;
|
||||
guestLanguage = language;
|
||||
}
|
||||
|
||||
function readTelegramMiniAppInitDataFromLocation() {
|
||||
return telegramSdk.readInitDataFromLocation();
|
||||
}
|
||||
@@ -2212,7 +2221,15 @@
|
||||
{telegramLoginUnavailableMessage}
|
||||
{privacyPolicyUrl}
|
||||
{userAgreementUrl}
|
||||
{currentLang}
|
||||
{currentLanguageOption}
|
||||
{languageOptions}
|
||||
{languageMenuOpen}
|
||||
{languageClickGuard}
|
||||
{languageClickGuardArmed}
|
||||
{t}
|
||||
{setLanguageMenuOpen}
|
||||
updateLoginLanguage={updateGuestLanguage}
|
||||
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
|
||||
loginWithEmailPassword={authStore.loginWithEmailPassword}
|
||||
verifyEmailCode={authStore.verifyEmailCode}
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
let adminLanguageClickGuardArmed = false;
|
||||
let adminLanguageClickGuardTimer = null;
|
||||
let adminLanguageClickGuardArmTimer = null;
|
||||
$: adminLanguageGuardActive = isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard);
|
||||
|
||||
function readReduceMotion() {
|
||||
return (
|
||||
@@ -295,6 +296,8 @@
|
||||
}
|
||||
|
||||
function changeLanguage(value) {
|
||||
adminLanguageMenuOpen = false;
|
||||
clearAdminLanguageClickGuard();
|
||||
onLanguageChange(value, { section: "admin", adminSection: active });
|
||||
}
|
||||
|
||||
@@ -472,8 +475,6 @@
|
||||
function setAdminLanguageMenuOpen(open) {
|
||||
adminLanguageMenuOpen = Boolean(open);
|
||||
clearAdminLanguageClickGuard();
|
||||
// Desktop doesn't need the click-guard overlay and it can block
|
||||
// option clicks in portaled select content.
|
||||
if (!isCompact) return;
|
||||
if (adminLanguageMenuOpen) {
|
||||
adminLanguageClickGuard = true;
|
||||
@@ -557,7 +558,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}>
|
||||
<div
|
||||
class="admin-screen-wrap"
|
||||
class:is-sidebar-open={sidebarOpen}
|
||||
class:is-admin-language-open={adminLanguageGuardActive}
|
||||
>
|
||||
{#if sidebarOpen}
|
||||
<button
|
||||
type="button"
|
||||
@@ -568,7 +573,7 @@
|
||||
on:click={() => (sidebarOpen = false)}
|
||||
></button>
|
||||
{/if}
|
||||
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
|
||||
{#if adminLanguageGuardActive}
|
||||
<button
|
||||
class="language-select-guard"
|
||||
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
||||
@@ -578,7 +583,6 @@
|
||||
on:click={closeAdminLanguageFromGuard}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
||||
<div class="admin-sidebar-brand">
|
||||
<BrandMark class="admin-brand-mark" {brand} />
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
ArrowUp,
|
||||
ChevronsUpDown,
|
||||
DollarSign,
|
||||
Sliders,
|
||||
X,
|
||||
UsersRound,
|
||||
} from "$components/ui/icons.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
@@ -44,6 +47,7 @@
|
||||
} = $usersStore);
|
||||
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
let usersFilterSheetOpen = false;
|
||||
$: usersHasMore = users.length === USERS_PAGE_SIZE;
|
||||
|
||||
const USERS_FILTER_OPTIONS = [
|
||||
@@ -102,6 +106,29 @@
|
||||
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
|
||||
];
|
||||
|
||||
function optionLabel(options, value) {
|
||||
return options.find((item) => item.value === value)?.label || value;
|
||||
}
|
||||
|
||||
function updateUsersFilterState(patch) {
|
||||
usersStore.updateState({ ...patch, usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}
|
||||
|
||||
function resetUsersFilters() {
|
||||
updateUsersFilterState({
|
||||
usersFilter: "all",
|
||||
usersPanelStatus: "all",
|
||||
usersPremiumTraffic: "all",
|
||||
});
|
||||
}
|
||||
|
||||
function clearUsersFilter(key) {
|
||||
if (key === "usersFilter") updateUsersFilterState({ usersFilter: "all" });
|
||||
if (key === "usersPanelStatus") updateUsersFilterState({ usersPanelStatus: "all" });
|
||||
if (key === "usersPremiumTraffic") updateUsersFilterState({ usersPremiumTraffic: "all" });
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||
function premiumTrafficBadgeVariant(pt) {
|
||||
if (!pt || pt.state === "none") return "muted";
|
||||
@@ -187,6 +214,24 @@
|
||||
return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB");
|
||||
}
|
||||
|
||||
$: activeUserFilterChips = [
|
||||
usersFilter !== "all" && {
|
||||
key: "usersFilter",
|
||||
label: at("filter", {}, "Фильтр"),
|
||||
value: optionLabel(USERS_FILTER_OPTIONS, usersFilter),
|
||||
},
|
||||
usersPanelStatus !== "all" && {
|
||||
key: "usersPanelStatus",
|
||||
label: at("panel_status", {}, "Статус панели"),
|
||||
value: optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus),
|
||||
},
|
||||
usersPremiumTraffic !== "all" && {
|
||||
key: "usersPremiumTraffic",
|
||||
label: at("premium_traffic_filter_label", {}, "Премиум трафик"),
|
||||
value: optionLabel(USERS_PREMIUM_TRAFFIC_OPTIONS, usersPremiumTraffic),
|
||||
},
|
||||
].filter(Boolean);
|
||||
$: activeUsersFilterCount = activeUserFilterChips.length;
|
||||
$: userTableHeaders = userTableColumns().map((column) => column.label);
|
||||
|
||||
onMount(() => {
|
||||
@@ -194,6 +239,65 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet renderUserFilterControls()}
|
||||
<Label.Root class="admin-toolbar-field admin-users-filter-field">
|
||||
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
|
||||
<AdminSelect
|
||||
value={usersFilter}
|
||||
items={USERS_FILTER_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("filter", {}, "Фильтр")}
|
||||
onValueChange={(value) => updateUsersFilterState({ usersFilter: value })}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field admin-users-filter-field">
|
||||
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
|
||||
<AdminSelect
|
||||
value={usersPanelStatus}
|
||||
items={USERS_PANEL_STATUS_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("panel_status", {}, "Статус панели")}
|
||||
onValueChange={(value) => updateUsersFilterState({ usersPanelStatus: value })}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field admin-users-filter-field">
|
||||
<span class="admin-toolbar-field-label"
|
||||
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
|
||||
>
|
||||
<AdminSelect
|
||||
value={usersPremiumTraffic}
|
||||
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
|
||||
onValueChange={(value) => updateUsersFilterState({ usersPremiumTraffic: value })}
|
||||
/>
|
||||
</Label.Root>
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderActiveUserFilterChips()}
|
||||
{#if activeUsersFilterCount}
|
||||
<div class="admin-users-filter-chips" aria-label={at("active_filters", {}, "Активные фильтры")}>
|
||||
{#each activeUserFilterChips as chip (chip.key)}
|
||||
<span class="admin-users-filter-chip">
|
||||
<span class="admin-users-filter-chip-text">
|
||||
<strong>{chip.label}</strong>
|
||||
<span>{chip.value}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={at("clear_filter", { label: chip.label }, "Сбросить фильтр")}
|
||||
on:click={() => clearUsersFilter(chip.key)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="admin-toolbar admin-toolbar-users">
|
||||
<div class="admin-toolbar-search">
|
||||
<Input
|
||||
@@ -207,11 +311,28 @@
|
||||
/>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
class="admin-users-search-button"
|
||||
onclick={() => {
|
||||
usersStore.updateState({ usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}>{at("find", {}, "Найти")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
variant={activeUsersFilterCount ? "primary" : "default"}
|
||||
class="admin-users-filter-toggle"
|
||||
aria-label={at("users_filters_open", {}, "Открыть фильтры")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={usersFilterSheetOpen}
|
||||
onclick={() => {
|
||||
usersFilterSheetOpen = true;
|
||||
}}
|
||||
>
|
||||
<Sliders size={15} />
|
||||
<span class="admin-users-filter-toggle-label">{at("filters", {}, "Фильтры")}</span>
|
||||
{#if activeUsersFilterCount}
|
||||
<span class="admin-users-filter-count">{activeUsersFilterCount}</span>
|
||||
{/if}
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
<div class="admin-toolbar-controls">
|
||||
@@ -264,8 +385,45 @@
|
||||
<strong>{usersTotal}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render renderActiveUserFilterChips()}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={usersFilterSheetOpen}
|
||||
class="admin-dialog admin-users-filter-dialog"
|
||||
title={at("users_filters_title", {}, "Фильтры пользователей")}
|
||||
description={at("users_filters_description", {}, "Уточните список пользователей")}
|
||||
closeLabel={at("close_menu", {}, "Закрыть меню")}
|
||||
onclose={() => {
|
||||
usersFilterSheetOpen = false;
|
||||
}}
|
||||
>
|
||||
<div class="admin-users-filter-sheet-body">
|
||||
<div class="admin-users-filter-fields admin-users-filter-fields-sheet">
|
||||
{@render renderUserFilterControls()}
|
||||
</div>
|
||||
{@render renderActiveUserFilterChips()}
|
||||
<div class="admin-users-filter-sheet-actions">
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
disabled={activeUsersFilterCount === 0}
|
||||
onclick={resetUsersFilters}
|
||||
>
|
||||
{at("reset", {}, "Сбросить")}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
usersFilterSheetOpen = false;
|
||||
}}
|
||||
>
|
||||
{at("done", {}, "Готово")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<div class="admin-users-table-wrap">
|
||||
{#if usersLoading}
|
||||
<AdminTableSkeleton
|
||||
@@ -429,7 +587,111 @@
|
||||
|
||||
<style>
|
||||
:global(.admin-toolbar-users .admin-toolbar-controls) {
|
||||
grid-template-columns: repeat(3, minmax(130px, 1fr)) minmax(96px, auto);
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(82px, auto);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
:global(.admin-users-search-button) {
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
:global(.admin-users-filter-toggle) {
|
||||
display: none;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-users-filter-count {
|
||||
display: inline-grid;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
place-items: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--admin-bg) 74%, transparent);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-users-filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 3px 5px 3px 10px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--admin-muted) 8%, transparent);
|
||||
color: var(--admin-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip strong {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip-text > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip button {
|
||||
display: inline-grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--admin-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip button:hover,
|
||||
.admin-users-filter-chip button:focus-visible {
|
||||
background: color-mix(in srgb, var(--admin-muted) 14%, transparent);
|
||||
color: var(--admin-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-users-filter-fields-sheet,
|
||||
.admin-users-filter-sheet-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-users-filter-sheet-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
:global(.admin-users-filter-dialog) {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-table-wrap) {
|
||||
@@ -571,6 +833,52 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:global(.admin-toolbar-users .admin-toolbar-search) {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
:global(.admin-toolbar-users .admin-toolbar-controls) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.admin-users-search-button) {
|
||||
min-width: 0;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
:global(.admin-users-filter-toggle) {
|
||||
display: inline-flex;
|
||||
min-width: 38px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.admin-users-filter-toggle-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-users-filter-chips {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-users-filter-chip-text {
|
||||
max-width: min(250px, calc(100vw - 96px));
|
||||
}
|
||||
|
||||
:global(.dialog:has(.admin-users-filter-dialog)) {
|
||||
align-items: end;
|
||||
padding: max(12px, env(safe-area-inset-top)) 0 0;
|
||||
}
|
||||
|
||||
:global(.admin-users-filter-dialog) {
|
||||
width: 100%;
|
||||
max-height: min(82dvh, 620px);
|
||||
padding: 16px;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table thead) {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
@@ -469,8 +469,11 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
|
||||
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
|
||||
else onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
if (res?.ok) {
|
||||
onToast(at("trial_reset", {}, "Триал сброшен"));
|
||||
await openUser(s.openedUser.user_id, { skipPush: true, pathContext: _pathContext });
|
||||
if (_activeRef === "users") await loadUsers();
|
||||
} else onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
export let ariaLabel = "";
|
||||
export let placeholder = "";
|
||||
export let disabled = false;
|
||||
export let side = "bottom";
|
||||
export let align = "start";
|
||||
export let sideOffset = 6;
|
||||
export let collisionPadding = 12;
|
||||
export let onValueChange = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
@@ -29,7 +32,7 @@
|
||||
<ChevronDown size={14} class="admin-select-icon" />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content class="admin-select-content" {sideOffset}>
|
||||
<Select.Content class="admin-select-content" {side} {align} {sideOffset} {collisionPadding}>
|
||||
<Select.Viewport class="admin-select-viewport">
|
||||
{#each items as item (item.value)}
|
||||
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
||||
|
||||
@@ -3605,6 +3605,15 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.admin-screen-wrap.is-admin-language-open .admin-sidebar {
|
||||
z-index: 120;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-screen-wrap.is-admin-language-open .admin-language-switch {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.admin-sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -4045,6 +4054,7 @@
|
||||
.admin-select-content {
|
||||
z-index: 90;
|
||||
min-width: var(--bits-select-anchor-width);
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: var(--bits-select-content-available-height, 320px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--admin-border);
|
||||
@@ -4098,6 +4108,13 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-select-item span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-select-item[data-highlighted],
|
||||
.admin-select-item:hover {
|
||||
background: var(--admin-surface-2);
|
||||
|
||||
@@ -336,6 +336,18 @@ a {
|
||||
margin-left: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.subscription-renew-action {
|
||||
min-height: 54px;
|
||||
padding-block: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.subscription-renew-action svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.traffic-top,
|
||||
@@ -2366,6 +2378,50 @@ a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-language-trigger {
|
||||
appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 26px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-language-trigger:hover,
|
||||
.auth-language-trigger[data-state="open"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-language-trigger:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.auth-language-trigger > span:last-of-type {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.auth-language-trigger > svg,
|
||||
.auth-language-trigger > .emoji-flag {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.auth-language-content {
|
||||
width: min(188px, calc(100vw - 40px));
|
||||
min-width: 154px;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.auth-bottom strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script>
|
||||
import { LockKeyhole, Mail, Send, TriangleAlert } from "$components/ui/icons.js";
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
Send,
|
||||
TriangleAlert,
|
||||
} from "$components/ui/icons.js";
|
||||
import { Select, Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
@@ -32,7 +40,15 @@
|
||||
export let telegramLoginUnavailableMessage;
|
||||
export let privacyPolicyUrl;
|
||||
export let userAgreementUrl;
|
||||
export let currentLang = "ru";
|
||||
export let currentLanguageOption = null;
|
||||
export let languageOptions = [];
|
||||
export let languageMenuOpen = false;
|
||||
export let languageClickGuard = false;
|
||||
export let languageClickGuardArmed = false;
|
||||
export let t;
|
||||
export let setLanguageMenuOpen = () => {};
|
||||
export let updateLoginLanguage = () => {};
|
||||
export let requestEmailCode;
|
||||
export let loginWithEmailPassword;
|
||||
export let verifyEmailCode;
|
||||
@@ -48,6 +64,13 @@
|
||||
$: emailAuthEnabled = CFG.emailAuthEnabled !== false;
|
||||
$: passwordModeActive = Boolean(passwordLoginMode && emailAuthEnabled);
|
||||
$: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
|
||||
$: showLanguageSelect = languageOptions.length > 1;
|
||||
|
||||
function closeLanguageFromGuard(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (languageClickGuardArmed) setLanguageMenuOpen(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if screen === "code"}
|
||||
@@ -236,40 +259,93 @@
|
||||
</div>
|
||||
{/key}
|
||||
</section>
|
||||
{#if userAgreementUrl || privacyPolicyUrl}
|
||||
{#if userAgreementUrl || privacyPolicyUrl || showLanguageSelect}
|
||||
<div class="auth-legal">
|
||||
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
|
||||
<div class="auth-legal-links">
|
||||
{#if privacyPolicyUrl}
|
||||
<a
|
||||
href={privacyPolicyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
openExternalLink(privacyPolicyUrl);
|
||||
}}
|
||||
{#if userAgreementUrl || privacyPolicyUrl}
|
||||
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
|
||||
<div class="auth-legal-links">
|
||||
{#if privacyPolicyUrl}
|
||||
<a
|
||||
href={privacyPolicyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
openExternalLink(privacyPolicyUrl);
|
||||
}}
|
||||
>
|
||||
{t("wa_auth_legal_privacy")}
|
||||
</a>
|
||||
{/if}
|
||||
{#if privacyPolicyUrl && userAgreementUrl}
|
||||
<span>{t("wa_auth_legal_and")}</span>
|
||||
{/if}
|
||||
{#if userAgreementUrl}
|
||||
<a
|
||||
href={userAgreementUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
openExternalLink(userAgreementUrl);
|
||||
}}
|
||||
>
|
||||
{t("wa_auth_legal_agreement")}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showLanguageSelect}
|
||||
{#if languageMenuOpen || languageClickGuard}
|
||||
<button
|
||||
class="language-select-guard"
|
||||
class:language-select-guard--armed={languageClickGuardArmed}
|
||||
type="button"
|
||||
aria-label={t("wa_close")}
|
||||
onpointerdown={closeLanguageFromGuard}
|
||||
onclick={closeLanguageFromGuard}
|
||||
></button>
|
||||
{/if}
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:open={languageMenuOpen}
|
||||
value={currentLang}
|
||||
items={languageOptions}
|
||||
onOpenChange={setLanguageMenuOpen}
|
||||
onValueChange={updateLoginLanguage}
|
||||
>
|
||||
<Select.Trigger class="auth-language-trigger" aria-label={t("wa_settings_language")}>
|
||||
<Globe2 size={13} />
|
||||
<span class="emoji-flag" aria-hidden="true"
|
||||
>{currentLanguageOption?.flag || "🏳️"}</span
|
||||
>
|
||||
<span>{currentLanguageOption?.label || currentLang}</span>
|
||||
<ChevronsUpDown size={12} />
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
class="language-select-content auth-language-content"
|
||||
side="bottom"
|
||||
align="center"
|
||||
sideOffset={7}
|
||||
>
|
||||
{t("wa_auth_legal_privacy")}
|
||||
</a>
|
||||
{/if}
|
||||
{#if privacyPolicyUrl && userAgreementUrl}
|
||||
<span>{t("wa_auth_legal_and")}</span>
|
||||
{/if}
|
||||
{#if userAgreementUrl}
|
||||
<a
|
||||
href={userAgreementUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
openExternalLink(userAgreementUrl);
|
||||
}}
|
||||
>
|
||||
{t("wa_auth_legal_agreement")}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<Select.Viewport class="language-select-viewport">
|
||||
{#each languageOptions as option (option.value)}
|
||||
<Select.Item
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
class="language-select-item"
|
||||
>
|
||||
<span class="language-select-item-main">
|
||||
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
<Check size={15} class="language-select-item-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const AUTO_OPEN_DELAY_MS = 80;
|
||||
const MANUAL_STATE_DELAY_MS = 1600;
|
||||
const DONE_STATE_DELAY_MS = 900;
|
||||
const CLOSE_ATTEMPT_DELAY_MS = 120;
|
||||
const CLOSE_ATTEMPT_DELAY_MS = 2500;
|
||||
|
||||
export let brand = {};
|
||||
export let appLaunchTarget = "";
|
||||
@@ -54,7 +54,6 @@
|
||||
autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS);
|
||||
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
window.addEventListener("blur", notePageLeft);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
@@ -63,7 +62,6 @@
|
||||
clearTimer(doneStateTimer);
|
||||
clearTimer(closeAttemptTimer);
|
||||
window.removeEventListener("pagehide", notePageLeft);
|
||||
window.removeEventListener("blur", notePageLeft);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
});
|
||||
@@ -91,7 +89,9 @@
|
||||
if (!attempted || state === "done" || !activeTarget) return;
|
||||
state = "done";
|
||||
clearTimer(closeAttemptTimer);
|
||||
closeAttemptTimer = window.setTimeout(tryCloseWindow, CLOSE_ATTEMPT_DELAY_MS);
|
||||
closeAttemptTimer = window.setTimeout(() => {
|
||||
if (pageLeft || document.hidden) tryCloseWindow();
|
||||
}, CLOSE_ATTEMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="wide"
|
||||
class={`wide${subscription.active ? " subscription-renew-action" : ""}`}
|
||||
variant={subscription.active ? "secondary" : "default"}
|
||||
onclick={openPaymentModal}
|
||||
>
|
||||
|
||||
@@ -1043,6 +1043,13 @@
|
||||
"admin_users_search_placeholder": "ID, @username, or email",
|
||||
"admin_find": "Find",
|
||||
"admin_filter": "Filter",
|
||||
"admin_filters": "Filters",
|
||||
"admin_active_filters": "Active filters",
|
||||
"admin_clear_filter": "Clear {label}",
|
||||
"admin_done": "Done",
|
||||
"admin_users_filters_open": "Open filters",
|
||||
"admin_users_filters_title": "User filters",
|
||||
"admin_users_filters_description": "Refine the user list without leaving the table.",
|
||||
"admin_sort": "Sort",
|
||||
"admin_total": "Total",
|
||||
"admin_users_empty": "No users found",
|
||||
|
||||
@@ -1043,6 +1043,13 @@
|
||||
"admin_users_search_placeholder": "ID, @username или email",
|
||||
"admin_find": "Найти",
|
||||
"admin_filter": "Фильтр",
|
||||
"admin_filters": "Фильтры",
|
||||
"admin_active_filters": "Активные фильтры",
|
||||
"admin_clear_filter": "Сбросить {label}",
|
||||
"admin_done": "Готово",
|
||||
"admin_users_filters_open": "Открыть фильтры",
|
||||
"admin_users_filters_title": "Фильтры пользователей",
|
||||
"admin_users_filters_description": "Уточните список пользователей, не уходя из таблицы.",
|
||||
"admin_sort": "Сортировка",
|
||||
"admin_total": "Всего",
|
||||
"admin_users_empty": "Никого не найдено",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _request(self, session: FakeSession):
|
||||
return SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(),
|
||||
"async_session_factory": lambda: session,
|
||||
},
|
||||
match_info={"user_id": "42"},
|
||||
)
|
||||
|
||||
async def test_marks_trial_reset_without_deleting_subscription_history(self):
|
||||
session = FakeSession()
|
||||
request = self._request(session)
|
||||
user = SimpleNamespace(user_id=42)
|
||||
|
||||
with (
|
||||
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||
patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)),
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"mark_trial_eligibility_reset",
|
||||
AsyncMock(return_value=object()),
|
||||
) as mark_reset,
|
||||
patch.object(
|
||||
admin_users.subscription_dal,
|
||||
"delete_all_user_subscriptions",
|
||||
AsyncMock(),
|
||||
) as delete_all,
|
||||
patch.object(
|
||||
admin_users.message_log_dal, "create_message_log_no_commit", AsyncMock()
|
||||
) as log,
|
||||
patch.object(
|
||||
admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()
|
||||
) as invalidate,
|
||||
):
|
||||
response = await admin_users.admin_user_reset_trial_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(json.loads(response.text)["ok"], True)
|
||||
mark_reset.assert_awaited_once_with(session, 42)
|
||||
delete_all.assert_not_awaited()
|
||||
log_payload = log.await_args.args[1]
|
||||
self.assertEqual(log_payload["event_type"], "admin_reset_trial_webapp")
|
||||
self.assertEqual(log_payload["target_user_id"], 42)
|
||||
invalidate.assert_awaited_once()
|
||||
self.assertTrue(session.committed)
|
||||
self.assertFalse(session.rolled_back)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,151 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.handlers.user.start import ensure_required_channel_subscription
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
)
|
||||
|
||||
|
||||
class I18nStub:
|
||||
def gettext(self, _lang, key, **_kwargs):
|
||||
return {
|
||||
"channel_subscription_required": "subscribe first",
|
||||
"channel_subscription_check_failed": "check failed",
|
||||
}.get(key, key)
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self, *, status="member", error=None):
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.calls = []
|
||||
|
||||
async def get_chat_member(self, chat_id, user_id):
|
||||
self.calls.append((chat_id, user_id))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return SimpleNamespace(status=self.status)
|
||||
|
||||
|
||||
def _settings(required_channel_id):
|
||||
return SimpleNamespace(
|
||||
REQUIRED_CHANNEL_ID=required_channel_id,
|
||||
REQUIRED_CHANNEL_LINK="https://t.me/example",
|
||||
ADMIN_IDS=[],
|
||||
DEFAULT_LANGUAGE="en",
|
||||
)
|
||||
|
||||
|
||||
def _message_event(bot, user_id=42):
|
||||
return SimpleNamespace(
|
||||
from_user=SimpleNamespace(id=user_id),
|
||||
bot=bot,
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
def _db_user(*, verified=False, verified_for=None):
|
||||
return SimpleNamespace(
|
||||
channel_subscription_verified=verified,
|
||||
channel_subscription_verified_for=verified_for,
|
||||
)
|
||||
|
||||
|
||||
class RequiredChannelIdNormalizationTests(unittest.TestCase):
|
||||
def test_normalizes_raw_channel_ids_to_bot_api_chat_ids(self):
|
||||
self.assertEqual(normalize_required_channel_id(1234567890), -1001234567890)
|
||||
self.assertEqual(normalize_required_channel_id("1234567890"), -1001234567890)
|
||||
self.assertEqual(normalize_required_channel_id(-1234567890), -1001234567890)
|
||||
self.assertEqual(normalize_required_channel_id(-1001234567890), -1001234567890)
|
||||
self.assertEqual(normalize_required_channel_id(-123456789), -123456789)
|
||||
|
||||
def test_ignores_empty_channel_ids(self):
|
||||
self.assertIsNone(normalize_required_channel_id(None))
|
||||
self.assertIsNone(normalize_required_channel_id(""))
|
||||
self.assertIsNone(normalize_required_channel_id(0))
|
||||
|
||||
def test_detects_channel_configuration_errors(self):
|
||||
self.assertTrue(
|
||||
is_required_channel_access_error(
|
||||
TelegramBadRequest(method=None, message="Bad Request: chat not found")
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
is_required_channel_access_error(
|
||||
TelegramBadRequest(method=None, message="Bad Request: user not found")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_check_uses_normalized_channel_id_and_persists_it(self):
|
||||
bot = FakeBot(status="member")
|
||||
event = _message_event(bot)
|
||||
user = _db_user()
|
||||
|
||||
with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user:
|
||||
result = await ensure_required_channel_subscription(
|
||||
event,
|
||||
_settings(1234567890),
|
||||
I18nStub(),
|
||||
"en",
|
||||
AsyncMock(),
|
||||
db_user=user,
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(bot.calls, [(-1001234567890, 42)])
|
||||
payload = update_user.await_args.args[2]
|
||||
self.assertTrue(payload["channel_subscription_verified"])
|
||||
self.assertEqual(payload["channel_subscription_verified_for"], -1001234567890)
|
||||
|
||||
async def test_channel_access_errors_show_check_failed_without_persisting_false_status(self):
|
||||
bot = FakeBot(error=TelegramBadRequest(method=None, message="Bad Request: chat not found"))
|
||||
event = _message_event(bot)
|
||||
user = _db_user()
|
||||
|
||||
with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user:
|
||||
result = await ensure_required_channel_subscription(
|
||||
event,
|
||||
_settings(1234567890),
|
||||
I18nStub(),
|
||||
"en",
|
||||
AsyncMock(),
|
||||
db_user=user,
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
event.answer.assert_awaited_once_with("check failed")
|
||||
update_user.assert_not_awaited()
|
||||
|
||||
|
||||
class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
|
||||
middleware = ChannelSubscriptionMiddleware(_settings(1234567890), I18nStub())
|
||||
handler = AsyncMock(return_value="ok")
|
||||
event = SimpleNamespace(callback_query=None, message=None)
|
||||
data = {
|
||||
"event_from_user": SimpleNamespace(id=42),
|
||||
"session": AsyncMock(),
|
||||
"i18n_data": {"current_language": "en", "i18n_instance": I18nStub()},
|
||||
}
|
||||
user = _db_user(verified=True, verified_for=-1001234567890)
|
||||
|
||||
with patch(
|
||||
"bot.middlewares.channel_subscription.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=user),
|
||||
):
|
||||
result = await middleware(handler, event, data)
|
||||
|
||||
self.assertEqual(result, "ok")
|
||||
handler.assert_awaited_once_with(event, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -202,7 +202,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
|
||||
TRIAL_SQUAD_UUIDS="trial-squad",
|
||||
)
|
||||
service = _make_service(settings)
|
||||
service.has_had_any_subscription = AsyncMock(return_value=False)
|
||||
service.has_trial_blocking_subscription = AsyncMock(return_value=False)
|
||||
service._get_or_create_panel_user_link_details = AsyncMock(
|
||||
return_value=("panel-user", "panel-sub", "short", True)
|
||||
)
|
||||
@@ -255,7 +255,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
|
||||
TRIAL_SQUAD_UUIDS=" , ",
|
||||
)
|
||||
service = _make_service(settings)
|
||||
service.has_had_any_subscription = AsyncMock(return_value=False)
|
||||
service.has_trial_blocking_subscription = AsyncMock(return_value=False)
|
||||
service._get_or_create_panel_user_link_details = AsyncMock(
|
||||
return_value=("panel-user", "panel-sub", "short", True)
|
||||
)
|
||||
@@ -292,6 +292,47 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
|
||||
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
|
||||
self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"])
|
||||
|
||||
async def test_activate_trial_rejects_users_with_blocking_subscription_history(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(
|
||||
_tariffs_config_payload(),
|
||||
tmpdir,
|
||||
TRIAL_ENABLED=True,
|
||||
TRIAL_DURATION_DAYS=3,
|
||||
)
|
||||
service = _make_service(settings)
|
||||
service.has_trial_blocking_subscription = AsyncMock(return_value=True)
|
||||
service._get_or_create_panel_user_link_details = AsyncMock()
|
||||
service.panel_service.update_user_details_on_panel = AsyncMock()
|
||||
session = AsyncMock()
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
telegram_id=42,
|
||||
email=None,
|
||||
username="trial-user",
|
||||
first_name="Trial",
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.trial.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription",
|
||||
AsyncMock(),
|
||||
) as upsert_subscription,
|
||||
):
|
||||
result = await service.activate_trial_subscription(session, user_id=42)
|
||||
|
||||
self.assertFalse(result["activated"])
|
||||
self.assertFalse(result["eligible"])
|
||||
self.assertEqual(result["message_key"], "trial_already_had_subscription_or_trial")
|
||||
service._get_or_create_panel_user_link_details.assert_not_awaited()
|
||||
service.panel_service.update_user_details_on_panel.assert_not_awaited()
|
||||
upsert_subscription.assert_not_awaited()
|
||||
|
||||
async def test_activate_subscription_dispatches_traffic_sale_mode(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||
|
||||
@@ -29,3 +29,13 @@ def test_support_models_expose_expected_tables():
|
||||
|
||||
def test_user_model_tracks_lifetime_traffic_sync_timestamp():
|
||||
assert "lifetime_used_traffic_synced_at" in User.__table__.columns
|
||||
|
||||
|
||||
def test_trial_eligibility_reset_migration_and_model_are_registered():
|
||||
ids = [migration.id for migration in MIGRATIONS]
|
||||
|
||||
assert "0033_add_trial_eligibility_reset_marker" in ids
|
||||
assert ids.index("0033_add_trial_eligibility_reset_marker") > ids.index(
|
||||
"0032_add_telegram_notification_status"
|
||||
)
|
||||
assert "trial_eligibility_reset_at" in User.__table__.columns
|
||||
|
||||
+39
-1
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.sql.dml import Delete, Update
|
||||
|
||||
from db.dal import user_dal
|
||||
from db.dal import subscription_dal, user_dal
|
||||
|
||||
|
||||
class FakeResult:
|
||||
@@ -122,6 +122,44 @@ class UserDalReferralTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertIs(result, referrer)
|
||||
|
||||
async def test_mark_trial_eligibility_reset_updates_user_marker(self):
|
||||
reset_at = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(rowcount=1)))
|
||||
|
||||
result = await user_dal.mark_trial_eligibility_reset(session, 42, reset_at=reset_at)
|
||||
|
||||
self.assertEqual(result, reset_at)
|
||||
stmt = session.execute.await_args.args[0]
|
||||
sql = str(
|
||||
stmt.compile(
|
||||
dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
).upper()
|
||||
self.assertIn("UPDATE USERS", sql)
|
||||
self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql)
|
||||
self.assertIn("USER_ID = 42", sql)
|
||||
|
||||
|
||||
class SubscriptionDalTrialEligibilityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_trial_blocking_history_honors_user_reset_marker(self):
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(7)))
|
||||
|
||||
result = await subscription_dal.has_trial_blocking_subscription_for_user(session, 42)
|
||||
|
||||
self.assertTrue(result)
|
||||
stmt = session.execute.await_args.args[0]
|
||||
sql = str(
|
||||
stmt.compile(
|
||||
dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
).upper()
|
||||
self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql)
|
||||
self.assertIn("SUBSCRIPTIONS.IS_ACTIVE = TRUE", sql)
|
||||
self.assertIn("COALESCE(SUBSCRIPTIONS.START_DATE, SUBSCRIPTIONS.END_DATE)", sql)
|
||||
self.assertIn("SUBSCRIPTIONS.USER_ID = 42", sql)
|
||||
|
||||
|
||||
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
||||
|
||||
@@ -296,6 +296,10 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
self.assertIn("URLSearchParams", response.text)
|
||||
self.assertIn("Settings added", response.text)
|
||||
self.assertIn("window.close()", response.text)
|
||||
self.assertNotIn('window.addEventListener("blur", notePageLeft)', response.text)
|
||||
self.assertNotIn("window.setTimeout(tryCloseWindow, 120)", response.text)
|
||||
self.assertIn("const CLOSE_ATTEMPT_DELAY_MS = 2500", response.text)
|
||||
self.assertIn("if (pageLeft || document.hidden) tryCloseWindow();", response.text)
|
||||
self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text)
|
||||
|
||||
def test_app_deeplink_gateway_uses_i18n_template(self):
|
||||
|
||||
@@ -44,6 +44,9 @@ def test_open_app_route_uses_fallback_screen_without_auth_flow():
|
||||
assert "AppLaunchScreen" in app_source
|
||||
assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source
|
||||
assert "window.close()" in screen_source
|
||||
assert 'window.addEventListener("blur", notePageLeft)' not in screen_source
|
||||
assert "CLOSE_ATTEMPT_DELAY_MS = 2500" in screen_source
|
||||
assert "if (pageLeft || document.hidden) tryCloseWindow();" in screen_source
|
||||
|
||||
launch_guard_pos = app_source.index("if (isAppLaunchRoute) return;")
|
||||
boot_pos = app_source.index("boot();", launch_guard_pos)
|
||||
|
||||
Reference in New Issue
Block a user