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(` +
+ +
+ Аватар + + + ${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