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