diff --git a/library_service/models/dto/__init__.py b/library_service/models/dto/__init__.py
index ad17b84..22cf1f1 100644
--- a/library_service/models/dto/__init__.py
+++ b/library_service/models/dto/__init__.py
@@ -3,7 +3,7 @@ from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpda
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
-from .user import UserBase, UserCreate, UserLogin, UserRead, UserUpdate
+from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
from .token import Token, TokenData
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
BookWithAuthorsAndGenres, BookFilteredList)
@@ -36,5 +36,6 @@ __all__ = [
"UserCreate",
"UserRead",
"UserUpdate",
+ "UserList",
"UserLogin",
]
diff --git a/library_service/models/dto/user.py b/library_service/models/dto/user.py
index ea70179..8549100 100644
--- a/library_service/models/dto/user.py
+++ b/library_service/models/dto/user.py
@@ -59,3 +59,9 @@ class UserUpdate(SQLModel):
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
+
+
+class UserList(SQLModel):
+ """Список пользователей"""
+ users: List[UserRead]
+ total: int
diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py
index ee923c8..ab34fa6 100644
--- a/library_service/routers/auth.py
+++ b/library_service/routers/auth.py
@@ -7,7 +7,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from library_service.models.db import Role, User
-from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate
+from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
from library_service.settings import get_session
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
RequireAuth, authenticate_user, get_password_hash,
@@ -136,7 +136,7 @@ def update_user_me(
@router.get(
"/users",
- response_model=list[UserRead],
+ response_model=UserList,
summary="Список пользователей",
description="Получить список всех пользователей (только для админов)",
)
@@ -148,10 +148,10 @@ def read_users(
):
"""Эндпоинт получения списка всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
- return [
- UserRead(**user.model_dump(), roles=[role.name for role in user.roles])
- for user in users
- ]
+ return UserList(
+ users=[UserRead(**user.model_dump()) for user in users],
+ total=len(users),
+ )
@router.post(
@@ -233,4 +233,21 @@ def remove_role_from_user(
session.commit()
session.refresh(user)
- return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
\ No newline at end of file
+ return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
+
+
+@router.get(
+ "/roles",
+ response_model=RoleList,
+ summary="Получить список ролей",
+ description="Возвращает список ролей",
+)
+def get_roles(
+ session: Session = Depends(get_session),
+):
+ """Эндпоинт получения списа ролей"""
+ roles = session.exec(select(Role)).all()
+ return RoleList(
+ roles=[RoleRead(**role.model_dump()) for role in roles],
+ total=len(roles),
+ )
\ No newline at end of file
diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py
index 3de7965..dcef225 100644
--- a/library_service/routers/misc.py
+++ b/library_service/routers/misc.py
@@ -66,6 +66,11 @@ async def auth(request: Request):
return templates.TemplateResponse(request, "auth.html")
+@router.get("/profile", include_in_schema=False)
+async def profile(request: Request):
+ """Эндпоинт страницы профиля"""
+ return templates.TemplateResponse(request, "profile.html")
+
@router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())):
diff --git a/library_service/static/profile.js b/library_service/static/profile.js
new file mode 100644
index 0000000..3b212b2
--- /dev/null
+++ b/library_service/static/profile.js
@@ -0,0 +1,509 @@
+$(document).ready(() => {
+ let currentUser = null;
+ let allRoles = [];
+
+ const token = localStorage.getItem("access_token");
+
+ if (!token) {
+ window.location.href = "/login";
+ return;
+ }
+
+ loadProfile();
+
+ function loadProfile() {
+ showLoadingState();
+
+ Promise.all([
+ fetch("/api/auth/me", {
+ headers: { Authorization: "Bearer " + token },
+ }).then((response) => {
+ if (!response.ok) {
+ if (response.status === 401) {
+ throw new Error("Unauthorized");
+ }
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+ }),
+ fetch("/api/auth/roles", {
+ headers: { Authorization: "Bearer " + token },
+ }).then((response) => {
+ if (response.ok) return response.json();
+ return { roles: [] };
+ }),
+ ])
+ .then(([user, rolesData]) => {
+ currentUser = user;
+ allRoles = rolesData.roles || [];
+ renderProfile(user);
+ renderAccountInfo(user);
+ renderRoles(user.roles, allRoles);
+ renderActions();
+ document.title = `LiB - ${user.full_name || user.username}`;
+ })
+ .catch((error) => {
+ console.error("Error loading profile:", error);
+ if (error.message === "Unauthorized") {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("refresh_token");
+ window.location.href = "/login";
+ } else {
+ showErrorState(error.message);
+ }
+ });
+ }
+
+ function renderProfile(user) {
+ const $card = $("#profile-card");
+ const displayName = user.full_name || user.username;
+ const firstLetter = displayName.charAt(0).toUpperCase();
+
+ const emailHash = sha256(user.email.trim().toLowerCase());
+ const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
+
+ $card.html(`
+
+
+
+

+
+ ${firstLetter}
+
+
+ ${user.is_verified ? `
+
+ ` : ''}
+
+
+
+
+
${escapeHtml(displayName)}
+
@${escapeHtml(user.username)}
+
+
+
+ ${user.is_active ? `
+
+
+ Активен
+
+ ` : `
+
+
+ Заблокирован
+
+ `}
+ ${user.is_verified ? `
+
+
+ Подтверждён
+
+ ` : `
+
+
+ Не подтверждён
+
+ `}
+
+
+
+ `);
+ }
+
+ function renderAccountInfo(user) {
+ const $container = $("#account-container");
+
+ $container.html(`
+
+
+
+
+
+
+
ID пользователя
+
${user.id}
+
+
+
+
+
+
+
+
+
Имя пользователя
+
@${escapeHtml(user.username)}
+
+
+
+
+
+
+
+
+
+
Полное имя
+
${escapeHtml(user.full_name || "Не указано")}
+
+
+
+
+
+
+
+
+
+
Email
+
${escapeHtml(user.email)}
+
+
+
+
+ `);
+ }
+
+ function renderRoles(userRoles, allRoles) {
+ const $container = $("#roles-container");
+
+ if (!userRoles || userRoles.length === 0) {
+ $container.html(`
+ У вас нет назначенных ролей
+ `);
+ return;
+ }
+
+ const roleDescriptions = {};
+ allRoles.forEach((role) => {
+ roleDescriptions[role.name] = role.description;
+ });
+
+ const roleIcons = {
+ admin: ``,
+ librarian: ``,
+ member: ``,
+ };
+
+ const roleColors = {
+ admin: "bg-red-100 text-red-800 border-red-200",
+ librarian: "bg-blue-100 text-blue-800 border-blue-200",
+ member: "bg-green-100 text-green-800 border-green-200",
+ };
+
+ let rolesHtml = '';
+
+ userRoles.forEach((roleName) => {
+ const description = roleDescriptions[roleName] || "Описание недоступно";
+ const icon = roleIcons[roleName] || roleIcons.member;
+ const colorClass = roleColors[roleName] || roleColors.member;
+
+ rolesHtml += `
+
+
+ ${icon}
+
+
+
${escapeHtml(roleName)}
+
${escapeHtml(description)}
+
+
+ `;
+ });
+
+ rolesHtml += '
';
+
+ $container.html(rolesHtml);
+ }
+
+ function renderActions() {
+ const $container = $("#actions-container");
+
+ $container.html(`
+
+
+
+
+
+
+
+ `);
+
+ $("#change-password-btn").on("click", openPasswordModal);
+ $("#logout-profile-btn").on("click", logout);
+ }
+
+ function openPasswordModal() {
+ $("#password-modal").removeClass("hidden").addClass("flex");
+ $("#current-password").focus();
+ }
+
+ function closePasswordModal() {
+ $("#password-modal").removeClass("flex").addClass("hidden");
+ $("#password-form")[0].reset();
+ $("#password-error").addClass("hidden").text("");
+ }
+
+ $("#close-password-modal, #cancel-password").on("click", closePasswordModal);
+
+ $("#password-modal").on("click", function (e) {
+ if (e.target === this) {
+ closePasswordModal();
+ }
+ });
+
+ $(document).on("keydown", function (e) {
+ if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
+ closePasswordModal();
+ }
+ });
+
+ $("#password-form").on("submit", function (e) {
+ e.preventDefault();
+
+ const currentPassword = $("#current-password").val();
+ const newPassword = $("#new-password").val();
+ const confirmPassword = $("#confirm-password").val();
+ const $error = $("#password-error");
+
+ if (newPassword !== confirmPassword) {
+ $error.text("Пароли не совпадают").removeClass("hidden");
+ return;
+ }
+
+ if (newPassword.length < 6) {
+ $error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
+ return;
+ }
+
+ // TODO: смена пароля, 2FA
+ // fetch("/api/auth/change-password", {
+ // method: "POST",
+ // headers: {
+ // "Content-Type": "application/json",
+ // Authorization: "Bearer " + token,
+ // },
+ // body: JSON.stringify({
+ // current_password: currentPassword,
+ // new_password: newPassword,
+ // }),
+ // })
+ // .then((response) => {
+ // if (!response.ok) throw new Error("Ошибка смены пароля");
+ // return response.json();
+ // })
+ // .then(() => {
+ // closePasswordModal();
+ // showNotification("Пароль успешно изменён", "success");
+ // })
+ // .catch((error) => {
+ // $error.text(error.message).removeClass("hidden");
+ // });
+
+ $error.text("Функция смены пароля временно недоступна").removeClass("hidden");
+ });
+
+ function logout() {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("refresh_token");
+ window.location.href = "/login";
+ }
+
+ function showLoadingState() {
+ const $profileCard = $("#profile-card");
+ const $accountContainer = $("#account-container");
+ const $rolesContainer = $("#roles-container");
+ const $actionsContainer = $("#actions-container");
+
+ $profileCard.html(`
+
+ `);
+
+ $accountContainer.html(`
+
+ ${Array(4)
+ .fill()
+ .map(
+ () => `
+
+ `
+ )
+ .join("")}
+
+ `);
+
+ $rolesContainer.html(`
+
+ `);
+
+ $actionsContainer.html(`
+
+ `);
+ }
+
+ function showErrorState(message) {
+ const $profileCard = $("#profile-card");
+ const $accountSection = $("#account-section");
+ const $rolesSection = $("#roles-section");
+ const $actionsSection = $("#actions-section");
+
+ $accountSection.hide();
+ $rolesSection.hide();
+ $actionsSection.hide();
+
+ $profileCard.html(`
+
+
+
${escapeHtml(message)}
+
Не удалось загрузить профиль
+
+
+ `);
+
+ $("#retry-btn").on("click", function () {
+ $accountSection.show();
+ $rolesSection.show();
+ $actionsSection.show();
+ loadProfile();
+ });
+ }
+
+ function escapeHtml(text) {
+ if (!text) return "";
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ const $guestLink = $("#guest-link");
+ const $userBtn = $("#user-btn");
+ const $userDropdown = $("#user-dropdown");
+ const $userArrow = $("#user-arrow");
+ const $userAvatar = $("#user-avatar");
+ const $dropdownName = $("#dropdown-name");
+ const $dropdownUsername = $("#dropdown-username");
+ const $dropdownEmail = $("#dropdown-email");
+ const $logoutBtn = $("#logout-btn");
+
+ let isDropdownOpen = false;
+
+ function openDropdown() {
+ isDropdownOpen = true;
+ $userDropdown.removeClass("hidden");
+ $userArrow.addClass("rotate-180");
+ }
+
+ function closeDropdown() {
+ isDropdownOpen = false;
+ $userDropdown.addClass("hidden");
+ $userArrow.removeClass("rotate-180");
+ }
+
+ $userBtn.on("click", function (e) {
+ e.stopPropagation();
+ isDropdownOpen ? closeDropdown() : openDropdown();
+ });
+
+ $(document).on("click", function (e) {
+ if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
+ closeDropdown();
+ }
+ });
+
+ $logoutBtn.on("click", logout);
+
+ function showGuest() {
+ $guestLink.removeClass("hidden");
+ $userBtn.addClass("hidden").removeClass("flex");
+ closeDropdown();
+ }
+
+ function showUser(user) {
+ $guestLink.addClass("hidden");
+ $userBtn.removeClass("hidden").addClass("flex");
+
+ const displayName = user.full_name || user.username;
+ const firstLetter = displayName.charAt(0).toUpperCase();
+
+ $userAvatar.text(firstLetter);
+ $dropdownName.text(displayName);
+ $dropdownUsername.text("@" + user.username);
+ $dropdownEmail.text(user.email);
+ }
+
+ if (currentUser) {
+ showUser(currentUser);
+ }
+ });
\ No newline at end of file
diff --git a/library_service/templates/profile.html b/library_service/templates/profile.html
new file mode 100644
index 0000000..c1385eb
--- /dev/null
+++ b/library_service/templates/profile.html
@@ -0,0 +1,69 @@
+{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %} {% block
+content %}
+
+
+
+
+
+
+
Информация об аккаунте
+
+
+
+
+
+
+
+
+
+
+
Смена пароля
+
+
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
\ No newline at end of file