feat: email and password login
This commit is contained in:
@@ -41,7 +41,7 @@ from bot.app.web.webapp_auth import (
|
||||
verify_telegram_oauth_nonce,
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
@@ -51,7 +51,7 @@ from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
|
||||
@@ -103,6 +103,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/email/request",
|
||||
"/api/auth/email/verify",
|
||||
"/api/auth/email/magic",
|
||||
"/api/auth/email/password",
|
||||
"/api/auth/logout",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _hash_email_password
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
@@ -201,6 +202,84 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||
|
||||
|
||||
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
email = db_user.email
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
|
||||
return await _request_email_code(
|
||||
request,
|
||||
email=email,
|
||||
purpose="set_password",
|
||||
language_code=lang,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
if password_payload.password != password_payload.password_confirm:
|
||||
return _json_error(400, "password_mismatch", "Passwords do not match")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
await session.rollback()
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
|
||||
verify_result = await email_service.verify_code(
|
||||
session,
|
||||
email=db_user.email,
|
||||
purpose="set_password",
|
||||
code=str(password_payload.code or ""),
|
||||
target_user_id=user_id,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
db_user.password_hash = _hash_email_password(str(password_payload.password))
|
||||
db_user.password_set_at = datetime.now(timezone.utc)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password setup failed")
|
||||
return _json_error(500, "password_setup_failed", "Password setup failed")
|
||||
|
||||
await cache_delete(settings, redis_key(settings, "cache", "webapp", "me", user_id))
|
||||
return web.json_response({"ok": True, "password_auth_enabled": True})
|
||||
|
||||
|
||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
@@ -132,6 +132,58 @@ def _urlsafe_sha256(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
|
||||
PASSWORD_HASH_ITERATIONS = 260_000
|
||||
|
||||
|
||||
def _password_hash_b64(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _password_hash_unb64(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
||||
|
||||
|
||||
def _hash_email_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(18)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
PASSWORD_HASH_ITERATIONS,
|
||||
)
|
||||
return "$".join(
|
||||
[
|
||||
PASSWORD_HASH_ALGORITHM,
|
||||
str(PASSWORD_HASH_ITERATIONS),
|
||||
_password_hash_b64(salt),
|
||||
_password_hash_b64(digest),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool:
|
||||
if not stored_hash:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3)
|
||||
if algorithm != PASSWORD_HASH_ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_raw)
|
||||
salt = _password_hash_unb64(salt_raw)
|
||||
expected_digest = _password_hash_unb64(digest_raw)
|
||||
actual_digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return hmac.compare_digest(actual_digest, expected_digest)
|
||||
|
||||
|
||||
async def _exchange_telegram_oauth_code(
|
||||
request: web.Request,
|
||||
*,
|
||||
@@ -438,6 +490,114 @@ async def logout_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _password_login_failure_response(
|
||||
*,
|
||||
status: int = 401,
|
||||
retry_after: Optional[int] = None,
|
||||
) -> web.Response:
|
||||
payload: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": "password_login_failed",
|
||||
"fallback": "email_code",
|
||||
"message": "Password login failed",
|
||||
}
|
||||
if retry_after is not None:
|
||||
payload["retry_after"] = retry_after
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def email_password_auth_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.email_auth_configured:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(
|
||||
WebAppEmailPasswordPayload,
|
||||
payload,
|
||||
)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
email = password_payload.email
|
||||
password = str(password_payload.password or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
authenticated_user_id: Optional[int] = None
|
||||
authenticated_telegram_id: Optional[int] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
await session.commit()
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_email(session, email)
|
||||
password_ok = bool(
|
||||
db_user
|
||||
and db_user.email_verified_at
|
||||
and db_user.password_hash
|
||||
and _verify_email_password(password, db_user.password_hash)
|
||||
)
|
||||
|
||||
if not password_ok:
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
max_failures=settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.commit()
|
||||
if throttle_result.locked:
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
return _password_login_failure_response()
|
||||
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
)
|
||||
authenticated_user_id = int(db_user.user_id)
|
||||
authenticated_telegram_id = _telegram_id_for_user(db_user)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(
|
||||
settings,
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": int(authenticated_user_id),
|
||||
"telegram_id": authenticated_telegram_id,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
@@ -37,6 +37,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
if field in {"description", "comment", "note"} and error_type == "string_too_long":
|
||||
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
|
||||
|
||||
if field in {"password", "password_confirm"}:
|
||||
if error_type == "string_too_short":
|
||||
return _json_error(400, "password_too_short", "Password is too short")
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "password_too_long", "Password is too long")
|
||||
return _json_error(400, "invalid_password", "Invalid password")
|
||||
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "text_too_long", "Text is too long")
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||
code: str = ""
|
||||
|
||||
|
||||
class WebAppEmailPasswordPayload(WebAppEmailPayload):
|
||||
password: constr(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WebAppSetPasswordPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
password: constr(min_length=8, max_length=128)
|
||||
password_confirm: constr(min_length=8, max_length=128)
|
||||
code: constr(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class WebAppEmailMagicPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
@@ -43,6 +44,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/email/password", email_password_auth_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
@@ -50,6 +52,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
|
||||
@@ -67,6 +67,9 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"email_verified": bool(db_user.email_verified_at),
|
||||
"password_auth_enabled": bool(
|
||||
db_user.email and db_user.email_verified_at and db_user.password_hash
|
||||
),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
|
||||
@@ -196,7 +196,11 @@ class EmailAuthService:
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
magic_token = secrets.token_urlsafe(32)
|
||||
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
|
||||
magic_link = (
|
||||
self._build_magic_link(token=magic_token, purpose=purpose)
|
||||
if purpose == "login"
|
||||
else None
|
||||
)
|
||||
code_model = EmailVerificationCode(
|
||||
email=normalized_email,
|
||||
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||
@@ -214,6 +218,7 @@ class EmailAuthService:
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
)
|
||||
return EmailCodeRequestResult(ok=True)
|
||||
|
||||
@@ -405,6 +410,7 @@ class EmailAuthService:
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
purpose: str = "login",
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_code_email_sync,
|
||||
@@ -412,6 +418,7 @@ class EmailAuthService:
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
async def send_custom_email(
|
||||
@@ -450,12 +457,14 @@ class EmailAuthService:
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
purpose: str = "login",
|
||||
) -> None:
|
||||
content = render_login_code(
|
||||
self.settings,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
|
||||
@@ -209,6 +209,7 @@ def render_login_code(
|
||||
code: str,
|
||||
language_code: Optional[str],
|
||||
magic_link: Optional[str] = None,
|
||||
purpose: str = "login",
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
@@ -216,16 +217,17 @@ def render_login_code(
|
||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_magic_link = (magic_link or "").strip()
|
||||
template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code"
|
||||
safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else ""
|
||||
|
||||
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
|
||||
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
|
||||
heading = _t_text(i18n, lang, "email_login_code_heading")
|
||||
intro = _t_text(i18n, lang, "email_login_code_intro")
|
||||
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
|
||||
security = _t_text(i18n, lang, "email_login_code_security")
|
||||
subject = _t_text(i18n, lang, f"{template_prefix}_subject", code=code)
|
||||
preheader = _t_text(i18n, lang, f"{template_prefix}_preheader", minutes=minutes)
|
||||
heading = _t_text(i18n, lang, f"{template_prefix}_heading")
|
||||
intro = _t_text(i18n, lang, f"{template_prefix}_intro")
|
||||
expiry_html = _t_html(i18n, lang, f"{template_prefix}_expiry_html", minutes=minutes)
|
||||
security = _t_text(i18n, lang, f"{template_prefix}_security")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
|
||||
text_lines = [_t_text(i18n, lang, f"{template_prefix}_text", code=code, minutes=minutes)]
|
||||
if safe_magic_link:
|
||||
text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ..models import SecurityThrottle
|
||||
|
||||
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
|
||||
EMAIL_PASSWORD_LOGIN_SCOPE = "email_password_login"
|
||||
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
|
||||
|
||||
|
||||
|
||||
@@ -744,6 +744,16 @@ def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> Non
|
||||
)
|
||||
|
||||
|
||||
def _migration_0023_add_email_password_auth_fields(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
|
||||
if "password_hash" not in columns:
|
||||
connection.execute(text("ALTER TABLE users ADD COLUMN password_hash VARCHAR"))
|
||||
if "password_set_at" not in columns:
|
||||
connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ"))
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -866,6 +876,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Indexes to speed up financial stats and admin log queries",
|
||||
upgrade=_migration_0022_add_indexes_for_admin_reports,
|
||||
),
|
||||
Migration(
|
||||
id="0023_add_email_password_auth_fields",
|
||||
description="Store hashed passwords for optional email password login",
|
||||
upgrade=_migration_0023_add_email_password_auth_fields,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ class User(Base):
|
||||
username = Column(String, nullable=True, index=True)
|
||||
email = Column(String, nullable=True, unique=True, index=True)
|
||||
email_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
password_hash = Column(String, nullable=True)
|
||||
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||
telegram_photo_url = Column(Text, nullable=True)
|
||||
first_name = Column(String, nullable=True)
|
||||
|
||||
+64
-1
@@ -203,6 +203,8 @@
|
||||
telegramLoginBusy,
|
||||
loginEmailFieldError,
|
||||
loginEmailTooltipOpen,
|
||||
passwordLoginFallback,
|
||||
passwordLoginMode,
|
||||
authResendCooldown,
|
||||
pendingEmail,
|
||||
} = $authStore);
|
||||
@@ -239,6 +241,12 @@
|
||||
linkEmailStatus,
|
||||
linkEmailIsError,
|
||||
linkEmailResendCooldown,
|
||||
setPasswordBusy,
|
||||
setPasswordIsError,
|
||||
setPasswordOpen,
|
||||
setPasswordPending,
|
||||
setPasswordResendCooldown,
|
||||
setPasswordStatus,
|
||||
languageBusy,
|
||||
} = $accountStore);
|
||||
|
||||
@@ -388,7 +396,8 @@
|
||||
changeConfirmOpen ||
|
||||
topupModalOpen ||
|
||||
deviceTopupModalOpen ||
|
||||
linkEmailOpen
|
||||
linkEmailOpen ||
|
||||
setPasswordOpen
|
||||
);
|
||||
$: if (!tariffMode && !$billingStore.selectedPlan && plans.length) {
|
||||
billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] }));
|
||||
@@ -451,6 +460,11 @@
|
||||
};
|
||||
const onPopState = () => {
|
||||
const section = sectionFromPath(window.location.pathname);
|
||||
if (mode === "login") {
|
||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||
screen = "login";
|
||||
return;
|
||||
}
|
||||
if (mode === "app") {
|
||||
if (section === "admin" && isAdmin) {
|
||||
screen = "admin";
|
||||
@@ -471,6 +485,7 @@
|
||||
authStore.stopTelegramLoginWatchdog();
|
||||
authStore.clearCooldownTimer();
|
||||
accountStore.clearLinkEmailResendTimer();
|
||||
accountStore.clearSetPasswordResendTimer();
|
||||
clearLanguageClickGuard();
|
||||
syncBodyScrollLock(false);
|
||||
};
|
||||
@@ -580,6 +595,34 @@
|
||||
window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`);
|
||||
}
|
||||
|
||||
function isPasswordLoginPath(pathname = window.location.pathname) {
|
||||
return (
|
||||
String(pathname || "")
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase() === "/login/password"
|
||||
);
|
||||
}
|
||||
|
||||
function syncPasswordLoginPath(enabled, replace = false) {
|
||||
if (typeof window === "undefined" || window.location.protocol === "file:") return;
|
||||
const targetPath = enabled ? "/login/password" : "/";
|
||||
if (window.location.pathname === targetPath) return;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
|
||||
}
|
||||
|
||||
function setPasswordLoginMode(enabled, replace = false) {
|
||||
const nextEnabled = Boolean(enabled);
|
||||
authStore.update((s) => ({
|
||||
...s,
|
||||
passwordLoginMode: nextEnabled,
|
||||
passwordLoginFallback: false,
|
||||
authStatus: "",
|
||||
authIsError: false,
|
||||
}));
|
||||
syncPasswordLoginPath(nextEnabled, replace);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const payload = await api("/me");
|
||||
if (!payload.ok) throw new Error(payload.error || "load_failed");
|
||||
@@ -646,6 +689,7 @@
|
||||
mode = "login";
|
||||
screen = "login";
|
||||
activeTab = "home";
|
||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -960,6 +1004,7 @@
|
||||
{brandTitle}
|
||||
{brand}
|
||||
bind:email={$authStore.email}
|
||||
bind:emailPassword={$authStore.emailPassword}
|
||||
bind:emailCode={$authStore.emailCode}
|
||||
{pendingEmail}
|
||||
{authStatus}
|
||||
@@ -968,6 +1013,8 @@
|
||||
{authResendCooldown}
|
||||
{loginEmailFieldError}
|
||||
{loginEmailTooltipOpen}
|
||||
{passwordLoginFallback}
|
||||
{passwordLoginMode}
|
||||
{telegramLoginBusy}
|
||||
{telegramLoginUnavailable}
|
||||
{telegramLoginChecking}
|
||||
@@ -977,6 +1024,7 @@
|
||||
{userAgreementUrl}
|
||||
{t}
|
||||
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
|
||||
loginWithEmailPassword={authStore.loginWithEmailPassword}
|
||||
verifyEmailCode={authStore.verifyEmailCode}
|
||||
openTelegramLogin={() =>
|
||||
authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)}
|
||||
@@ -987,6 +1035,7 @@
|
||||
loginEmailFieldError = "";
|
||||
loginEmailTooltipOpen = false;
|
||||
}}
|
||||
setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)}
|
||||
/>
|
||||
{:else if screen === "admin" && isAdmin}
|
||||
<AdminPanel
|
||||
@@ -1109,6 +1158,7 @@
|
||||
{openAdminPanel}
|
||||
{openExternalLink}
|
||||
openLinkEmailDialog={accountStore.openLinkEmailDialog}
|
||||
openSetPasswordDialog={accountStore.openSetPasswordDialog}
|
||||
{setLanguageMenuOpen}
|
||||
{t}
|
||||
updateAccountLanguage={accountStore.updateAccountLanguage}
|
||||
@@ -1125,6 +1175,10 @@
|
||||
bind:selectedMethod={$billingStore.selectedMethod}
|
||||
bind:selectedPlan={$billingStore.selectedPlan}
|
||||
bind:selectedTariffKey={$billingStore.selectedTariffKey}
|
||||
bind:setPasswordCode={$accountStore.setPasswordCode}
|
||||
bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
|
||||
bind:setPasswordValue={$accountStore.setPasswordValue}
|
||||
setPasswordEmail={user?.email || ""}
|
||||
createPayment={billingStore.createPayment}
|
||||
{deviceConfirmOpen}
|
||||
{deviceDisconnectBusy}
|
||||
@@ -1136,6 +1190,12 @@
|
||||
{linkEmailPending}
|
||||
{linkEmailResendCooldown}
|
||||
{linkEmailStatus}
|
||||
{setPasswordBusy}
|
||||
{setPasswordIsError}
|
||||
{setPasswordOpen}
|
||||
{setPasswordPending}
|
||||
{setPasswordResendCooldown}
|
||||
{setPasswordStatus}
|
||||
{hasMultipleTariffs}
|
||||
{methods}
|
||||
{payBusy}
|
||||
@@ -1149,13 +1209,16 @@
|
||||
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
|
||||
closeLinkEmailDialog={accountStore.closeLinkEmailDialog}
|
||||
closePaymentModal={billingStore.closePaymentModal}
|
||||
closeSetPasswordDialog={accountStore.closeSetPasswordDialog}
|
||||
{backToTariffList}
|
||||
{continueWithSelectedTariff}
|
||||
requestLinkEmailCode={accountStore.requestLinkEmailCode}
|
||||
requestSetPasswordCode={accountStore.requestSetPasswordCode}
|
||||
{selectTariff}
|
||||
{t}
|
||||
{termUnitLabel}
|
||||
verifyLinkEmailCode={accountStore.verifyLinkEmailCode}
|
||||
confirmSetPassword={accountStore.confirmSetPassword}
|
||||
/>
|
||||
|
||||
<TariffDialogs
|
||||
|
||||
@@ -231,7 +231,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
ok: true,
|
||||
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
|
||||
variants: {
|
||||
"32": "/webapp-favicon/1111111111111111/icon-32.png",
|
||||
32: "/webapp-favicon/1111111111111111/icon-32.png",
|
||||
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
@@ -254,7 +254,8 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) {
|
||||
DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
|
||||
DEV_MOCK.config.faviconUrl =
|
||||
updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
|
||||
DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM);
|
||||
@@ -337,6 +338,9 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/auth/email/password") {
|
||||
return { ok: false, error: "password_login_failed", fallback: "email_code" };
|
||||
}
|
||||
if (path === "/auth/token") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
@@ -403,6 +407,19 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (
|
||||
path === "/account/password/request" &&
|
||||
String(options.method || "").toUpperCase() === "POST"
|
||||
) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (
|
||||
path === "/account/password/confirm" &&
|
||||
String(options.method || "").toUpperCase() === "POST"
|
||||
) {
|
||||
DEV_MOCK.data.user.password_auth_enabled = true;
|
||||
return { ok: true, password_auth_enabled: true };
|
||||
}
|
||||
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export const DEV_MOCK = {
|
||||
username: "username",
|
||||
email: "user@example.com",
|
||||
email_verified: true,
|
||||
password_auth_enabled: false,
|
||||
telegram_id: 100200300,
|
||||
telegram_linked: true,
|
||||
telegram_photo_url: "",
|
||||
|
||||
@@ -29,10 +29,20 @@ export function createAccountStore({
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailResendCooldown: 0,
|
||||
setPasswordOpen: false,
|
||||
setPasswordBusy: false,
|
||||
setPasswordPending: false,
|
||||
setPasswordValue: "",
|
||||
setPasswordConfirm: "",
|
||||
setPasswordCode: "",
|
||||
setPasswordStatus: "",
|
||||
setPasswordIsError: false,
|
||||
setPasswordResendCooldown: 0,
|
||||
languageBusy: false,
|
||||
});
|
||||
|
||||
let linkEmailResendTimer = null;
|
||||
let setPasswordResendTimer = null;
|
||||
|
||||
function setLinkEmailStatus(message, isError = false) {
|
||||
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
|
||||
@@ -45,6 +55,13 @@ export function createAccountStore({
|
||||
}
|
||||
}
|
||||
|
||||
function clearPasswordCooldownTimer() {
|
||||
if (setPasswordResendTimer) {
|
||||
window.clearInterval(setPasswordResendTimer);
|
||||
setPasswordResendTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldownTimer(seconds = 60) {
|
||||
clearCooldownTimer();
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||
@@ -59,6 +76,20 @@ export function createAccountStore({
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function startPasswordCooldownTimer(seconds = 60) {
|
||||
clearPasswordCooldownTimer();
|
||||
state.update((s) => ({ ...s, setPasswordResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||
setPasswordResendTimer = window.setInterval(() => {
|
||||
const s = get(state);
|
||||
if (s.setPasswordResendCooldown <= 1) {
|
||||
state.update((s) => ({ ...s, setPasswordResendCooldown: 0 }));
|
||||
clearPasswordCooldownTimer();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, setPasswordResendCooldown: s.setPasswordResendCooldown - 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLinkEmailDialog(email) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
@@ -90,6 +121,65 @@ export function createAccountStore({
|
||||
clearCooldownTimer();
|
||||
}
|
||||
|
||||
function setPasswordStatus(message, isError = false) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
setPasswordStatus: message,
|
||||
setPasswordIsError: isError,
|
||||
}));
|
||||
}
|
||||
|
||||
function openSetPasswordDialog() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
setPasswordOpen: true,
|
||||
setPasswordBusy: false,
|
||||
setPasswordPending: false,
|
||||
setPasswordValue: "",
|
||||
setPasswordConfirm: "",
|
||||
setPasswordCode: "",
|
||||
setPasswordStatus: "",
|
||||
setPasswordIsError: false,
|
||||
setPasswordResendCooldown: 0,
|
||||
}));
|
||||
clearPasswordCooldownTimer();
|
||||
}
|
||||
|
||||
function closeSetPasswordDialog() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
setPasswordOpen: false,
|
||||
setPasswordBusy: false,
|
||||
setPasswordPending: false,
|
||||
setPasswordValue: "",
|
||||
setPasswordConfirm: "",
|
||||
setPasswordCode: "",
|
||||
setPasswordStatus: "",
|
||||
setPasswordIsError: false,
|
||||
setPasswordResendCooldown: 0,
|
||||
}));
|
||||
clearPasswordCooldownTimer();
|
||||
}
|
||||
|
||||
function validatePasswordDraft() {
|
||||
const s = get(state);
|
||||
const password = String(s.setPasswordValue || "");
|
||||
const passwordConfirm = String(s.setPasswordConfirm || "");
|
||||
if (password.length < 8) {
|
||||
setPasswordStatus(t("wa_password_too_short"), true);
|
||||
return false;
|
||||
}
|
||||
if (password.length > 128) {
|
||||
setPasswordStatus(t("wa_password_too_long"), true);
|
||||
return false;
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setPasswordStatus(t("wa_password_mismatch"), true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function requestLinkEmailCode() {
|
||||
const s = get(state);
|
||||
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
|
||||
@@ -150,6 +240,66 @@ export function createAccountStore({
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSetPasswordCode() {
|
||||
const s = get(state);
|
||||
if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return;
|
||||
if (!validatePasswordDraft()) return;
|
||||
state.update((s) => ({ ...s, setPasswordBusy: true }));
|
||||
setPasswordStatus(t("wa_auth_sending_code"));
|
||||
try {
|
||||
const response = await api("/account/password/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({ ...s, setPasswordPending: true, setPasswordCode: "" }));
|
||||
setPasswordStatus("");
|
||||
startPasswordCooldownTimer(60);
|
||||
} catch (error) {
|
||||
setPasswordStatus(emailError(error, t("wa_password_code_send_failed"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, setPasswordBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSetPassword() {
|
||||
const s = get(state);
|
||||
if (!validatePasswordDraft()) return;
|
||||
const code = String(s.setPasswordCode || "")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 6);
|
||||
if (code.length !== 6) {
|
||||
setPasswordStatus(t("wa_auth_enter_code_6digits"), true);
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, setPasswordBusy: true }));
|
||||
setPasswordStatus(t("wa_auth_checking_code"));
|
||||
try {
|
||||
const response = await api("/account/password/confirm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
password: s.setPasswordValue,
|
||||
password_confirm: s.setPasswordConfirm,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
await loadData();
|
||||
closeSetPasswordDialog();
|
||||
showToast(t("wa_password_set_success"));
|
||||
} catch (error) {
|
||||
const fallback =
|
||||
error?.error === "password_mismatch"
|
||||
? t("wa_password_mismatch")
|
||||
: error?.error === "password_too_short"
|
||||
? t("wa_password_too_short")
|
||||
: t("wa_password_set_failed");
|
||||
setPasswordStatus(emailError(error, fallback, t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, setPasswordBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function linkTelegramAccountWithPayload(payload) {
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
||||
try {
|
||||
@@ -228,11 +378,16 @@ export function createAccountStore({
|
||||
update: state.update,
|
||||
openLinkEmailDialog,
|
||||
closeLinkEmailDialog,
|
||||
openSetPasswordDialog,
|
||||
closeSetPasswordDialog,
|
||||
requestLinkEmailCode,
|
||||
verifyLinkEmailCode,
|
||||
requestSetPasswordCode,
|
||||
confirmSetPassword,
|
||||
linkTelegramAccount,
|
||||
updateAccountLanguage,
|
||||
logout,
|
||||
clearLinkEmailResendTimer: clearCooldownTimer,
|
||||
clearSetPasswordResendTimer: clearPasswordCooldownTimer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ export function createAuthStore({
|
||||
loginEmailTooltipOpen: false,
|
||||
authResendCooldown: 0,
|
||||
email: "",
|
||||
emailPassword: "",
|
||||
pendingEmail: "",
|
||||
emailCode: "",
|
||||
passwordLoginMode: false,
|
||||
passwordLoginFallback: false,
|
||||
});
|
||||
|
||||
let authResendTimer = null;
|
||||
@@ -169,6 +172,7 @@ export function createAuthStore({
|
||||
loginEmailFieldError: "",
|
||||
loginEmailTooltipOpen: false,
|
||||
authBusy: true,
|
||||
passwordLoginFallback: false,
|
||||
}));
|
||||
setAuthStatus(t("wa_auth_sending_code"));
|
||||
try {
|
||||
@@ -188,6 +192,53 @@ export function createAuthStore({
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithEmailPassword() {
|
||||
const s = get(state);
|
||||
const normalized = s.email.trim().toLowerCase();
|
||||
const password = String(s.emailPassword || "");
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
loginEmailFieldError: t("wa_auth_invalid_email"),
|
||||
loginEmailTooltipOpen: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setAuthStatus(t("wa_auth_password_required"), true);
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
loginEmailFieldError: "",
|
||||
loginEmailTooltipOpen: false,
|
||||
authBusy: true,
|
||||
passwordLoginFallback: false,
|
||||
}));
|
||||
setAuthStatus(t("wa_auth_checking_password"));
|
||||
try {
|
||||
const response = await publicApi("/auth/email/password", {
|
||||
email: normalized,
|
||||
password,
|
||||
});
|
||||
if (!response.ok || !response.csrf_token) throw response;
|
||||
setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
setAuthStatus("");
|
||||
} catch (error) {
|
||||
if (error?.error === "rate_limited") {
|
||||
setAuthStatus(emailError(error, t("wa_auth_password_login_failed"), t), true);
|
||||
} else if (error?.error === "banned") {
|
||||
setAuthStatus(t("wa_auth_access_denied"), true);
|
||||
} else {
|
||||
state.update((s) => ({ ...s, passwordLoginFallback: true }));
|
||||
setAuthStatus(t("wa_auth_password_login_failed"), true);
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEmailCode() {
|
||||
const s = get(state);
|
||||
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
|
||||
@@ -278,6 +329,7 @@ export function createAuthStore({
|
||||
finalizeMagicLogin,
|
||||
finalizeTelegramAuth,
|
||||
requestEmailCode,
|
||||
loginWithEmailPassword,
|
||||
verifyEmailCode,
|
||||
openTelegramLogin,
|
||||
clearCooldownTimer,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
.dialog-skeleton {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -12,11 +10,8 @@
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
padding:
|
||||
max(14px, env(safe-area-inset-top))
|
||||
max(14px, env(safe-area-inset-right))
|
||||
max(14px, env(safe-area-inset-bottom))
|
||||
max(14px, env(safe-area-inset-left));
|
||||
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right))
|
||||
max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
@@ -116,6 +111,26 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.password-code-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 220;
|
||||
background: var(--bg);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
animation: password-code-fullscreen-enter 0.2s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes password-code-fullscreen-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.dialog-card {
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
@@ -1290,6 +1290,22 @@ a {
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.settings-row-linked-with-action {
|
||||
grid-template-columns: 28px minmax(0, 1fr) max-content;
|
||||
}
|
||||
|
||||
.settings-inline-action {
|
||||
justify-self: end;
|
||||
min-height: 34px;
|
||||
max-width: 132px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-row-linked > svg:first-child {
|
||||
color: var(--success-text);
|
||||
}
|
||||
@@ -1603,8 +1619,23 @@ a {
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
box-sizing: content-box;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
overflow: hidden;
|
||||
transition: height 0.22s ease;
|
||||
will-change: height;
|
||||
}
|
||||
|
||||
.auth-mode-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
animation: auth-mode-enter 0.18s ease-out both;
|
||||
}
|
||||
|
||||
.auth-mode-panel-password {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.auth-pane {
|
||||
@@ -1661,6 +1692,38 @@ a {
|
||||
animation: telegram-button-spin 0.72s linear infinite;
|
||||
}
|
||||
|
||||
.password-switch-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.password-switch-stack {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
justify-items: center;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.password-switch-button,
|
||||
.auth-code-fallback {
|
||||
width: fit-content;
|
||||
justify-self: center;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@keyframes auth-mode-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes telegram-button-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -2027,9 +2090,14 @@ a {
|
||||
.language-select-content,
|
||||
.dialog-backdrop,
|
||||
.dialog-card,
|
||||
.auth-mode-panel,
|
||||
.toast {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
||||
|
||||
@@ -52,6 +52,16 @@
|
||||
export let selectedTariff = null;
|
||||
export let selectedTariffKey = "";
|
||||
export let selectedTariffPlans = [];
|
||||
export let setPasswordBusy = false;
|
||||
export let setPasswordCode = "";
|
||||
export let setPasswordConfirm = "";
|
||||
export let setPasswordEmail = "";
|
||||
export let setPasswordIsError = false;
|
||||
export let setPasswordOpen = false;
|
||||
export let setPasswordPending = false;
|
||||
export let setPasswordResendCooldown = 0;
|
||||
export let setPasswordStatus = "";
|
||||
export let setPasswordValue = "";
|
||||
export let singleTariffMode = false;
|
||||
export let subscription = {};
|
||||
export let tariffCatalog = [];
|
||||
@@ -104,13 +114,16 @@
|
||||
export let closeDeviceDisconnectDialog = () => {};
|
||||
export let closeLinkEmailDialog = () => {};
|
||||
export let closePaymentModal = () => {};
|
||||
export let closeSetPasswordDialog = () => {};
|
||||
export let backToTariffList = () => {};
|
||||
export let continueWithSelectedTariff = () => {};
|
||||
export let requestLinkEmailCode = () => {};
|
||||
export let requestSetPasswordCode = () => {};
|
||||
export let selectTariff = () => {};
|
||||
export let t = (key) => key;
|
||||
export let termUnitLabel = () => "";
|
||||
export let verifyLinkEmailCode = () => {};
|
||||
export let confirmSetPassword = () => {};
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
@@ -299,6 +312,101 @@
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={setPasswordOpen && !setPasswordPending}
|
||||
title={t("wa_password_modal_title")}
|
||||
description={t("wa_password_modal_desc")}
|
||||
closeLabel={t("wa_close")}
|
||||
onclose={closeSetPasswordDialog}
|
||||
class="payment-dialog-card"
|
||||
>
|
||||
<div class="payment-dialog-body">
|
||||
<Input
|
||||
bind:value={setPasswordValue}
|
||||
type="password"
|
||||
placeholder={t("wa_password_new_placeholder")}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
bind:value={setPasswordConfirm}
|
||||
type="password"
|
||||
placeholder={t("wa_password_confirm_placeholder")}
|
||||
autocomplete="new-password"
|
||||
on:keydown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
requestSetPasswordCode();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
class="wide bottom-action payment-submit-button"
|
||||
onclick={requestSetPasswordCode}
|
||||
disabled={setPasswordBusy}
|
||||
>
|
||||
<LockKeyhole size={17} />
|
||||
{t("wa_password_send_code_action")}
|
||||
</Button>
|
||||
{#if setPasswordStatus}
|
||||
<StatusMessage error={setPasswordIsError}>{setPasswordStatus}</StatusMessage>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{#if setPasswordOpen && setPasswordPending}
|
||||
<div class="password-code-fullscreen" role="dialog" aria-modal="true">
|
||||
<div class="phone-screen auth-screen">
|
||||
<header class="screen-head center-title">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
onclick={closeSetPasswordDialog}
|
||||
aria-label={t("wa_back")}
|
||||
>
|
||||
<ArrowLeft size={19} />
|
||||
</Button>
|
||||
<div>
|
||||
<h1>{t("wa_email_verification_title")}</h1>
|
||||
<p>{t("wa_email_sent_to", { email: setPasswordEmail || "" })}</p>
|
||||
</div>
|
||||
<span></span>
|
||||
</header>
|
||||
<div class="otp-wrap">
|
||||
<label class="otp-input-wrap">
|
||||
<input
|
||||
bind:value={setPasswordCode}
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
aria-label={t("wa_email_code_aria")}
|
||||
/>
|
||||
<span class="otp-slots" aria-hidden="true">
|
||||
{#each Array.from({ length: 6 }) as _, index}
|
||||
<span class:filled={setPasswordCode[index]}>{setPasswordCode[index] || ""}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<Button class="wide" onclick={confirmSetPassword} disabled={setPasswordBusy}>
|
||||
{t("wa_confirm")}
|
||||
</Button>
|
||||
{#if setPasswordStatus}
|
||||
<StatusMessage error={setPasswordIsError}>{setPasswordStatus}</StatusMessage>
|
||||
{/if}
|
||||
<button
|
||||
class="link-button"
|
||||
type="button"
|
||||
onclick={requestSetPasswordCode}
|
||||
disabled={setPasswordBusy || setPasswordResendCooldown > 0}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
{setPasswordResendCooldown > 0
|
||||
? t("wa_auth_resend_wait", { seconds: setPasswordResendCooldown })
|
||||
: t("wa_resend_code")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog
|
||||
open={linkEmailOpen}
|
||||
title={t("wa_link_email_modal_title")}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script>
|
||||
import { ArrowLeft, Mail, RefreshCw, Send, TriangleAlert } from "$components/ui/icons.js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Send,
|
||||
TriangleAlert,
|
||||
} from "$components/ui/icons.js";
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import Input from "$components/ui/input.svelte";
|
||||
import Spinner from "$components/ui/spinner.svelte";
|
||||
import { StatusMessage } from "$components/patterns/webapp/index.js";
|
||||
@@ -14,6 +20,7 @@
|
||||
export let brand = {};
|
||||
export let brandTitle;
|
||||
export let email;
|
||||
export let emailPassword;
|
||||
export let emailCode;
|
||||
export let pendingEmail;
|
||||
export let authStatus;
|
||||
@@ -22,6 +29,8 @@
|
||||
export let authResendCooldown;
|
||||
export let loginEmailFieldError;
|
||||
export let loginEmailTooltipOpen;
|
||||
export let passwordLoginFallback;
|
||||
export let passwordLoginMode;
|
||||
export let telegramLoginBusy;
|
||||
export let telegramLoginUnavailable;
|
||||
export let telegramLoginChecking;
|
||||
@@ -31,12 +40,19 @@
|
||||
export let userAgreementUrl;
|
||||
export let t;
|
||||
export let requestEmailCode;
|
||||
export let loginWithEmailPassword;
|
||||
export let verifyEmailCode;
|
||||
export let openTelegramLogin;
|
||||
export let openExternalLink;
|
||||
export let submitEmailOnEnter;
|
||||
export let onBackToLogin;
|
||||
export let clearLoginEmailError;
|
||||
export let setPasswordLoginMode;
|
||||
|
||||
let authPanelHeight = 0;
|
||||
|
||||
$: passwordModeActive = Boolean(passwordLoginMode && CFG.emailAuthEnabled !== false);
|
||||
$: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
|
||||
</script>
|
||||
|
||||
<div class="phone-screen auth-screen">
|
||||
@@ -90,74 +106,168 @@
|
||||
<BrandMark {brand} size="xl" />
|
||||
<h1>{brandTitle}</h1>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
{#if CFG.emailAuthEnabled !== false}
|
||||
<div class="auth-pane">
|
||||
<div class="auth-email-stack">
|
||||
<div class="field-error-wrap">
|
||||
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
|
||||
<section class="card auth-card" style:height={authCardHeight}>
|
||||
{#key passwordModeActive}
|
||||
<div
|
||||
class={`auth-mode-panel${passwordModeActive ? " auth-mode-panel-password" : ""}`}
|
||||
bind:clientHeight={authPanelHeight}
|
||||
>
|
||||
{#if passwordModeActive}
|
||||
<div class="auth-pane">
|
||||
<div class="auth-email-stack">
|
||||
<div class="field-error-wrap">
|
||||
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={t("wa_email_placeholder")}
|
||||
autocomplete="email"
|
||||
class={loginEmailFieldError ? "input-error" : ""}
|
||||
on:input={clearLoginEmailError}
|
||||
/>
|
||||
{#if loginEmailFieldError}
|
||||
<Tooltip.Trigger
|
||||
class="field-error-trigger"
|
||||
aria-label={loginEmailFieldError}
|
||||
>
|
||||
<span class="field-error-icon" aria-hidden="true"
|
||||
><TriangleAlert size={18} /></span
|
||||
>
|
||||
</Tooltip.Trigger>
|
||||
{/if}
|
||||
{#if loginEmailFieldError}
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="field-error-tooltip"
|
||||
>{loginEmailFieldError}</Tooltip.Content
|
||||
>
|
||||
</Tooltip.Portal>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={t("wa_email_placeholder")}
|
||||
autocomplete="email"
|
||||
class={loginEmailFieldError ? "input-error" : ""}
|
||||
on:keydown={submitEmailOnEnter}
|
||||
on:input={clearLoginEmailError}
|
||||
bind:value={emailPassword}
|
||||
type="password"
|
||||
placeholder={t("wa_password_placeholder")}
|
||||
autocomplete="current-password"
|
||||
on:keydown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
loginWithEmailPassword();
|
||||
}}
|
||||
/>
|
||||
{#if loginEmailFieldError}
|
||||
<Tooltip.Trigger class="field-error-trigger" aria-label={loginEmailFieldError}>
|
||||
<span class="field-error-icon" aria-hidden="true"
|
||||
><TriangleAlert size={18} /></span
|
||||
>
|
||||
</Tooltip.Trigger>
|
||||
<Button class="wide" onclick={loginWithEmailPassword} disabled={authBusy}>
|
||||
<LockKeyhole size={18} />
|
||||
{t("wa_login_password_submit")}
|
||||
</Button>
|
||||
{#if passwordLoginFallback}
|
||||
<button
|
||||
class="link-button auth-code-fallback"
|
||||
type="button"
|
||||
onclick={requestEmailCode}
|
||||
disabled={authBusy}
|
||||
>
|
||||
<Mail size={15} />
|
||||
{t("wa_login_use_email_code")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="link-button auth-code-fallback"
|
||||
type="button"
|
||||
onclick={() => setPasswordLoginMode(false)}
|
||||
disabled={authBusy}
|
||||
>
|
||||
{t("wa_login_use_email_code")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if loginEmailFieldError}
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="field-error-tooltip"
|
||||
>{loginEmailFieldError}</Tooltip.Content
|
||||
>
|
||||
</Tooltip.Portal>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="wide" onclick={requestEmailCode} disabled={authBusy}>
|
||||
<Mail size={18} />
|
||||
{t("wa_send_code_email")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if CFG.emailAuthEnabled !== false}
|
||||
<div class="or-line"><span></span>{t("wa_or")}<span></span></div>
|
||||
{/if}
|
||||
<div class="auth-pane">
|
||||
<Button
|
||||
variant="telegram"
|
||||
class={`wide telegram-login-button${telegramLoginUnavailable ? " unavailable" : ""}${telegramLoginChecking ? " checking" : ""}`}
|
||||
onclick={openTelegramLogin}
|
||||
disabled={authBusy || telegramLoginBusy || telegramLoginUnavailable}
|
||||
aria-label={telegramLoginLabel}
|
||||
>
|
||||
<span class="telegram-login-text">
|
||||
{#if telegramLoginChecking}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Send size={17} />
|
||||
{#if authStatus}
|
||||
<StatusMessage error={authIsError} class="auth-login-status">
|
||||
{authStatus}
|
||||
</StatusMessage>
|
||||
{/if}
|
||||
{telegramLoginLabel}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
|
||||
<StatusMessage
|
||||
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
|
||||
class="auth-login-status"
|
||||
>
|
||||
{authStatus || telegramLoginUnavailableMessage}
|
||||
</StatusMessage>
|
||||
{/if}
|
||||
</Card>
|
||||
{:else if CFG.emailAuthEnabled !== false}
|
||||
<div class="auth-pane">
|
||||
<div class="auth-email-stack">
|
||||
<div class="field-error-wrap">
|
||||
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={t("wa_email_placeholder")}
|
||||
autocomplete="email"
|
||||
class={loginEmailFieldError ? "input-error" : ""}
|
||||
on:keydown={submitEmailOnEnter}
|
||||
on:input={clearLoginEmailError}
|
||||
/>
|
||||
{#if loginEmailFieldError}
|
||||
<Tooltip.Trigger
|
||||
class="field-error-trigger"
|
||||
aria-label={loginEmailFieldError}
|
||||
>
|
||||
<span class="field-error-icon" aria-hidden="true"
|
||||
><TriangleAlert size={18} /></span
|
||||
>
|
||||
</Tooltip.Trigger>
|
||||
{/if}
|
||||
{#if loginEmailFieldError}
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="field-error-tooltip"
|
||||
>{loginEmailFieldError}</Tooltip.Content
|
||||
>
|
||||
</Tooltip.Portal>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Button class="wide" onclick={requestEmailCode} disabled={authBusy}>
|
||||
<Mail size={18} />
|
||||
{t("wa_send_code_email")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="or-line"><span></span>{t("wa_or")}<span></span></div>
|
||||
<div class="auth-pane">
|
||||
<Button
|
||||
variant="telegram"
|
||||
class={`wide telegram-login-button${telegramLoginUnavailable ? " unavailable" : ""}${telegramLoginChecking ? " checking" : ""}`}
|
||||
onclick={openTelegramLogin}
|
||||
disabled={authBusy || telegramLoginBusy || telegramLoginUnavailable}
|
||||
aria-label={telegramLoginLabel}
|
||||
>
|
||||
<span class="telegram-login-text">
|
||||
{#if telegramLoginChecking}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Send size={17} />
|
||||
{/if}
|
||||
{telegramLoginLabel}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="password-switch-stack">
|
||||
<div class="password-switch-divider" aria-hidden="true"></div>
|
||||
<button
|
||||
class="link-button password-switch-button"
|
||||
type="button"
|
||||
onclick={() => setPasswordLoginMode(true)}
|
||||
disabled={authBusy}
|
||||
>
|
||||
<LockKeyhole size={15} />
|
||||
{t("wa_login_use_password")}
|
||||
</button>
|
||||
</div>
|
||||
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
|
||||
<StatusMessage
|
||||
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
|
||||
class="auth-login-status"
|
||||
>
|
||||
{authStatus || telegramLoginUnavailableMessage}
|
||||
</StatusMessage>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</section>
|
||||
{#if userAgreementUrl || privacyPolicyUrl}
|
||||
<div class="auth-legal">
|
||||
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
export let openAdminPanel = () => {};
|
||||
export let openExternalLink = () => {};
|
||||
export let openLinkEmailDialog = () => {};
|
||||
export let openSetPasswordDialog = () => {};
|
||||
export let setLanguageMenuOpen = () => {};
|
||||
export let t = (key) => key;
|
||||
export let updateAccountLanguage = () => {};
|
||||
@@ -100,12 +101,24 @@
|
||||
</Button>
|
||||
{/if}
|
||||
{#if user?.email}
|
||||
<div class="settings-row settings-row-linked">
|
||||
<div class="settings-row settings-row-linked settings-row-linked-with-action">
|
||||
<CheckCircle2 size={21} />
|
||||
<span>
|
||||
<strong>{t("wa_settings_email_linked_title")}</strong>
|
||||
<small>{user?.email}</small>
|
||||
</span>
|
||||
{#if user?.email_verified}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="settings-inline-action"
|
||||
onclick={openSetPasswordDialog}
|
||||
>
|
||||
{user?.password_auth_enabled
|
||||
? t("wa_settings_change_password_action")
|
||||
: t("wa_settings_set_password_action")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
|
||||
+30
-1
@@ -579,6 +579,13 @@
|
||||
"email_login_code_magic_or": "or",
|
||||
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
|
||||
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
|
||||
"email_set_password_code_subject": "{code} — code to create your password",
|
||||
"email_set_password_code_preheader": "Confirm password creation. The code expires in {minutes} min.",
|
||||
"email_set_password_code_heading": "Confirm password creation",
|
||||
"email_set_password_code_intro": "Enter this 6-digit code in your account settings to create or change the password for email sign-in.",
|
||||
"email_set_password_code_expiry_html": "The password creation code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
|
||||
"email_set_password_code_security": "If you didn't try to create a password, ignore this email — your account password will not change.",
|
||||
"email_set_password_code_text": "Your password creation code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't try to create a password, ignore this email — your account password will not change.",
|
||||
"email_account_merged_subject": "Accounts merged",
|
||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||
"email_account_merged_heading": "Accounts merged",
|
||||
@@ -1381,5 +1388,27 @@
|
||||
"admin_settings_field_my_devices_section_enabled_label": "My Devices Section Enabled",
|
||||
"admin_settings_field_user_hwid_device_limit_label": "User HWID Device Limit",
|
||||
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
|
||||
"admin_settings_field_user_traffic_strategy_label": "User Traffic Strategy"
|
||||
"admin_settings_field_user_traffic_strategy_label": "User Traffic Strategy",
|
||||
"wa_auth_checking_password": "Checking password...",
|
||||
"wa_auth_password_required": "Enter your password",
|
||||
"wa_auth_password_login_failed": "Could not sign in with this email and password. You can use an email code instead.",
|
||||
"wa_password_placeholder": "Password",
|
||||
"wa_login_password_submit": "Sign in",
|
||||
"wa_login_use_password": "Use password",
|
||||
"wa_login_use_email_code": "Use email code",
|
||||
"wa_settings_set_password_action": "Set password",
|
||||
"wa_settings_change_password_action": "Change password",
|
||||
"wa_password_modal_title": "Email password",
|
||||
"wa_password_modal_desc": "Enter the new password twice. Then we will send a code to your linked email.",
|
||||
"wa_password_modal_code_desc": "Enter the email code to confirm the password.",
|
||||
"wa_password_new_placeholder": "New password",
|
||||
"wa_password_confirm_placeholder": "Repeat password",
|
||||
"wa_password_send_code_action": "Get code",
|
||||
"wa_password_confirm_action": "Save password",
|
||||
"wa_password_too_short": "Password must be at least 8 characters",
|
||||
"wa_password_too_long": "Password must be at most 128 characters",
|
||||
"wa_password_mismatch": "Passwords do not match",
|
||||
"wa_password_code_send_failed": "Could not send the code",
|
||||
"wa_password_set_failed": "Could not save password",
|
||||
"wa_password_set_success": "Password saved"
|
||||
}
|
||||
|
||||
+30
-1
@@ -579,6 +579,13 @@
|
||||
"email_login_code_magic_or": "или",
|
||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
|
||||
"email_set_password_code_subject": "{code} — код для создания пароля",
|
||||
"email_set_password_code_preheader": "Подтвердите создание пароля. Код действует {minutes} мин.",
|
||||
"email_set_password_code_heading": "Подтвердите создание пароля",
|
||||
"email_set_password_code_intro": "Введите этот 6-значный код в личном кабинете, чтобы создать или изменить пароль для входа по почте.",
|
||||
"email_set_password_code_expiry_html": "Код для создания пароля действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
|
||||
"email_set_password_code_security": "Если вы не создавали пароль, просто проигнорируйте это письмо — пароль для аккаунта не изменится.",
|
||||
"email_set_password_code_text": "Ваш код для создания пароля: {code}\n\nКод действует {minutes} мин.\nЕсли вы не создавали пароль, проигнорируйте это письмо — пароль для аккаунта не изменится.",
|
||||
"email_account_merged_subject": "Аккаунты объединены",
|
||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||
"email_account_merged_heading": "Аккаунты объединены",
|
||||
@@ -1381,5 +1388,27 @@
|
||||
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
|
||||
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
|
||||
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
|
||||
"admin_settings_field_user_traffic_strategy_label": "Стратегия сброса трафика"
|
||||
"admin_settings_field_user_traffic_strategy_label": "Стратегия сброса трафика",
|
||||
"wa_auth_checking_password": "Проверяем пароль...",
|
||||
"wa_auth_password_required": "Введите пароль",
|
||||
"wa_auth_password_login_failed": "Не удалось войти с этим email и паролем. Можно войти по коду из письма.",
|
||||
"wa_password_placeholder": "Пароль",
|
||||
"wa_login_password_submit": "Войти",
|
||||
"wa_login_use_password": "Использовать пароль",
|
||||
"wa_login_use_email_code": "Войти по коду на почту",
|
||||
"wa_settings_set_password_action": "Задать пароль",
|
||||
"wa_settings_change_password_action": "Изменить пароль",
|
||||
"wa_password_modal_title": "Пароль для входа",
|
||||
"wa_password_modal_desc": "Введите новый пароль два раза. Затем мы отправим код на привязанную почту.",
|
||||
"wa_password_modal_code_desc": "Введите код из письма, чтобы подтвердить пароль.",
|
||||
"wa_password_new_placeholder": "Новый пароль",
|
||||
"wa_password_confirm_placeholder": "Повторите пароль",
|
||||
"wa_password_send_code_action": "Получить код",
|
||||
"wa_password_confirm_action": "Сохранить пароль",
|
||||
"wa_password_too_short": "Пароль должен быть не короче 8 символов",
|
||||
"wa_password_too_long": "Пароль должен быть не длиннее 128 символов",
|
||||
"wa_password_mismatch": "Пароли не совпадают",
|
||||
"wa_password_code_send_failed": "Не удалось отправить код",
|
||||
"wa_password_set_failed": "Не удалось сохранить пароль",
|
||||
"wa_password_set_success": "Пароль сохранён"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from aiohttp import web
|
||||
|
||||
from bot.app.web import admin_api, subscription_webapp
|
||||
from bot.app.web.admin_api_impl import settings as admin_settings_routes
|
||||
from bot.app.web.webapp import account as account_routes
|
||||
from bot.app.web.webapp_auth import (
|
||||
create_telegram_oauth_nonce,
|
||||
create_webapp_session_token,
|
||||
@@ -19,8 +20,10 @@ from bot.payment_providers.cryptopay import CryptoPayService
|
||||
from bot.payment_providers.freekassa import FreeKassaService
|
||||
from bot.payment_providers.heleket import HeleketConfig, HeleketService, _compute_signature
|
||||
from bot.payment_providers.yookassa import yookassa_webhook_route
|
||||
from bot.services.email_templates import render_login_code
|
||||
from bot.utils.request_security import request_client_ip
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.database_setup import redacted_database_url
|
||||
|
||||
|
||||
@@ -197,6 +200,25 @@ class HeleketServiceTests(unittest.TestCase):
|
||||
|
||||
|
||||
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
class _AsyncSessionFactory:
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def rollback(self):
|
||||
return None
|
||||
|
||||
async def flush(self):
|
||||
return None
|
||||
|
||||
def test_auth_response_sets_cookies_and_does_not_return_session_token(self):
|
||||
settings = SimpleNamespace(
|
||||
WEBAPP_SESSION_SECRET="session-secret",
|
||||
@@ -306,6 +328,208 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn("email_too_long", response.text)
|
||||
|
||||
def test_email_password_hash_round_trips_without_plaintext(self):
|
||||
password = "correct horse battery staple"
|
||||
|
||||
stored_hash = subscription_webapp._hash_email_password(password)
|
||||
|
||||
self.assertNotIn(password, stored_hash)
|
||||
self.assertTrue(subscription_webapp._verify_email_password(password, stored_hash))
|
||||
self.assertFalse(subscription_webapp._verify_email_password("wrong-password", stored_hash))
|
||||
|
||||
def test_set_password_email_code_copy_mentions_password_creation(self):
|
||||
settings = SimpleNamespace(
|
||||
DEFAULT_LANGUAGE="ru",
|
||||
EMAIL_CODE_TTL_SECONDS=600,
|
||||
WEBAPP_LOGO_URL="",
|
||||
WEBAPP_LOGO_USE_EMOJI=False,
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a",
|
||||
WEBAPP_TITLE="Remnawave",
|
||||
)
|
||||
|
||||
content = render_login_code(
|
||||
settings,
|
||||
code="123456",
|
||||
language_code="ru",
|
||||
magic_link="https://example.com/login",
|
||||
purpose="set_password",
|
||||
)
|
||||
|
||||
self.assertIn("создания пароля", content.subject)
|
||||
self.assertIn("создание пароля", content.html)
|
||||
self.assertIn("код для создания пароля", content.text)
|
||||
self.assertNotIn("Подтвердите вход", content.html)
|
||||
self.assertNotIn("https://example.com/login", content.text)
|
||||
|
||||
async def test_email_password_login_success_sets_session_cookie(self):
|
||||
settings = SimpleNamespace(
|
||||
WEBAPP_SESSION_SECRET="session-secret",
|
||||
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||
email_auth_configured=True,
|
||||
BRUTE_FORCE_MAX_FAILURES=5,
|
||||
BRUTE_FORCE_WINDOW_SECONDS=60,
|
||||
BRUTE_FORCE_LOCK_SECONDS=300,
|
||||
)
|
||||
stored_hash = subscription_webapp._hash_email_password("secret-password")
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email_verified_at=object(),
|
||||
password_hash=stored_hash,
|
||||
is_banned=False,
|
||||
telegram_id=None,
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={"settings": settings, "async_session_factory": self._AsyncSessionFactory()},
|
||||
json=AsyncMock(
|
||||
return_value={"email": "user@example.com", "password": "secret-password"}
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
subscription_webapp.security_dal,
|
||||
"check_throttle",
|
||||
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
|
||||
),
|
||||
patch.object(
|
||||
subscription_webapp.security_dal,
|
||||
"clear_throttle_state",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(
|
||||
subscription_webapp.user_dal,
|
||||
"get_user_by_email",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
):
|
||||
response = await subscription_webapp.email_password_auth_route(request)
|
||||
|
||||
payload = json.loads(response.text)
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["user_id"], 42)
|
||||
self.assertIn("rw_webapp_session", response.cookies)
|
||||
|
||||
async def test_email_password_login_failures_use_same_fallback_error(self):
|
||||
settings = SimpleNamespace(
|
||||
email_auth_configured=True,
|
||||
BRUTE_FORCE_MAX_FAILURES=5,
|
||||
BRUTE_FORCE_WINDOW_SECONDS=60,
|
||||
BRUTE_FORCE_LOCK_SECONDS=300,
|
||||
)
|
||||
cases = [
|
||||
None,
|
||||
SimpleNamespace(
|
||||
user_id=42,
|
||||
email_verified_at=object(),
|
||||
password_hash=None,
|
||||
is_banned=False,
|
||||
),
|
||||
SimpleNamespace(
|
||||
user_id=42,
|
||||
email_verified_at=object(),
|
||||
password_hash=subscription_webapp._hash_email_password("other-password"),
|
||||
is_banned=False,
|
||||
),
|
||||
]
|
||||
|
||||
for db_user in cases:
|
||||
request = SimpleNamespace(
|
||||
app={"settings": settings, "async_session_factory": self._AsyncSessionFactory()},
|
||||
json=AsyncMock(
|
||||
return_value={"email": "user@example.com", "password": "secret-password"}
|
||||
),
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
subscription_webapp.security_dal,
|
||||
"check_throttle",
|
||||
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
|
||||
),
|
||||
patch.object(
|
||||
subscription_webapp.security_dal,
|
||||
"record_throttle_failure",
|
||||
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
|
||||
),
|
||||
patch.object(
|
||||
subscription_webapp.user_dal,
|
||||
"get_user_by_email",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
):
|
||||
response = await subscription_webapp.email_password_auth_route(request)
|
||||
|
||||
payload = json.loads(response.text)
|
||||
self.assertEqual(response.status, 401)
|
||||
self.assertEqual(payload["error"], "password_login_failed")
|
||||
self.assertEqual(payload["fallback"], "email_code")
|
||||
|
||||
async def test_account_password_confirm_requires_matching_passwords(self):
|
||||
request = SimpleNamespace(
|
||||
app={},
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"password": "secret-password",
|
||||
"password_confirm": "other-password",
|
||||
"code": "123456",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with patch.object(account_routes, "_require_user_id", return_value=42):
|
||||
response = await account_routes.account_password_confirm_route(request)
|
||||
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn("password_mismatch", response.text)
|
||||
|
||||
async def test_account_password_confirm_sets_hash_after_email_code(self):
|
||||
settings = SimpleNamespace(
|
||||
REDIS_URL=None,
|
||||
REDIS_KEY_PREFIX="test",
|
||||
)
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email="user@example.com",
|
||||
email_verified_at=object(),
|
||||
is_banned=False,
|
||||
password_hash=None,
|
||||
password_set_at=None,
|
||||
)
|
||||
email_service = SimpleNamespace(
|
||||
verify_code=AsyncMock(
|
||||
return_value=SimpleNamespace(ok=True, error=None, retry_after=None)
|
||||
)
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": settings,
|
||||
"email_auth_service": email_service,
|
||||
"async_session_factory": self._AsyncSessionFactory(),
|
||||
},
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"password": "secret-password",
|
||||
"password_confirm": "secret-password",
|
||||
"code": "123456",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(account_routes, "_require_user_id", return_value=42),
|
||||
patch.object(
|
||||
account_routes.user_dal,
|
||||
"get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
):
|
||||
response = await account_routes.account_password_confirm_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue(
|
||||
subscription_webapp._verify_email_password("secret-password", db_user.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(db_user.password_set_at)
|
||||
|
||||
def test_payment_payload_rejects_overlong_description(self):
|
||||
model, response = subscription_webapp._validate_model_payload(
|
||||
subscription_webapp.WebAppPaymentCreatePayload,
|
||||
|
||||
@@ -48,6 +48,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
routes = _route_map(app)
|
||||
expected = {
|
||||
("GET", "/"): "index_route",
|
||||
("GET", "/login/password"): "index_route",
|
||||
("GET", "/home"): "index_route",
|
||||
("GET", "/invite"): "index_route",
|
||||
("GET", "/devices"): "index_route",
|
||||
@@ -70,12 +71,15 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("POST", "/api/auth/email/request"): "email_auth_request_route",
|
||||
("POST", "/api/auth/email/verify"): "email_auth_verify_route",
|
||||
("POST", "/api/auth/email/magic"): "email_auth_magic_route",
|
||||
("POST", "/api/auth/email/password"): "email_password_auth_route",
|
||||
("POST", "/api/auth/logout"): "logout_route",
|
||||
("GET", "/api/me"): "me_route",
|
||||
("GET", "/api/account/avatar"): "account_avatar_route",
|
||||
("POST", "/api/account/language"): "account_language_route",
|
||||
("POST", "/api/account/email/request"): "account_email_request_route",
|
||||
("POST", "/api/account/email/verify"): "account_email_verify_route",
|
||||
("POST", "/api/account/password/request"): "account_password_request_route",
|
||||
("POST", "/api/account/password/confirm"): "account_password_confirm_route",
|
||||
("POST", "/api/account/telegram/link"): "account_telegram_link_route",
|
||||
("POST", "/api/promo/apply"): "apply_promo_route",
|
||||
("POST", "/api/trial/activate"): "activate_trial_route",
|
||||
|
||||
Reference in New Issue
Block a user