feat: email and password login
This commit is contained in:
+64
-1
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user