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_telegram_oauth_nonce,
|
||||||
verify_webapp_session_token,
|
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_auth_service import EmailAuthService, normalize_email
|
||||||
from bot.services.email_templates import render_account_merged
|
from bot.services.email_templates import render_account_merged
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
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.request_security import parse_ip_entries, request_client_ip
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||||
from config.settings import Settings
|
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.dal.user_dal import UserMergeConflictError
|
||||||
from db.models import Payment, User, UserTelegramAvatar
|
from db.models import Payment, User, UserTelegramAvatar
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
|||||||
"/api/auth/email/request",
|
"/api/auth/email/request",
|
||||||
"/api/auth/email/verify",
|
"/api/auth/email/verify",
|
||||||
"/api/auth/email/magic",
|
"/api/auth/email/magic",
|
||||||
|
"/api/auth/email/password",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
from .auth import _hash_email_password
|
||||||
|
|
||||||
|
|
||||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
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)
|
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:
|
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
|||||||
@@ -132,6 +132,58 @@ def _urlsafe_sha256(value: str) -> str:
|
|||||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
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(
|
async def _exchange_telegram_oauth_code(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
*,
|
*,
|
||||||
@@ -438,6 +490,114 @@ async def logout_route(request: web.Request) -> web.Response:
|
|||||||
return 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:
|
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
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":
|
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")
|
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":
|
if error_type == "string_too_long":
|
||||||
return _json_error(400, "text_too_long", "Text is too long")
|
return _json_error(400, "text_too_long", "Text is too long")
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
|
|||||||
code: str = ""
|
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):
|
class WebAppEmailMagicPayload(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||||
app.router.add_get("/", index_route)
|
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("/home", 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)
|
||||||
@@ -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/request", email_auth_request_route)
|
||||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_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/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_post("/api/auth/logout", logout_route)
|
||||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||||
app.router.add_get("/api/me", me_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/language", account_language_route)
|
||||||
app.router.add_post("/api/account/email/request", account_email_request_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/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/account/telegram/link", account_telegram_link_route)
|
||||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||||
app.router.add_post("/api/trial/activate", activate_trial_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,
|
"username": db_user.username,
|
||||||
"email": db_user.email,
|
"email": db_user.email,
|
||||||
"email_verified": bool(db_user.email_verified_at),
|
"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_id": db_user.telegram_id,
|
||||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||||
|
|||||||
@@ -196,7 +196,11 @@ class EmailAuthService:
|
|||||||
|
|
||||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
magic_token = secrets.token_urlsafe(32)
|
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(
|
code_model = EmailVerificationCode(
|
||||||
email=normalized_email,
|
email=normalized_email,
|
||||||
code_hash=self._hash_code(normalized_email, purpose, code),
|
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||||
@@ -214,6 +218,7 @@ class EmailAuthService:
|
|||||||
code=code,
|
code=code,
|
||||||
language_code=language_code,
|
language_code=language_code,
|
||||||
magic_link=magic_link,
|
magic_link=magic_link,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
return EmailCodeRequestResult(ok=True)
|
return EmailCodeRequestResult(ok=True)
|
||||||
|
|
||||||
@@ -405,6 +410,7 @@ class EmailAuthService:
|
|||||||
code: str,
|
code: str,
|
||||||
language_code: str,
|
language_code: str,
|
||||||
magic_link: Optional[str] = None,
|
magic_link: Optional[str] = None,
|
||||||
|
purpose: str = "login",
|
||||||
) -> None:
|
) -> None:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._send_code_email_sync,
|
self._send_code_email_sync,
|
||||||
@@ -412,6 +418,7 @@ class EmailAuthService:
|
|||||||
code=code,
|
code=code,
|
||||||
language_code=language_code,
|
language_code=language_code,
|
||||||
magic_link=magic_link,
|
magic_link=magic_link,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def send_custom_email(
|
async def send_custom_email(
|
||||||
@@ -450,12 +457,14 @@ class EmailAuthService:
|
|||||||
code: str,
|
code: str,
|
||||||
language_code: str,
|
language_code: str,
|
||||||
magic_link: Optional[str] = None,
|
magic_link: Optional[str] = None,
|
||||||
|
purpose: str = "login",
|
||||||
) -> None:
|
) -> None:
|
||||||
content = render_login_code(
|
content = render_login_code(
|
||||||
self.settings,
|
self.settings,
|
||||||
code=code,
|
code=code,
|
||||||
language_code=language_code,
|
language_code=language_code,
|
||||||
magic_link=magic_link,
|
magic_link=magic_link,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
|
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ def render_login_code(
|
|||||||
code: str,
|
code: str,
|
||||||
language_code: Optional[str],
|
language_code: Optional[str],
|
||||||
magic_link: Optional[str] = None,
|
magic_link: Optional[str] = None,
|
||||||
|
purpose: str = "login",
|
||||||
i18n: Optional[JsonI18n] = None,
|
i18n: Optional[JsonI18n] = None,
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
@@ -216,16 +217,17 @@ def render_login_code(
|
|||||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand = _brand_title(settings)
|
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)
|
subject = _t_text(i18n, lang, f"{template_prefix}_subject", code=code)
|
||||||
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
|
preheader = _t_text(i18n, lang, f"{template_prefix}_preheader", minutes=minutes)
|
||||||
heading = _t_text(i18n, lang, "email_login_code_heading")
|
heading = _t_text(i18n, lang, f"{template_prefix}_heading")
|
||||||
intro = _t_text(i18n, lang, "email_login_code_intro")
|
intro = _t_text(i18n, lang, f"{template_prefix}_intro")
|
||||||
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
|
expiry_html = _t_html(i18n, lang, f"{template_prefix}_expiry_html", minutes=minutes)
|
||||||
security = _t_text(i18n, lang, "email_login_code_security")
|
security = _t_text(i18n, lang, f"{template_prefix}_security")
|
||||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
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:
|
if safe_magic_link:
|
||||||
text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=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
|
from ..models import SecurityThrottle
|
||||||
|
|
||||||
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
|
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
|
||||||
|
EMAIL_PASSWORD_LOGIN_SCOPE = "email_password_login"
|
||||||
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
|
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] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -866,6 +876,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Indexes to speed up financial stats and admin log queries",
|
description="Indexes to speed up financial stats and admin log queries",
|
||||||
upgrade=_migration_0022_add_indexes_for_admin_reports,
|
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)
|
username = Column(String, nullable=True, index=True)
|
||||||
email = Column(String, nullable=True, unique=True, index=True)
|
email = Column(String, nullable=True, unique=True, index=True)
|
||||||
email_verified_at = Column(DateTime(timezone=True), nullable=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_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||||
telegram_photo_url = Column(Text, nullable=True)
|
telegram_photo_url = Column(Text, nullable=True)
|
||||||
first_name = Column(String, nullable=True)
|
first_name = Column(String, nullable=True)
|
||||||
|
|||||||
+64
-1
@@ -203,6 +203,8 @@
|
|||||||
telegramLoginBusy,
|
telegramLoginBusy,
|
||||||
loginEmailFieldError,
|
loginEmailFieldError,
|
||||||
loginEmailTooltipOpen,
|
loginEmailTooltipOpen,
|
||||||
|
passwordLoginFallback,
|
||||||
|
passwordLoginMode,
|
||||||
authResendCooldown,
|
authResendCooldown,
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
} = $authStore);
|
} = $authStore);
|
||||||
@@ -239,6 +241,12 @@
|
|||||||
linkEmailStatus,
|
linkEmailStatus,
|
||||||
linkEmailIsError,
|
linkEmailIsError,
|
||||||
linkEmailResendCooldown,
|
linkEmailResendCooldown,
|
||||||
|
setPasswordBusy,
|
||||||
|
setPasswordIsError,
|
||||||
|
setPasswordOpen,
|
||||||
|
setPasswordPending,
|
||||||
|
setPasswordResendCooldown,
|
||||||
|
setPasswordStatus,
|
||||||
languageBusy,
|
languageBusy,
|
||||||
} = $accountStore);
|
} = $accountStore);
|
||||||
|
|
||||||
@@ -388,7 +396,8 @@
|
|||||||
changeConfirmOpen ||
|
changeConfirmOpen ||
|
||||||
topupModalOpen ||
|
topupModalOpen ||
|
||||||
deviceTopupModalOpen ||
|
deviceTopupModalOpen ||
|
||||||
linkEmailOpen
|
linkEmailOpen ||
|
||||||
|
setPasswordOpen
|
||||||
);
|
);
|
||||||
$: if (!tariffMode && !$billingStore.selectedPlan && plans.length) {
|
$: if (!tariffMode && !$billingStore.selectedPlan && plans.length) {
|
||||||
billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] }));
|
billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] }));
|
||||||
@@ -451,6 +460,11 @@
|
|||||||
};
|
};
|
||||||
const onPopState = () => {
|
const onPopState = () => {
|
||||||
const section = sectionFromPath(window.location.pathname);
|
const section = sectionFromPath(window.location.pathname);
|
||||||
|
if (mode === "login") {
|
||||||
|
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||||
|
screen = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mode === "app") {
|
if (mode === "app") {
|
||||||
if (section === "admin" && isAdmin) {
|
if (section === "admin" && isAdmin) {
|
||||||
screen = "admin";
|
screen = "admin";
|
||||||
@@ -471,6 +485,7 @@
|
|||||||
authStore.stopTelegramLoginWatchdog();
|
authStore.stopTelegramLoginWatchdog();
|
||||||
authStore.clearCooldownTimer();
|
authStore.clearCooldownTimer();
|
||||||
accountStore.clearLinkEmailResendTimer();
|
accountStore.clearLinkEmailResendTimer();
|
||||||
|
accountStore.clearSetPasswordResendTimer();
|
||||||
clearLanguageClickGuard();
|
clearLanguageClickGuard();
|
||||||
syncBodyScrollLock(false);
|
syncBodyScrollLock(false);
|
||||||
};
|
};
|
||||||
@@ -580,6 +595,34 @@
|
|||||||
window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`);
|
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() {
|
async function loadData() {
|
||||||
const payload = await api("/me");
|
const payload = await api("/me");
|
||||||
if (!payload.ok) throw new Error(payload.error || "load_failed");
|
if (!payload.ok) throw new Error(payload.error || "load_failed");
|
||||||
@@ -646,6 +689,7 @@
|
|||||||
mode = "login";
|
mode = "login";
|
||||||
screen = "login";
|
screen = "login";
|
||||||
activeTab = "home";
|
activeTab = "home";
|
||||||
|
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -960,6 +1004,7 @@
|
|||||||
{brandTitle}
|
{brandTitle}
|
||||||
{brand}
|
{brand}
|
||||||
bind:email={$authStore.email}
|
bind:email={$authStore.email}
|
||||||
|
bind:emailPassword={$authStore.emailPassword}
|
||||||
bind:emailCode={$authStore.emailCode}
|
bind:emailCode={$authStore.emailCode}
|
||||||
{pendingEmail}
|
{pendingEmail}
|
||||||
{authStatus}
|
{authStatus}
|
||||||
@@ -968,6 +1013,8 @@
|
|||||||
{authResendCooldown}
|
{authResendCooldown}
|
||||||
{loginEmailFieldError}
|
{loginEmailFieldError}
|
||||||
{loginEmailTooltipOpen}
|
{loginEmailTooltipOpen}
|
||||||
|
{passwordLoginFallback}
|
||||||
|
{passwordLoginMode}
|
||||||
{telegramLoginBusy}
|
{telegramLoginBusy}
|
||||||
{telegramLoginUnavailable}
|
{telegramLoginUnavailable}
|
||||||
{telegramLoginChecking}
|
{telegramLoginChecking}
|
||||||
@@ -977,6 +1024,7 @@
|
|||||||
{userAgreementUrl}
|
{userAgreementUrl}
|
||||||
{t}
|
{t}
|
||||||
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
|
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
|
||||||
|
loginWithEmailPassword={authStore.loginWithEmailPassword}
|
||||||
verifyEmailCode={authStore.verifyEmailCode}
|
verifyEmailCode={authStore.verifyEmailCode}
|
||||||
openTelegramLogin={() =>
|
openTelegramLogin={() =>
|
||||||
authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)}
|
authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)}
|
||||||
@@ -987,6 +1035,7 @@
|
|||||||
loginEmailFieldError = "";
|
loginEmailFieldError = "";
|
||||||
loginEmailTooltipOpen = false;
|
loginEmailTooltipOpen = false;
|
||||||
}}
|
}}
|
||||||
|
setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)}
|
||||||
/>
|
/>
|
||||||
{:else if screen === "admin" && isAdmin}
|
{:else if screen === "admin" && isAdmin}
|
||||||
<AdminPanel
|
<AdminPanel
|
||||||
@@ -1109,6 +1158,7 @@
|
|||||||
{openAdminPanel}
|
{openAdminPanel}
|
||||||
{openExternalLink}
|
{openExternalLink}
|
||||||
openLinkEmailDialog={accountStore.openLinkEmailDialog}
|
openLinkEmailDialog={accountStore.openLinkEmailDialog}
|
||||||
|
openSetPasswordDialog={accountStore.openSetPasswordDialog}
|
||||||
{setLanguageMenuOpen}
|
{setLanguageMenuOpen}
|
||||||
{t}
|
{t}
|
||||||
updateAccountLanguage={accountStore.updateAccountLanguage}
|
updateAccountLanguage={accountStore.updateAccountLanguage}
|
||||||
@@ -1125,6 +1175,10 @@
|
|||||||
bind:selectedMethod={$billingStore.selectedMethod}
|
bind:selectedMethod={$billingStore.selectedMethod}
|
||||||
bind:selectedPlan={$billingStore.selectedPlan}
|
bind:selectedPlan={$billingStore.selectedPlan}
|
||||||
bind:selectedTariffKey={$billingStore.selectedTariffKey}
|
bind:selectedTariffKey={$billingStore.selectedTariffKey}
|
||||||
|
bind:setPasswordCode={$accountStore.setPasswordCode}
|
||||||
|
bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
|
||||||
|
bind:setPasswordValue={$accountStore.setPasswordValue}
|
||||||
|
setPasswordEmail={user?.email || ""}
|
||||||
createPayment={billingStore.createPayment}
|
createPayment={billingStore.createPayment}
|
||||||
{deviceConfirmOpen}
|
{deviceConfirmOpen}
|
||||||
{deviceDisconnectBusy}
|
{deviceDisconnectBusy}
|
||||||
@@ -1136,6 +1190,12 @@
|
|||||||
{linkEmailPending}
|
{linkEmailPending}
|
||||||
{linkEmailResendCooldown}
|
{linkEmailResendCooldown}
|
||||||
{linkEmailStatus}
|
{linkEmailStatus}
|
||||||
|
{setPasswordBusy}
|
||||||
|
{setPasswordIsError}
|
||||||
|
{setPasswordOpen}
|
||||||
|
{setPasswordPending}
|
||||||
|
{setPasswordResendCooldown}
|
||||||
|
{setPasswordStatus}
|
||||||
{hasMultipleTariffs}
|
{hasMultipleTariffs}
|
||||||
{methods}
|
{methods}
|
||||||
{payBusy}
|
{payBusy}
|
||||||
@@ -1149,13 +1209,16 @@
|
|||||||
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
|
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
|
||||||
closeLinkEmailDialog={accountStore.closeLinkEmailDialog}
|
closeLinkEmailDialog={accountStore.closeLinkEmailDialog}
|
||||||
closePaymentModal={billingStore.closePaymentModal}
|
closePaymentModal={billingStore.closePaymentModal}
|
||||||
|
closeSetPasswordDialog={accountStore.closeSetPasswordDialog}
|
||||||
{backToTariffList}
|
{backToTariffList}
|
||||||
{continueWithSelectedTariff}
|
{continueWithSelectedTariff}
|
||||||
requestLinkEmailCode={accountStore.requestLinkEmailCode}
|
requestLinkEmailCode={accountStore.requestLinkEmailCode}
|
||||||
|
requestSetPasswordCode={accountStore.requestSetPasswordCode}
|
||||||
{selectTariff}
|
{selectTariff}
|
||||||
{t}
|
{t}
|
||||||
{termUnitLabel}
|
{termUnitLabel}
|
||||||
verifyLinkEmailCode={accountStore.verifyLinkEmailCode}
|
verifyLinkEmailCode={accountStore.verifyLinkEmailCode}
|
||||||
|
confirmSetPassword={accountStore.confirmSetPassword}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TariffDialogs
|
<TariffDialogs
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
|
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
|
||||||
variants: {
|
variants: {
|
||||||
"32": "/webapp-favicon/1111111111111111/icon-32.png",
|
32: "/webapp-favicon/1111111111111111/icon-32.png",
|
||||||
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.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 || "";
|
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_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")) {
|
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
|
||||||
DEV_MOCK.config.faviconUseCustom = Boolean(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") {
|
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
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") {
|
if (path === "/auth/token") {
|
||||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
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") {
|
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
|
||||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
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") {
|
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
|
||||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const DEV_MOCK = {
|
|||||||
username: "username",
|
username: "username",
|
||||||
email: "user@example.com",
|
email: "user@example.com",
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
|
password_auth_enabled: false,
|
||||||
telegram_id: 100200300,
|
telegram_id: 100200300,
|
||||||
telegram_linked: true,
|
telegram_linked: true,
|
||||||
telegram_photo_url: "",
|
telegram_photo_url: "",
|
||||||
|
|||||||
@@ -29,10 +29,20 @@ export function createAccountStore({
|
|||||||
linkEmailIsError: false,
|
linkEmailIsError: false,
|
||||||
linkEmailFieldError: "",
|
linkEmailFieldError: "",
|
||||||
linkEmailResendCooldown: 0,
|
linkEmailResendCooldown: 0,
|
||||||
|
setPasswordOpen: false,
|
||||||
|
setPasswordBusy: false,
|
||||||
|
setPasswordPending: false,
|
||||||
|
setPasswordValue: "",
|
||||||
|
setPasswordConfirm: "",
|
||||||
|
setPasswordCode: "",
|
||||||
|
setPasswordStatus: "",
|
||||||
|
setPasswordIsError: false,
|
||||||
|
setPasswordResendCooldown: 0,
|
||||||
languageBusy: false,
|
languageBusy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let linkEmailResendTimer = null;
|
let linkEmailResendTimer = null;
|
||||||
|
let setPasswordResendTimer = null;
|
||||||
|
|
||||||
function setLinkEmailStatus(message, isError = false) {
|
function setLinkEmailStatus(message, isError = false) {
|
||||||
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
|
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) {
|
function startCooldownTimer(seconds = 60) {
|
||||||
clearCooldownTimer();
|
clearCooldownTimer();
|
||||||
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||||
@@ -59,6 +76,20 @@ export function createAccountStore({
|
|||||||
}, 1000);
|
}, 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) {
|
function openLinkEmailDialog(email) {
|
||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -90,6 +121,65 @@ export function createAccountStore({
|
|||||||
clearCooldownTimer();
|
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() {
|
async function requestLinkEmailCode() {
|
||||||
const s = get(state);
|
const s = get(state);
|
||||||
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
|
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) {
|
async function linkTelegramAccountWithPayload(payload) {
|
||||||
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
||||||
try {
|
try {
|
||||||
@@ -228,11 +378,16 @@ export function createAccountStore({
|
|||||||
update: state.update,
|
update: state.update,
|
||||||
openLinkEmailDialog,
|
openLinkEmailDialog,
|
||||||
closeLinkEmailDialog,
|
closeLinkEmailDialog,
|
||||||
|
openSetPasswordDialog,
|
||||||
|
closeSetPasswordDialog,
|
||||||
requestLinkEmailCode,
|
requestLinkEmailCode,
|
||||||
verifyLinkEmailCode,
|
verifyLinkEmailCode,
|
||||||
|
requestSetPasswordCode,
|
||||||
|
confirmSetPassword,
|
||||||
linkTelegramAccount,
|
linkTelegramAccount,
|
||||||
updateAccountLanguage,
|
updateAccountLanguage,
|
||||||
logout,
|
logout,
|
||||||
clearLinkEmailResendTimer: clearCooldownTimer,
|
clearLinkEmailResendTimer: clearCooldownTimer,
|
||||||
|
clearSetPasswordResendTimer: clearPasswordCooldownTimer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ export function createAuthStore({
|
|||||||
loginEmailTooltipOpen: false,
|
loginEmailTooltipOpen: false,
|
||||||
authResendCooldown: 0,
|
authResendCooldown: 0,
|
||||||
email: "",
|
email: "",
|
||||||
|
emailPassword: "",
|
||||||
pendingEmail: "",
|
pendingEmail: "",
|
||||||
emailCode: "",
|
emailCode: "",
|
||||||
|
passwordLoginMode: false,
|
||||||
|
passwordLoginFallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let authResendTimer = null;
|
let authResendTimer = null;
|
||||||
@@ -169,6 +172,7 @@ export function createAuthStore({
|
|||||||
loginEmailFieldError: "",
|
loginEmailFieldError: "",
|
||||||
loginEmailTooltipOpen: false,
|
loginEmailTooltipOpen: false,
|
||||||
authBusy: true,
|
authBusy: true,
|
||||||
|
passwordLoginFallback: false,
|
||||||
}));
|
}));
|
||||||
setAuthStatus(t("wa_auth_sending_code"));
|
setAuthStatus(t("wa_auth_sending_code"));
|
||||||
try {
|
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() {
|
async function verifyEmailCode() {
|
||||||
const s = get(state);
|
const s = get(state);
|
||||||
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
|
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
|
||||||
@@ -278,6 +329,7 @@ export function createAuthStore({
|
|||||||
finalizeMagicLogin,
|
finalizeMagicLogin,
|
||||||
finalizeTelegramAuth,
|
finalizeTelegramAuth,
|
||||||
requestEmailCode,
|
requestEmailCode,
|
||||||
|
loginWithEmailPassword,
|
||||||
verifyEmailCode,
|
verifyEmailCode,
|
||||||
openTelegramLogin,
|
openTelegramLogin,
|
||||||
clearCooldownTimer,
|
clearCooldownTimer,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
.dialog-skeleton {
|
.dialog-skeleton {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -12,11 +10,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
padding:
|
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right))
|
||||||
max(14px, env(safe-area-inset-top))
|
max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
|
||||||
max(14px, env(safe-area-inset-right))
|
|
||||||
max(14px, env(safe-area-inset-bottom))
|
|
||||||
max(14px, env(safe-area-inset-left));
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
@@ -116,6 +111,26 @@
|
|||||||
min-height: 0;
|
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) {
|
@media (min-width: 720px) {
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -1290,6 +1290,22 @@ a {
|
|||||||
background: var(--success-soft);
|
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 {
|
.settings-row-linked > svg:first-child {
|
||||||
color: var(--success-text);
|
color: var(--success-text);
|
||||||
}
|
}
|
||||||
@@ -1603,8 +1619,23 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
|
box-sizing: content-box;
|
||||||
display: grid;
|
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 {
|
.auth-pane {
|
||||||
@@ -1661,6 +1692,38 @@ a {
|
|||||||
animation: telegram-button-spin 0.72s linear infinite;
|
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 {
|
@keyframes telegram-button-spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
@@ -2027,9 +2090,14 @@ a {
|
|||||||
.language-select-content,
|
.language-select-content,
|
||||||
.dialog-backdrop,
|
.dialog-backdrop,
|
||||||
.dialog-card,
|
.dialog-card,
|
||||||
|
.auth-mode-panel,
|
||||||
.toast {
|
.toast {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
||||||
|
|||||||
@@ -52,6 +52,16 @@
|
|||||||
export let selectedTariff = null;
|
export let selectedTariff = null;
|
||||||
export let selectedTariffKey = "";
|
export let selectedTariffKey = "";
|
||||||
export let selectedTariffPlans = [];
|
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 singleTariffMode = false;
|
||||||
export let subscription = {};
|
export let subscription = {};
|
||||||
export let tariffCatalog = [];
|
export let tariffCatalog = [];
|
||||||
@@ -104,13 +114,16 @@
|
|||||||
export let closeDeviceDisconnectDialog = () => {};
|
export let closeDeviceDisconnectDialog = () => {};
|
||||||
export let closeLinkEmailDialog = () => {};
|
export let closeLinkEmailDialog = () => {};
|
||||||
export let closePaymentModal = () => {};
|
export let closePaymentModal = () => {};
|
||||||
|
export let closeSetPasswordDialog = () => {};
|
||||||
export let backToTariffList = () => {};
|
export let backToTariffList = () => {};
|
||||||
export let continueWithSelectedTariff = () => {};
|
export let continueWithSelectedTariff = () => {};
|
||||||
export let requestLinkEmailCode = () => {};
|
export let requestLinkEmailCode = () => {};
|
||||||
|
export let requestSetPasswordCode = () => {};
|
||||||
export let selectTariff = () => {};
|
export let selectTariff = () => {};
|
||||||
export let t = (key) => key;
|
export let t = (key) => key;
|
||||||
export let termUnitLabel = () => "";
|
export let termUnitLabel = () => "";
|
||||||
export let verifyLinkEmailCode = () => {};
|
export let verifyLinkEmailCode = () => {};
|
||||||
|
export let confirmSetPassword = () => {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -299,6 +312,101 @@
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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
|
<Dialog
|
||||||
open={linkEmailOpen}
|
open={linkEmailOpen}
|
||||||
title={t("wa_link_email_modal_title")}
|
title={t("wa_link_email_modal_title")}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script>
|
<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 { Tooltip } from "$components/ui/primitives.js";
|
||||||
|
|
||||||
import Button from "$components/ui/button.svelte";
|
import Button from "$components/ui/button.svelte";
|
||||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||||
import Card from "$components/ui/card.svelte";
|
|
||||||
import Input from "$components/ui/input.svelte";
|
import Input from "$components/ui/input.svelte";
|
||||||
import Spinner from "$components/ui/spinner.svelte";
|
import Spinner from "$components/ui/spinner.svelte";
|
||||||
import { StatusMessage } from "$components/patterns/webapp/index.js";
|
import { StatusMessage } from "$components/patterns/webapp/index.js";
|
||||||
@@ -14,6 +20,7 @@
|
|||||||
export let brand = {};
|
export let brand = {};
|
||||||
export let brandTitle;
|
export let brandTitle;
|
||||||
export let email;
|
export let email;
|
||||||
|
export let emailPassword;
|
||||||
export let emailCode;
|
export let emailCode;
|
||||||
export let pendingEmail;
|
export let pendingEmail;
|
||||||
export let authStatus;
|
export let authStatus;
|
||||||
@@ -22,6 +29,8 @@
|
|||||||
export let authResendCooldown;
|
export let authResendCooldown;
|
||||||
export let loginEmailFieldError;
|
export let loginEmailFieldError;
|
||||||
export let loginEmailTooltipOpen;
|
export let loginEmailTooltipOpen;
|
||||||
|
export let passwordLoginFallback;
|
||||||
|
export let passwordLoginMode;
|
||||||
export let telegramLoginBusy;
|
export let telegramLoginBusy;
|
||||||
export let telegramLoginUnavailable;
|
export let telegramLoginUnavailable;
|
||||||
export let telegramLoginChecking;
|
export let telegramLoginChecking;
|
||||||
@@ -31,12 +40,19 @@
|
|||||||
export let userAgreementUrl;
|
export let userAgreementUrl;
|
||||||
export let t;
|
export let t;
|
||||||
export let requestEmailCode;
|
export let requestEmailCode;
|
||||||
|
export let loginWithEmailPassword;
|
||||||
export let verifyEmailCode;
|
export let verifyEmailCode;
|
||||||
export let openTelegramLogin;
|
export let openTelegramLogin;
|
||||||
export let openExternalLink;
|
export let openExternalLink;
|
||||||
export let submitEmailOnEnter;
|
export let submitEmailOnEnter;
|
||||||
export let onBackToLogin;
|
export let onBackToLogin;
|
||||||
export let clearLoginEmailError;
|
export let clearLoginEmailError;
|
||||||
|
export let setPasswordLoginMode;
|
||||||
|
|
||||||
|
let authPanelHeight = 0;
|
||||||
|
|
||||||
|
$: passwordModeActive = Boolean(passwordLoginMode && CFG.emailAuthEnabled !== false);
|
||||||
|
$: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="phone-screen auth-screen">
|
<div class="phone-screen auth-screen">
|
||||||
@@ -90,74 +106,168 @@
|
|||||||
<BrandMark {brand} size="xl" />
|
<BrandMark {brand} size="xl" />
|
||||||
<h1>{brandTitle}</h1>
|
<h1>{brandTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Card class="auth-card">
|
<section class="card auth-card" style:height={authCardHeight}>
|
||||||
{#if CFG.emailAuthEnabled !== false}
|
{#key passwordModeActive}
|
||||||
<div class="auth-pane">
|
<div
|
||||||
<div class="auth-email-stack">
|
class={`auth-mode-panel${passwordModeActive ? " auth-mode-panel-password" : ""}`}
|
||||||
<div class="field-error-wrap">
|
bind:clientHeight={authPanelHeight}
|
||||||
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
|
>
|
||||||
|
{#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
|
<Input
|
||||||
bind:value={email}
|
bind:value={emailPassword}
|
||||||
type="email"
|
type="password"
|
||||||
placeholder={t("wa_email_placeholder")}
|
placeholder={t("wa_password_placeholder")}
|
||||||
autocomplete="email"
|
autocomplete="current-password"
|
||||||
class={loginEmailFieldError ? "input-error" : ""}
|
on:keydown={(event) => {
|
||||||
on:keydown={submitEmailOnEnter}
|
if (event.key !== "Enter") return;
|
||||||
on:input={clearLoginEmailError}
|
event.preventDefault();
|
||||||
|
loginWithEmailPassword();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{#if loginEmailFieldError}
|
<Button class="wide" onclick={loginWithEmailPassword} disabled={authBusy}>
|
||||||
<Tooltip.Trigger class="field-error-trigger" aria-label={loginEmailFieldError}>
|
<LockKeyhole size={18} />
|
||||||
<span class="field-error-icon" aria-hidden="true"
|
{t("wa_login_password_submit")}
|
||||||
><TriangleAlert size={18} /></span
|
</Button>
|
||||||
>
|
{#if passwordLoginFallback}
|
||||||
</Tooltip.Trigger>
|
<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}
|
||||||
{#if loginEmailFieldError}
|
</div>
|
||||||
<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}>
|
{#if authStatus}
|
||||||
<Mail size={18} />
|
<StatusMessage error={authIsError} class="auth-login-status">
|
||||||
{t("wa_send_code_email")}
|
{authStatus}
|
||||||
</Button>
|
</StatusMessage>
|
||||||
</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}
|
{/if}
|
||||||
{telegramLoginLabel}
|
{:else if CFG.emailAuthEnabled !== false}
|
||||||
</span>
|
<div class="auth-pane">
|
||||||
</Button>
|
<div class="auth-email-stack">
|
||||||
</div>
|
<div class="field-error-wrap">
|
||||||
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
|
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
|
||||||
<StatusMessage
|
<Input
|
||||||
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
|
bind:value={email}
|
||||||
class="auth-login-status"
|
type="email"
|
||||||
>
|
placeholder={t("wa_email_placeholder")}
|
||||||
{authStatus || telegramLoginUnavailableMessage}
|
autocomplete="email"
|
||||||
</StatusMessage>
|
class={loginEmailFieldError ? "input-error" : ""}
|
||||||
{/if}
|
on:keydown={submitEmailOnEnter}
|
||||||
</Card>
|
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}
|
{#if userAgreementUrl || privacyPolicyUrl}
|
||||||
<div class="auth-legal">
|
<div class="auth-legal">
|
||||||
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
|
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
export let openAdminPanel = () => {};
|
export let openAdminPanel = () => {};
|
||||||
export let openExternalLink = () => {};
|
export let openExternalLink = () => {};
|
||||||
export let openLinkEmailDialog = () => {};
|
export let openLinkEmailDialog = () => {};
|
||||||
|
export let openSetPasswordDialog = () => {};
|
||||||
export let setLanguageMenuOpen = () => {};
|
export let setLanguageMenuOpen = () => {};
|
||||||
export let t = (key) => key;
|
export let t = (key) => key;
|
||||||
export let updateAccountLanguage = () => {};
|
export let updateAccountLanguage = () => {};
|
||||||
@@ -100,12 +101,24 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if user?.email}
|
{#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} />
|
<CheckCircle2 size={21} />
|
||||||
<span>
|
<span>
|
||||||
<strong>{t("wa_settings_email_linked_title")}</strong>
|
<strong>{t("wa_settings_email_linked_title")}</strong>
|
||||||
<small>{user?.email}</small>
|
<small>{user?.email}</small>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+30
-1
@@ -579,6 +579,13 @@
|
|||||||
"email_login_code_magic_or": "or",
|
"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": "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_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_subject": "Accounts merged",
|
||||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||||
"email_account_merged_heading": "Accounts merged",
|
"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_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_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_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_magic_or": "или",
|
||||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||||
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
|
"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_subject": "Аккаунты объединены",
|
||||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||||
"email_account_merged_heading": "Аккаунты объединены",
|
"email_account_merged_heading": "Аккаунты объединены",
|
||||||
@@ -1381,5 +1388,27 @@
|
|||||||
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
|
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
|
||||||
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
|
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
|
||||||
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
|
"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 import admin_api, subscription_webapp
|
||||||
from bot.app.web.admin_api_impl import settings as admin_settings_routes
|
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 (
|
from bot.app.web.webapp_auth import (
|
||||||
create_telegram_oauth_nonce,
|
create_telegram_oauth_nonce,
|
||||||
create_webapp_session_token,
|
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.freekassa import FreeKassaService
|
||||||
from bot.payment_providers.heleket import HeleketConfig, HeleketService, _compute_signature
|
from bot.payment_providers.heleket import HeleketConfig, HeleketService, _compute_signature
|
||||||
from bot.payment_providers.yookassa import yookassa_webhook_route
|
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 bot.utils.request_security import request_client_ip
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.dal import security_dal
|
||||||
from db.database_setup import redacted_database_url
|
from db.database_setup import redacted_database_url
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +200,25 @@ class HeleketServiceTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
|
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):
|
def test_auth_response_sets_cookies_and_does_not_return_session_token(self):
|
||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
WEBAPP_SESSION_SECRET="session-secret",
|
WEBAPP_SESSION_SECRET="session-secret",
|
||||||
@@ -306,6 +328,208 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(response.status, 400)
|
self.assertEqual(response.status, 400)
|
||||||
self.assertIn("email_too_long", response.text)
|
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):
|
def test_payment_payload_rejects_overlong_description(self):
|
||||||
model, response = subscription_webapp._validate_model_payload(
|
model, response = subscription_webapp._validate_model_payload(
|
||||||
subscription_webapp.WebAppPaymentCreatePayload,
|
subscription_webapp.WebAppPaymentCreatePayload,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
routes = _route_map(app)
|
routes = _route_map(app)
|
||||||
expected = {
|
expected = {
|
||||||
("GET", "/"): "index_route",
|
("GET", "/"): "index_route",
|
||||||
|
("GET", "/login/password"): "index_route",
|
||||||
("GET", "/home"): "index_route",
|
("GET", "/home"): "index_route",
|
||||||
("GET", "/invite"): "index_route",
|
("GET", "/invite"): "index_route",
|
||||||
("GET", "/devices"): "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/request"): "email_auth_request_route",
|
||||||
("POST", "/api/auth/email/verify"): "email_auth_verify_route",
|
("POST", "/api/auth/email/verify"): "email_auth_verify_route",
|
||||||
("POST", "/api/auth/email/magic"): "email_auth_magic_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",
|
("POST", "/api/auth/logout"): "logout_route",
|
||||||
("GET", "/api/me"): "me_route",
|
("GET", "/api/me"): "me_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",
|
||||||
("POST", "/api/account/email/request"): "account_email_request_route",
|
("POST", "/api/account/email/request"): "account_email_request_route",
|
||||||
("POST", "/api/account/email/verify"): "account_email_verify_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/account/telegram/link"): "account_telegram_link_route",
|
||||||
("POST", "/api/promo/apply"): "apply_promo_route",
|
("POST", "/api/promo/apply"): "apply_promo_route",
|
||||||
("POST", "/api/trial/activate"): "activate_trial_route",
|
("POST", "/api/trial/activate"): "activate_trial_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user