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