$(() => { const SELECTORS = { loginForm: "#login-form", registerForm: "#register-form", resetForm: "#reset-password-form", loginTab: "#login-tab", registerTab: "#register-tab", forgotBtn: "#forgot-password-btn", backToLoginBtn: "#back-to-login-btn", backToCredentialsBtn: "#back-to-credentials-btn", submitLogin: "#login-submit", submitRegister: "#register-submit", submitReset: "#reset-submit", usernameLogin: "#login-username", passwordLogin: "#login-password", totpInput: "#login-totp", rememberMe: "#remember-me", credentialsSection: "#credentials-section", totpSection: "#totp-section", registerUsername: "#register-username", registerEmail: "#register-email", registerFullname: "#register-fullname", registerPassword: "#register-password", registerConfirm: "#register-password-confirm", passwordStrengthBar: "#password-strength-bar", passwordStrengthText: "#password-strength-text", passwordMatchError: "#password-match-error", resetUsername: "#reset-username", resetCode: "#reset-recovery-code", resetNewPassword: "#reset-new-password", resetConfirmPassword: "#reset-confirm-password", resetMatchError: "#reset-password-match-error", recoveryModal: "#recovery-codes-modal", recoveryList: "#recovery-codes-list", codesSavedCheckbox: "#codes-saved-checkbox", closeRecoveryBtn: "#close-recovery-modal-btn", copyCodesBtn: "#copy-codes-btn", downloadCodesBtn: "#download-codes-btn", gotoLoginAfterReset: "#goto-login-after-reset", capWidget: "#cap", lockProgressCircle: "#lock-progress-circle", }; const STORAGE_KEYS = { partialToken: "partial_token", partialUsername: "partial_username", }; const TEXTS = { login: "Войти", confirm: "Подтвердить", checking: "Проверка...", registering: "Регистрация...", resetting: "Сброс...", enterTotp: "Введите код из приложения аутентификатора", sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.", invalidCode: "Неверный код", passwordsNotMatch: "Пароли не совпадают", captchaRequired: "Пожалуйста, пройдите проверку Captcha", registrationSuccess: "Регистрация успешна! Войдите в систему.", codesCopied: "Коды скопированы в буфер обмена", codesDownloaded: "Файл с кодами скачан", passwordResetSuccess: "Пароль успешно изменён!", invalidRecoveryCode: "Неверный формат резервного кода", passwordTooShort: "Пароль должен содержать минимум 8 символов", }; const TOTP_PERIOD = 30; const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38; let loginState = { step: "credentials", partialToken: null, username: "", rememberMe: false, }; let registeredRecoveryCodes = []; let totpAnimationFrame = null; const getTotpProgress = () => { const now = Date.now() / 1000; const elapsed = now % TOTP_PERIOD; return elapsed / TOTP_PERIOD; }; const updateTotpTimer = () => { const circle = $(SELECTORS.lockProgressCircle).get(0); if (!circle) return; const progress = getTotpProgress(); const offset = CIRCLE_CIRCUMFERENCE * (1 - progress); circle.style.strokeDashoffset = offset; totpAnimationFrame = requestAnimationFrame(updateTotpTimer); }; const startTotpTimer = () => { stopTotpTimer(); updateTotpTimer(); }; const stopTotpTimer = () => { if (totpAnimationFrame) { cancelAnimationFrame(totpAnimationFrame); totpAnimationFrame = null; } }; const resetCircle = () => { const circle = $(SELECTORS.lockProgressCircle).get(0); if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE; }; const savePartialToken = (token, username) => { sessionStorage.setItem(STORAGE_KEYS.partialToken, token); sessionStorage.setItem(STORAGE_KEYS.partialUsername, username); }; const clearPartialToken = () => { sessionStorage.removeItem(STORAGE_KEYS.partialToken); sessionStorage.removeItem(STORAGE_KEYS.partialUsername); }; const showForm = (formId) => { $( `${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`, ).addClass("hidden"); $(formId).removeClass("hidden"); $(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`) .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") .addClass("text-gray-400 hover:text-gray-600"); if (formId === SELECTORS.loginForm) { $(SELECTORS.loginTab) .removeClass("text-gray-400 hover:text-gray-600") .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); resetLoginState(); } else if (formId === SELECTORS.registerForm) { $(SELECTORS.registerTab) .removeClass("text-gray-400 hover:text-gray-600") .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); } }; const resetLoginState = () => { clearPartialToken(); stopTotpTimer(); loginState = { step: "credentials", partialToken: null, username: "", rememberMe: false, }; $(SELECTORS.totpSection).addClass("hidden"); $(SELECTORS.totpInput).val(""); $(SELECTORS.credentialsSection).removeClass("hidden"); $(SELECTORS.submitLogin).text(TEXTS.login); resetCircle(); }; const checkPasswordMatch = (passwordId, confirmId, errorId) => { const password = $(passwordId).val(); const confirm = $(confirmId).val(); const $error = $(errorId); if (confirm && password !== confirm) { $error.removeClass("hidden"); return false; } $error.addClass("hidden"); return true; }; const saveTokensAndRedirect = (data, rememberMe) => { const storage = rememberMe ? localStorage : sessionStorage; const otherStorage = rememberMe ? sessionStorage : localStorage; storage.setItem("access_token", data.access_token); if (data.refresh_token) storage.setItem("refresh_token", data.refresh_token); otherStorage.removeItem("access_token"); otherStorage.removeItem("refresh_token"); window.location.href = "/"; }; const initLoginState = () => { const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken); const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername); if (savedToken && savedUsername) { loginState.partialToken = savedToken; loginState.username = savedUsername; loginState.step = "2fa"; $(SELECTORS.usernameLogin).val(savedUsername); $(SELECTORS.credentialsSection).addClass("hidden"); $(SELECTORS.totpSection).removeClass("hidden"); $(SELECTORS.submitLogin).text(TEXTS.confirm); startTotpTimer(); setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100); } }; $(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm)); $(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm)); $(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm)); $(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm)); $(SELECTORS.backToCredentialsBtn).on("click", resetLoginState); $("body").on("click", ".toggle-password", function () { const $input = $(this).siblings("input"); const isPassword = $input.attr("type") === "password"; $input.attr("type", isPassword ? "text" : "password"); $(this).find("svg").toggleClass("hidden"); }); $(SELECTORS.registerPassword).on("input", function () { const password = $(this).val(); let strength = 0; if (password.length >= 8) strength++; if (password.length >= 12) strength++; if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; if (/\d/.test(password)) strength++; if (/[^a-zA-Z0-9]/.test(password)) strength++; const levels = [ { width: "0%", color: "", text: "" }, { width: "20%", color: "bg-red-500", text: "Очень слабый" }, { width: "40%", color: "bg-orange-500", text: "Слабый" }, { width: "60%", color: "bg-yellow-500", text: "Средний" }, { width: "80%", color: "bg-lime-500", text: "Хороший" }, { width: "100%", color: "bg-green-500", text: "Отличный" }, ]; const level = levels[strength]; $(SELECTORS.passwordStrengthBar) .css("width", level.width) .attr("class", `h-full transition-all duration-300 ${level.color}`); $(SELECTORS.passwordStrengthText).text(level.text); checkPasswordMatch( SELECTORS.registerPassword, SELECTORS.registerConfirm, SELECTORS.passwordMatchError, ); }); $(SELECTORS.registerConfirm).on("input", () => checkPasswordMatch( SELECTORS.registerPassword, SELECTORS.registerConfirm, SELECTORS.passwordMatchError, ), ); $(SELECTORS.resetCode).on("input", function () { let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, ""); let formatted = ""; for (let i = 0; i < value.length && i < 16; i++) { if (i > 0 && i % 4 === 0) formatted += "-"; formatted += value[i]; } this.value = formatted; }); $(SELECTORS.totpInput).on("input", function () { this.value = this.value.replace(/\D/g, "").slice(0, 6); if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit"); }); $(SELECTORS.loginForm).on("submit", async function (event) { event.preventDefault(); const $submitBtn = $(SELECTORS.submitLogin); if (loginState.step === "credentials") { const username = $(SELECTORS.usernameLogin).val(); const password = $(SELECTORS.passwordLogin).val(); const rememberMe = $(SELECTORS.rememberMe).prop("checked"); loginState.username = username; loginState.rememberMe = rememberMe; $submitBtn.prop("disabled", true).text("Вход..."); try { const formData = new URLSearchParams({ username, password }); const data = await Api.postForm("/api/auth/token", formData); if (data.requires_2fa && data.partial_token) { loginState.partialToken = data.partial_token; loginState.step = "2fa"; savePartialToken(data.partial_token, username); $(SELECTORS.credentialsSection).addClass("hidden"); $(SELECTORS.totpSection).removeClass("hidden"); startTotpTimer(); $(SELECTORS.totpInput).get(0)?.focus(); $submitBtn.text(TEXTS.confirm); Utils.showToast(TEXTS.enterTotp, "info"); } else if (data.access_token) { clearPartialToken(); saveTokensAndRedirect(data, rememberMe); } } catch (error) { Utils.showToast(error.message || "Ошибка входа", "error"); } finally { $submitBtn.prop("disabled", false); if (loginState.step === "credentials") $submitBtn.text(TEXTS.login); } } else if (loginState.step === "2fa") { const totpCode = $(SELECTORS.totpInput).val(); if (!totpCode || totpCode.length !== 6) { Utils.showToast("Введите 6-значный код", "error"); return; } $submitBtn.prop("disabled", true).text(TEXTS.checking); try { const response = await fetch("/api/auth/2fa/verify", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${loginState.partialToken}`, }, body: JSON.stringify({ code: totpCode }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); if (response.status === 401) { resetLoginState(); throw new Error(TEXTS.sessionExpired); } throw new Error(errorData.detail || TEXTS.invalidCode); } const data = await response.json(); clearPartialToken(); stopTotpTimer(); saveTokensAndRedirect(data, loginState.rememberMe); } catch (error) { Utils.showToast(error.message || TEXTS.invalidCode, "error"); $(SELECTORS.totpInput).val(""); $(SELECTORS.totpInput).get(0)?.focus(); } finally { $submitBtn.prop("disabled", false).text(TEXTS.confirm); } } }); $(SELECTORS.registerForm).on("submit", async function (event) { event.preventDefault(); const $submitBtn = $(SELECTORS.submitRegister); const pass = $(SELECTORS.registerPassword).val(); const confirm = $(SELECTORS.registerConfirm).val(); if (pass !== confirm) { Utils.showToast(TEXTS.passwordsNotMatch, "error"); return; } const userData = { username: $(SELECTORS.registerUsername).val(), email: $(SELECTORS.registerEmail).val(), full_name: $(SELECTORS.registerFullname).val() || null, password: pass, }; $submitBtn.prop("disabled", true).text(TEXTS.registering); try { const response = await Api.post("/api/auth/register", userData); if (response.recovery_codes && response.recovery_codes.codes) { registeredRecoveryCodes = response.recovery_codes.codes; showRecoveryCodesModal(registeredRecoveryCodes, userData.username); } else { Utils.showToast(TEXTS.registrationSuccess, "success"); setTimeout(() => { showForm(SELECTORS.loginForm); $(SELECTORS.usernameLogin).val(userData.username); }, 1500); } } catch (error) { if (error.detail && error.detail.error === "captcha_required") { Utils.showToast(TEXTS.captchaRequired, "error"); const $capElement = $(SELECTORS.capWidget); const $parent = $capElement.parent(); $capElement.remove(); $parent.append( ``, ); return; } let msg = error.message; if (error.detail && Array.isArray(error.detail)) { msg = error.detail.map((e) => e.msg).join(". "); } Utils.showToast(msg || "Ошибка регистрации", "error"); } finally { $submitBtn .prop("disabled", false) .text(TEXTS.registering.replace("...", "")); } }); const showRecoveryCodesModal = (codes, username) => { const $list = $(SELECTORS.recoveryList); $list.empty(); codes.forEach((code, index) => { $list.append( `
${index + 1}. ${Utils.escapeHtml(code)}
`, ); }); $(SELECTORS.codesSavedCheckbox).prop("checked", false); $(SELECTORS.closeRecoveryBtn).prop("disabled", true); $(SELECTORS.recoveryModal).data("username", username).removeClass("hidden"); }; const renderRecoveryCodesStatus = (usedCodes) => { return usedCodes .map((used, index) => { const codeDisplay = "████-████-████-████"; const statusClass = used ? "text-gray-300 line-through" : "text-green-600"; const statusIcon = used ? "✗" : "✓"; return `
${index + 1}. ${codeDisplay}${statusIcon}
`; }) .join(""); }; $(SELECTORS.codesSavedCheckbox).on("change", function () { $(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked); }); $(SELECTORS.copyCodesBtn).on("click", function () { const codesText = registeredRecoveryCodes.join("\n"); navigator.clipboard .writeText(codesText) .then(() => Utils.showToast(TEXTS.codesCopied, "success")); }); $(SELECTORS.downloadCodesBtn).on("click", function () { const username = $(SELECTORS.recoveryModal).data("username") || "user"; const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`; const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `recovery-codes-${username}.txt`; a.click(); URL.revokeObjectURL(url); Utils.showToast(TEXTS.codesDownloaded, "success"); }); $(SELECTORS.closeRecoveryBtn).on("click", function () { const username = $(SELECTORS.recoveryModal).data("username"); $(SELECTORS.recoveryModal).addClass("hidden"); Utils.showToast(TEXTS.registrationSuccess, "success"); showForm(SELECTORS.loginForm); $(SELECTORS.usernameLogin).val(username); }); $(SELECTORS.resetConfirmPassword).on("input", () => checkPasswordMatch( SELECTORS.resetNewPassword, SELECTORS.resetConfirmPassword, SELECTORS.resetMatchError, ), ); $(SELECTORS.resetForm).on("submit", async function (event) { event.preventDefault(); const $submitBtn = $(SELECTORS.submitReset); const newPassword = $(SELECTORS.resetNewPassword).val(); const confirmPassword = $(SELECTORS.resetConfirmPassword).val(); if (newPassword !== confirmPassword) { Utils.showToast(TEXTS.passwordsNotMatch, "error"); return; } if (newPassword.length < 8) { Utils.showToast(TEXTS.passwordTooShort, "error"); return; } const data = { username: $(SELECTORS.resetUsername).val(), recovery_code: $(SELECTORS.resetCode).val().toUpperCase(), new_password: newPassword, }; if ( !/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test( data.recovery_code, ) ) { Utils.showToast(TEXTS.invalidRecoveryCode, "error"); return; } $submitBtn.prop("disabled", true).text(TEXTS.resetting); try { const response = await Api.post("/api/auth/password/reset", data); showPasswordResetResult(response, data.username); } catch (error) { Utils.showToast(error.message || "Ошибка сброса пароля", "error"); $submitBtn.prop("disabled", false).text("Сбросить пароль"); } }); const showPasswordResetResult = (response, username) => { const $form = $(SELECTORS.resetForm); $form.html(`

${TEXTS.passwordResetSuccess}

Осталось резервных кодов: ${response.remaining} из ${response.total}

${ response.should_regenerate ? `

Рекомендуем сгенерировать новые коды в профиле

` : "" }

Статус резервных кодов:

${renderRecoveryCodesStatus(response.used_codes)}
${ response.generated_at ? `

Сгенерированы: ${new Date(response.generated_at).toLocaleString()}

` : "" }
`); $form.off("submit"); $("#goto-login-after-reset").on("click", function () { location.reload(); setTimeout(() => { showForm(SELECTORS.loginForm); $(SELECTORS.usernameLogin).val(username); }, 100); }); }; initLoginState(); const widget = $(SELECTORS.capWidget).get(0); if (widget && widget.shadowRoot) { const style = document.createElement("style"); style.textContent = `.credits { right: 20px !important; }`; $(widget.shadowRoot).append(style); } });