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
+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