feat: add install guide share tokens and animations
This commit is contained in:
@@ -32,12 +32,14 @@ async def subscription_guides_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
|
|
||||||
async def public_subscription_guides_route(request: web.Request) -> web.Response:
|
async def public_subscription_guides_route(request: web.Request) -> web.Response:
|
||||||
short_uuid = _normalize_short_uuid(request.match_info.get("short_uuid"))
|
share_token = subscription_dal.normalize_install_share_token(
|
||||||
if not short_uuid:
|
request.match_info.get("share_token")
|
||||||
return web.json_response({"ok": False, "error": "invalid_short_uuid"}, status=404)
|
)
|
||||||
|
if not share_token:
|
||||||
|
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
|
||||||
|
|
||||||
status = await _subscription_guides_status_shared(request.app)
|
status = await _subscription_guides_status_shared(request.app)
|
||||||
subscription = await _public_subscription_payload(request, short_uuid)
|
subscription = await _public_subscription_payload(request, share_token)
|
||||||
payload = {
|
payload = {
|
||||||
"enabled": bool(status.get("enabled")),
|
"enabled": bool(status.get("enabled")),
|
||||||
"config": status.get("config") if status.get("enabled") else None,
|
"config": status.get("config") if status.get("enabled") else None,
|
||||||
@@ -160,19 +162,19 @@ async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> st
|
|||||||
|
|
||||||
async def _public_subscription_payload(
|
async def _public_subscription_payload(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
short_uuid: str,
|
share_token: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
panel_service = _panel_service_from_app(request.app)
|
panel_service = _panel_service_from_app(request.app)
|
||||||
raw_link = ""
|
raw_link = ""
|
||||||
username = ""
|
username = ""
|
||||||
resolved_short_uuid = short_uuid
|
resolved_short_uuid = ""
|
||||||
|
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
local_sub = await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
local_sub = await subscription_dal.get_subscription_by_install_share_token(
|
||||||
session,
|
session,
|
||||||
short_uuid,
|
share_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -185,16 +187,17 @@ async def _public_subscription_payload(
|
|||||||
if panel_user:
|
if panel_user:
|
||||||
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
|
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
|
||||||
username = str(panel_user.get("username") or "").strip()
|
username = str(panel_user.get("username") or "").strip()
|
||||||
resolved_short_uuid = str(panel_user.get("shortUuid") or short_uuid).strip()
|
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
|
||||||
|
|
||||||
display_link, connect_url = await prepare_config_links(settings, raw_link)
|
display_link, connect_url = await prepare_config_links(settings, raw_link)
|
||||||
return {
|
return {
|
||||||
"active": bool(display_link),
|
"active": bool(display_link),
|
||||||
"config_link": display_link,
|
"config_link": display_link,
|
||||||
"connect_url": connect_url or display_link,
|
"connect_url": connect_url or display_link,
|
||||||
"panel_short_uuid": resolved_short_uuid,
|
"panel_short_uuid": resolved_short_uuid or None,
|
||||||
|
"install_share_token": share_token,
|
||||||
"username": username,
|
"username": username,
|
||||||
"share_url": _public_install_url(request, resolved_short_uuid),
|
"share_url": _public_install_url(request, share_token),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -231,14 +234,7 @@ def _local_subscription_is_publicly_active(subscription: Any) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_short_uuid(value: Any) -> str:
|
def _public_install_url(request: web.Request, share_token: str) -> str:
|
||||||
short_uuid = str(value or "").strip()
|
|
||||||
if not re.fullmatch(r"[A-Za-z0-9_-]{8,128}", short_uuid):
|
|
||||||
return ""
|
|
||||||
return short_uuid
|
|
||||||
|
|
||||||
|
|
||||||
def _public_install_url(request: web.Request, short_uuid: str) -> str:
|
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||||
if configured_base:
|
if configured_base:
|
||||||
@@ -255,7 +251,7 @@ def _public_install_url(request: web.Request, short_uuid: str) -> str:
|
|||||||
)
|
)
|
||||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||||
base = f"{proto}://{host}"
|
base = f"{proto}://{host}"
|
||||||
return f"{base.rstrip('/')}/install/share/{quote(short_uuid)}"
|
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||||
|
|
||||||
|
|
||||||
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
|
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
app.router.add_get("/login/password", index_route)
|
app.router.add_get("/login/password", index_route)
|
||||||
app.router.add_get("/home", index_route)
|
app.router.add_get("/home", index_route)
|
||||||
app.router.add_get("/install", index_route)
|
app.router.add_get("/install", index_route)
|
||||||
app.router.add_get(r"/install/share/{short_uuid:[A-Za-z0-9_-]{8,128}}", index_route)
|
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||||
app.router.add_get("/invite", index_route)
|
app.router.add_get("/invite", index_route)
|
||||||
app.router.add_get("/devices", index_route)
|
app.router.add_get("/devices", index_route)
|
||||||
app.router.add_get("/settings", index_route)
|
app.router.add_get("/settings", index_route)
|
||||||
@@ -64,7 +64,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
app.router.add_get("/api/me", me_route)
|
app.router.add_get("/api/me", me_route)
|
||||||
app.router.add_get("/api/subscription-guides", subscription_guides_route)
|
app.router.add_get("/api/subscription-guides", subscription_guides_route)
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
r"/api/subscription-guides/public/{short_uuid:[A-Za-z0-9_-]{8,128}}",
|
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
|
||||||
public_subscription_guides_route,
|
public_subscription_guides_route,
|
||||||
)
|
)
|
||||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
if db_user.panel_user_uuid
|
if db_user.panel_user_uuid
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
install_share_token = (
|
||||||
|
await subscription_dal.ensure_install_share_token(session, local_sub)
|
||||||
|
if active and local_sub
|
||||||
|
else None
|
||||||
|
)
|
||||||
trial_available = bool(
|
trial_available = bool(
|
||||||
settings.TRIAL_ENABLED
|
settings.TRIAL_ENABLED
|
||||||
and settings.TRIAL_DURATION_DAYS > 0
|
and settings.TRIAL_DURATION_DAYS > 0
|
||||||
@@ -83,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
"language_code": lang,
|
"language_code": lang,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
},
|
},
|
||||||
"subscription": _serialize_subscription(request, settings, active, local_sub, lang),
|
"subscription": _serialize_subscription(
|
||||||
|
request,
|
||||||
|
settings,
|
||||||
|
active,
|
||||||
|
local_sub,
|
||||||
|
lang,
|
||||||
|
install_share_token=install_share_token,
|
||||||
|
),
|
||||||
"referral": {
|
"referral": {
|
||||||
"code": referral_code,
|
"code": referral_code,
|
||||||
"bot_link": referral_link,
|
"bot_link": referral_link,
|
||||||
@@ -181,12 +193,26 @@ def _build_webapp_referral_link(
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_subscription(
|
def _serialize_subscription(
|
||||||
request: web.Request,
|
request_or_settings: Any,
|
||||||
settings: Settings,
|
settings_or_active: Any,
|
||||||
active: Optional[Dict[str, Any]],
|
active_or_local_sub: Optional[Any] = None,
|
||||||
local_sub: Optional[Any],
|
local_sub_or_lang: Optional[Any] = None,
|
||||||
lang: str,
|
lang: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
install_share_token: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
if lang is None:
|
||||||
|
request = None
|
||||||
|
settings = request_or_settings
|
||||||
|
active = settings_or_active
|
||||||
|
local_sub = active_or_local_sub
|
||||||
|
lang = str(local_sub_or_lang or "ru")
|
||||||
|
else:
|
||||||
|
request = request_or_settings
|
||||||
|
settings = settings_or_active
|
||||||
|
active = active_or_local_sub
|
||||||
|
local_sub = local_sub_or_lang
|
||||||
|
|
||||||
if not active:
|
if not active:
|
||||||
return {
|
return {
|
||||||
"active": False,
|
"active": False,
|
||||||
@@ -196,6 +222,7 @@ def _serialize_subscription(
|
|||||||
"config_link": None,
|
"config_link": None,
|
||||||
"connect_url": None,
|
"connect_url": None,
|
||||||
"panel_short_uuid": None,
|
"panel_short_uuid": None,
|
||||||
|
"install_share_token": None,
|
||||||
"install_share_url": None,
|
"install_share_url": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +264,9 @@ def _serialize_subscription(
|
|||||||
can_topup_devices = False
|
can_topup_devices = False
|
||||||
|
|
||||||
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
|
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
|
||||||
|
share_token = str(
|
||||||
|
install_share_token or getattr(local_sub, "install_share_token", "") or ""
|
||||||
|
).strip()
|
||||||
return {
|
return {
|
||||||
"active": seconds_left > 0,
|
"active": seconds_left > 0,
|
||||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||||
@@ -247,7 +277,8 @@ def _serialize_subscription(
|
|||||||
"config_link": active.get("config_link"),
|
"config_link": active.get("config_link"),
|
||||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||||
"panel_short_uuid": panel_short_uuid or None,
|
"panel_short_uuid": panel_short_uuid or None,
|
||||||
"install_share_url": _build_install_share_link(request, settings, panel_short_uuid),
|
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
|
||||||
|
"install_share_url": _build_install_share_link(request, settings, share_token),
|
||||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
||||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||||
@@ -293,12 +324,12 @@ def _serialize_subscription(
|
|||||||
|
|
||||||
|
|
||||||
def _build_install_share_link(
|
def _build_install_share_link(
|
||||||
request: web.Request,
|
request: Optional[web.Request],
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
short_uuid: str,
|
share_token: str,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
short_uuid = str(short_uuid or "").strip()
|
share_token = subscription_dal.normalize_install_share_token(share_token)
|
||||||
if not short_uuid:
|
if not share_token or request is None:
|
||||||
return None
|
return None
|
||||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||||
if configured_base:
|
if configured_base:
|
||||||
@@ -315,7 +346,7 @@ def _build_install_share_link(
|
|||||||
)
|
)
|
||||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||||
base = f"{proto}://{host}"
|
base = f"{proto}://{host}"
|
||||||
return f"{base.rstrip('/')}/install/share/{quote(short_uuid)}"
|
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||||
|
|
||||||
|
|
||||||
def _serialize_plans(
|
def _serialize_plans(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
|
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||||
|
|
||||||
|
|
||||||
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
|
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
model_columns = Subscription.__mapper__.columns.keys()
|
model_columns = Subscription.__mapper__.columns.keys()
|
||||||
@@ -42,6 +46,77 @@ async def get_subscription_by_panel_subscription_uuid(
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_install_share_token(value: Any) -> str:
|
||||||
|
token = str(value or "").strip().lower()
|
||||||
|
if not re.fullmatch(r"[a-f0-9]{32}", token):
|
||||||
|
return ""
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subscription_by_install_share_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
token: str,
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
normalized = normalize_install_share_token(token)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
stmt = select(Subscription).where(Subscription.install_share_token == normalized)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_install_share_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription: Subscription,
|
||||||
|
) -> str:
|
||||||
|
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
|
||||||
|
existing = normalize_install_share_token(raw_existing)
|
||||||
|
if existing:
|
||||||
|
if existing != getattr(subscription, "install_share_token", None):
|
||||||
|
subscription.install_share_token = existing
|
||||||
|
await session.flush()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
subscription_id = getattr(subscription, "subscription_id", None)
|
||||||
|
for _attempt in range(10):
|
||||||
|
token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES)
|
||||||
|
if await get_subscription_by_install_share_token(session, token):
|
||||||
|
continue
|
||||||
|
if subscription_id:
|
||||||
|
result = await session.execute(
|
||||||
|
update(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.subscription_id == subscription_id,
|
||||||
|
or_(
|
||||||
|
Subscription.install_share_token.is_(None),
|
||||||
|
Subscription.install_share_token == "",
|
||||||
|
Subscription.install_share_token == raw_existing,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(install_share_token=token)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
if result.rowcount:
|
||||||
|
await session.refresh(subscription)
|
||||||
|
return normalize_install_share_token(
|
||||||
|
getattr(subscription, "install_share_token", None)
|
||||||
|
) or token
|
||||||
|
|
||||||
|
await session.refresh(subscription)
|
||||||
|
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
|
||||||
|
existing = normalize_install_share_token(raw_existing)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
continue
|
||||||
|
|
||||||
|
subscription.install_share_token = token
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(subscription)
|
||||||
|
return token
|
||||||
|
|
||||||
|
raise RuntimeError("Failed to generate a unique install share token")
|
||||||
|
|
||||||
|
|
||||||
async def get_active_subscriptions_for_user(
|
async def get_active_subscriptions_for_user(
|
||||||
session: AsyncSession, user_id: int
|
session: AsyncSession, user_id: int
|
||||||
) -> List[Subscription]:
|
) -> List[Subscription]:
|
||||||
|
|||||||
@@ -882,6 +882,26 @@ def _migration_0026_add_lifetime_traffic_synced_at(connection: Connection) -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0027_add_subscription_install_share_token(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
|
||||||
|
|
||||||
|
if "install_share_token" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE subscriptions ADD COLUMN install_share_token VARCHAR(32)")
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscriptions_install_share_token
|
||||||
|
ON subscriptions (install_share_token)
|
||||||
|
WHERE install_share_token IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -1024,6 +1044,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Track when lifetime traffic usage was last synced from panel",
|
description="Track when lifetime traffic usage was last synced from panel",
|
||||||
upgrade=_migration_0026_add_lifetime_traffic_synced_at,
|
upgrade=_migration_0026_add_lifetime_traffic_synced_at,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0027_add_subscription_install_share_token",
|
||||||
|
description="Add stable public share tokens for install instructions",
|
||||||
|
upgrade=_migration_0027_add_subscription_install_share_token,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class Subscription(Base):
|
|||||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||||
panel_user_uuid = Column(String, nullable=False, index=True)
|
panel_user_uuid = Column(String, nullable=False, index=True)
|
||||||
panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True)
|
panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True)
|
||||||
|
install_share_token = Column(String(32), unique=True, index=True, nullable=True)
|
||||||
start_date = Column(DateTime(timezone=True), nullable=True)
|
start_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
end_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
end_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
duration_months = Column(Integer, nullable=True)
|
duration_months = Column(Integer, nullable=True)
|
||||||
|
|||||||
+14
-15
@@ -73,7 +73,7 @@
|
|||||||
adminSectionFromPath,
|
adminSectionFromPath,
|
||||||
adminUserIdFromPath,
|
adminUserIdFromPath,
|
||||||
normalizeSection,
|
normalizeSection,
|
||||||
publicInstallShortUuidFromPath,
|
publicInstallTokenFromPath,
|
||||||
sectionFromPath,
|
sectionFromPath,
|
||||||
supportTicketIdFromPath,
|
supportTicketIdFromPath,
|
||||||
syncSectionPath,
|
syncSectionPath,
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
let screen = "home";
|
let screen = "home";
|
||||||
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
|
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
|
||||||
let publicInstallSubscription = null;
|
let publicInstallSubscription = null;
|
||||||
let publicInstallShortUuid = "";
|
let publicInstallToken = "";
|
||||||
let trialBusy = false;
|
let trialBusy = false;
|
||||||
let promoCode = "";
|
let promoCode = "";
|
||||||
let promoBusy = false;
|
let promoBusy = false;
|
||||||
@@ -497,9 +497,9 @@
|
|||||||
if (mode === "login") loginEmailTooltipOpen = false;
|
if (mode === "login") loginEmailTooltipOpen = false;
|
||||||
};
|
};
|
||||||
const onPopState = () => {
|
const onPopState = () => {
|
||||||
const publicShortUuid = publicInstallShortUuidFromPath(window.location.pathname);
|
const shareToken = publicInstallTokenFromPath(window.location.pathname);
|
||||||
if (publicShortUuid) {
|
if (shareToken) {
|
||||||
void loadPublicInstall(publicShortUuid);
|
void loadPublicInstall(shareToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "publicInstall") {
|
if (mode === "publicInstall") {
|
||||||
@@ -802,9 +802,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
const shareShortUuid = publicInstallShortUuidFromPath(window.location.pathname);
|
const shareToken = publicInstallTokenFromPath(window.location.pathname);
|
||||||
if (!MOCK && shareShortUuid) {
|
if (shareToken) {
|
||||||
await loadPublicInstall(shareShortUuid);
|
await loadPublicInstall(shareToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runWebappBoot({
|
await runWebappBoot({
|
||||||
@@ -987,17 +987,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPublicInstall(shortUuid) {
|
async function loadPublicInstall(shareToken) {
|
||||||
mode = "publicInstall";
|
mode = "publicInstall";
|
||||||
screen = "install";
|
screen = "install";
|
||||||
activeTab = "home";
|
activeTab = "home";
|
||||||
publicInstallShortUuid = shortUuid;
|
publicInstallToken = shareToken;
|
||||||
publicInstallSubscription = {
|
publicInstallSubscription = {
|
||||||
panel_short_uuid: shortUuid,
|
install_share_token: shareToken,
|
||||||
share_url:
|
share_url: typeof window !== "undefined" ? `${window.location.origin}/s/${shareToken}` : "",
|
||||||
typeof window !== "undefined" ? `${window.location.origin}/install/share/${shortUuid}` : "",
|
|
||||||
};
|
};
|
||||||
const response = await installGuidesStore.loadPublic(shortUuid, true);
|
const response = await installGuidesStore.loadPublic(shareToken, true);
|
||||||
publicInstallSubscription = response?.subscription || publicInstallSubscription;
|
publicInstallSubscription = response?.subscription || publicInstallSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,7 +1361,7 @@
|
|||||||
{currentLang}
|
{currentLang}
|
||||||
telegramPlatform={tg?.platform || ""}
|
telegramPlatform={tg?.platform || ""}
|
||||||
user={{}}
|
user={{}}
|
||||||
subscription={publicInstallSubscription || { panel_short_uuid: publicInstallShortUuid }}
|
subscription={publicInstallSubscription || { install_share_token: publicInstallToken }}
|
||||||
{goHome}
|
{goHome}
|
||||||
{openConnectLink}
|
{openConnectLink}
|
||||||
{openExternalLink}
|
{openExternalLink}
|
||||||
|
|||||||
@@ -606,9 +606,13 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
if (path === "/me") return clone(DEV_MOCK.data);
|
if (path === "/me") return clone(DEV_MOCK.data);
|
||||||
if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides);
|
if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides);
|
||||||
if (cleanPath.startsWith("/subscription-guides/public/")) {
|
if (cleanPath.startsWith("/subscription-guides/public/")) {
|
||||||
|
const shareToken = decodeURIComponent(cleanPath.split("/").pop() || "");
|
||||||
|
const subscription = clone(DEV_MOCK.data.subscription);
|
||||||
|
subscription.install_share_token = shareToken;
|
||||||
|
subscription.share_url = `${window.location.origin}/s/${shareToken}`;
|
||||||
return {
|
return {
|
||||||
...clone(DEV_MOCK.data.subscription_guides),
|
...clone(DEV_MOCK.data.subscription_guides),
|
||||||
subscription: clone(DEV_MOCK.data.subscription),
|
subscription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (path === "/auth/email/request") return { ok: true };
|
if (path === "/auth/email/request") return { ok: true };
|
||||||
|
|||||||
@@ -276,7 +276,8 @@ export const DEV_MOCK = {
|
|||||||
config_link: "https://sub.example.com/sub/preview-token",
|
config_link: "https://sub.example.com/sub/preview-token",
|
||||||
connect_url: "https://sub.example.com/connect/preview-token",
|
connect_url: "https://sub.example.com/connect/preview-token",
|
||||||
panel_short_uuid: "preview-token",
|
panel_short_uuid: "preview-token",
|
||||||
install_share_url: "https://app.example.com/install/share/preview-token",
|
install_share_token: "8f559061460e8fede78ef18dce887236",
|
||||||
|
install_share_url: "https://app.example.com/s/8f559061460e8fede78ef18dce887236",
|
||||||
traffic_used: "18.4 GB",
|
traffic_used: "18.4 GB",
|
||||||
traffic_limit: "100 GB",
|
traffic_limit: "100 GB",
|
||||||
traffic_used_bytes: 19756849561,
|
traffic_used_bytes: 19756849561,
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export function sectionFromPath(pathname) {
|
|||||||
return normalizeSection(section);
|
return normalizeSection(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicInstallShortUuidFromPath(pathname) {
|
export function publicInstallTokenFromPath(pathname) {
|
||||||
const normalized = String(pathname || "").trim().replace(/\/+$/, "");
|
const normalized = String(pathname || "").trim().replace(/\/+$/, "");
|
||||||
const match = normalized.match(/^\/install\/share\/([A-Za-z0-9_-]{8,128})$/);
|
const match = normalized.match(/^\/s\/([a-f0-9]{32})$/i);
|
||||||
return match ? match[1] : "";
|
return match ? match[1].toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminSectionFromPath(pathname) {
|
export function adminSectionFromPath(pathname) {
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export function createInstallGuidesStore({ api, t, showToast }) {
|
|||||||
return fetchGuides("/subscription-guides", force);
|
return fetchGuides("/subscription-guides", force);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPublic(shortUuid, force = false) {
|
async function loadPublic(shareToken, force = false) {
|
||||||
const encoded = encodeURIComponent(String(shortUuid || ""));
|
const encoded = encodeURIComponent(String(shareToken || ""));
|
||||||
return fetchGuides(`/subscription-guides/public/${encoded}`, force);
|
return fetchGuides(`/subscription-guides/public/${encoded}`, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import { fade, fly, scale } from "svelte/transition";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -346,8 +348,14 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
|
{#key selectedPlatformKey}
|
||||||
{#if apps.length > 1}
|
{#if apps.length > 1}
|
||||||
<section class="install-selector-block" aria-label={t("wa_install_app", {}, "App")}>
|
<section
|
||||||
|
class="install-selector-block"
|
||||||
|
aria-label={t("wa_install_app", {}, "App")}
|
||||||
|
in:fly={{ y: 8, duration: 180, easing: cubicOut }}
|
||||||
|
out:fade={{ duration: 90 }}
|
||||||
|
>
|
||||||
<div class="install-section-title">
|
<div class="install-section-title">
|
||||||
<span>{t("wa_install_app", {}, "App")}</span>
|
<span>{t("wa_install_app", {}, "App")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,13 +365,18 @@
|
|||||||
class:apps-remainder-one={apps.length % 3 === 1}
|
class:apps-remainder-one={apps.length % 3 === 1}
|
||||||
class:apps-remainder-two={apps.length % 3 === 2}
|
class:apps-remainder-two={apps.length % 3 === 2}
|
||||||
>
|
>
|
||||||
{#each apps as app, index}
|
{#each apps as app, index (`${selectedPlatformKey}:${app.name}:${index}`)}
|
||||||
<button
|
<button
|
||||||
class="install-app-button attention-wrap"
|
class="install-app-button attention-wrap"
|
||||||
class:active={selectedAppIndex === index}
|
class:active={selectedAppIndex === index}
|
||||||
class:featured={app.featured}
|
class:featured={app.featured}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedAppIndex = index)}
|
onclick={() => (selectedAppIndex = index)}
|
||||||
|
in:scale={{
|
||||||
|
start: 0.97,
|
||||||
|
duration: 150 + Math.min(index, 5) * 18,
|
||||||
|
easing: cubicOut,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if app.featured}
|
{#if app.featured}
|
||||||
<AttentionDot class="install-feature-star" />
|
<AttentionDot class="install-feature-star" />
|
||||||
@@ -377,10 +390,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
{#if selectedApp}
|
{#if selectedApp}
|
||||||
<section class="install-steps" aria-label={selectedApp.name}>
|
{#key `${selectedPlatformKey}:${selectedAppIndex}:${selectedApp.name}`}
|
||||||
{#each selectedApp.blocks as block}
|
<section
|
||||||
|
class="install-steps"
|
||||||
|
aria-label={selectedApp.name}
|
||||||
|
in:fly={{ y: 12, duration: 190, easing: cubicOut }}
|
||||||
|
out:fade={{ duration: 90 }}
|
||||||
|
>
|
||||||
|
{#each selectedApp.blocks as block, blockIndex (`${selectedPlatformKey}:${selectedApp.name}:${blockIndex}:${localized(block.title)}`)}
|
||||||
|
<div
|
||||||
|
class="install-step-motion"
|
||||||
|
in:fly={{
|
||||||
|
y: 10,
|
||||||
|
duration: 170 + Math.min(blockIndex, 6) * 18,
|
||||||
|
easing: cubicOut,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card class="install-step">
|
<Card class="install-step">
|
||||||
<div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true">
|
<div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true">
|
||||||
{#if iconSvg(block.svgIconKey)}
|
{#if iconSvg(block.svgIconKey)}
|
||||||
@@ -411,16 +439,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
{/key}
|
||||||
{#if finalSubscriptionLink && !publicMode}
|
{#if finalSubscriptionLink && !publicMode}
|
||||||
<div class="install-qr-divider" aria-hidden="true">
|
<div
|
||||||
|
class="install-qr-divider"
|
||||||
|
aria-hidden="true"
|
||||||
|
in:fade={{ duration: 180 }}
|
||||||
|
>
|
||||||
<svg viewBox="0 0 240 18" preserveAspectRatio="none">
|
<svg viewBox="0 0 240 18" preserveAspectRatio="none">
|
||||||
<path
|
<path
|
||||||
d="M0 9 Q 4 2 8 9 T 16 9 T 24 9 T 32 9 T 40 9 T 48 9 T 56 9 T 64 9 T 72 9 T 80 9 T 88 9 T 96 9 T 104 9 T 112 9 T 120 9 T 128 9 T 136 9 T 144 9 T 152 9 T 160 9 T 168 9 T 176 9 T 184 9 T 192 9 T 200 9 T 208 9 T 216 9 T 224 9 T 232 9 T 240 9"
|
d="M0 9 Q 4 2 8 9 T 16 9 T 24 9 T 32 9 T 40 9 T 48 9 T 56 9 T 64 9 T 72 9 T 80 9 T 88 9 T 96 9 T 104 9 T 112 9 T 120 9 T 128 9 T 136 9 T 144 9 T 152 9 T 160 9 T 168 9 T 176 9 T 184 9 T 192 9 T 200 9 T 208 9 T 216 9 T 224 9 T 232 9 T 240 9"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="install-subscription-motion"
|
||||||
|
in:fly={{ y: 10, duration: 200, easing: cubicOut }}
|
||||||
|
>
|
||||||
<Card class="install-subscription-card">
|
<Card class="install-subscription-card">
|
||||||
<div class="install-subscription-header">
|
<div class="install-subscription-header">
|
||||||
<div class="install-subscription-header-icon" aria-hidden="true">
|
<div class="install-subscription-header-icon" aria-hidden="true">
|
||||||
@@ -433,7 +471,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="install-subscription-body">
|
<div class="install-subscription-body">
|
||||||
{#if qrDataUrl}
|
{#if qrDataUrl}
|
||||||
<div class="install-qr-wrap">
|
<div class="install-qr-wrap" in:scale={{ start: 0.96, duration: 180, easing: cubicOut }}>
|
||||||
<img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
|
<img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -449,6 +487,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -551,11 +590,31 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.install-apps button.active {
|
.install-apps button.active {
|
||||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
|
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||||
|
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-apps button:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-apps button:active {
|
||||||
|
transform: translateY(0) scale(0.99);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.install-platform-trigger) {
|
:global(.install-platform-trigger) {
|
||||||
@@ -703,10 +762,24 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.install-step-motion {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.install-step) {
|
:global(.install-step) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.install-step:hover) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.install-qr-divider {
|
.install-qr-divider {
|
||||||
@@ -736,6 +809,20 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-subscription-motion {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.install-subscription-card:hover) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.install-subscription-header {
|
.install-subscription-header {
|
||||||
@@ -878,6 +965,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.install-apps button:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--text) 4%, var(--panel));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-apps button.active:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, var(--panel));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.install-apps button,
|
||||||
|
:global(.install-step),
|
||||||
|
:global(.install-subscription-card) {
|
||||||
|
transition: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-apps button.active,
|
||||||
|
.install-apps button:active,
|
||||||
|
.install-apps button:hover,
|
||||||
|
.install-apps button.active:hover,
|
||||||
|
:global(.install-step:hover),
|
||||||
|
:global(.install-subscription-card:hover) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.install-topbar {
|
.install-topbar {
|
||||||
grid-template-columns: auto minmax(0, 1fr) minmax(260px, 340px);
|
grid-template-columns: auto minmax(0, 1fr) minmax(260px, 340px);
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
async def test_public_route_returns_shared_config_and_subscription_payload(self):
|
async def test_public_route_returns_shared_config_and_subscription_payload(self):
|
||||||
default_uuid = "00000000-0000-0000-0000-000000000000"
|
default_uuid = "00000000-0000-0000-0000-000000000000"
|
||||||
|
share_token = "8f559061460e8fede78ef18dce887236"
|
||||||
panel_config = json.loads(default_subscription_guides_config_text())
|
panel_config = json.loads(default_subscription_guides_config_text())
|
||||||
panel_service = SimpleNamespace(
|
panel_service = SimpleNamespace(
|
||||||
get_subscription_page_config_list=AsyncMock(
|
get_subscription_page_config_list=AsyncMock(
|
||||||
@@ -179,17 +180,18 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
request = self._request(
|
request = self._request(
|
||||||
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
|
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
|
||||||
panel_service,
|
panel_service,
|
||||||
match_info={"short_uuid": "share-short"},
|
match_info={"share_token": share_token},
|
||||||
)
|
)
|
||||||
local_sub = SimpleNamespace(
|
local_sub = SimpleNamespace(
|
||||||
panel_user_uuid="panel-user",
|
panel_user_uuid="panel-user",
|
||||||
|
install_share_token=share_token,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
end_date=datetime.now(timezone.utc) + timedelta(days=3),
|
end_date=datetime.now(timezone.utc) + timedelta(days=3),
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
guides.subscription_dal,
|
guides.subscription_dal,
|
||||||
"get_subscription_by_panel_subscription_uuid",
|
"get_subscription_by_install_share_token",
|
||||||
AsyncMock(return_value=local_sub),
|
AsyncMock(return_value=local_sub),
|
||||||
):
|
):
|
||||||
response = await guides.public_subscription_guides_route(request)
|
response = await guides.public_subscription_guides_route(request)
|
||||||
@@ -199,8 +201,9 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(body["subscription"]["config_link"], "https://sb.example.test/share-short")
|
self.assertEqual(body["subscription"]["config_link"], "https://sb.example.test/share-short")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
body["subscription"]["share_url"],
|
body["subscription"]["share_url"],
|
||||||
"https://app.example.test/install/share/share-short",
|
f"https://app.example.test/s/{share_token}",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(body["subscription"]["install_share_token"], share_token)
|
||||||
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-user")
|
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-user")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("GET", "/login/password"): "index_route",
|
("GET", "/login/password"): "index_route",
|
||||||
("GET", "/home"): "index_route",
|
("GET", "/home"): "index_route",
|
||||||
("GET", "/install"): "index_route",
|
("GET", "/install"): "index_route",
|
||||||
("GET", "/install/share/{short_uuid}"): "index_route",
|
("GET", "/s/{share_token}"): "index_route",
|
||||||
("GET", "/invite"): "index_route",
|
("GET", "/invite"): "index_route",
|
||||||
("GET", "/devices"): "index_route",
|
("GET", "/devices"): "index_route",
|
||||||
("GET", "/settings"): "index_route",
|
("GET", "/settings"): "index_route",
|
||||||
@@ -82,7 +82,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("GET", "/api/subscription-guides"): "subscription_guides_route",
|
("GET", "/api/subscription-guides"): "subscription_guides_route",
|
||||||
(
|
(
|
||||||
"GET",
|
"GET",
|
||||||
"/api/subscription-guides/public/{short_uuid}",
|
"/api/subscription-guides/public/{share_token}",
|
||||||
): "public_subscription_guides_route",
|
): "public_subscription_guides_route",
|
||||||
("GET", "/api/account/avatar"): "account_avatar_route",
|
("GET", "/api/account/avatar"): "account_avatar_route",
|
||||||
("POST", "/api/account/language"): "account_language_route",
|
("POST", "/api/account/language"): "account_language_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user