Улучшение безопасности

This commit is contained in:
2026-01-19 23:22:29 +03:00
parent 758e0fc9e6
commit d6ecd4066f
59 changed files with 2712 additions and 1010 deletions
-152
View File
@@ -1,152 +0,0 @@
$(() => {
$("#login-tab").on("click", function () {
$(this)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
$("#register-tab")
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
$("#login-form").removeClass("hidden");
$("#register-form").addClass("hidden");
});
$("#register-tab").on("click", function () {
$(this)
.removeClass("text-gray-400 hover:text-gray-600")
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
$("#login-tab")
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
$("#register-form").removeClass("hidden");
$("#login-form").addClass("hidden");
});
$("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];
const $bar = $("#password-strength-bar");
$bar.css("width", level.width);
$bar.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();
const $error = $("#password-match-error");
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
} else {
$error.addClass("hidden");
return true;
}
}
$("#register-password-confirm").on("input", checkPasswordMatch);
$("#login-form").on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#login-submit");
const username = $("#login-username").val();
const password = $("#login-password").val();
const rememberMe = $("#remember-me").prop("checked");
$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);
const storage = rememberMe ? localStorage : sessionStorage;
storage.setItem("access_token", data.access_token);
if (rememberMe && data.refresh_token) {
storage.setItem("refresh_token", data.refresh_token);
}
const otherStorage = rememberMe ? sessionStorage : localStorage;
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
} catch (error) {
Utils.showToast(error.message || "Ошибка входа", "error");
} finally {
$submitBtn.prop("disabled", false).text("Войти");
}
});
$("#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 {
await Api.post("/api/auth/register", userData);
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
setTimeout(() => {
$("#login-tab").trigger("click");
$("#login-username").val(userData.username);
}, 1500);
} catch (error) {
let msg = error.message;
if (Array.isArray(error.detail)) {
msg = error.detail.map((e) => e.msg).join(". ");
}
Utils.showToast(msg || "Ошибка регистрации", "error");
} finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
}
});
});
@@ -177,8 +177,10 @@ $(async () => {
$msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]");
try {
await Api.post("/api/auth/2fa/verify", {
code: code,
await Api.post("/api/auth/2fa/enable", {
data: {
code: code,
},
secret: secretKey,
});
@@ -1,7 +1,7 @@
$(document).ready(() => {
if (!window.isAdmin()) {
$(".container").html(
'<div class="bg-white rounded-xl shadow-sm p-8 text-center border border-gray-100"><svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg><h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3><p class="text-gray-500 mb-4">Только администраторы могут просматривать аналитику</p><a href="/" class="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">На главную</a></div>'
'<div class="bg-white rounded-xl shadow-sm p-8 text-center border border-gray-100"><svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg><h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3><p class="text-gray-500 mb-4">Только администраторы могут просматривать аналитику</p><a href="/" class="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">На главную</a></div>',
);
return;
}
@@ -45,21 +45,28 @@ $(document).ready(() => {
}
function renderCharts(data) {
// Подготовка данных для графиков
const startDate = new Date(data.start_date);
const endDate = new Date(data.end_date);
const dates = [];
const loansData = [];
const returnsData = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
for (
let d = new Date(startDate);
d <= endDate;
d.setDate(d.getDate() + 1)
) {
const dateStr = d.toISOString().split("T")[0];
dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }));
dates.push(
new Date(d).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
}),
);
loansData.push(data.daily_loans[dateStr] || 0);
returnsData.push(data.daily_returns[dateStr] || 0);
}
// График выдач
const loansCtx = document.getElementById("loans-chart");
if (loansChart) {
loansChart.destroy();
@@ -141,7 +148,6 @@ $(document).ready(() => {
},
});
// График возвратов
const returnsCtx = document.getElementById("returns-chart");
if (returnsChart) {
returnsChart.destroy();
@@ -230,7 +236,7 @@ $(document).ready(() => {
if (!topBooks || topBooks.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет данных</div>'
'<div class="text-center text-gray-500 py-8">Нет данных</div>',
);
return;
}
@@ -259,4 +265,3 @@ $(document).ready(() => {
});
}
});
+553
View File
@@ -0,0 +1,553 @@
$(() => {
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(`
<div class="py-1 px-2 bg-white rounded border select-all font-mono">
${index + 1}. ${Utils.escapeHtml(code)}
</div>
`);
});
$("#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 `
<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}">
<span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span>
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
</div>
`;
})
.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(`
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-800">Пароль успешно изменён!</h3>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2 text-center">
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
</p>
${
response.should_regenerate
? `
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
<p class="text-sm text-yellow-800 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
Рекомендуем сгенерировать новые коды в профиле
</p>
</div>
`
: ""
}
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
${renderRecoveryCodesStatus(response.used_codes)}
</div>
${
response.generated_at
? `
<p class="text-xs text-gray-400 mt-2 text-center">
Сгенерированы: ${new Date(response.generated_at).toLocaleString()}
</p>
`
: ""
}
</div>
<button type="button" id="goto-login-after-reset"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Перейти к входу
</button>
`);
$form.off("submit");
$("#goto-login-after-reset").on("click", function () {
location.reload();
setTimeout(() => {
showForm("#login-form");
$("#login-username").val(username);
}, 100);
});
}
initLoginState();
});
@@ -103,7 +103,7 @@ $(document).ready(() => {
try {
const data = await Api.get(
`/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`
`/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`,
);
activeLoan = data.loans.length > 0 ? data.loans[0] : null;
renderLoans(data.loans);
@@ -128,7 +128,7 @@ $(document).ready(() => {
loans.forEach((loan) => {
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU"
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const isOverdue =
@@ -531,11 +531,9 @@ $(document).ready(() => {
due_date: new Date(dueDate).toISOString(),
};
// Используем прямой эндпоинт выдачи для администраторов
if (window.isAdmin()) {
await Api.post("/api/loans/issue", payload);
} else {
// Для библиотекарей создаем бронь, которую потом нужно подтвердить
await Api.post("/api/loans/", payload);
}
@@ -18,11 +18,10 @@ $(document).ready(() => {
try {
const data = await Api.get("/api/loans/?page=1&size=100");
allLoans = data.loans;
// Загружаем информацию о книгах
const bookIds = [...new Set(allLoans.map(loan => loan.book_id))];
const bookIds = [...new Set(allLoans.map((loan) => loan.book_id))];
await loadBooks(bookIds);
renderLoans();
} catch (error) {
console.error("Failed to load loans", error);
@@ -46,12 +45,12 @@ $(document).ready(() => {
function renderLoans() {
const reservations = allLoans.filter(
loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved"
(loan) => !loan.returned_at && getBookStatus(loan.book_id) === "reserved",
);
const activeLoans = allLoans.filter(
loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed"
(loan) => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed",
);
const returned = allLoans.filter(loan => loan.returned_at !== null);
const returned = allLoans.filter((loan) => loan.returned_at !== null);
renderReservations(reservations);
renderActiveLoans(activeLoans);
@@ -70,7 +69,7 @@ $(document).ready(() => {
if (reservations.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>'
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>',
);
return;
}
@@ -79,7 +78,9 @@ $(document).ready(() => {
const book = booksCache.get(loan.book_id);
if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const $card = $(`
@@ -90,7 +91,7 @@ $(document).ready(() => {
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
@@ -130,7 +131,7 @@ $(document).ready(() => {
if (activeLoans.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>'
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
);
return;
}
@@ -139,7 +140,9 @@ $(document).ready(() => {
const book = booksCache.get(loan.book_id);
if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const isOverdue = new Date(loan.due_date) < new Date();
@@ -151,7 +154,7 @@ $(document).ready(() => {
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
@@ -179,7 +182,7 @@ $(document).ready(() => {
if (returned.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>'
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>',
);
return;
}
@@ -188,8 +191,12 @@ $(document).ready(() => {
const book = booksCache.get(loan.book_id);
if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU");
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU",
);
const returnedDate = new Date(loan.returned_at).toLocaleDateString(
"ru-RU",
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const $card = $(`
@@ -200,7 +207,7 @@ $(document).ready(() => {
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
@@ -229,15 +236,14 @@ $(document).ready(() => {
try {
await Api.delete(`/api/loans/${loanId}`);
Utils.showToast("Бронирование отменено", "success");
// Удаляем из кэша и перезагружаем
allLoans = allLoans.filter(loan => loan.id !== loanId);
allLoans = allLoans.filter((loan) => loan.id !== loanId);
const book = booksCache.get(bookId);
if (book) {
book.status = "active";
booksCache.set(bookId, book);
}
renderLoans();
} catch (error) {
console.error(error);
@@ -245,4 +251,3 @@ $(document).ready(() => {
}
}
});
+387
View File
@@ -0,0 +1,387 @@
$(document).ready(() => {
const token = StorageHelper.get("access_token");
if (!token) {
window.location.href = "/auth";
return;
}
let currentUsername = "";
let currentRecoveryCodes = [];
loadProfile();
function loadProfile() {
Promise.all([
Api.get("/api/auth/me"),
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
Api.get("/api/auth/recovery-codes/status").catch(() => null),
])
.then(async ([user, rolesData, recoveryStatus]) => {
document.title = `LiB - ${user.full_name || user.username}`;
currentUsername = user.username;
await renderProfileHeader(user);
renderInfo(user);
renderRoles(user.roles || [], rolesData.roles || []);
window.dispatchEvent(
new CustomEvent("update-2fa", { detail: user.is_2fa_enabled }),
);
if (recoveryStatus) {
window.dispatchEvent(
new CustomEvent("update-recovery-codes", {
detail: recoveryStatus.remaining,
}),
);
}
$("#account-section, #roles-section").removeClass("hidden");
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки профиля", "error");
});
}
async function renderProfileHeader(user) {
const avatarUrl = await Utils.getGravatarUrl(user.email);
const displayName = Utils.escapeHtml(user.full_name || user.username);
$("#profile-card").html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<div class="relative mb-4 sm:mb-0 sm:mr-6">
<img src="${avatarUrl}" class="w-24 h-24 rounded-full object-cover border-4 border-gray-200">
${user.is_verified ? '<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1 border-2 border-white"><svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg></div>' : ""}
</div>
<div class="flex-1 text-center sm:text-left">
<h1 class="text-2xl font-bold text-gray-900 mb-1">${displayName}</h1>
<p class="text-gray-500 mb-3">@${Utils.escapeHtml(user.username)}</p>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm ${user.is_active ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}">
${user.is_active ? "Активен" : "Заблокирован"}
</span>
</div>
</div>
`);
}
function renderInfo(user) {
const fields = [
{ label: "ID пользователя", value: user.id },
{ label: "Email", value: user.email },
{ label: "Полное имя", value: user.full_name || "Не указано" },
];
const html = fields
.map(
(f) => `
<div class="flex justify-between py-2 border-b last:border-0">
<span class="text-gray-500">${f.label}</span>
<span class="font-medium text-gray-900">${Utils.escapeHtml(String(f.value))}</span>
</div>
`,
)
.join("");
$("#account-info").html(html);
}
function renderRoles(userRoles, allRoles) {
const $container = $("#roles-container");
if (userRoles.length === 0) {
$container.html('<p class="text-gray-500">Нет ролей</p>');
return;
}
const roleMap = {};
allRoles.forEach((r) => (roleMap[r.name] = r.description));
const html = userRoles
.map(
(role) => `
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
</div>
`,
)
.join("");
$container.html(html);
}
$("#recovery-codes-btn").on("click", function () {
resetRecoveryCodesModal();
window.dispatchEvent(new CustomEvent("open-recovery-codes-modal"));
loadRecoveryCodesStatus();
});
function resetRecoveryCodesModal() {
$("#recovery-codes-loading").removeClass("hidden");
$("#recovery-codes-status").addClass("hidden");
$("#recovery-codes-display").addClass("hidden");
$("#codes-saved-checkbox").prop("checked", false);
$("#close-recovery-modal-btn").prop("disabled", true);
$("#regenerate-codes-btn")
.prop("disabled", false)
.text("Сгенерировать новые коды");
currentRecoveryCodes = [];
}
async function loadRecoveryCodesStatus() {
try {
const status = await Api.get("/api/auth/recovery-codes/status");
renderRecoveryCodesStatus(status);
} catch (error) {
Utils.showToast(
error.message || "Ошибка загрузки статуса кодов",
"error",
);
window.dispatchEvent(new CustomEvent("close-recovery-codes-modal"));
}
}
function renderRecoveryCodesStatus(status) {
const { total, remaining, used_codes, generated_at, should_regenerate } =
status;
let iconBgClass, iconColorClass, iconSvg;
if (remaining <= 2) {
iconBgClass = "bg-red-100";
iconColorClass = "text-red-600";
iconSvg = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />`;
} else if (remaining <= 5) {
iconBgClass = "bg-yellow-100";
iconColorClass = "text-yellow-600";
iconSvg = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />`;
} else {
iconBgClass = "bg-green-100";
iconColorClass = "text-green-600";
iconSvg = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />`;
}
$("#status-icon-container")
.removeClass()
.addClass(
`flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4 ${iconBgClass}`,
)
.html(
`<svg class="w-6 h-6 ${iconColorClass}" fill="none" stroke="currentColor" viewBox="0 0 24 24">${iconSvg}</svg>`,
);
let statusColorClass;
if (remaining <= 2) {
statusColorClass = "text-red-600";
} else if (remaining <= 5) {
statusColorClass = "text-yellow-600";
} else {
statusColorClass = "text-green-600";
}
$("#codes-status-summary").html(`
<p class="text-sm text-gray-600">
Доступно кодов: <strong class="${statusColorClass}">${remaining}</strong> из <strong>${total}</strong>
</p>
`);
const $list = $("#codes-status-list");
$list.empty();
used_codes.forEach((used, index) => {
const codeDisplay = "████-████-████-████";
const statusClass = used
? "text-gray-300 line-through"
: "text-green-600";
const statusIcon = used ? "✗" : "✓";
const bgClass = used ? "bg-gray-50" : "bg-green-50";
$list.append(`
<div class="flex items-center justify-between py-1 px-2 rounded ${bgClass}">
<span class="font-mono text-sm ${statusClass}">${index + 1}. ${codeDisplay}</span>
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
</div>
`);
});
if (should_regenerate || remaining <= 2) {
let warningText;
if (remaining === 0) {
warningText =
"У вас не осталось резервных кодов! Срочно сгенерируйте новые.";
} else if (remaining <= 2) {
warningText = "Осталось мало кодов. Рекомендуем сгенерировать новые.";
} else {
warningText = "Рекомендуем сгенерировать новые коды для безопасности.";
}
$("#warning-text").text(warningText);
$("#codes-warning").removeClass("hidden");
} else {
$("#codes-warning").addClass("hidden");
}
if (generated_at) {
const date = new Date(generated_at);
$("#codes-generated-at").text(`Сгенерированы: ${date.toLocaleString()}`);
}
$("#recovery-codes-loading").addClass("hidden");
$("#recovery-codes-status").removeClass("hidden");
}
$("#regenerate-codes-btn").on("click", async function () {
const $btn = $(this);
$btn.prop("disabled", true).text("Генерация...");
try {
const response = await Api.post("/api/auth/recovery-codes/regenerate");
currentRecoveryCodes = response.codes;
displayNewRecoveryCodes(response.codes, response.generated_at);
window.dispatchEvent(
new CustomEvent("update-recovery-codes", {
detail: response.codes.length,
}),
);
Utils.showToast("Новые коды успешно сгенерированы", "success");
} catch (error) {
Utils.showToast(error.message || "Ошибка генерации кодов", "error");
$btn.prop("disabled", false).text("Сгенерировать новые коды");
}
});
function displayNewRecoveryCodes(codes, generatedAt) {
const $list = $("#recovery-codes-list");
$list.empty();
codes.forEach((code, index) => {
$list.append(`
<div class="py-1 px-2 bg-white rounded border select-all font-mono text-gray-800">
${index + 1}. ${Utils.escapeHtml(code)}
</div>
`);
});
if (generatedAt) {
const date = new Date(generatedAt);
$("#recovery-codes-generated-at").text(
`Сгенерированы: ${date.toLocaleString()}`,
);
}
$("#recovery-codes-status").addClass("hidden");
$("#recovery-codes-display").removeClass("hidden");
}
$("#codes-saved-checkbox").on("change", function () {
$("#close-recovery-modal-btn").prop("disabled", !this.checked);
});
$("#copy-codes-btn").on("click", function () {
if (currentRecoveryCodes.length === 0) return;
const codesText = currentRecoveryCodes.join("\n");
navigator.clipboard.writeText(codesText).then(() => {
const $btn = $(this);
const originalHtml = $btn.html();
$btn.html(`
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span>Скопировано!</span>
`);
setTimeout(() => $btn.html(originalHtml), 2000);
Utils.showToast("Коды скопированы в буфер обмена", "success");
});
});
$("#download-codes-btn").on("click", function () {
if (currentRecoveryCodes.length === 0) return;
const username = currentUsername || "user";
const codesText = `Резервные коды для аккаунта: ${username}
Дата: ${new Date().toLocaleString()}
${currentRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\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`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Utils.showToast("Файл с кодами скачан", "success");
});
$("#close-recovery-modal-btn, #close-status-modal-btn").on(
"click",
function () {
window.dispatchEvent(new CustomEvent("close-recovery-codes-modal"));
},
);
$("#submit-disable-2fa-btn").on("click", async function () {
const $btn = $(this);
const password = $("#disable-2fa-password").val();
if (!password) {
Utils.showToast("Введите пароль", "error");
return;
}
$btn.prop("disabled", true).text("Отключение...");
try {
await Api.post("/api/auth/2fa/disable", { password });
Utils.showToast("2FA успешно отключена", "success");
window.dispatchEvent(new CustomEvent("update-2fa", { detail: false }));
window.dispatchEvent(new CustomEvent("close-disable-2fa-modal"));
$("#disable-2fa-form")[0].reset();
} catch (error) {
Utils.showToast(error.message || "Ошибка отключения 2FA", "error");
} finally {
$btn.prop("disabled", false).text("Отключить");
}
});
$("#submit-password-btn").on("click", async function () {
const $btn = $(this);
const newPass = $("#new-password").val();
const confirm = $("#confirm-password").val();
if (newPass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
return;
}
if (newPass.length < 8) {
Utils.showToast("Пароль должен быть минимум 8 символов", "error");
return;
}
$btn.prop("disabled", true).text("Сохранение...");
try {
await Api.put("/api/auth/me", { password: newPass });
Utils.showToast("Пароль успешно изменён", "success");
window.dispatchEvent(new CustomEvent("close-password-modal"));
$("#change-password-form")[0].reset();
} catch (error) {
Utils.showToast(error.message || "Ошибка смены пароля", "error");
} finally {
$btn.prop("disabled", false).text("Сменить");
}
});
});
@@ -544,10 +544,6 @@ $(document).ready(() => {
updateData.password = password;
}
// Note: This uses the /api/auth/me endpoint structure
// For admin editing other users, you might need a different endpoint
// Here we'll simulate by updating local data
Api.put(`/api/auth/me`, updateData)
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
@@ -601,7 +597,6 @@ $(document).ready(() => {
Utils.showToast("Удаление пользователей не поддерживается API", "error");
closeDeleteModal();
// When API supports deletion:
// Api.delete(`/api/auth/users/${userToDelete.id}`)
// .then(() => {
// users = users.filter(u => u.id !== userToDelete.id);
-128
View File
@@ -1,128 +0,0 @@
$(document).ready(() => {
const token = StorageHelper.get("access_token");
if (!token) {
window.location.href = "/auth";
return;
}
loadProfile();
function loadProfile() {
Promise.all([
Api.get("/api/auth/me"),
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
])
.then(async ([user, rolesData]) => {
document.title = `LiB - ${user.full_name || user.username}`;
await renderProfileHeader(user);
renderInfo(user);
renderRoles(user.roles || [], rolesData.roles || []);
$("#account-section, #roles-section").removeClass("hidden");
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки профиля", "error");
});
}
async function renderProfileHeader(user) {
const avatarUrl = await Utils.getGravatarUrl(user.email);
const displayName = Utils.escapeHtml(user.full_name || user.username);
$("#profile-card").html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<div class="relative mb-4 sm:mb-0 sm:mr-6">
<img src="${avatarUrl}" class="w-24 h-24 rounded-full object-cover border-4 border-gray-200">
${user.is_verified ? '<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1 border-2 border-white"><svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg></div>' : ""}
</div>
<div class="flex-1 text-center sm:text-left">
<h1 class="text-2xl font-bold text-gray-900 mb-1">${displayName}</h1>
<p class="text-gray-500 mb-3">@${Utils.escapeHtml(user.username)}</p>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm ${user.is_active ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}">
${user.is_active ? "Активен" : "Заблокирован"}
</span>
</div>
</div>
`);
}
function renderInfo(user) {
const fields = [
{ label: "ID пользователя", value: user.id },
{ label: "Email", value: user.email },
{ label: "Полное имя", value: user.full_name || "Не указано" },
];
const html = fields
.map(
(f) => `
<div class="flex justify-between py-2 border-b last:border-0">
<span class="text-gray-500">${f.label}</span>
<span class="font-medium text-gray-900">${Utils.escapeHtml(String(f.value))}</span>
</div>
`,
)
.join("");
$("#account-info").html(html);
}
function renderRoles(userRoles, allRoles) {
const $container = $("#roles-container");
if (userRoles.length === 0) {
$container.html('<p class="text-gray-500">Нет ролей</p>');
return;
}
const roleMap = {};
allRoles.forEach((r) => (roleMap[r.name] = r.description));
const html = userRoles
.map(
(role) => `
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
</div>
`,
)
.join("");
$container.html(html);
}
$("#submit-password-btn").on("click", async function () {
const $btn = $(this);
const newPass = $("#new-password").val();
const confirm = $("#confirm-password").val();
if (newPass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
return;
}
if (newPass.length < 4) {
Utils.showToast("Пароль слишком короткий", "error");
return;
}
$btn.prop("disabled", true).text("Меняем...");
try {
await Api.put("/api/auth/me", {
password: newPass,
});
Utils.showToast("Пароль успешно изменен", "success");
window.dispatchEvent(new CustomEvent("close-modal"));
$("#change-password-form")[0].reset();
} catch (error) {
console.error(error);
Utils.showToast(error.message || "Ошибка смены пароля", "error");
} finally {
$btn.prop("disabled", false).text("Сменить");
}
});
});
+26
View File
@@ -242,3 +242,29 @@ button:disabled {
-webkit-box-orient: vertical;
overflow: hidden;
}
footer a {
color: #9ca3af;
text-decoration: none;
transition: all 0.25s ease;
position: relative;
}
footer a::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: #fff;
transition: width 0.25s ease;
}
footer a:hover {
color: #fff;
}
footer a:hover::after {
width: 100%;
}