feat: email and password login

This commit is contained in:
3252a8
2026-05-19 15:21:02 +03:00
parent 3152631911
commit f5006af6c0
26 changed files with 1273 additions and 90 deletions
+3 -2
View File
@@ -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",
}
+79
View File
@@ -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"]
+160
View File
@@ -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)
+7
View File
@@ -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")
+12
View File
@@ -20,6 +20,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
code: str = ""
class WebAppEmailPasswordPayload(WebAppEmailPayload):
password: constr(min_length=1, max_length=128)
class WebAppSetPasswordPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
password: constr(min_length=8, max_length=128)
password_confirm: constr(min_length=8, max_length=128)
code: constr(min_length=1, max_length=32)
class WebAppEmailMagicPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
+4
View File
@@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route)
app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route)
@@ -43,6 +44,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
app.router.add_post("/api/auth/email/password", email_password_auth_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/me", me_route)
@@ -50,6 +52,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
app.router.add_post("/api/account/password/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
@@ -67,6 +67,9 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"password_auth_enabled": bool(
db_user.email and db_user.email_verified_at and db_user.password_hash
),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_photo_url": _telegram_avatar_url(avatar),
+10 -1
View File
@@ -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()
+10 -8
View File
@@ -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))
+1
View File
@@ -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"
+15
View File
@@ -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,
),
]
+2
View File
@@ -29,6 +29,8 @@ class User(Base):
username = Column(String, nullable=True, index=True)
email = Column(String, nullable=True, unique=True, index=True)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
password_hash = Column(String, nullable=True)
password_set_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=True)
first_name = Column(String, nullable=True)
+64 -1
View File
@@ -203,6 +203,8 @@
telegramLoginBusy,
loginEmailFieldError,
loginEmailTooltipOpen,
passwordLoginFallback,
passwordLoginMode,
authResendCooldown,
pendingEmail,
} = $authStore);
@@ -239,6 +241,12 @@
linkEmailStatus,
linkEmailIsError,
linkEmailResendCooldown,
setPasswordBusy,
setPasswordIsError,
setPasswordOpen,
setPasswordPending,
setPasswordResendCooldown,
setPasswordStatus,
languageBusy,
} = $accountStore);
@@ -388,7 +396,8 @@
changeConfirmOpen ||
topupModalOpen ||
deviceTopupModalOpen ||
linkEmailOpen
linkEmailOpen ||
setPasswordOpen
);
$: if (!tariffMode && !$billingStore.selectedPlan && plans.length) {
billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] }));
@@ -451,6 +460,11 @@
};
const onPopState = () => {
const section = sectionFromPath(window.location.pathname);
if (mode === "login") {
setPasswordLoginMode(isPasswordLoginPath(), true);
screen = "login";
return;
}
if (mode === "app") {
if (section === "admin" && isAdmin) {
screen = "admin";
@@ -471,6 +485,7 @@
authStore.stopTelegramLoginWatchdog();
authStore.clearCooldownTimer();
accountStore.clearLinkEmailResendTimer();
accountStore.clearSetPasswordResendTimer();
clearLanguageClickGuard();
syncBodyScrollLock(false);
};
@@ -580,6 +595,34 @@
window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`);
}
function isPasswordLoginPath(pathname = window.location.pathname) {
return (
String(pathname || "")
.replace(/\/+$/, "")
.toLowerCase() === "/login/password"
);
}
function syncPasswordLoginPath(enabled, replace = false) {
if (typeof window === "undefined" || window.location.protocol === "file:") return;
const targetPath = enabled ? "/login/password" : "/";
if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
}
function setPasswordLoginMode(enabled, replace = false) {
const nextEnabled = Boolean(enabled);
authStore.update((s) => ({
...s,
passwordLoginMode: nextEnabled,
passwordLoginFallback: false,
authStatus: "",
authIsError: false,
}));
syncPasswordLoginPath(nextEnabled, replace);
}
async function loadData() {
const payload = await api("/me");
if (!payload.ok) throw new Error(payload.error || "load_failed");
@@ -646,6 +689,7 @@
mode = "login";
screen = "login";
activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true);
}
async function api(path, options = {}) {
@@ -960,6 +1004,7 @@
{brandTitle}
{brand}
bind:email={$authStore.email}
bind:emailPassword={$authStore.emailPassword}
bind:emailCode={$authStore.emailCode}
{pendingEmail}
{authStatus}
@@ -968,6 +1013,8 @@
{authResendCooldown}
{loginEmailFieldError}
{loginEmailTooltipOpen}
{passwordLoginFallback}
{passwordLoginMode}
{telegramLoginBusy}
{telegramLoginUnavailable}
{telegramLoginChecking}
@@ -977,6 +1024,7 @@
{userAgreementUrl}
{t}
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
loginWithEmailPassword={authStore.loginWithEmailPassword}
verifyEmailCode={authStore.verifyEmailCode}
openTelegramLogin={() =>
authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)}
@@ -987,6 +1035,7 @@
loginEmailFieldError = "";
loginEmailTooltipOpen = false;
}}
setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)}
/>
{:else if screen === "admin" && isAdmin}
<AdminPanel
@@ -1109,6 +1158,7 @@
{openAdminPanel}
{openExternalLink}
openLinkEmailDialog={accountStore.openLinkEmailDialog}
openSetPasswordDialog={accountStore.openSetPasswordDialog}
{setLanguageMenuOpen}
{t}
updateAccountLanguage={accountStore.updateAccountLanguage}
@@ -1125,6 +1175,10 @@
bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedPlan={$billingStore.selectedPlan}
bind:selectedTariffKey={$billingStore.selectedTariffKey}
bind:setPasswordCode={$accountStore.setPasswordCode}
bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
bind:setPasswordValue={$accountStore.setPasswordValue}
setPasswordEmail={user?.email || ""}
createPayment={billingStore.createPayment}
{deviceConfirmOpen}
{deviceDisconnectBusy}
@@ -1136,6 +1190,12 @@
{linkEmailPending}
{linkEmailResendCooldown}
{linkEmailStatus}
{setPasswordBusy}
{setPasswordIsError}
{setPasswordOpen}
{setPasswordPending}
{setPasswordResendCooldown}
{setPasswordStatus}
{hasMultipleTariffs}
{methods}
{payBusy}
@@ -1149,13 +1209,16 @@
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
closeLinkEmailDialog={accountStore.closeLinkEmailDialog}
closePaymentModal={billingStore.closePaymentModal}
closeSetPasswordDialog={accountStore.closeSetPasswordDialog}
{backToTariffList}
{continueWithSelectedTariff}
requestLinkEmailCode={accountStore.requestLinkEmailCode}
requestSetPasswordCode={accountStore.requestSetPasswordCode}
{selectTariff}
{t}
{termUnitLabel}
verifyLinkEmailCode={accountStore.verifyLinkEmailCode}
confirmSetPassword={accountStore.confirmSetPassword}
/>
<TariffDialogs
+19 -2
View File
@@ -231,7 +231,7 @@ export async function mockApi(path, options = {}, context = {}) {
ok: true,
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
variants: {
"32": "/webapp-favicon/1111111111111111/icon-32.png",
32: "/webapp-favicon/1111111111111111/icon-32.png",
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png",
},
};
@@ -254,7 +254,8 @@ export async function mockApi(path, options = {}, context = {}) {
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) {
DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
DEV_MOCK.config.faviconUrl =
updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM);
@@ -337,6 +338,9 @@ export async function mockApi(path, options = {}, context = {}) {
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (path === "/auth/email/password") {
return { ok: false, error: "password_login_failed", fallback: "email_code" };
}
if (path === "/auth/token") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
@@ -403,6 +407,19 @@ export async function mockApi(path, options = {}, context = {}) {
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (
path === "/account/password/request" &&
String(options.method || "").toUpperCase() === "POST"
) {
return { ok: true };
}
if (
path === "/account/password/confirm" &&
String(options.method || "").toUpperCase() === "POST"
) {
DEV_MOCK.data.user.password_auth_enabled = true;
return { ok: true, password_auth_enabled: true };
}
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
+1
View File
@@ -84,6 +84,7 @@ export const DEV_MOCK = {
username: "username",
email: "user@example.com",
email_verified: true,
password_auth_enabled: false,
telegram_id: 100200300,
telegram_linked: true,
telegram_photo_url: "",
@@ -29,10 +29,20 @@ export function createAccountStore({
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailResendCooldown: 0,
setPasswordOpen: false,
setPasswordBusy: false,
setPasswordPending: false,
setPasswordValue: "",
setPasswordConfirm: "",
setPasswordCode: "",
setPasswordStatus: "",
setPasswordIsError: false,
setPasswordResendCooldown: 0,
languageBusy: false,
});
let linkEmailResendTimer = null;
let setPasswordResendTimer = null;
function setLinkEmailStatus(message, isError = false) {
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
@@ -45,6 +55,13 @@ export function createAccountStore({
}
}
function clearPasswordCooldownTimer() {
if (setPasswordResendTimer) {
window.clearInterval(setPasswordResendTimer);
setPasswordResendTimer = null;
}
}
function startCooldownTimer(seconds = 60) {
clearCooldownTimer();
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
@@ -59,6 +76,20 @@ export function createAccountStore({
}, 1000);
}
function startPasswordCooldownTimer(seconds = 60) {
clearPasswordCooldownTimer();
state.update((s) => ({ ...s, setPasswordResendCooldown: Math.max(0, Number(seconds || 60)) }));
setPasswordResendTimer = window.setInterval(() => {
const s = get(state);
if (s.setPasswordResendCooldown <= 1) {
state.update((s) => ({ ...s, setPasswordResendCooldown: 0 }));
clearPasswordCooldownTimer();
return;
}
state.update((s) => ({ ...s, setPasswordResendCooldown: s.setPasswordResendCooldown - 1 }));
}, 1000);
}
function openLinkEmailDialog(email) {
state.update((s) => ({
...s,
@@ -90,6 +121,65 @@ export function createAccountStore({
clearCooldownTimer();
}
function setPasswordStatus(message, isError = false) {
state.update((s) => ({
...s,
setPasswordStatus: message,
setPasswordIsError: isError,
}));
}
function openSetPasswordDialog() {
state.update((s) => ({
...s,
setPasswordOpen: true,
setPasswordBusy: false,
setPasswordPending: false,
setPasswordValue: "",
setPasswordConfirm: "",
setPasswordCode: "",
setPasswordStatus: "",
setPasswordIsError: false,
setPasswordResendCooldown: 0,
}));
clearPasswordCooldownTimer();
}
function closeSetPasswordDialog() {
state.update((s) => ({
...s,
setPasswordOpen: false,
setPasswordBusy: false,
setPasswordPending: false,
setPasswordValue: "",
setPasswordConfirm: "",
setPasswordCode: "",
setPasswordStatus: "",
setPasswordIsError: false,
setPasswordResendCooldown: 0,
}));
clearPasswordCooldownTimer();
}
function validatePasswordDraft() {
const s = get(state);
const password = String(s.setPasswordValue || "");
const passwordConfirm = String(s.setPasswordConfirm || "");
if (password.length < 8) {
setPasswordStatus(t("wa_password_too_short"), true);
return false;
}
if (password.length > 128) {
setPasswordStatus(t("wa_password_too_long"), true);
return false;
}
if (password !== passwordConfirm) {
setPasswordStatus(t("wa_password_mismatch"), true);
return false;
}
return true;
}
async function requestLinkEmailCode() {
const s = get(state);
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
@@ -150,6 +240,66 @@ export function createAccountStore({
}
}
async function requestSetPasswordCode() {
const s = get(state);
if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return;
if (!validatePasswordDraft()) return;
state.update((s) => ({ ...s, setPasswordBusy: true }));
setPasswordStatus(t("wa_auth_sending_code"));
try {
const response = await api("/account/password/request", {
method: "POST",
body: JSON.stringify({}),
});
if (!response?.ok) throw response;
state.update((s) => ({ ...s, setPasswordPending: true, setPasswordCode: "" }));
setPasswordStatus("");
startPasswordCooldownTimer(60);
} catch (error) {
setPasswordStatus(emailError(error, t("wa_password_code_send_failed"), t), true);
} finally {
state.update((s) => ({ ...s, setPasswordBusy: false }));
}
}
async function confirmSetPassword() {
const s = get(state);
if (!validatePasswordDraft()) return;
const code = String(s.setPasswordCode || "")
.replace(/\D/g, "")
.slice(0, 6);
if (code.length !== 6) {
setPasswordStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update((s) => ({ ...s, setPasswordBusy: true }));
setPasswordStatus(t("wa_auth_checking_code"));
try {
const response = await api("/account/password/confirm", {
method: "POST",
body: JSON.stringify({
password: s.setPasswordValue,
password_confirm: s.setPasswordConfirm,
code,
}),
});
if (!response?.ok) throw response;
await loadData();
closeSetPasswordDialog();
showToast(t("wa_password_set_success"));
} catch (error) {
const fallback =
error?.error === "password_mismatch"
? t("wa_password_mismatch")
: error?.error === "password_too_short"
? t("wa_password_too_short")
: t("wa_password_set_failed");
setPasswordStatus(emailError(error, fallback, t), true);
} finally {
state.update((s) => ({ ...s, setPasswordBusy: false }));
}
}
async function linkTelegramAccountWithPayload(payload) {
state.update((s) => ({ ...s, linkTelegramBusy: true }));
try {
@@ -228,11 +378,16 @@ export function createAccountStore({
update: state.update,
openLinkEmailDialog,
closeLinkEmailDialog,
openSetPasswordDialog,
closeSetPasswordDialog,
requestLinkEmailCode,
verifyLinkEmailCode,
requestSetPasswordCode,
confirmSetPassword,
linkTelegramAccount,
updateAccountLanguage,
logout,
clearLinkEmailResendTimer: clearCooldownTimer,
clearSetPasswordResendTimer: clearPasswordCooldownTimer,
};
}
@@ -25,8 +25,11 @@ export function createAuthStore({
loginEmailTooltipOpen: false,
authResendCooldown: 0,
email: "",
emailPassword: "",
pendingEmail: "",
emailCode: "",
passwordLoginMode: false,
passwordLoginFallback: false,
});
let authResendTimer = null;
@@ -169,6 +172,7 @@ export function createAuthStore({
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
authBusy: true,
passwordLoginFallback: false,
}));
setAuthStatus(t("wa_auth_sending_code"));
try {
@@ -188,6 +192,53 @@ export function createAuthStore({
}
}
async function loginWithEmailPassword() {
const s = get(state);
const normalized = s.email.trim().toLowerCase();
const password = String(s.emailPassword || "");
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({
...s,
loginEmailFieldError: t("wa_auth_invalid_email"),
loginEmailTooltipOpen: true,
}));
return;
}
if (!password) {
setAuthStatus(t("wa_auth_password_required"), true);
return;
}
state.update((s) => ({
...s,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
authBusy: true,
passwordLoginFallback: false,
}));
setAuthStatus(t("wa_auth_checking_password"));
try {
const response = await publicApi("/auth/email/password", {
email: normalized,
password,
});
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
await loadData();
setAuthStatus("");
} catch (error) {
if (error?.error === "rate_limited") {
setAuthStatus(emailError(error, t("wa_auth_password_login_failed"), t), true);
} else if (error?.error === "banned") {
setAuthStatus(t("wa_auth_access_denied"), true);
} else {
state.update((s) => ({ ...s, passwordLoginFallback: true }));
setAuthStatus(t("wa_auth_password_login_failed"), true);
}
} finally {
state.update((s) => ({ ...s, authBusy: false }));
}
}
async function verifyEmailCode() {
const s = get(state);
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
@@ -278,6 +329,7 @@ export function createAuthStore({
finalizeMagicLogin,
finalizeTelegramAuth,
requestEmailCode,
loginWithEmailPassword,
verifyEmailCode,
openTelegramLogin,
clearCooldownTimer,
+22 -7
View File
@@ -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);
+69 -1
View File
@@ -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)) {
+108
View File
@@ -52,6 +52,16 @@
export let selectedTariff = null;
export let selectedTariffKey = "";
export let selectedTariffPlans = [];
export let setPasswordBusy = false;
export let setPasswordCode = "";
export let setPasswordConfirm = "";
export let setPasswordEmail = "";
export let setPasswordIsError = false;
export let setPasswordOpen = false;
export let setPasswordPending = false;
export let setPasswordResendCooldown = 0;
export let setPasswordStatus = "";
export let setPasswordValue = "";
export let singleTariffMode = false;
export let subscription = {};
export let tariffCatalog = [];
@@ -104,13 +114,16 @@
export let closeDeviceDisconnectDialog = () => {};
export let closeLinkEmailDialog = () => {};
export let closePaymentModal = () => {};
export let closeSetPasswordDialog = () => {};
export let backToTariffList = () => {};
export let continueWithSelectedTariff = () => {};
export let requestLinkEmailCode = () => {};
export let requestSetPasswordCode = () => {};
export let selectTariff = () => {};
export let t = (key) => key;
export let termUnitLabel = () => "";
export let verifyLinkEmailCode = () => {};
export let confirmSetPassword = () => {};
</script>
<Dialog
@@ -299,6 +312,101 @@
</div>
</Dialog>
<Dialog
open={setPasswordOpen && !setPasswordPending}
title={t("wa_password_modal_title")}
description={t("wa_password_modal_desc")}
closeLabel={t("wa_close")}
onclose={closeSetPasswordDialog}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
<Input
bind:value={setPasswordValue}
type="password"
placeholder={t("wa_password_new_placeholder")}
autocomplete="new-password"
/>
<Input
bind:value={setPasswordConfirm}
type="password"
placeholder={t("wa_password_confirm_placeholder")}
autocomplete="new-password"
on:keydown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
requestSetPasswordCode();
}}
/>
<Button
class="wide bottom-action payment-submit-button"
onclick={requestSetPasswordCode}
disabled={setPasswordBusy}
>
<LockKeyhole size={17} />
{t("wa_password_send_code_action")}
</Button>
{#if setPasswordStatus}
<StatusMessage error={setPasswordIsError}>{setPasswordStatus}</StatusMessage>
{/if}
</div>
</Dialog>
{#if setPasswordOpen && setPasswordPending}
<div class="password-code-fullscreen" role="dialog" aria-modal="true">
<div class="phone-screen auth-screen">
<header class="screen-head center-title">
<Button
variant="icon"
size="icon"
onclick={closeSetPasswordDialog}
aria-label={t("wa_back")}
>
<ArrowLeft size={19} />
</Button>
<div>
<h1>{t("wa_email_verification_title")}</h1>
<p>{t("wa_email_sent_to", { email: setPasswordEmail || "" })}</p>
</div>
<span></span>
</header>
<div class="otp-wrap">
<label class="otp-input-wrap">
<input
bind:value={setPasswordCode}
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
aria-label={t("wa_email_code_aria")}
/>
<span class="otp-slots" aria-hidden="true">
{#each Array.from({ length: 6 }) as _, index}
<span class:filled={setPasswordCode[index]}>{setPasswordCode[index] || ""}</span>
{/each}
</span>
</label>
<Button class="wide" onclick={confirmSetPassword} disabled={setPasswordBusy}>
{t("wa_confirm")}
</Button>
{#if setPasswordStatus}
<StatusMessage error={setPasswordIsError}>{setPasswordStatus}</StatusMessage>
{/if}
<button
class="link-button"
type="button"
onclick={requestSetPasswordCode}
disabled={setPasswordBusy || setPasswordResendCooldown > 0}
>
<RefreshCw size={15} />
{setPasswordResendCooldown > 0
? t("wa_auth_resend_wait", { seconds: setPasswordResendCooldown })
: t("wa_resend_code")}
</button>
</div>
</div>
</div>
{/if}
<Dialog
open={linkEmailOpen}
title={t("wa_link_email_modal_title")}
+175 -65
View File
@@ -1,10 +1,16 @@
<script>
import { ArrowLeft, Mail, RefreshCw, Send, TriangleAlert } from "$components/ui/icons.js";
import {
ArrowLeft,
LockKeyhole,
Mail,
RefreshCw,
Send,
TriangleAlert,
} from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import Card from "$components/ui/card.svelte";
import Input from "$components/ui/input.svelte";
import Spinner from "$components/ui/spinner.svelte";
import { StatusMessage } from "$components/patterns/webapp/index.js";
@@ -14,6 +20,7 @@
export let brand = {};
export let brandTitle;
export let email;
export let emailPassword;
export let emailCode;
export let pendingEmail;
export let authStatus;
@@ -22,6 +29,8 @@
export let authResendCooldown;
export let loginEmailFieldError;
export let loginEmailTooltipOpen;
export let passwordLoginFallback;
export let passwordLoginMode;
export let telegramLoginBusy;
export let telegramLoginUnavailable;
export let telegramLoginChecking;
@@ -31,12 +40,19 @@
export let userAgreementUrl;
export let t;
export let requestEmailCode;
export let loginWithEmailPassword;
export let verifyEmailCode;
export let openTelegramLogin;
export let openExternalLink;
export let submitEmailOnEnter;
export let onBackToLogin;
export let clearLoginEmailError;
export let setPasswordLoginMode;
let authPanelHeight = 0;
$: passwordModeActive = Boolean(passwordLoginMode && CFG.emailAuthEnabled !== false);
$: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
</script>
<div class="phone-screen auth-screen">
@@ -90,74 +106,168 @@
<BrandMark {brand} size="xl" />
<h1>{brandTitle}</h1>
</div>
<Card class="auth-card">
{#if CFG.emailAuthEnabled !== false}
<div class="auth-pane">
<div class="auth-email-stack">
<div class="field-error-wrap">
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
<section class="card auth-card" style:height={authCardHeight}>
{#key passwordModeActive}
<div
class={`auth-mode-panel${passwordModeActive ? " auth-mode-panel-password" : ""}`}
bind:clientHeight={authPanelHeight}
>
{#if passwordModeActive}
<div class="auth-pane">
<div class="auth-email-stack">
<div class="field-error-wrap">
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
<Input
bind:value={email}
type="email"
placeholder={t("wa_email_placeholder")}
autocomplete="email"
class={loginEmailFieldError ? "input-error" : ""}
on:input={clearLoginEmailError}
/>
{#if loginEmailFieldError}
<Tooltip.Trigger
class="field-error-trigger"
aria-label={loginEmailFieldError}
>
<span class="field-error-icon" aria-hidden="true"
><TriangleAlert size={18} /></span
>
</Tooltip.Trigger>
{/if}
{#if loginEmailFieldError}
<Tooltip.Portal>
<Tooltip.Content class="field-error-tooltip"
>{loginEmailFieldError}</Tooltip.Content
>
</Tooltip.Portal>
{/if}
</Tooltip.Root>
</div>
<Input
bind:value={email}
type="email"
placeholder={t("wa_email_placeholder")}
autocomplete="email"
class={loginEmailFieldError ? "input-error" : ""}
on:keydown={submitEmailOnEnter}
on:input={clearLoginEmailError}
bind:value={emailPassword}
type="password"
placeholder={t("wa_password_placeholder")}
autocomplete="current-password"
on:keydown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
loginWithEmailPassword();
}}
/>
{#if loginEmailFieldError}
<Tooltip.Trigger class="field-error-trigger" aria-label={loginEmailFieldError}>
<span class="field-error-icon" aria-hidden="true"
><TriangleAlert size={18} /></span
>
</Tooltip.Trigger>
<Button class="wide" onclick={loginWithEmailPassword} disabled={authBusy}>
<LockKeyhole size={18} />
{t("wa_login_password_submit")}
</Button>
{#if passwordLoginFallback}
<button
class="link-button auth-code-fallback"
type="button"
onclick={requestEmailCode}
disabled={authBusy}
>
<Mail size={15} />
{t("wa_login_use_email_code")}
</button>
{:else}
<button
class="link-button auth-code-fallback"
type="button"
onclick={() => setPasswordLoginMode(false)}
disabled={authBusy}
>
{t("wa_login_use_email_code")}
</button>
{/if}
{#if loginEmailFieldError}
<Tooltip.Portal>
<Tooltip.Content class="field-error-tooltip"
>{loginEmailFieldError}</Tooltip.Content
>
</Tooltip.Portal>
{/if}
</Tooltip.Root>
</div>
</div>
<Button class="wide" onclick={requestEmailCode} disabled={authBusy}>
<Mail size={18} />
{t("wa_send_code_email")}
</Button>
</div>
</div>
{/if}
{#if CFG.emailAuthEnabled !== false}
<div class="or-line"><span></span>{t("wa_or")}<span></span></div>
{/if}
<div class="auth-pane">
<Button
variant="telegram"
class={`wide telegram-login-button${telegramLoginUnavailable ? " unavailable" : ""}${telegramLoginChecking ? " checking" : ""}`}
onclick={openTelegramLogin}
disabled={authBusy || telegramLoginBusy || telegramLoginUnavailable}
aria-label={telegramLoginLabel}
>
<span class="telegram-login-text">
{#if telegramLoginChecking}
<Spinner size="sm" />
{:else}
<Send size={17} />
{#if authStatus}
<StatusMessage error={authIsError} class="auth-login-status">
{authStatus}
</StatusMessage>
{/if}
{telegramLoginLabel}
</span>
</Button>
</div>
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
<StatusMessage
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
class="auth-login-status"
>
{authStatus || telegramLoginUnavailableMessage}
</StatusMessage>
{/if}
</Card>
{:else if CFG.emailAuthEnabled !== false}
<div class="auth-pane">
<div class="auth-email-stack">
<div class="field-error-wrap">
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
<Input
bind:value={email}
type="email"
placeholder={t("wa_email_placeholder")}
autocomplete="email"
class={loginEmailFieldError ? "input-error" : ""}
on:keydown={submitEmailOnEnter}
on:input={clearLoginEmailError}
/>
{#if loginEmailFieldError}
<Tooltip.Trigger
class="field-error-trigger"
aria-label={loginEmailFieldError}
>
<span class="field-error-icon" aria-hidden="true"
><TriangleAlert size={18} /></span
>
</Tooltip.Trigger>
{/if}
{#if loginEmailFieldError}
<Tooltip.Portal>
<Tooltip.Content class="field-error-tooltip"
>{loginEmailFieldError}</Tooltip.Content
>
</Tooltip.Portal>
{/if}
</Tooltip.Root>
</div>
<Button class="wide" onclick={requestEmailCode} disabled={authBusy}>
<Mail size={18} />
{t("wa_send_code_email")}
</Button>
</div>
</div>
<div class="or-line"><span></span>{t("wa_or")}<span></span></div>
<div class="auth-pane">
<Button
variant="telegram"
class={`wide telegram-login-button${telegramLoginUnavailable ? " unavailable" : ""}${telegramLoginChecking ? " checking" : ""}`}
onclick={openTelegramLogin}
disabled={authBusy || telegramLoginBusy || telegramLoginUnavailable}
aria-label={telegramLoginLabel}
>
<span class="telegram-login-text">
{#if telegramLoginChecking}
<Spinner size="sm" />
{:else}
<Send size={17} />
{/if}
{telegramLoginLabel}
</span>
</Button>
</div>
<div class="password-switch-stack">
<div class="password-switch-divider" aria-hidden="true"></div>
<button
class="link-button password-switch-button"
type="button"
onclick={() => setPasswordLoginMode(true)}
disabled={authBusy}
>
<LockKeyhole size={15} />
{t("wa_login_use_password")}
</button>
</div>
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
<StatusMessage
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
class="auth-login-status"
>
{authStatus || telegramLoginUnavailableMessage}
</StatusMessage>
{/if}
{/if}
</div>
{/key}
</section>
{#if userAgreementUrl || privacyPolicyUrl}
<div class="auth-legal">
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
@@ -39,6 +39,7 @@
export let openAdminPanel = () => {};
export let openExternalLink = () => {};
export let openLinkEmailDialog = () => {};
export let openSetPasswordDialog = () => {};
export let setLanguageMenuOpen = () => {};
export let t = (key) => key;
export let updateAccountLanguage = () => {};
@@ -100,12 +101,24 @@
</Button>
{/if}
{#if user?.email}
<div class="settings-row settings-row-linked">
<div class="settings-row settings-row-linked settings-row-linked-with-action">
<CheckCircle2 size={21} />
<span>
<strong>{t("wa_settings_email_linked_title")}</strong>
<small>{user?.email}</small>
</span>
{#if user?.email_verified}
<Button
variant="secondary"
size="sm"
class="settings-inline-action"
onclick={openSetPasswordDialog}
>
{user?.password_auth_enabled
? t("wa_settings_change_password_action")
: t("wa_settings_set_password_action")}
</Button>
{/if}
</div>
{:else}
<button
+30 -1
View File
@@ -579,6 +579,13 @@
"email_login_code_magic_or": "or",
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
"email_set_password_code_subject": "{code} — code to create your password",
"email_set_password_code_preheader": "Confirm password creation. The code expires in {minutes} min.",
"email_set_password_code_heading": "Confirm password creation",
"email_set_password_code_intro": "Enter this 6-digit code in your account settings to create or change the password for email sign-in.",
"email_set_password_code_expiry_html": "The password creation code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
"email_set_password_code_security": "If you didn't try to create a password, ignore this email — your account password will not change.",
"email_set_password_code_text": "Your password creation code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't try to create a password, ignore this email — your account password will not change.",
"email_account_merged_subject": "Accounts merged",
"email_account_merged_preheader": "Your accounts were combined into one profile.",
"email_account_merged_heading": "Accounts merged",
@@ -1381,5 +1388,27 @@
"admin_settings_field_my_devices_section_enabled_label": "My Devices Section Enabled",
"admin_settings_field_user_hwid_device_limit_label": "User HWID Device Limit",
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
"admin_settings_field_user_traffic_strategy_label": "User Traffic Strategy"
"admin_settings_field_user_traffic_strategy_label": "User Traffic Strategy",
"wa_auth_checking_password": "Checking password...",
"wa_auth_password_required": "Enter your password",
"wa_auth_password_login_failed": "Could not sign in with this email and password. You can use an email code instead.",
"wa_password_placeholder": "Password",
"wa_login_password_submit": "Sign in",
"wa_login_use_password": "Use password",
"wa_login_use_email_code": "Use email code",
"wa_settings_set_password_action": "Set password",
"wa_settings_change_password_action": "Change password",
"wa_password_modal_title": "Email password",
"wa_password_modal_desc": "Enter the new password twice. Then we will send a code to your linked email.",
"wa_password_modal_code_desc": "Enter the email code to confirm the password.",
"wa_password_new_placeholder": "New password",
"wa_password_confirm_placeholder": "Repeat password",
"wa_password_send_code_action": "Get code",
"wa_password_confirm_action": "Save password",
"wa_password_too_short": "Password must be at least 8 characters",
"wa_password_too_long": "Password must be at most 128 characters",
"wa_password_mismatch": "Passwords do not match",
"wa_password_code_send_failed": "Could not send the code",
"wa_password_set_failed": "Could not save password",
"wa_password_set_success": "Password saved"
}
+30 -1
View File
@@ -579,6 +579,13 @@
"email_login_code_magic_or": "или",
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
"email_set_password_code_subject": "{code} — код для создания пароля",
"email_set_password_code_preheader": "Подтвердите создание пароля. Код действует {minutes} мин.",
"email_set_password_code_heading": "Подтвердите создание пароля",
"email_set_password_code_intro": "Введите этот 6-значный код в личном кабинете, чтобы создать или изменить пароль для входа по почте.",
"email_set_password_code_expiry_html": "Код для создания пароля действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
"email_set_password_code_security": "Если вы не создавали пароль, просто проигнорируйте это письмо — пароль для аккаунта не изменится.",
"email_set_password_code_text": "Ваш код для создания пароля: {code}\n\nКод действует {minutes} мин.\nЕсли вы не создавали пароль, проигнорируйте это письмо — пароль для аккаунта не изменится.",
"email_account_merged_subject": "Аккаунты объединены",
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
"email_account_merged_heading": "Аккаунты объединены",
@@ -1381,5 +1388,27 @@
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
"admin_settings_field_user_traffic_strategy_label": "Стратегия сброса трафика"
"admin_settings_field_user_traffic_strategy_label": "Стратегия сброса трафика",
"wa_auth_checking_password": "Проверяем пароль...",
"wa_auth_password_required": "Введите пароль",
"wa_auth_password_login_failed": "Не удалось войти с этим email и паролем. Можно войти по коду из письма.",
"wa_password_placeholder": "Пароль",
"wa_login_password_submit": "Войти",
"wa_login_use_password": "Использовать пароль",
"wa_login_use_email_code": "Войти по коду на почту",
"wa_settings_set_password_action": "Задать пароль",
"wa_settings_change_password_action": "Изменить пароль",
"wa_password_modal_title": "Пароль для входа",
"wa_password_modal_desc": "Введите новый пароль два раза. Затем мы отправим код на привязанную почту.",
"wa_password_modal_code_desc": "Введите код из письма, чтобы подтвердить пароль.",
"wa_password_new_placeholder": "Новый пароль",
"wa_password_confirm_placeholder": "Повторите пароль",
"wa_password_send_code_action": "Получить код",
"wa_password_confirm_action": "Сохранить пароль",
"wa_password_too_short": "Пароль должен быть не короче 8 символов",
"wa_password_too_long": "Пароль должен быть не длиннее 128 символов",
"wa_password_mismatch": "Пароли не совпадают",
"wa_password_code_send_failed": "Не удалось отправить код",
"wa_password_set_failed": "Не удалось сохранить пароль",
"wa_password_set_success": "Пароль сохранён"
}
+224
View File
@@ -10,6 +10,7 @@ from aiohttp import web
from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import settings as admin_settings_routes
from bot.app.web.webapp import account as account_routes
from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce,
create_webapp_session_token,
@@ -19,8 +20,10 @@ from bot.payment_providers.cryptopay import CryptoPayService
from bot.payment_providers.freekassa import FreeKassaService
from bot.payment_providers.heleket import HeleketConfig, HeleketService, _compute_signature
from bot.payment_providers.yookassa import yookassa_webhook_route
from bot.services.email_templates import render_login_code
from bot.utils.request_security import request_client_ip
from config.settings import Settings
from db.dal import security_dal
from db.database_setup import redacted_database_url
@@ -197,6 +200,25 @@ class HeleketServiceTests(unittest.TestCase):
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
class _AsyncSessionFactory:
def __call__(self):
return self
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
return None
async def rollback(self):
return None
async def flush(self):
return None
def test_auth_response_sets_cookies_and_does_not_return_session_token(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
@@ -306,6 +328,208 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status, 400)
self.assertIn("email_too_long", response.text)
def test_email_password_hash_round_trips_without_plaintext(self):
password = "correct horse battery staple"
stored_hash = subscription_webapp._hash_email_password(password)
self.assertNotIn(password, stored_hash)
self.assertTrue(subscription_webapp._verify_email_password(password, stored_hash))
self.assertFalse(subscription_webapp._verify_email_password("wrong-password", stored_hash))
def test_set_password_email_code_copy_mentions_password_creation(self):
settings = SimpleNamespace(
DEFAULT_LANGUAGE="ru",
EMAIL_CODE_TTL_SECONDS=600,
WEBAPP_LOGO_URL="",
WEBAPP_LOGO_USE_EMOJI=False,
WEBAPP_PRIMARY_COLOR="#00fe7a",
WEBAPP_TITLE="Remnawave",
)
content = render_login_code(
settings,
code="123456",
language_code="ru",
magic_link="https://example.com/login",
purpose="set_password",
)
self.assertIn("создания пароля", content.subject)
self.assertIn("создание пароля", content.html)
self.assertIn("код для создания пароля", content.text)
self.assertNotIn("Подтвердите вход", content.html)
self.assertNotIn("https://example.com/login", content.text)
async def test_email_password_login_success_sets_session_cookie(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
email_auth_configured=True,
BRUTE_FORCE_MAX_FAILURES=5,
BRUTE_FORCE_WINDOW_SECONDS=60,
BRUTE_FORCE_LOCK_SECONDS=300,
)
stored_hash = subscription_webapp._hash_email_password("secret-password")
db_user = SimpleNamespace(
user_id=42,
email_verified_at=object(),
password_hash=stored_hash,
is_banned=False,
telegram_id=None,
)
request = SimpleNamespace(
app={"settings": settings, "async_session_factory": self._AsyncSessionFactory()},
json=AsyncMock(
return_value={"email": "user@example.com", "password": "secret-password"}
),
)
with (
patch.object(
subscription_webapp.security_dal,
"check_throttle",
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
),
patch.object(
subscription_webapp.security_dal,
"clear_throttle_state",
AsyncMock(return_value=None),
),
patch.object(
subscription_webapp.user_dal,
"get_user_by_email",
AsyncMock(return_value=db_user),
),
):
response = await subscription_webapp.email_password_auth_route(request)
payload = json.loads(response.text)
self.assertTrue(payload["ok"])
self.assertEqual(payload["user_id"], 42)
self.assertIn("rw_webapp_session", response.cookies)
async def test_email_password_login_failures_use_same_fallback_error(self):
settings = SimpleNamespace(
email_auth_configured=True,
BRUTE_FORCE_MAX_FAILURES=5,
BRUTE_FORCE_WINDOW_SECONDS=60,
BRUTE_FORCE_LOCK_SECONDS=300,
)
cases = [
None,
SimpleNamespace(
user_id=42,
email_verified_at=object(),
password_hash=None,
is_banned=False,
),
SimpleNamespace(
user_id=42,
email_verified_at=object(),
password_hash=subscription_webapp._hash_email_password("other-password"),
is_banned=False,
),
]
for db_user in cases:
request = SimpleNamespace(
app={"settings": settings, "async_session_factory": self._AsyncSessionFactory()},
json=AsyncMock(
return_value={"email": "user@example.com", "password": "secret-password"}
),
)
with (
patch.object(
subscription_webapp.security_dal,
"check_throttle",
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
),
patch.object(
subscription_webapp.security_dal,
"record_throttle_failure",
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
),
patch.object(
subscription_webapp.user_dal,
"get_user_by_email",
AsyncMock(return_value=db_user),
),
):
response = await subscription_webapp.email_password_auth_route(request)
payload = json.loads(response.text)
self.assertEqual(response.status, 401)
self.assertEqual(payload["error"], "password_login_failed")
self.assertEqual(payload["fallback"], "email_code")
async def test_account_password_confirm_requires_matching_passwords(self):
request = SimpleNamespace(
app={},
json=AsyncMock(
return_value={
"password": "secret-password",
"password_confirm": "other-password",
"code": "123456",
}
),
)
with patch.object(account_routes, "_require_user_id", return_value=42):
response = await account_routes.account_password_confirm_route(request)
self.assertEqual(response.status, 400)
self.assertIn("password_mismatch", response.text)
async def test_account_password_confirm_sets_hash_after_email_code(self):
settings = SimpleNamespace(
REDIS_URL=None,
REDIS_KEY_PREFIX="test",
)
db_user = SimpleNamespace(
user_id=42,
email="user@example.com",
email_verified_at=object(),
is_banned=False,
password_hash=None,
password_set_at=None,
)
email_service = SimpleNamespace(
verify_code=AsyncMock(
return_value=SimpleNamespace(ok=True, error=None, retry_after=None)
)
)
request = SimpleNamespace(
app={
"settings": settings,
"email_auth_service": email_service,
"async_session_factory": self._AsyncSessionFactory(),
},
json=AsyncMock(
return_value={
"password": "secret-password",
"password_confirm": "secret-password",
"code": "123456",
}
),
)
with (
patch.object(account_routes, "_require_user_id", return_value=42),
patch.object(
account_routes.user_dal,
"get_user_by_id",
AsyncMock(return_value=db_user),
),
):
response = await account_routes.account_password_confirm_route(request)
self.assertEqual(response.status, 200)
self.assertTrue(
subscription_webapp._verify_email_password("secret-password", db_user.password_hash)
)
self.assertIsNotNone(db_user.password_set_at)
def test_payment_payload_rejects_overlong_description(self):
model, response = subscription_webapp._validate_model_payload(
subscription_webapp.WebAppPaymentCreatePayload,
+4
View File
@@ -48,6 +48,7 @@ class WebAppRouteContractTests(unittest.TestCase):
routes = _route_map(app)
expected = {
("GET", "/"): "index_route",
("GET", "/login/password"): "index_route",
("GET", "/home"): "index_route",
("GET", "/invite"): "index_route",
("GET", "/devices"): "index_route",
@@ -70,12 +71,15 @@ class WebAppRouteContractTests(unittest.TestCase):
("POST", "/api/auth/email/request"): "email_auth_request_route",
("POST", "/api/auth/email/verify"): "email_auth_verify_route",
("POST", "/api/auth/email/magic"): "email_auth_magic_route",
("POST", "/api/auth/email/password"): "email_password_auth_route",
("POST", "/api/auth/logout"): "logout_route",
("GET", "/api/me"): "me_route",
("GET", "/api/account/avatar"): "account_avatar_route",
("POST", "/api/account/language"): "account_language_route",
("POST", "/api/account/email/request"): "account_email_request_route",
("POST", "/api/account/email/verify"): "account_email_verify_route",
("POST", "/api/account/password/request"): "account_password_request_route",
("POST", "/api/account/password/confirm"): "account_password_confirm_route",
("POST", "/api/account/telegram/link"): "account_telegram_link_route",
("POST", "/api/promo/apply"): "apply_promo_route",
("POST", "/api/trial/activate"): "activate_trial_route",