$(() => { const PARTIAL_TOKEN_KEY = "partial_token"; const PARTIAL_USERNAME_KEY = "partial_username"; 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; function getTotpProgress() { const now = Date.now() / 1000; const elapsed = now % TOTP_PERIOD; return elapsed / TOTP_PERIOD; } function updateTotpTimer() { const circle = document.getElementById("lock-progress-circle"); if (!circle) return; const progress = getTotpProgress(); const offset = CIRCLE_CIRCUMFERENCE * (1 - progress); circle.style.strokeDashoffset = offset; totpAnimationFrame = requestAnimationFrame(updateTotpTimer); } function startTotpTimer() { stopTotpTimer(); updateTotpTimer(); } function stopTotpTimer() { if (totpAnimationFrame) { cancelAnimationFrame(totpAnimationFrame); totpAnimationFrame = null; } } function resetCircle() { const circle = document.getElementById("lock-progress-circle"); if (circle) { circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE; } } function initLoginState() { const savedToken = sessionStorage.getItem(PARTIAL_TOKEN_KEY); const savedUsername = sessionStorage.getItem(PARTIAL_USERNAME_KEY); if (savedToken && savedUsername) { loginState.partialToken = savedToken; loginState.username = savedUsername; loginState.step = "2fa"; $("#login-username").val(savedUsername); $("#credentials-section").addClass("hidden"); $("#totp-section").removeClass("hidden"); $("#login-submit").text("Подтвердить"); startTotpTimer(); setTimeout(() => { const totpInput = document.getElementById("login-totp"); if (totpInput) totpInput.focus(); }, 100); } } function savePartialToken(token, username) { sessionStorage.setItem(PARTIAL_TOKEN_KEY, token); sessionStorage.setItem(PARTIAL_USERNAME_KEY, username); } function clearPartialToken() { sessionStorage.removeItem(PARTIAL_TOKEN_KEY); sessionStorage.removeItem(PARTIAL_USERNAME_KEY); } function showForm(formId) { $("#login-form, #register-form, #reset-password-form").addClass("hidden"); $(formId).removeClass("hidden"); $("#login-tab, #register-tab") .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") .addClass("text-gray-400 hover:text-gray-600"); if (formId === "#login-form") { $("#login-tab") .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 === "#register-form") { $("#register-tab") .removeClass("text-gray-400 hover:text-gray-600") .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); } } function resetLoginState() { clearPartialToken(); stopTotpTimer(); loginState = { step: "credentials", partialToken: null, username: "", rememberMe: false, }; $("#totp-section").addClass("hidden"); $("#login-totp").val(""); $("#credentials-section").removeClass("hidden"); $("#login-submit").text("Войти"); resetCircle(); } $("#login-tab").on("click", () => showForm("#login-form")); $("#register-tab").on("click", () => showForm("#register-form")); $("#forgot-password-btn").on("click", () => showForm("#reset-password-form")); $("#back-to-login-btn").on("click", () => showForm("#login-form")); $("body").on("click", ".toggle-password", function () { const $btn = $(this); const $input = $btn.siblings("input"); const isPassword = $input.attr("type") === "password"; $input.attr("type", isPassword ? "text" : "password"); $btn.find("svg").toggleClass("hidden"); }); $("#register-password").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]; $("#password-strength-bar") .css("width", level.width) .attr("class", "h-full transition-all duration-300 " + level.color); $("#password-strength-text").text(level.text); checkPasswordMatch(); }); function checkPasswordMatch() { const password = $("#register-password").val(); const confirm = $("#register-password-confirm").val(); if (confirm && password !== confirm) { $("#password-match-error").removeClass("hidden"); return false; } $("#password-match-error").addClass("hidden"); return true; } $("#register-password-confirm").on("input", checkPasswordMatch); function formatRecoveryCode(input) { let value = input.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]; } input.value = formatted; } $("#reset-recovery-code").on("input", function () { formatRecoveryCode(this); }); $("#login-totp").on("input", function () { this.value = this.value.replace(/\D/g, "").slice(0, 6); if (this.value.length === 6) { $("#login-form").trigger("submit"); } }); $("#back-to-credentials-btn").on("click", function () { resetLoginState(); }); $("#login-form").on("submit", async function (event) { event.preventDefault(); const $submitBtn = $("#login-submit"); if (loginState.step === "credentials") { const username = $("#login-username").val(); const password = $("#login-password").val(); const rememberMe = $("#remember-me").prop("checked"); loginState.username = username; loginState.rememberMe = rememberMe; $submitBtn.prop("disabled", true).text("Вход..."); try { const formData = new URLSearchParams(); formData.append("username", username); formData.append("password", 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); $("#credentials-section").addClass("hidden"); $("#totp-section").removeClass("hidden"); startTotpTimer(); const totpInput = document.getElementById("login-totp"); if (totpInput) totpInput.focus(); $submitBtn.text("Подтвердить"); Utils.showToast("Введите код из приложения аутентификатора", "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("Войти"); } } } else if (loginState.step === "2fa") { const totpCode = $("#login-totp").val(); if (!totpCode || totpCode.length !== 6) { Utils.showToast("Введите 6-значный код", "error"); return; } $submitBtn.prop("disabled", true).text("Проверка..."); 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( "Время сессии истекло. Пожалуйста, войдите заново.", ); } throw new Error(errorData.detail || "Неверный код"); } const data = await response.json(); clearPartialToken(); stopTotpTimer(); saveTokensAndRedirect(data, loginState.rememberMe); } catch (error) { Utils.showToast(error.message || "Неверный код", "error"); $("#login-totp").val(""); const totpInput = document.getElementById("login-totp"); if (totpInput) totpInput.focus(); } finally { $submitBtn.prop("disabled", false).text("Подтвердить"); } } }); function 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 = "/"; } $("#register-form").on("submit", async function (event) { event.preventDefault(); const $submitBtn = $("#register-submit"); const pass = $("#register-password").val(); const confirm = $("#register-password-confirm").val(); if (pass !== confirm) { Utils.showToast("Пароли не совпадают", "error"); return; } const userData = { username: $("#register-username").val(), email: $("#register-email").val(), full_name: $("#register-fullname").val() || null, password: pass, }; $submitBtn.prop("disabled", true).text("Регистрация..."); 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("Регистрация успешна! Войдите в систему.", "success"); setTimeout(() => { showForm("#login-form"); $("#login-username").val(userData.username); }, 1500); } } catch (error) { 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("Зарегистрироваться"); } }); function showRecoveryCodesModal(codes, username) { const $list = $("#recovery-codes-list"); $list.empty(); codes.forEach((code, index) => { $list.append(`
Осталось резервных кодов: ${response.remaining} из ${response.total}
${ response.should_regenerate ? `Рекомендуем сгенерировать новые коды в профиле
Статус резервных кодов:
${renderRecoveryCodesStatus(response.used_codes)}Сгенерированы: ${new Date(response.generated_at).toLocaleString()}
` : "" }