diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 33bf47b..59d41f0 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -41,7 +41,7 @@ from bot.app.web.webapp_auth import ( verify_telegram_oauth_nonce, verify_webapp_session_token, ) -from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key +from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key from bot.services.email_auth_service import EmailAuthService, normalize_email from bot.services.email_templates import render_account_merged from bot.services.promo_code_service import PromoCodeService @@ -51,7 +51,7 @@ from bot.utils.config_link import prepare_config_links from bot.utils.request_security import parse_ip_entries, request_client_ip from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from config.settings import Settings -from db.dal import payment_dal, subscription_dal, user_dal +from db.dal import payment_dal, security_dal, subscription_dal, user_dal from db.dal.user_dal import UserMergeConflictError from db.models import Payment, User, UserTelegramAvatar @@ -103,6 +103,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = { "/api/auth/email/request", "/api/auth/email/verify", "/api/auth/email/magic", + "/api/auth/email/password", "/api/auth/logout", } diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index a20a1ef..5a05e2c 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -1,5 +1,6 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from .auth import _hash_email_password async def account_email_request_route(request: web.Request) -> web.Response: @@ -201,6 +202,84 @@ async def account_email_verify_route(request: web.Request) -> web.Response: return _build_webapp_auth_response(settings, response_payload, token=token) +async def account_password_request_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + if not db_user.email or not db_user.email_verified_at: + return _json_error(400, "email_not_linked", "Email is not linked") + email = db_user.email + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + + return await _request_email_code( + request, + email=email, + purpose="set_password", + language_code=lang, + target_user_id=user_id, + ) + + +async def account_password_confirm_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload) + if validation_error: + return validation_error + if password_payload.password != password_payload.password_confirm: + return _json_error(400, "password_mismatch", "Passwords do not match") + + settings: Settings = request.app["settings"] + email_service: EmailAuthService = request.app["email_auth_service"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + await session.rollback() + return _json_error(403, "access_denied", "Access denied") + if not db_user.email or not db_user.email_verified_at: + await session.rollback() + return _json_error(400, "email_not_linked", "Email is not linked") + + verify_result = await email_service.verify_code( + session, + email=db_user.email, + purpose="set_password", + code=str(password_payload.code or ""), + target_user_id=user_id, + ) + if not verify_result.ok: + await session.commit() + status = 429 if verify_result.error == "rate_limited" else 400 + return web.json_response( + { + "ok": False, + "error": verify_result.error or "invalid_code", + "retry_after": verify_result.retry_after, + "message": "Invalid code", + }, + status=status, + ) + + db_user.password_hash = _hash_email_password(str(password_payload.password)) + db_user.password_set_at = datetime.now(timezone.utc) + await session.flush() + await session.commit() + except Exception: + await session.rollback() + logger.exception("Email password setup failed") + return _json_error(500, "password_setup_failed", "Password setup failed") + + await cache_delete(settings, redis_key(settings, "cache", "webapp", "me", user_id)) + return web.json_response({"ok": True, "password_auth_enabled": True}) + + async def account_telegram_link_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index cc8db5d..5c1195f 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -132,6 +132,58 @@ def _urlsafe_sha256(value: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") +PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256" +PASSWORD_HASH_ITERATIONS = 260_000 + + +def _password_hash_b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _password_hash_unb64(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode((value + padding).encode("ascii")) + + +def _hash_email_password(password: str) -> str: + salt = secrets.token_bytes(18) + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + PASSWORD_HASH_ITERATIONS, + ) + return "$".join( + [ + PASSWORD_HASH_ALGORITHM, + str(PASSWORD_HASH_ITERATIONS), + _password_hash_b64(salt), + _password_hash_b64(digest), + ] + ) + + +def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool: + if not stored_hash: + return False + try: + algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3) + if algorithm != PASSWORD_HASH_ALGORITHM: + return False + iterations = int(iterations_raw) + salt = _password_hash_unb64(salt_raw) + expected_digest = _password_hash_unb64(digest_raw) + actual_digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + except Exception: + return False + return hmac.compare_digest(actual_digest, expected_digest) + + async def _exchange_telegram_oauth_code( request: web.Request, *, @@ -438,6 +490,114 @@ async def logout_route(request: web.Request) -> web.Response: return response +def _password_login_failure_response( + *, + status: int = 401, + retry_after: Optional[int] = None, +) -> web.Response: + payload: Dict[str, Any] = { + "ok": False, + "error": "password_login_failed", + "fallback": "email_code", + "message": "Password login failed", + } + if retry_after is not None: + payload["retry_after"] = retry_after + return web.json_response(payload, status=status) + + +async def email_password_auth_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.email_auth_configured: + return _json_error(503, "email_auth_not_configured", "Email auth is not configured") + + payload = await _read_json(request) + password_payload, validation_error = _validate_model_payload( + WebAppEmailPasswordPayload, + payload, + ) + if validation_error: + return validation_error + + email = password_payload.email + password = str(password_payload.password or "") + now = datetime.now(timezone.utc) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + authenticated_user_id: Optional[int] = None + authenticated_telegram_id: Optional[int] = None + async with async_session_factory() as session: + try: + throttle = await security_dal.check_throttle( + session, + scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE, + identifier=email, + now=now, + ) + if throttle.locked: + await session.commit() + return _json_error( + 429, + "rate_limited", + "Too many password attempts", + ) + + db_user = await user_dal.get_user_by_email(session, email) + password_ok = bool( + db_user + and db_user.email_verified_at + and db_user.password_hash + and _verify_email_password(password, db_user.password_hash) + ) + + if not password_ok: + throttle_result = await security_dal.record_throttle_failure( + session, + scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE, + identifier=email, + max_failures=settings.BRUTE_FORCE_MAX_FAILURES, + window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS, + lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS, + now=now, + ) + await session.commit() + if throttle_result.locked: + return _json_error( + 429, + "rate_limited", + "Too many password attempts", + ) + return _password_login_failure_response() + + if db_user.is_banned: + await session.rollback() + return _json_error(403, "banned", "Access denied") + + await security_dal.clear_throttle_state( + session, + scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE, + identifier=email, + ) + authenticated_user_id = int(db_user.user_id) + authenticated_telegram_id = _telegram_id_for_user(db_user) + await session.commit() + except Exception: + await session.rollback() + logger.exception("Email password auth failed") + return _json_error(500, "auth_failed", "Auth failed") + + token = create_webapp_session_token(settings, int(authenticated_user_id)) + return _build_webapp_auth_response( + settings, + { + "ok": True, + "user_id": int(authenticated_user_id), + "telegram_id": authenticated_telegram_id, + }, + token=token, + ) + + async def email_auth_request_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) diff --git a/backend/bot/app/web/webapp/common.py b/backend/bot/app/web/webapp/common.py index 3d24921..d6c32e3 100644 --- a/backend/bot/app/web/webapp/common.py +++ b/backend/bot/app/web/webapp/common.py @@ -37,6 +37,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response: if field in {"description", "comment", "note"} and error_type == "string_too_long": return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long") + if field in {"password", "password_confirm"}: + if error_type == "string_too_short": + return _json_error(400, "password_too_short", "Password is too short") + if error_type == "string_too_long": + return _json_error(400, "password_too_long", "Password is too long") + return _json_error(400, "invalid_password", "Invalid password") + if error_type == "string_too_long": return _json_error(400, "text_too_long", "Text is too long") diff --git a/backend/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py index 3e7b44b..8b0b183 100644 --- a/backend/bot/app/web/webapp/payloads.py +++ b/backend/bot/app/web/webapp/payloads.py @@ -20,6 +20,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload): code: str = "" +class WebAppEmailPasswordPayload(WebAppEmailPayload): + password: constr(min_length=1, max_length=128) + + +class WebAppSetPasswordPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + password: constr(min_length=8, max_length=128) + password_confirm: constr(min_length=8, max_length=128) + code: constr(min_length=1, max_length=32) + + class WebAppEmailMagicPayload(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 5d1f5ca..4ba940b 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405 def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/", index_route) + app.router.add_get("/login/password", index_route) app.router.add_get("/home", index_route) app.router.add_get("/invite", index_route) app.router.add_get("/devices", index_route) @@ -43,6 +44,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/auth/email/request", email_auth_request_route) app.router.add_post("/api/auth/email/verify", email_auth_verify_route) app.router.add_post("/api/auth/email/magic", email_auth_magic_route) + app.router.add_post("/api/auth/email/password", email_password_auth_route) app.router.add_post("/api/auth/logout", logout_route) app.router.add_get("/api/bootstrap", bootstrap_route) app.router.add_get("/api/me", me_route) @@ -50,6 +52,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/account/language", account_language_route) app.router.add_post("/api/account/email/request", account_email_request_route) app.router.add_post("/api/account/email/verify", account_email_verify_route) + app.router.add_post("/api/account/password/request", account_password_request_route) + app.router.add_post("/api/account/password/confirm", account_password_confirm_route) app.router.add_post("/api/account/telegram/link", account_telegram_link_route) app.router.add_post("/api/promo/apply", apply_promo_route) app.router.add_post("/api/trial/activate", activate_trial_route) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index a6e91a3..855fd8b 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -67,6 +67,9 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "username": db_user.username, "email": db_user.email, "email_verified": bool(db_user.email_verified_at), + "password_auth_enabled": bool( + db_user.email and db_user.email_verified_at and db_user.password_hash + ), "telegram_id": db_user.telegram_id, "telegram_linked": bool(_telegram_id_for_user(db_user)), "telegram_photo_url": _telegram_avatar_url(avatar), diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py index 2e2d389..c9afd1d 100644 --- a/backend/bot/services/email_auth_service.py +++ b/backend/bot/services/email_auth_service.py @@ -196,7 +196,11 @@ class EmailAuthService: code = f"{secrets.randbelow(1_000_000):06d}" magic_token = secrets.token_urlsafe(32) - magic_link = self._build_magic_link(token=magic_token, purpose=purpose) + magic_link = ( + self._build_magic_link(token=magic_token, purpose=purpose) + if purpose == "login" + else None + ) code_model = EmailVerificationCode( email=normalized_email, code_hash=self._hash_code(normalized_email, purpose, code), @@ -214,6 +218,7 @@ class EmailAuthService: code=code, language_code=language_code, magic_link=magic_link, + purpose=purpose, ) return EmailCodeRequestResult(ok=True) @@ -405,6 +410,7 @@ class EmailAuthService: code: str, language_code: str, magic_link: Optional[str] = None, + purpose: str = "login", ) -> None: await asyncio.to_thread( self._send_code_email_sync, @@ -412,6 +418,7 @@ class EmailAuthService: code=code, language_code=language_code, magic_link=magic_link, + purpose=purpose, ) async def send_custom_email( @@ -450,12 +457,14 @@ class EmailAuthService: code: str, language_code: str, magic_link: Optional[str] = None, + purpose: str = "login", ) -> None: content = render_login_code( self.settings, code=code, language_code=language_code, magic_link=magic_link, + purpose=purpose, ) message = EmailMessage() diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index a17ac85..afe32b8 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -209,6 +209,7 @@ def render_login_code( code: str, language_code: Optional[str], magic_link: Optional[str] = None, + purpose: str = "login", i18n: Optional[JsonI18n] = None, ) -> EmailContent: i18n = _resolve_i18n(i18n) @@ -216,16 +217,17 @@ def render_login_code( minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS) accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) brand = _brand_title(settings) - safe_magic_link = (magic_link or "").strip() + template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code" + safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else "" - subject = _t_text(i18n, lang, "email_login_code_subject", code=code) - preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes) - heading = _t_text(i18n, lang, "email_login_code_heading") - intro = _t_text(i18n, lang, "email_login_code_intro") - expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes) - security = _t_text(i18n, lang, "email_login_code_security") + subject = _t_text(i18n, lang, f"{template_prefix}_subject", code=code) + preheader = _t_text(i18n, lang, f"{template_prefix}_preheader", minutes=minutes) + heading = _t_text(i18n, lang, f"{template_prefix}_heading") + intro = _t_text(i18n, lang, f"{template_prefix}_intro") + expiry_html = _t_html(i18n, lang, f"{template_prefix}_expiry_html", minutes=minutes) + security = _t_text(i18n, lang, f"{template_prefix}_security") footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) - text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)] + text_lines = [_t_text(i18n, lang, f"{template_prefix}_text", code=code, minutes=minutes)] if safe_magic_link: text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)) diff --git a/backend/db/dal/security_dal.py b/backend/db/dal/security_dal.py index 1662422..65633ab 100644 --- a/backend/db/dal/security_dal.py +++ b/backend/db/dal/security_dal.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..models import SecurityThrottle EMAIL_CODE_VERIFY_SCOPE = "email_code_verify" +EMAIL_PASSWORD_LOGIN_SCOPE = "email_password_login" PROMO_CODE_APPLY_SCOPE = "promo_code_apply" diff --git a/backend/db/migrator.py b/backend/db/migrator.py index cc7c926..1920971 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -744,6 +744,16 @@ def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> Non ) +def _migration_0023_add_email_password_auth_fields(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + + if "password_hash" not in columns: + connection.execute(text("ALTER TABLE users ADD COLUMN password_hash VARCHAR")) + if "password_set_at" not in columns: + connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ")) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -866,6 +876,11 @@ MIGRATIONS: List[Migration] = [ description="Indexes to speed up financial stats and admin log queries", upgrade=_migration_0022_add_indexes_for_admin_reports, ), + Migration( + id="0023_add_email_password_auth_fields", + description="Store hashed passwords for optional email password login", + upgrade=_migration_0023_add_email_password_auth_fields, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 3ce5338..97b9438 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -29,6 +29,8 @@ class User(Base): username = Column(String, nullable=True, index=True) email = Column(String, nullable=True, unique=True, index=True) email_verified_at = Column(DateTime(timezone=True), nullable=True) + password_hash = Column(String, nullable=True) + password_set_at = Column(DateTime(timezone=True), nullable=True) telegram_id = Column(BigInteger, nullable=True, unique=True, index=True) telegram_photo_url = Column(Text, nullable=True) first_name = Column(String, nullable=True) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index fbb9161..5ec95ac 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -203,6 +203,8 @@ telegramLoginBusy, loginEmailFieldError, loginEmailTooltipOpen, + passwordLoginFallback, + passwordLoginMode, authResendCooldown, pendingEmail, } = $authStore); @@ -239,6 +241,12 @@ linkEmailStatus, linkEmailIsError, linkEmailResendCooldown, + setPasswordBusy, + setPasswordIsError, + setPasswordOpen, + setPasswordPending, + setPasswordResendCooldown, + setPasswordStatus, languageBusy, } = $accountStore); @@ -388,7 +396,8 @@ changeConfirmOpen || topupModalOpen || deviceTopupModalOpen || - linkEmailOpen + linkEmailOpen || + setPasswordOpen ); $: if (!tariffMode && !$billingStore.selectedPlan && plans.length) { billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] })); @@ -451,6 +460,11 @@ }; const onPopState = () => { const section = sectionFromPath(window.location.pathname); + if (mode === "login") { + setPasswordLoginMode(isPasswordLoginPath(), true); + screen = "login"; + return; + } if (mode === "app") { if (section === "admin" && isAdmin) { screen = "admin"; @@ -471,6 +485,7 @@ authStore.stopTelegramLoginWatchdog(); authStore.clearCooldownTimer(); accountStore.clearLinkEmailResendTimer(); + accountStore.clearSetPasswordResendTimer(); clearLanguageClickGuard(); syncBodyScrollLock(false); }; @@ -580,6 +595,34 @@ window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`); } + function isPasswordLoginPath(pathname = window.location.pathname) { + return ( + String(pathname || "") + .replace(/\/+$/, "") + .toLowerCase() === "/login/password" + ); + } + + function syncPasswordLoginPath(enabled, replace = false) { + if (typeof window === "undefined" || window.location.protocol === "file:") return; + const targetPath = enabled ? "/login/password" : "/"; + if (window.location.pathname === targetPath) return; + const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; + window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); + } + + function setPasswordLoginMode(enabled, replace = false) { + const nextEnabled = Boolean(enabled); + authStore.update((s) => ({ + ...s, + passwordLoginMode: nextEnabled, + passwordLoginFallback: false, + authStatus: "", + authIsError: false, + })); + syncPasswordLoginPath(nextEnabled, replace); + } + async function loadData() { const payload = await api("/me"); if (!payload.ok) throw new Error(payload.error || "load_failed"); @@ -646,6 +689,7 @@ mode = "login"; screen = "login"; activeTab = "home"; + setPasswordLoginMode(isPasswordLoginPath(), true); } async function api(path, options = {}) { @@ -960,6 +1004,7 @@ {brandTitle} {brand} bind:email={$authStore.email} + bind:emailPassword={$authStore.emailPassword} bind:emailCode={$authStore.emailCode} {pendingEmail} {authStatus} @@ -968,6 +1013,8 @@ {authResendCooldown} {loginEmailFieldError} {loginEmailTooltipOpen} + {passwordLoginFallback} + {passwordLoginMode} {telegramLoginBusy} {telegramLoginUnavailable} {telegramLoginChecking} @@ -977,6 +1024,7 @@ {userAgreementUrl} {t} requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))} + loginWithEmailPassword={authStore.loginWithEmailPassword} verifyEmailCode={authStore.verifyEmailCode} openTelegramLogin={() => authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)} @@ -987,6 +1035,7 @@ loginEmailFieldError = ""; loginEmailTooltipOpen = false; }} + setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)} /> {:else if screen === "admin" && isAdmin} ({ ...s, linkEmailStatus: message, linkEmailIsError: isError })); @@ -45,6 +55,13 @@ export function createAccountStore({ } } + function clearPasswordCooldownTimer() { + if (setPasswordResendTimer) { + window.clearInterval(setPasswordResendTimer); + setPasswordResendTimer = null; + } + } + function startCooldownTimer(seconds = 60) { clearCooldownTimer(); state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) })); @@ -59,6 +76,20 @@ export function createAccountStore({ }, 1000); } + function startPasswordCooldownTimer(seconds = 60) { + clearPasswordCooldownTimer(); + state.update((s) => ({ ...s, setPasswordResendCooldown: Math.max(0, Number(seconds || 60)) })); + setPasswordResendTimer = window.setInterval(() => { + const s = get(state); + if (s.setPasswordResendCooldown <= 1) { + state.update((s) => ({ ...s, setPasswordResendCooldown: 0 })); + clearPasswordCooldownTimer(); + return; + } + state.update((s) => ({ ...s, setPasswordResendCooldown: s.setPasswordResendCooldown - 1 })); + }, 1000); + } + function openLinkEmailDialog(email) { state.update((s) => ({ ...s, @@ -90,6 +121,65 @@ export function createAccountStore({ clearCooldownTimer(); } + function setPasswordStatus(message, isError = false) { + state.update((s) => ({ + ...s, + setPasswordStatus: message, + setPasswordIsError: isError, + })); + } + + function openSetPasswordDialog() { + state.update((s) => ({ + ...s, + setPasswordOpen: true, + setPasswordBusy: false, + setPasswordPending: false, + setPasswordValue: "", + setPasswordConfirm: "", + setPasswordCode: "", + setPasswordStatus: "", + setPasswordIsError: false, + setPasswordResendCooldown: 0, + })); + clearPasswordCooldownTimer(); + } + + function closeSetPasswordDialog() { + state.update((s) => ({ + ...s, + setPasswordOpen: false, + setPasswordBusy: false, + setPasswordPending: false, + setPasswordValue: "", + setPasswordConfirm: "", + setPasswordCode: "", + setPasswordStatus: "", + setPasswordIsError: false, + setPasswordResendCooldown: 0, + })); + clearPasswordCooldownTimer(); + } + + function validatePasswordDraft() { + const s = get(state); + const password = String(s.setPasswordValue || ""); + const passwordConfirm = String(s.setPasswordConfirm || ""); + if (password.length < 8) { + setPasswordStatus(t("wa_password_too_short"), true); + return false; + } + if (password.length > 128) { + setPasswordStatus(t("wa_password_too_long"), true); + return false; + } + if (password !== passwordConfirm) { + setPasswordStatus(t("wa_password_mismatch"), true); + return false; + } + return true; + } + async function requestLinkEmailCode() { const s = get(state); if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return; @@ -150,6 +240,66 @@ export function createAccountStore({ } } + async function requestSetPasswordCode() { + const s = get(state); + if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return; + if (!validatePasswordDraft()) return; + state.update((s) => ({ ...s, setPasswordBusy: true })); + setPasswordStatus(t("wa_auth_sending_code")); + try { + const response = await api("/account/password/request", { + method: "POST", + body: JSON.stringify({}), + }); + if (!response?.ok) throw response; + state.update((s) => ({ ...s, setPasswordPending: true, setPasswordCode: "" })); + setPasswordStatus(""); + startPasswordCooldownTimer(60); + } catch (error) { + setPasswordStatus(emailError(error, t("wa_password_code_send_failed"), t), true); + } finally { + state.update((s) => ({ ...s, setPasswordBusy: false })); + } + } + + async function confirmSetPassword() { + const s = get(state); + if (!validatePasswordDraft()) return; + const code = String(s.setPasswordCode || "") + .replace(/\D/g, "") + .slice(0, 6); + if (code.length !== 6) { + setPasswordStatus(t("wa_auth_enter_code_6digits"), true); + return; + } + state.update((s) => ({ ...s, setPasswordBusy: true })); + setPasswordStatus(t("wa_auth_checking_code")); + try { + const response = await api("/account/password/confirm", { + method: "POST", + body: JSON.stringify({ + password: s.setPasswordValue, + password_confirm: s.setPasswordConfirm, + code, + }), + }); + if (!response?.ok) throw response; + await loadData(); + closeSetPasswordDialog(); + showToast(t("wa_password_set_success")); + } catch (error) { + const fallback = + error?.error === "password_mismatch" + ? t("wa_password_mismatch") + : error?.error === "password_too_short" + ? t("wa_password_too_short") + : t("wa_password_set_failed"); + setPasswordStatus(emailError(error, fallback, t), true); + } finally { + state.update((s) => ({ ...s, setPasswordBusy: false })); + } + } + async function linkTelegramAccountWithPayload(payload) { state.update((s) => ({ ...s, linkTelegramBusy: true })); try { @@ -228,11 +378,16 @@ export function createAccountStore({ update: state.update, openLinkEmailDialog, closeLinkEmailDialog, + openSetPasswordDialog, + closeSetPasswordDialog, requestLinkEmailCode, verifyLinkEmailCode, + requestSetPasswordCode, + confirmSetPassword, linkTelegramAccount, updateAccountLanguage, logout, clearLinkEmailResendTimer: clearCooldownTimer, + clearSetPasswordResendTimer: clearPasswordCooldownTimer, }; } diff --git a/frontend/src/lib/webapp/stores/authStore.js b/frontend/src/lib/webapp/stores/authStore.js index 8d42af7..8545e15 100644 --- a/frontend/src/lib/webapp/stores/authStore.js +++ b/frontend/src/lib/webapp/stores/authStore.js @@ -25,8 +25,11 @@ export function createAuthStore({ loginEmailTooltipOpen: false, authResendCooldown: 0, email: "", + emailPassword: "", pendingEmail: "", emailCode: "", + passwordLoginMode: false, + passwordLoginFallback: false, }); let authResendTimer = null; @@ -169,6 +172,7 @@ export function createAuthStore({ loginEmailFieldError: "", loginEmailTooltipOpen: false, authBusy: true, + passwordLoginFallback: false, })); setAuthStatus(t("wa_auth_sending_code")); try { @@ -188,6 +192,53 @@ export function createAuthStore({ } } + async function loginWithEmailPassword() { + const s = get(state); + const normalized = s.email.trim().toLowerCase(); + const password = String(s.emailPassword || ""); + if (!normalized || !normalized.includes("@")) { + state.update((s) => ({ + ...s, + loginEmailFieldError: t("wa_auth_invalid_email"), + loginEmailTooltipOpen: true, + })); + return; + } + if (!password) { + setAuthStatus(t("wa_auth_password_required"), true); + return; + } + state.update((s) => ({ + ...s, + loginEmailFieldError: "", + loginEmailTooltipOpen: false, + authBusy: true, + passwordLoginFallback: false, + })); + setAuthStatus(t("wa_auth_checking_password")); + try { + const response = await publicApi("/auth/email/password", { + email: normalized, + password, + }); + if (!response.ok || !response.csrf_token) throw response; + setToken("", response.csrf_token); + await loadData(); + setAuthStatus(""); + } catch (error) { + if (error?.error === "rate_limited") { + setAuthStatus(emailError(error, t("wa_auth_password_login_failed"), t), true); + } else if (error?.error === "banned") { + setAuthStatus(t("wa_auth_access_denied"), true); + } else { + state.update((s) => ({ ...s, passwordLoginFallback: true })); + setAuthStatus(t("wa_auth_password_login_failed"), true); + } + } finally { + state.update((s) => ({ ...s, authBusy: false })); + } + } + async function verifyEmailCode() { const s = get(state); const code = s.emailCode.replace(/\\D/g, "").slice(0, 6); @@ -278,6 +329,7 @@ export function createAuthStore({ finalizeMagicLogin, finalizeTelegramAuth, requestEmailCode, + loginWithEmailPassword, verifyEmailCode, openTelegramLogin, clearCooldownTimer, diff --git a/frontend/src/styles/dialogs.css b/frontend/src/styles/dialogs.css index 40cca5c..f08be8b 100644 --- a/frontend/src/styles/dialogs.css +++ b/frontend/src/styles/dialogs.css @@ -1,5 +1,3 @@ - - .dialog-skeleton { display: grid; gap: 12px; @@ -12,11 +10,8 @@ display: grid; align-items: center; justify-items: center; - padding: - max(14px, env(safe-area-inset-top)) - max(14px, env(safe-area-inset-right)) - max(14px, env(safe-area-inset-bottom)) - max(14px, env(safe-area-inset-left)); + padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) + max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left)); overflow: hidden; isolation: isolate; } @@ -116,6 +111,26 @@ min-height: 0; } +.password-code-fullscreen { + position: fixed; + inset: 0; + z-index: 220; + background: var(--bg); + overflow-y: auto; + overscroll-behavior: contain; + animation: password-code-fullscreen-enter 0.2s ease-out both; +} + +@keyframes password-code-fullscreen-enter { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + @media (min-width: 720px) { .dialog-card { border-radius: var(--radius-lg); diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 62a9001..0f49f5d 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -1290,6 +1290,22 @@ a { background: var(--success-soft); } +.settings-row-linked-with-action { + grid-template-columns: 28px minmax(0, 1fr) max-content; +} + +.settings-inline-action { + justify-self: end; + min-height: 34px; + max-width: 132px; + padding-left: 10px; + padding-right: 10px; + font-size: 11px; + font-weight: 500; + line-height: 1.1; + white-space: nowrap; +} + .settings-row-linked > svg:first-child { color: var(--success-text); } @@ -1603,8 +1619,23 @@ a { } .auth-card { + box-sizing: content-box; display: grid; - gap: 14px; + align-content: start; + overflow: hidden; + transition: height 0.22s ease; + will-change: height; +} + +.auth-mode-panel { + display: grid; + gap: 10px; + min-width: 0; + animation: auth-mode-enter 0.18s ease-out both; +} + +.auth-mode-panel-password { + padding-top: 2px; } .auth-pane { @@ -1661,6 +1692,38 @@ a { animation: telegram-button-spin 0.72s linear infinite; } +.password-switch-divider { + height: 1px; + background: var(--border); +} + +.password-switch-stack { + display: grid; + gap: 5px; + justify-items: center; + margin-top: -3px; +} + +.password-switch-button, +.auth-code-fallback { + width: fit-content; + justify-self: center; + margin-right: auto; + margin-left: auto; +} + +@keyframes auth-mode-enter { + from { + opacity: 0; + transform: translateY(5px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @keyframes telegram-button-spin { to { transform: rotate(360deg); @@ -2027,9 +2090,14 @@ a { .language-select-content, .dialog-backdrop, .dialog-card, + .auth-mode-panel, .toast { animation: none !important; } + + .auth-card { + transition: none !important; + } } @supports not (color: color-mix(in srgb, #000 50%, #fff)) { diff --git a/frontend/src/webapp/PaymentDialogs.svelte b/frontend/src/webapp/PaymentDialogs.svelte index 60e80dd..eaaafb9 100644 --- a/frontend/src/webapp/PaymentDialogs.svelte +++ b/frontend/src/webapp/PaymentDialogs.svelte @@ -52,6 +52,16 @@ export let selectedTariff = null; export let selectedTariffKey = ""; export let selectedTariffPlans = []; + export let setPasswordBusy = false; + export let setPasswordCode = ""; + export let setPasswordConfirm = ""; + export let setPasswordEmail = ""; + export let setPasswordIsError = false; + export let setPasswordOpen = false; + export let setPasswordPending = false; + export let setPasswordResendCooldown = 0; + export let setPasswordStatus = ""; + export let setPasswordValue = ""; export let singleTariffMode = false; export let subscription = {}; export let tariffCatalog = []; @@ -104,13 +114,16 @@ export let closeDeviceDisconnectDialog = () => {}; export let closeLinkEmailDialog = () => {}; export let closePaymentModal = () => {}; + export let closeSetPasswordDialog = () => {}; export let backToTariffList = () => {}; export let continueWithSelectedTariff = () => {}; export let requestLinkEmailCode = () => {}; + export let requestSetPasswordCode = () => {}; export let selectTariff = () => {}; export let t = (key) => key; export let termUnitLabel = () => ""; export let verifyLinkEmailCode = () => {}; + export let confirmSetPassword = () => {}; + +
+ + { + if (event.key !== "Enter") return; + event.preventDefault(); + requestSetPasswordCode(); + }} + /> + + {#if setPasswordStatus} + {setPasswordStatus} + {/if} +
+
+ +{#if setPasswordOpen && setPasswordPending} + +{/if} + - import { ArrowLeft, Mail, RefreshCw, Send, TriangleAlert } from "$components/ui/icons.js"; + import { + ArrowLeft, + LockKeyhole, + Mail, + RefreshCw, + Send, + TriangleAlert, + } from "$components/ui/icons.js"; import { Tooltip } from "$components/ui/primitives.js"; import Button from "$components/ui/button.svelte"; import BrandMark from "$lib/webapp/BrandMark.svelte"; - import Card from "$components/ui/card.svelte"; import Input from "$components/ui/input.svelte"; import Spinner from "$components/ui/spinner.svelte"; import { StatusMessage } from "$components/patterns/webapp/index.js"; @@ -14,6 +20,7 @@ export let brand = {}; export let brandTitle; export let email; + export let emailPassword; export let emailCode; export let pendingEmail; export let authStatus; @@ -22,6 +29,8 @@ export let authResendCooldown; export let loginEmailFieldError; export let loginEmailTooltipOpen; + export let passwordLoginFallback; + export let passwordLoginMode; export let telegramLoginBusy; export let telegramLoginUnavailable; export let telegramLoginChecking; @@ -31,12 +40,19 @@ export let userAgreementUrl; export let t; export let requestEmailCode; + export let loginWithEmailPassword; export let verifyEmailCode; export let openTelegramLogin; export let openExternalLink; export let submitEmailOnEnter; export let onBackToLogin; export let clearLoginEmailError; + export let setPasswordLoginMode; + + let authPanelHeight = 0; + + $: passwordModeActive = Boolean(passwordLoginMode && CFG.emailAuthEnabled !== false); + $: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
@@ -90,74 +106,168 @@

{brandTitle}

- - {#if CFG.emailAuthEnabled !== false} -
-
-
- +
+ {#key passwordModeActive} +
+ {#if passwordModeActive} +
+
+
+ + + {#if loginEmailFieldError} + + + + {/if} + {#if loginEmailFieldError} + + {loginEmailFieldError} + + {/if} + +
{ + if (event.key !== "Enter") return; + event.preventDefault(); + loginWithEmailPassword(); + }} /> - {#if loginEmailFieldError} - - - + + {#if passwordLoginFallback} + + {:else} + {/if} - {#if loginEmailFieldError} - - {loginEmailFieldError} - - {/if} - +
- -
-
- {/if} - {#if CFG.emailAuthEnabled !== false} -
{t("wa_or")}
- {/if} -
- -
- {#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)} - - {/if} - + {:else if CFG.emailAuthEnabled !== false} +
+
+
+ + + {#if loginEmailFieldError} + + + + {/if} + {#if loginEmailFieldError} + + {loginEmailFieldError} + + {/if} + +
+ +
+
+
{t("wa_or")}
+
+ +
+
+ + +
+ {#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)} + + {/if} + {/if} +
+ {/key} + {#if userAgreementUrl || privacyPolicyUrl}