$(() => { 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(`
${index + 1}. ${Utils.escapeHtml(code)}
`); }); $("#codes-saved-checkbox").prop("checked", false); $("#close-recovery-modal-btn").prop("disabled", true); $("#recovery-codes-modal").data("username", username); $("#recovery-codes-modal").removeClass("hidden"); } function 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(""); } $("#codes-saved-checkbox").on("change", function () { $("#close-recovery-modal-btn").prop("disabled", !this.checked); }); $("#copy-codes-btn").on("click", function () { const codesText = registeredRecoveryCodes.join("\n"); navigator.clipboard.writeText(codesText).then(() => { Utils.showToast("Коды скопированы в буфер обмена", "success"); }); }); $("#download-codes-btn").on("click", function () { const username = $("#recovery-codes-modal").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("Файл с кодами скачан", "success"); }); $("#close-recovery-modal-btn").on("click", function () { const username = $("#recovery-codes-modal").data("username"); $("#recovery-codes-modal").addClass("hidden"); Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); showForm("#login-form"); $("#login-username").val(username); }); function checkResetPasswordMatch() { const password = $("#reset-new-password").val(); const confirm = $("#reset-confirm-password").val(); if (confirm && password !== confirm) { $("#reset-password-match-error").removeClass("hidden"); return false; } $("#reset-password-match-error").addClass("hidden"); return true; } $("#reset-confirm-password").on("input", checkResetPasswordMatch); $("#reset-password-form").on("submit", async function (event) { event.preventDefault(); const $submitBtn = $("#reset-submit"); const newPassword = $("#reset-new-password").val(); const confirmPassword = $("#reset-confirm-password").val(); if (newPassword !== confirmPassword) { Utils.showToast("Пароли не совпадают", "error"); return; } if (newPassword.length < 8) { Utils.showToast("Пароль должен содержать минимум 8 символов", "error"); return; } const data = { username: $("#reset-username").val(), recovery_code: $("#reset-recovery-code").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("Неверный формат резервного кода", "error"); return; } $submitBtn.prop("disabled", true).text("Сброс..."); 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("Сбросить пароль"); } }); function showPasswordResetResult(response, username) { const $form = $("#reset-password-form"); $form.html(`

Пароль успешно изменён!

Осталось резервных кодов: ${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("#login-form"); $("#login-username").val(username); }, 100); }); } initLoginState(); });