Compare commits

..

3 Commits

Author SHA1 Message Date
1e0c3478a1 Улучшение админки 2026-01-20 01:01:42 +03:00
e507896b7a Улучшение безопасности 2026-01-19 23:23:39 +03:00
d6ecd4066f Улучшение безопасности 2026-01-19 23:22:29 +03:00
65 changed files with 3308 additions and 1388 deletions
+3
View File
@@ -0,0 +1,3 @@
*.log
__pycache__/
+15 -1
View File
@@ -5,6 +5,7 @@ POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="lib"
# Default admin account
# DEFAULT_ADMIN_USERNAME="admin"
# DEFAULT_ADMIN_EMAIL="admin@example.com"
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
@@ -13,7 +14,8 @@ POSTGRES_DB="lib"
ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15"
# SECRET_KEY="your-secret-key-change-in-production"
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
SECRET_KEY="your-secret-key-change-in-production"
# Hash
ARGON2_TYPE="id"
@@ -21,3 +23,15 @@ ARGON2_TIME_COST="3"
ARGON2_MEMORY_COST="65536"
ARGON2_PARALLELISM="4"
ARGON2_SALT_LENGTH="16"
ARGON2_HASH_LENGTH="48"
# Recovery codes
RECOVERY_CODES_COUNT="10"
RECOVERY_CODE_SEGMENTS="4"
RECOVERY_CODE_SEGMENT_BYTES="2"
RECOVERY_MIN_REMAINING_WARNING="3"
RECOVERY_MAX_AGE_DAYS="365"
# TOTP_2FA
TOTP_ISSUER="LiB"
TOTP_VALID_WINDOW="1"
+74 -55
View File
@@ -68,88 +68,107 @@
#### **Аутентификация** (`/api/auth`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|-----------------------------------------------|----------------|------------------------------------------|
| POST | `/api/auth/register` | Публичный | Регистрация нового пользователя |
| POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) |
| POST | `/api/auth/refresh` | Публичный | Обновление пары токенов |
| GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя |
| GET | `/api/auth/users` | Сотрудник | Список всех пользователей |
| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе |
|--------|------------------------------|----------------|------------------------------------------|
| POST | `/register` | Публичный | Регистрация нового пользователя |
| POST | `/token` | Публичный | Получение JWT токенов (access + refresh) |
| POST | `/refresh` | Публичный | Обновление пары токенов |
| GET | `/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя |
| GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA |
| POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA |
| POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию |
| POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию |
| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления |
| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля |
| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода |
#### **Авторы** (`/api/authors`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|---------------------------------|
| POST | `/api/authors` | Сотрудник | Создать нового автора |
| GET | `/api/authors` | Публичный | Получить список всех авторов |
| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами |
| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID |
|--------|----------|-----------|---------------------------------|
| POST | `/` | Сотрудник | Создать нового автора |
| GET | `/` | Публичный | Получить список всех авторов |
| GET | `/{id}` | Публичный | Получить автора по ID с книгами |
| PUT | `/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/{id}` | Сотрудник | Удалить автора по ID |
#### **Книги** (`/api/books`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|-----------------------------------------------------------|
| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией |
| POST | `/api/books` | Сотрудник | Создать новую книгу |
| GET | `/api/books` | Публичный | Получить список всех книг |
| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID |
| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID |
|--------|-----------|-----------|----------------------------------------------|
| POST | `/` | Сотрудник | Создать новую книгу |
| GET | `/` | Публичный | Получить список всех книг |
| GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| PUT | `/{id}` | Сотрудник | Обновить книгу по ID |
| DELETE | `/{id}` | Сотрудник | Удалить книгу по ID |
| GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам |
#### **Жанры** (`/api/genres`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|--------------------|-----------|-------------------------------|
| POST | `/api/genres` | Сотрудник | Создать новый жанр |
| GET | `/api/genres` | Публичный | Получить список всех жанров |
| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID |
|--------|----------|-----------|-------------------------------|
| POST | `/` | Сотрудник | Создать новый жанр |
| GET | `/` | Публичный | Получить список всех жанров |
| GET | `/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/{id}` | Сотрудник | Удалить жанр по ID |
#### **Выдачи** (`/api/loans`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|------------------------------------|----------------|--------------------------------------------------------------|
| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) |
| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов |
| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования |
|--------|-------------------------|----------------|------------------------------------------------------------|
| POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) |
| GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| POST | `issue` | Админ | Выдать книгу напрямую без бронирования |
| GET | `analytics` | Админ | Аналитика выдач и возвратов |
#### **Связи** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|----------------------------------|-----------|-------------------------------|
| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги |
|--------|------------------------------|-----------|-------------------------------|
| POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/books/{id}/genres` | Публичный | Получить список жанров книги |
#### **Пользователи** (`/api/users`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|-------------------------------|----------------|------------------------------|
| POST | `/` | Админ | Создать нового пользователя |
| GET | `/` | Админ | Список всех пользователей |
| GET | `/{id}` | Админ | Получить пользователя по ID |
| PUT | `/{id}` | Админ | Обновить пользователя по ID |
| DELETE | `/{id}` | Админ | Удалить пользователя по ID |
| POST | `/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
| DELETE | `/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
| GET | `/roles` | Авторизованный | Список ролей в системе |
#### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|-------|--------------|-----------|----------------------|
| GET | `/api/info` | Публичный | Информация о сервисе |
| GET | `/api/stats` | Публичный | Статистика системы |
|-------|----------|-----------|----------------------|
| GET | `/info` | Публичный | Информация о сервисе |
| GET | `/stats` | Публичный | Статистика системы |
### **Веб-страницы**
| Путь | Доступ | Описание |
|---------------------|----------------|-----------------------------------------|
|---------------------|----------------|-----------------------------|
| `/` | Публичный | Главная страница |
| `/api` | Публичный | Ссылки на документацию |
| `/auth` | Публичный | Страница авторизации |
| `/profile` | Авторизованный | Профиль пользователя |
| `/books` | Публичный | Каталог книг с фильтрацией |
@@ -163,9 +182,9 @@
| `/genre/create` | Сотрудник | Создание жанра |
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
| `/my-books` | Авторизованный | Мои выдачи |
| `/users` | Сотрудник | Управление пользователями |
| `/users` | Админ | Управление пользователями |
| `/analytics` | Админ | Аналитика выдач и возвратов |
| `/api` | Публичный | Страница с ссылками на документацию API |
### **Схема базы данных**
+109
View File
@@ -0,0 +1,109 @@
"""Пакет авторизации и аутентификации"""
from .core import (
SECRET_KEY,
ALGORITHM,
PARTIAL_TOKEN_EXPIRE_MINUTES,
ACCESS_TOKEN_EXPIRE_MINUTES,
REFRESH_TOKEN_EXPIRE_DAYS,
ARGON2_TIME_COST,
ARGON2_MEMORY_COST,
ARGON2_PARALLELISM,
ARGON2_SALT_LENGTH,
ARGON2_HASH_LENGTH,
RECOVERY_CODES_COUNT,
RECOVERY_CODE_SEGMENTS,
RECOVERY_CODE_SEGMENT_BYTES,
RECOVERY_MIN_REMAINING_WARNING,
RECOVERY_MAX_AGE_DAYS,
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
create_partial_token,
decode_token,
authenticate_user,
get_current_user,
get_current_active_user,
get_user_from_partial_token,
require_role,
require_any_role,
is_user_staff,
is_user_admin,
RequireAuth,
RequireAdmin,
RequireMember,
RequireLibrarian,
RequirePartialAuth,
RequireStaff,
)
from .seed import (
seed_roles,
seed_admin,
run_seeds,
)
from .recovery import (
generate_codes_for_user,
verify_and_use_code,
get_codes_status,
)
from .totp import (
generate_secret,
get_provisioning_uri,
verify_totp_code,
qr_to_bitmap_b64,
generate_totp_setup,
TOTP_ISSUER,
TOTP_VALID_WINDOW,
)
__all__ = [
"SECRET_KEY",
"ALGORITHM",
"ACCESS_TOKEN_EXPIRE_MINUTES",
"REFRESH_TOKEN_EXPIRE_DAYS",
"ARGON2_TIME_COST",
"ARGON2_MEMORY_COST",
"ARGON2_PARALLELISM",
"ARGON2_SALT_LENGTH",
"ARGON2_HASH_LENGTH",
"RECOVERY_CODES_COUNT",
"RECOVERY_CODE_SEGMENTS",
"RECOVERY_CODE_SEGMENT_BYTES",
"RECOVERY_MIN_REMAINING_WARNING",
"RECOVERY_MAX_AGE_DAYS",
"verify_password",
"get_password_hash",
"create_access_token",
"create_refresh_token",
"decode_token",
"authenticate_user",
"get_current_user",
"get_current_active_user",
"require_role",
"require_any_role",
"is_user_staff",
"is_user_admin",
"RequireAuth",
"RequireAdmin",
"RequireMember",
"RequireLibrarian",
"RequireStaff",
"seed_roles",
"seed_admin",
"run_seeds",
"generate_secre",
"get_provisioning_uri",
"verify_totp_code",
"qr_to_bitmap_b64",
"generate_totp_setup," "generate_codes_for_user",
"verify_and_use_code",
"get_codes_status",
"CODES_COUNT",
"MIN_REMAINING_WARNING",
"TOTP_ISSUER",
"TOTP_VALID_WINDOW",
]
@@ -1,7 +1,6 @@
"""Модуль авторизации и аутентификации"""
"""Модуль основного функционала авторизации и аутентификации"""
import os
import base64
from datetime import datetime, timedelta, timezone
from typing import Annotated
@@ -11,10 +10,8 @@ from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext
from sqlmodel import Session, select
import pyotp
import qrcode
from library_service.models.db import Role, User
from library_service.models.db import User
from library_service.models.dto import TokenData
from library_service.settings import get_session, get_logger
@@ -22,15 +19,24 @@ from library_service.settings import get_session, get_logger
# Конфигурация JWT из переменных окружения
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# Конфигурация хэширования паролей из переменных окружения
# Конфигурация хэширования из переменных окружения
ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id")
ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3"))
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "65536"))
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "4"))
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "131072"))
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "2"))
ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16"))
ARGON2_HASH_LENGTH = int(os.getenv("ARGON2_HASH_LENGTH", "48"))
# Конфигурация кодов восстановления
RECOVERY_CODES_COUNT = int(os.getenv("RECOVERY_CODES_COUNT", "10"))
RECOVERY_CODE_SEGMENTS = int(os.getenv("RECOVERY_CODE_SEGMENTS", "4"))
RECOVERY_CODE_SEGMENT_BYTES = int(os.getenv("RECOVERY_CODE_SEGMENT_BYTES", "2"))
RECOVERY_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3"))
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
# Получение логгера
logger = get_logger()
@@ -51,6 +57,7 @@ pwd_context = CryptContext(
argon2__memory_cost=ARGON2_MEMORY_COST,
argon2__parallelism=ARGON2_PARALLELISM,
argon2__salt_len=ARGON2_SALT_LENGTH,
argon2__hash_len=ARGON2_HASH_LENGTH,
)
@@ -64,15 +71,32 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def _create_token(data: dict, expires_delta: timedelta, token_type: str) -> str:
def _create_token(
data: dict,
expires_delta: timedelta,
token_type: str,
is_partial: bool = False,
) -> str:
"""Базовая функция создания токена"""
now = datetime.now(timezone.utc)
to_encode = {**data, "iat": now, "exp": now + expires_delta, "type": token_type}
to_encode = {
**data,
"iat": now,
"exp": now + expires_delta,
"type": token_type,
"partial": is_partial,
}
if token_type == "refresh":
to_encode.update({"jti": str(uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_partial_token(data: dict) -> str:
"""Создает partial токен для незавершённой 2FA аутентификации"""
delta = timedelta(minutes=PARTIAL_TOKEN_EXPIRE_MINUTES)
return _create_token(data, delta, "partial", is_partial=True)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Создает JWT access токен"""
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
@@ -84,7 +108,11 @@ def create_refresh_token(data: dict) -> str:
return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
def decode_token(token: str, expected_type: str = "access") -> TokenData:
def decode_token(
token: str,
expected_type: str = "access",
allow_partial: bool = False,
) -> TokenData:
"""Декодирует и проверяет JWT токен"""
token_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -95,13 +123,21 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData:
username: str | None = payload.get("sub")
user_id: int | None = payload.get("user_id")
token_type: str | None = payload.get("type")
if token_type != expected_type:
is_partial: bool = payload.get("partial", False)
if token_type == "partial":
if not allow_partial:
token_error.detail = "2FA verification required"
raise token_error
elif token_type != expected_type:
token_error.detail = f"Invalid token type. Expected {expected_type}"
raise token_error
if username is None or user_id is None:
token_error.detail = "Could not validate credentials"
raise token_error
return TokenData(username=username, user_id=user_id)
return TokenData(username=username, user_id=user_id, is_partial=is_partial)
except ExpiredSignatureError:
token_error.detail = "Token expired"
raise token_error
@@ -147,6 +183,29 @@ def get_current_active_user(
return current_user
def get_user_from_partial_token(
token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session),
) -> User:
"""Возвращает пользователя из partial токена (для 2FA верификации)"""
token_data = decode_token(token, expected_type="access", allow_partial=True)
if not token_data.is_partial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Full token provided, 2FA not required",
)
user = session.get(User, token_data.user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(role_name: str):
"""Создает dependency для проверки наличия определенной роли"""
@@ -182,6 +241,7 @@ RequireAuth = Annotated[User, Depends(get_current_active_user)]
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
RequireMember = Annotated[User, Depends(require_role("member"))]
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)]
RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))]
@@ -195,121 +255,3 @@ def is_user_admin(user: User) -> bool:
"""Проверяет, является ли пользователь администратором"""
roles = {role.name for role in user.roles}
return "admin" in roles
def seed_roles(session: Session) -> dict[str, Role]:
"""Создает роли по умолчанию, если их нет"""
default_roles = [
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
]
roles = {}
for role_data in default_roles:
existing = session.exec(
select(Role).where(Role.name == role_data["name"])
).first()
if existing:
roles[role_data["name"]] = existing
else:
role = Role(**role_data)
session.add(role)
session.commit()
session.refresh(role)
roles[role_data["name"]] = role
logger.info(f"[+] Created role: {role_data['name']}")
return roles
def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создает администратора по умолчанию, если нет ни одного"""
existing_admins = session.exec(
select(User).join(User.roles).where(Role.name == "admin")
).all()
if existing_admins:
logger.info(
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
)
return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
generated = False
if not admin_password:
import secrets
admin_password = secrets.token_urlsafe(16)
generated = True
admin_user = User(
username=admin_username,
email=admin_email,
full_name="Системный администратор",
hashed_password=get_password_hash(admin_password),
is_active=True,
is_verified=True,
)
admin_user.roles.append(admin_role)
session.add(admin_user)
session.commit()
session.refresh(admin_user)
logger.info(f"[+] Created admin user: {admin_username}")
if generated:
logger.warning("=" * 52)
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
logger.warning("[!] Save this password! It won't be shown again!")
logger.warning("=" * 52)
return admin_user
def run_seeds(session: Session) -> None:
"""Запускает создание ролей и администратора"""
roles = seed_roles(session)
seed_admin(session, roles["admin"])
def qr_to_bitmap_b64(data: str) -> dict:
"""
Конвертирует данные в QR-код и возвращает как base64 bitmap.
0 = чёрный, 1 = белый
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=0,
)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
size = len(matrix)
bits = []
for row in matrix:
for cell in row:
bits.append(0 if cell else 1)
padding = (8 - len(bits) % 8) % 8
bits.extend([0] * padding)
bytes_array = bytearray()
for i in range(0, len(bits), 8):
byte = 0
for j in range(8):
byte = (byte << 1) | bits[i + j]
bytes_array.append(byte)
b64 = base64.b64encode(bytes_array).decode("ascii")
return {"size": size, "padding": padding, "bitmap_b64": b64}
+148
View File
@@ -0,0 +1,148 @@
"""Модуль резервных кодов восстановления пароля"""
import secrets
from datetime import datetime, timezone, timedelta
import argon2
from sqlmodel import Session
from .core import (
ARGON2_TIME_COST,
ARGON2_MEMORY_COST,
ARGON2_PARALLELISM,
ARGON2_SALT_LENGTH,
ARGON2_HASH_LENGTH,
RECOVERY_CODES_COUNT,
RECOVERY_CODE_SEGMENTS,
RECOVERY_CODE_SEGMENT_BYTES,
RECOVERY_MIN_REMAINING_WARNING,
RECOVERY_MAX_AGE_DAYS,
)
from library_service.settings import get_logger
logger = get_logger()
# Argon2 для кодов
_recovery_hasher = argon2.PasswordHasher(
type=argon2.Type.ID,
time_cost=ARGON2_TIME_COST,
hash_len=ARGON2_HASH_LENGTH,
salt_len=ARGON2_SALT_LENGTH,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
)
def generate_code() -> str:
"""Генерация кода в формате xxxx-xxxx-xxxx-xxxx"""
segments = [
secrets.token_hex(RECOVERY_CODE_SEGMENT_BYTES)
for _ in range(RECOVERY_CODE_SEGMENTS)
]
return "-".join(segments)
def normalize_code(code: str) -> str:
"""Нормализация: убираем дефисы, lowercase"""
return code.replace("-", "").lower().strip()
def hash_code(code: str) -> str:
"""Хеширование кода"""
return _recovery_hasher.hash(normalize_code(code))
def verify_code(plain_code: str, hashed: str) -> bool:
"""Проверка кода"""
if not hashed:
return False
try:
_recovery_hasher.verify(hashed, normalize_code(plain_code))
return True
except argon2.exceptions.VerifyMismatchError:
return False
except argon2.exceptions.InvalidHashError:
logger.warning("Invalid recovery code hash format")
return False
def generate_codes_for_user(session: Session, user) -> list[str]:
"""Генерация новых резервных кодов для пользователя."""
plain_codes: list[str] = []
hashed_codes: list[str] = []
for _ in range(RECOVERY_CODES_COUNT):
code = generate_code()
plain_codes.append(code)
hashed_codes.append(hash_code(code))
user.recovery_code_hashes = " ".join(hashed_codes)
user.recovery_codes_generated_at = datetime.now(timezone.utc)
session.add(user)
session.commit()
session.refresh(user)
logger.info(f"Generated {RECOVERY_CODES_COUNT} recovery codes for user {user.id}")
return plain_codes
def verify_and_use_code(session: Session, user, code: str) -> bool:
"""Проверка и использование кода. При успехе хеш заменяется на пустую строку"""
if not user.recovery_code_hashes:
return False
hashes = user.recovery_code_hashes.split(" ")
for i, stored_hash in enumerate(hashes):
if stored_hash and verify_code(code, stored_hash):
hashes[i] = ""
user.recovery_code_hashes = " ".join(hashes)
session.add(user)
session.commit()
logger.info(
f"Recovery code #{i + 1} used for user {user.id}, "
f"remaining: {sum(1 for h in hashes if h)}"
)
return True
logger.warning(f"Invalid recovery code attempt for user {user.id}")
return False
def get_codes_status(user) -> dict:
"""Статус резервных кодов"""
if not user.recovery_code_hashes:
return {
"total": 0,
"remaining": 0,
"used_codes": [],
"generated_at": None,
"should_regenerate": True,
}
hashes = user.recovery_code_hashes.split(" ")
used_codes = [h == "" for h in hashes]
remaining = sum(1 for h in hashes if h)
total = len(hashes)
generated_at = user.recovery_codes_generated_at
should_regenerate = remaining <= RECOVERY_MIN_REMAINING_WARNING
if generated_at:
generated_at = generated_at.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - generated_at
if age > timedelta(days=RECOVERY_MAX_AGE_DAYS):
should_regenerate = True
return {
"total": total,
"remaining": remaining,
"used_codes": used_codes,
"generated_at": generated_at,
"should_regenerate": should_regenerate,
}
+95
View File
@@ -0,0 +1,95 @@
"""Модуль создания начальных ролей и администратора"""
import os
from sqlmodel import Session, select
from library_service.models.db import Role, User
from .core import get_password_hash
from library_service.settings import get_logger
# Получение логгера
logger = get_logger()
def seed_roles(session: Session) -> dict[str, Role]:
"""Создает роли по умолчанию, если их нет"""
default_roles = [
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
]
roles = {}
for role_data in default_roles:
existing = session.exec(
select(Role).where(Role.name == role_data["name"])
).first()
if existing:
roles[role_data["name"]] = existing
else:
role = Role(**role_data)
session.add(role)
session.commit()
session.refresh(role)
roles[role_data["name"]] = role
logger.info(f"[+] Created role: {role_data['name']}")
return roles
def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создает администратора по умолчанию, если нет ни одного"""
existing_admins = session.exec(
select(User)
.join(User.roles) # ty: ignore[invalid-argument-type]
.where(Role.name == "admin")
).all()
if existing_admins:
logger.info(
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
)
return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
generated = False
if not admin_password:
import secrets
admin_password = secrets.token_urlsafe(16)
generated = True
admin_user = User(
username=admin_username,
email=admin_email,
full_name="Системный администратор",
hashed_password=get_password_hash(admin_password),
is_active=True,
is_verified=True,
)
admin_user.roles.append(admin_role)
session.add(admin_user)
session.commit()
session.refresh(admin_user)
logger.info(f"[+] Created admin user: {admin_username}")
if generated:
logger.warning("=" * 52)
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
logger.warning("[!] Save this password! It won't be shown again!")
logger.warning("=" * 52)
return admin_user
def run_seeds(session: Session) -> None:
"""Запускает создание ролей и администратора"""
roles = seed_roles(session)
seed_admin(session, roles["admin"])
+78
View File
@@ -0,0 +1,78 @@
"""Модуль TOTP 2FA"""
import base64
import os
import pyotp
import qrcode
# Настройкт из переменных окружения
TOTP_ISSUER = os.getenv("TOTP_ISSUER", "LiB")
TOTP_VALID_WINDOW = int(os.getenv("TOTP_VALID_WINDOW", "1"))
def generate_secret() -> str:
"""Генерация нового TOTP секрета"""
return pyotp.random_base32()
def get_provisioning_uri(secret: str, username: str) -> str:
"""Получение URI для QR-кода"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=TOTP_ISSUER)
def verify_totp_code(secret: str, code: str) -> bool:
"""Проверка TOTP кода"""
if not secret or not code:
return False
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=TOTP_VALID_WINDOW)
def qr_to_bitmap_b64(data: str) -> dict:
"""Конвертирует данные в QR-код и возвращает как base64 bitmap"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=0,
)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
size = len(matrix)
bits = []
for row in matrix:
for cell in row:
bits.append(0 if cell else 1)
padding = (8 - len(bits) % 8) % 8
bits.extend([0] * padding)
bytes_array = bytearray()
for i in range(0, len(bits), 8):
byte = 0
for j in range(8):
byte = (byte << 1) | bits[i + j]
bytes_array.append(byte)
b64 = base64.b64encode(bytes_array).decode("ascii")
return {"size": size, "padding": padding, "bitmap_b64": b64}
def generate_totp_setup(username: str) -> dict:
"""Генерация данных для настройки TOTP"""
secret = generate_secret()
uri = get_provisioning_uri(secret, username)
bitmap_data = qr_to_bitmap_b64(uri)
return {
"secret": secret,
"username": username,
"issuer": TOTP_ISSUER,
**bitmap_data,
}
+6 -2
View File
@@ -1,4 +1,5 @@
"""Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List
from sqlalchemy import Column, String
@@ -15,10 +16,11 @@ if TYPE_CHECKING:
class Book(BookBase, table=True):
"""Модель книги в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True)
status: BookStatus = Field(
default=BookStatus.ACTIVE,
sa_column=Column(String, nullable=False, default="active")
sa_column=Column(String, nullable=False, default="active"),
)
authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink
@@ -26,4 +28,6 @@ class Book(BookBase, table=True):
genres: List["Genre"] = Relationship(
back_populates="books", link_model=GenreBookLink
)
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"})
loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference]
sa_relationship_kwargs={"cascade": "all, delete"}
)
+7 -2
View File
@@ -1,10 +1,12 @@
"""Модуль связей между сущностями в БД"""
from datetime import datetime
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field
class AuthorBookLink(SQLModel, table=True):
"""Модель связи автора и книги"""
author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True
)
@@ -13,12 +15,14 @@ class AuthorBookLink(SQLModel, table=True):
class GenreBookLink(SQLModel, table=True):
"""Модель связи жанра и книги"""
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class UserRoleLink(SQLModel, table=True):
"""Модель связи роли и пользователя"""
__tablename__ = "user_roles"
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
@@ -30,6 +34,7 @@ class BookUserLink(SQLModel, table=True):
Модель истории выдачи книг (Loan).
Связывает книгу и пользователя с фиксацией времени.
"""
__tablename__ = "book_loans"
id: int | None = Field(default=None, primary_key=True, index=True)
@@ -37,6 +42,6 @@ class BookUserLink(SQLModel, table=True):
book_id: int = Field(foreign_key="book.id")
user_id: int = Field(foreign_key="users.id")
borrowed_at: datetime = Field(default_factory=datetime.utcnow)
borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
due_date: datetime
returned_at: datetime | None = Field(default=None)
+47 -5
View File
@@ -1,5 +1,6 @@
"""Модуль DB-моделей пользователей"""
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List
from sqlmodel import Field, Relationship
@@ -13,17 +14,58 @@ if TYPE_CHECKING:
class User(UserBase, table=True):
"""Модель пользователя в базе данных"""
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True, index=True)
hashed_password: str = Field(nullable=False)
is_2fa_enabled: bool = Field(default=False)
totp_secret: str | None = Field(default=None, max_length=64)
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
recovery_codes_generated_at: datetime | None = Field(default=None)
is_active: bool = Field(default=True)
is_verified: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime | None = Field(
default=None, sa_column_kwargs={"onupdate": datetime.utcnow}
default=None, sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)}
)
# Связи
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"})
loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference]
sa_relationship_kwargs={"cascade": "all, delete"}
)
@property
def recovery_codes_list(self) -> list[str]:
"""Список хешей"""
if not self.recovery_code_hashes:
return []
return self.recovery_code_hashes.split(" ")
@property
def recovery_codes_total(self) -> int:
"""Общее количество слотов"""
if not self.recovery_code_hashes:
return 0
return len(self.recovery_codes_list)
@property
def recovery_codes_remaining(self) -> int:
"""Количество неиспользованных кодов"""
return sum(1 for h in self.recovery_codes_list if h)
@property
def recovery_codes_used(self) -> int:
"""Количество использованных кодов"""
return self.recovery_codes_total - self.recovery_codes_remaining
def get_recovery_code_positions(self) -> dict[str, list[int]]:
"""Возвращает позиции использованных и оставшихся кодов"""
used = []
remaining = []
for i, h in enumerate(self.recovery_codes_list, start=1):
if h:
remaining.append(i)
else:
used.append(i)
return {"used": used, "remaining": remaining}
+33 -3
View File
@@ -1,13 +1,31 @@
"""Модуль DTO-моделей"""
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
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, UserList, UserRead, UserUpdate, UserLogin
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
from .token import Token, TokenData
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
BookWithAuthorsAndGenres, BookFilteredList, BookStatusUpdate, LoanWithBook)
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
from .token import Token, TokenData, PartialToken
from .misc import (
AuthorWithBooks,
GenreWithBooks,
BookWithAuthors,
BookWithGenres,
BookWithAuthorsAndGenres,
BookFilteredList,
BookStatusUpdate,
LoanWithBook,
LoginResponse,
RegisterResponse,
UserCreateByAdmin,
UserUpdateByAdmin,
TOTPSetupResponse,
TOTPVerifyRequest,
TOTPDisableRequest,
PasswordResetResponse,
)
__all__ = [
"AuthorBase",
@@ -46,4 +64,16 @@ __all__ = [
"RoleList",
"Token",
"TokenData",
"PartialToken",
"TOTPSetupResponse",
"TOTPVerifyRequest",
"TOTPDisableRequest",
"RecoveryCodeUse",
"UserCreateByAdmin",
"UserUpdateByAdmin",
"LoginResponse",
"RegisterResponse",
"RecoveryCodesStatus",
"PasswordResetResponse",
"RecoveryCodesResponse",
]
-63
View File
@@ -1,63 +0,0 @@
"""Модуль объединёных объектов"""
from typing import List
from sqlmodel import SQLModel, Field
from .author import AuthorRead
from .genre import GenreRead
from .book import BookRead
from .loan import LoanRead
from ..enums import BookStatus
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int
title: str
description: str
authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres]
total: int
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
status: str
+144
View File
@@ -0,0 +1,144 @@
"""Модуль разных моделей"""
from datetime import datetime
from typing import List
from sqlmodel import SQLModel, Field
from .author import AuthorRead
from .genre import GenreRead
from .book import BookRead
from .loan import LoanRead
from ..enums import BookStatus
from .user import UserCreate, UserRead, UserUpdate
from .recovery import RecoveryCodesResponse
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class GenreWithBooks(SQLModel):
"""Модель жанра с книгами"""
id: int
name: str
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(SQLModel):
"""Модель книги с авторами"""
id: int
title: str
description: str
authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(SQLModel):
"""Модель книги с жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(SQLModel):
"""Модель с авторами и жанрами"""
id: int
title: str
description: str
status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
class BookFilteredList(SQLModel):
"""Список книг с фильтрацией"""
books: List[BookWithAuthorsAndGenres]
total: int
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
status: str
class UserCreateByAdmin(UserCreate):
"""Создание пользователя администратором"""
is_active: bool = True
roles: list[str] | None = None
class UserUpdateByAdmin(UserUpdate):
"""Обновление пользователя администратором"""
is_active: bool | None = None
roles: list[str] | None = None
class LoginResponse(SQLModel):
"""Модель для авторизации пользователя"""
access_token: str | None = None
partial_token: str | None = None
refresh_token: str | None = None
token_type: str = "bearer"
requires_2fa: bool = False
class RegisterResponse(SQLModel):
"""Модель для регистрации пользователя"""
user: UserRead
recovery_codes: RecoveryCodesResponse
class PasswordResetResponse(SQLModel):
"""Модель для сброса пароля"""
total: int
remaining: int
used_codes: list[bool]
generated_at: datetime | None
should_regenerate: bool
class TOTPSetupResponse(SQLModel):
"""Модель для генерации данных для настройки TOTP"""
secret: str
username: str
issuer: str
size: int
padding: int
bitmap_b64: str
class TOTPVerifyRequest(SQLModel):
"""Модель для проверки TOTP кода"""
code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$")
class TOTPDisableRequest(SQLModel):
"""Модель для отключения TOTP 2FA"""
password: str
+52
View File
@@ -0,0 +1,52 @@
"""Модуль DTO-моделей для резервных кодов восстановления"""
from datetime import datetime
import re
from pydantic import field_validator
from sqlmodel import SQLModel, Field
class RecoveryCodesResponse(SQLModel):
"""Ответ при генерации резервных кодов"""
codes: list[str]
generated_at: datetime
class RecoveryCodesStatus(SQLModel):
"""Статус резервных кодов пользователя"""
total: int
remaining: int
used_codes: list[bool]
generated_at: datetime | None
should_regenerate: bool
class RecoveryCodeUse(SQLModel):
"""Запрос на сброс пароля через резервный код"""
username: str
recovery_code: str = Field(min_length=19, max_length=19)
new_password: str = Field(min_length=8, max_length=100)
@field_validator("recovery_code")
@classmethod
def validate_recovery_code(cls, v: str) -> str:
if not re.match(
r"^[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}$", v
):
raise ValueError("Invalid recovery code format")
return v.lower()
@field_validator("new_password")
@classmethod
def validate_password(cls, v: str) -> str:
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain uppercase")
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain lowercase")
if not re.search(r"\d", v):
raise ValueError("Password must contain digit")
return v
+12
View File
@@ -1,15 +1,27 @@
"""Модуль DTO-моделей токенов"""
from sqlmodel import SQLModel
class Token(SQLModel):
"""Модель токена"""
access_token: str
token_type: str = "bearer"
refresh_token: str | None = None
class PartialToken(SQLModel):
"""Частичный токен — для подтверждения 2FA"""
partial_token: str
token_type: str = "partial"
requires_2fa: bool = True
class TokenData(SQLModel):
"""Модель содержимого токена"""
username: str | None = None
user_id: int | None = None
is_partial: bool = False
+8
View File
@@ -1,4 +1,5 @@
"""Модуль DTO-моделей пользователей"""
import re
from typing import List
@@ -8,6 +9,7 @@ from sqlmodel import Field, SQLModel
class UserBase(SQLModel):
"""Базовая модель пользователя"""
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
email: EmailStr = Field(index=True, unique=True)
full_name: str | None = Field(default=None, max_length=100)
@@ -25,6 +27,7 @@ class UserBase(SQLModel):
class UserCreate(UserBase):
"""Модель пользователя для создания"""
password: str = Field(min_length=8, max_length=100)
@field_validator("password")
@@ -42,20 +45,24 @@ class UserCreate(UserBase):
class UserLogin(SQLModel):
"""Модель аутентификации для пользователя"""
username: str
password: str
class UserRead(UserBase):
"""Модель пользователя для чтения"""
id: int
is_active: bool
is_verified: bool
is_2fa_enabled: bool
roles: List[str] = []
class UserUpdate(SQLModel):
"""Модель пользователя для обновления"""
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
@@ -63,5 +70,6 @@ class UserUpdate(SQLModel):
class UserList(SQLModel):
"""Список пользователей"""
users: List[UserRead]
total: int
+3
View File
@@ -1,4 +1,5 @@
"""Модуль объединения роутеров"""
from fastapi import APIRouter
from .auth import router as auth_router
@@ -7,6 +8,7 @@ from .books import router as books_router
from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router
from .users import router as users_router
from .misc import router as misc_router
@@ -20,4 +22,5 @@ api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api")
+213 -138
View File
@@ -3,10 +3,9 @@
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
import pyotp
from library_service.models.db import Role, User
from library_service.models.dto import (
@@ -17,7 +16,19 @@ from library_service.models.dto import (
UserList,
RoleRead,
RoleList,
Token,
PartialToken,
LoginResponse,
RecoveryCodeUse,
RegisterResponse,
RecoveryCodesStatus,
RecoveryCodesResponse,
PasswordResetResponse,
TOTPSetupResponse,
TOTPVerifyRequest,
TOTPDisableRequest,
)
from library_service.settings import get_session
from library_service.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -29,21 +40,28 @@ from library_service.auth import (
decode_token,
create_access_token,
create_refresh_token,
generate_totp_setup,
generate_codes_for_user,
verify_and_use_code,
get_codes_status,
verify_totp_code,
verify_password,
qr_to_bitmap_b64,
create_partial_token,
RequirePartialAuth,
verify_and_use_code,
)
from pathlib import Path
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post(
"/register",
response_model=UserRead,
response_model=RegisterResponse,
status_code=status.HTTP_201_CREATED,
summary="Регистрация нового пользователя",
description="Создает нового пользователя в системе",
description="Создает нового пользователя и возвращает резервные коды",
)
def register(user_data: UserCreate, session: Session = Depends(get_session)):
"""Регистрирует нового пользователя в системе"""
@@ -61,7 +79,8 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
db_user = User(
@@ -77,14 +96,25 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
session.commit()
session.refresh(db_user)
return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles])
recovery_codes = generate_codes_for_user(session, db_user)
return RegisterResponse(
user=UserRead(
**db_user.model_dump(),
roles=[role.name for role in db_user.roles],
),
recovery_codes=RecoveryCodesResponse(
codes=recovery_codes,
generated_at=db_user.recovery_codes_generated_at,
),
)
@router.post(
"/token",
response_model=Token,
response_model=LoginResponse,
summary="Получение токена",
description="Аутентификация и получение JWT токена",
description="Аутентификация и получение токенов",
)
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
@@ -99,17 +129,23 @@ def login(
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username, "user_id": user.id},
expires_delta=access_token_expires,
)
refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
token_data = {"sub": user.username, "user_id": user.id}
if user.is_2fa_enabled:
return LoginResponse(
partial_token=create_partial_token(token_data),
token_type="partial",
requires_2fa=True,
)
return Token(
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return LoginResponse(
access_token=create_access_token(
data=token_data, expires_delta=access_token_expires
),
refresh_token=create_refresh_token(data=token_data),
token_type="bearer",
requires_2fa=False,
)
@@ -117,7 +153,7 @@ def login(
"/refresh",
response_model=Token,
summary="Обновление токена",
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен",
description="Получение новой пары токенов, используя действующий Refresh токен",
)
def refresh_token(
refresh_token: str = Body(..., embed=True),
@@ -204,144 +240,183 @@ def update_user_me(
@router.get(
"/users",
response_model=UserList,
summary="Список пользователей",
description="Получить список всех пользователей (только для админов)",
"/2fa",
response_model=TOTPSetupResponse,
summary="Создание QR-кода TOTP 2FA",
description="Генерирует секрет и QR-код для настройки TOTP",
)
def read_users(
current_user: RequireStaff,
skip: int = 0,
limit: int = 100,
def get_totp_qr_bitmap(auth: RequireAuth):
"""Возвращает данные для настройки TOTP"""
return TOTPSetupResponse(**generate_totp_setup(auth.username))
@router.post(
"/2fa/enable",
summary="Включение TOTP 2FA",
description="Подтверждает настройку и включает 2FA",
)
def enable_2fa(
data: TOTPVerifyRequest,
current_user: RequireAuth,
secret: str = Body(..., embed=True),
session: Session = Depends(get_session),
):
"""Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
return UserList(
users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
for user in users
],
total=len(users),
"""Включает 2FA после проверки кода"""
if current_user.is_2fa_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA already enabled",
)
if not verify_totp_code(secret, data.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid TOTP code",
)
current_user.totp_secret = secret
current_user.is_2fa_enabled = True
session.add(current_user)
session.commit()
return {"success": True}
@router.post(
"/2fa/disable",
summary="Отключение TOTP 2FA",
description="Отключает 2FA после проверки пароля и кода",
)
def disable_2fa(
data: TOTPDisableRequest,
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Отключает 2FA"""
if not current_user.is_2fa_enabled or not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA not enabled",
)
if not verify_password(data.password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password",
)
current_user.totp_secret = None
current_user.is_2fa_enabled = False
session.add(current_user)
session.commit()
return {"success": True}
@router.post(
"/2fa/verify",
response_model=Token,
summary="Верификация 2FA",
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
)
def verify_2fa(
data: TOTPVerifyRequest,
user: RequirePartialAuth,
session: Session = Depends(get_session),
):
"""Верифицирует 2FA и возвращает полный токен"""
if not data.code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Provide TOTP code",
)
verified = False
if data.code and user.totp_secret:
if verify_totp_code(user.totp_secret, data.code):
verified = True
if not verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid 2FA code",
)
token_data = {"sub": user.username, "user_id": user.id}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return Token(
access_token=create_access_token(
data=token_data, expires_delta=access_token_expires
),
refresh_token=create_refresh_token(data=token_data),
)
@router.get(
"/recovery-codes/status",
response_model=RecoveryCodesStatus,
summary="Статус резервных кодов",
description="Показывает количество оставшихся кодов и какие использованы",
)
def get_recovery_codes_status(current_user: RequireAuth):
"""Возвращает статус резервных кодов"""
return RecoveryCodesStatus(**get_codes_status(current_user))
@router.post(
"/recovery-codes/regenerate",
response_model=RecoveryCodesResponse,
summary="Перегенерация резервных кодов",
description="Генерирует новые коды, старые аннулируются",
)
def regenerate_recovery_codes(
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Генерирует новые резервные коды"""
codes = generate_codes_for_user(session, current_user)
return RecoveryCodesResponse(
codes=codes,
generated_at=current_user.recovery_codes_generated_at,
)
@router.post(
"/users/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Назначить роль пользователю",
description="Добавить указанную роль пользователю",
"/password/reset",
response_model=PasswordResetResponse,
summary="Сброс пароля через резервный код",
description="Устанавливает новый пароль используя резервный код",
)
def add_role_to_user(
user_id: int,
role_name: str,
admin: RequireAdmin,
def reset_password(
data: RecoveryCodeUse,
session: Session = Depends(get_session),
):
"""Добавляет роль пользователю"""
user = session.get(User, user_id)
"""Сброс пароля с использованием резервного кода"""
user = session.exec(select(User).where(User.username == data.username)).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid username or recovery code",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated",
)
if role in user.roles:
if not verify_and_use_code(session, user, data.recovery_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role",
detail="Invalid username or recovery code",
)
user.roles.append(role)
user.hashed_password = get_password_hash(data.new_password)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/users/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Удалить роль у пользователя",
description="Убрать указанную роль у пользователя",
)
def remove_role_from_user(
user_id: int,
role_name: str,
admin: RequireAdmin,
session: Session = Depends(get_session),
):
"""Удаляет роль у пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role not in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have this role",
)
user.roles.remove(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.get(
"/roles",
response_model=RoleList,
summary="Получить список ролей",
description="Возвращает список ролей",
)
def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" in user_roles else set()
roles = session.exec(select(Role)).all()
return RoleList(
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles),
)
@router.get(
"/2fa",
summary="Создание QR-кода TOTP 2FA",
description="Получить информацию о текущем авторизованном пользователе",
)
def get_totp_qr_bitmap(auth: RequireAuth):
"""Возвращает qr-код bitmap"""
issuer = "issuer"
username = auth.username
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(name=username, issuer_name=issuer)
bitmap_data = qr_to_bitmap_b64(provisioning_uri)
return {"secret": secret, "username": username, "issuer": issuer, **bitmap_data}
return PasswordResetResponse(**get_codes_status(user))
+39 -13
View File
@@ -1,5 +1,6 @@
"""Модуль работы с книгами"""
from datetime import datetime
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query
@@ -8,11 +9,25 @@ from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff
from library_service.settings import get_session
from library_service.models.enums import BookStatus
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
from library_service.models.dto.combined import (
from library_service.models.db import (
Author,
AuthorBookLink,
Book,
GenreBookLink,
Genre,
BookUserLink,
)
from library_service.models.dto import (
AuthorRead,
BookCreate,
BookList,
BookRead,
BookUpdate,
GenreRead,
)
from library_service.models.dto.misc import (
BookWithAuthorsAndGenres,
BookFilteredList
BookFilteredList,
)
@@ -28,7 +43,7 @@ def close_active_loan(session: Session, book_id: int) -> None:
).first()
if active_loan:
active_loan.returned_at = datetime.utcnow()
active_loan.returned_at = datetime.now(timezone.utc)
session.add(active_loan)
@@ -36,7 +51,7 @@ def close_active_loan(session: Session, book_id: int) -> None:
"/filter",
response_model=BookFilteredList,
summary="Фильтрация книг",
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией",
)
def filter_books(
session: Session = Depends(get_session),
@@ -55,10 +70,18 @@ def filter_books(
)
if author_ids:
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
author_ids
)
)
if genre_ids:
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
statement = statement.join(GenreBookLink).where(
GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
genre_ids
)
)
total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one()
@@ -73,7 +96,7 @@ def filter_books(
BookWithAuthorsAndGenres(
**db_book.model_dump(),
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
genres=[GenreRead(**g.model_dump()) for g in db_book.genres],
)
)
@@ -89,7 +112,7 @@ def filter_books(
def create_book(
book: BookCreate,
current_user: RequireStaff,
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""Создает новую книгу в системе"""
db_book = Book(**book.model_dump())
@@ -168,7 +191,7 @@ def update_book(
if book_update.status == BookStatus.BORROWED:
raise HTTPException(
status_code=400,
detail="Статус 'borrowed' устанавливается только через выдачу книги"
detail="Статус 'borrowed' устанавливается только через выдачу книги",
)
if db_book.status == BookStatus.BORROWED:
@@ -205,7 +228,10 @@ def delete_book(
if not book:
raise HTTPException(status_code=404, detail="Book not found")
book_read = BookRead(
id=(book.id or 0), title=book.title, description=book.description, status=book.status
id=(book.id or 0),
title=book.title,
description=book.description,
status=book.status,
)
session.delete(book)
session.commit()
+35 -42
View File
@@ -1,5 +1,6 @@
"""Модуль работы с выдачей и бронированием книг"""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
@@ -34,7 +35,7 @@ def create_loan(
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only create loans for yourself"
detail="You can only create loans for yourself",
)
book = session.get(Book, loan.book_id)
@@ -44,7 +45,7 @@ def create_loan(
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400,
detail=f"Book is not available for loan (status: {book.status})"
detail=f"Book is not available for loan (status: {book.status})",
)
target_user = session.get(User, loan.user_id)
@@ -55,7 +56,7 @@ def create_loan(
book_id=loan.book_id,
user_id=loan.user_id,
due_date=loan.due_date,
borrowed_at=datetime.utcnow()
borrowed_at=datetime.now(timezone.utc),
)
book.status = BookStatus.RESERVED
@@ -109,8 +110,7 @@ def read_loans(
loans = session.exec(statement).all()
return LoanList(
loans=[LoanRead(**loan.model_dump()) for loan in loans],
total=total
loans=[LoanRead(**loan.model_dump()) for loan in loans], total=total
)
@@ -125,11 +125,12 @@ def get_loans_analytics(
session: Session = Depends(get_session),
):
"""Возвращает аналитику по выдачам и возвратам книг"""
end_date = datetime.utcnow()
end_date = datetime.now(timezone.utc)
start_date = end_date - timedelta(days=days)
total_loans = session.exec(
select(func.count(BookUserLink.id))
.where(BookUserLink.borrowed_at >= start_date)
select(func.count(BookUserLink.id)).where(
BookUserLink.borrowed_at >= start_date
)
).one()
active_loans = session.exec(
@@ -156,7 +157,7 @@ def get_loans_analytics(
loans_by_date = session.exec(
select(
cast(BookUserLink.borrowed_at, Date).label("date"),
func.count(BookUserLink.id).label("count")
func.count(BookUserLink.id).label("count"),
)
.where(BookUserLink.borrowed_at >= start_date)
.group_by(cast(BookUserLink.borrowed_at, Date))
@@ -166,9 +167,11 @@ def get_loans_analytics(
returns_by_date = session.exec(
select(
cast(BookUserLink.returned_at, Date).label("date"),
func.count(BookUserLink.id).label("count")
func.count(BookUserLink.id).label("count"),
)
.where(
BookUserLink.returned_at >= start_date # ty: ignore[unsupported-operator]
)
.where(BookUserLink.returned_at >= start_date)
.where(BookUserLink.returned_at != None) # noqa: E711
.group_by(cast(BookUserLink.returned_at, Date))
.order_by(cast(BookUserLink.returned_at, Date))
@@ -185,10 +188,7 @@ def get_loans_analytics(
daily_returns[date_str] = count
top_books = session.exec(
select(
BookUserLink.book_id,
func.count(BookUserLink.id).label("loan_count")
)
select(BookUserLink.book_id, func.count(BookUserLink.id).label("loan_count"))
.where(BookUserLink.borrowed_at >= start_date)
.group_by(BookUserLink.book_id)
.order_by(func.count(BookUserLink.id).desc())
@@ -201,23 +201,20 @@ def get_loans_analytics(
loan_count = row[1] if isinstance(row, tuple) else row.loan_count
book = session.get(Book, book_id)
if book:
top_books_data.append({
"book_id": book_id,
"title": book.title,
"loan_count": loan_count
})
top_books_data.append(
{"book_id": book_id, "title": book.title, "loan_count": loan_count}
)
reserved_count = session.exec(
select(func.count(Book.id))
.where(Book.status == BookStatus.RESERVED)
select(func.count(Book.id)).where(Book.status == BookStatus.RESERVED)
).one()
borrowed_count = session.exec(
select(func.count(Book.id))
.where(Book.status == BookStatus.BORROWED)
select(func.count(Book.id)).where(Book.status == BookStatus.BORROWED)
).one()
return JSONResponse(content={
return JSONResponse(
content={
"summary": {
"total_loans": total_loans,
"active_loans": active_loans,
@@ -232,7 +229,8 @@ def get_loans_analytics(
"period_days": days,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
})
}
)
@router.get(
@@ -256,8 +254,7 @@ def get_loan(
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this loan"
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this loan"
)
return LoanRead(**loan.model_dump())
@@ -285,7 +282,7 @@ def update_loan(
if not is_staff and db_loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only update your own loans"
detail="You can only update your own loans",
)
book = session.get(Book, db_loan.book_id)
@@ -296,7 +293,7 @@ def update_loan(
if not is_staff:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only staff can change loan user"
detail="Only staff can change loan user",
)
new_user = session.get(User, loan_update.user_id)
if not new_user:
@@ -308,10 +305,7 @@ def update_loan(
if loan_update.returned_at is not None:
if db_loan.returned_at is not None:
raise HTTPException(
status_code=400,
detail="Loan is already returned"
)
raise HTTPException(status_code=400, detail="Loan is already returned")
db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE
@@ -349,7 +343,7 @@ def confirm_loan(
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
raise HTTPException(
status_code=400,
detail=f"Cannot confirm loan for book with status: {book.status}"
detail=f"Cannot confirm loan for book with status: {book.status}",
)
book.status = BookStatus.BORROWED
@@ -381,7 +375,7 @@ def return_loan(
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
loan.returned_at = datetime.utcnow()
loan.returned_at = datetime.now(timezone.utc)
book = session.get(Book, loan.book_id)
if book:
@@ -416,7 +410,7 @@ def delete_loan(
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own loans"
detail="You can only delete your own loans",
)
book = session.get(Book, loan.book_id)
@@ -424,7 +418,7 @@ def delete_loan(
if book and book.status != BookStatus.RESERVED:
raise HTTPException(
status_code=400,
detail="Can only delete reservations. Use update endpoint to return borrowed books"
detail="Can only delete reservations. Use update endpoint to return borrowed books",
)
loan_read = LoanRead(**loan.model_dump())
@@ -481,8 +475,7 @@ def issue_book_directly(
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400,
detail=f"Book is not available (status: {book.status})"
status_code=400, detail=f"Book is not available (status: {book.status})"
)
target_user = session.get(User, loan.user_id)
@@ -493,7 +486,7 @@ def issue_book_directly(
book_id=loan.book_id,
user_id=loan.user_id,
due_date=loan.due_date,
borrowed_at=datetime.utcnow()
borrowed_at=datetime.now(timezone.utc),
)
book.status = BookStatus.BORROWED
+1 -1
View File
@@ -103,7 +103,7 @@ async def auth(request: Request):
return templates.TemplateResponse(request, "auth.html")
@router.get("/set-2fa", include_in_schema=False)
@router.get("/2fa", include_in_schema=False)
async def set2fa(request: Request):
"""Рендерит страницу установки двухфакторной аутентификации"""
return templates.TemplateResponse(request, "2fa.html")
+302
View File
@@ -0,0 +1,302 @@
"""Модуль управления пользователями (для администраторов)"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.dto import (
RoleRead,
RoleList,
UserRead,
UserList,
UserCreateByAdmin,
UserUpdateByAdmin,
)
from library_service.settings import get_session
from library_service.auth import (
RequireAuth,
RequireAdmin,
RequireStaff,
get_password_hash,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get(
"/roles",
response_model=RoleList,
summary="Список ролей",
)
def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" not in user_roles else set()
roles = session.exec(select(Role)).all()
return RoleList(
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles),
)
@router.get(
"/",
response_model=UserList,
summary="Список пользователей",
)
def list_users(
current_user: RequireStaff,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
):
"""Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
total = session.exec(select(User)).all()
return UserList(
users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
for user in users
],
total=len(total),
)
@router.post(
"/",
response_model=UserRead,
status_code=status.HTTP_201_CREATED,
summary="Создать пользователя",
)
def create_user(
user_data: UserCreateByAdmin,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Создает пользователя (без резервных кодов)"""
if session.exec(select(User).where(User.username == user_data.username)).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
if session.exec(select(User).where(User.email == user_data.email)).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
db_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
hashed_password=get_password_hash(user_data.password),
is_active=user_data.is_active,
)
if user_data.roles:
for role_name in user_data.roles:
role = session.exec(select(Role).where(Role.name == role_name)).first()
if role:
db_user.roles.append(role)
else:
default_role = session.exec(select(Role).where(Role.name == "member")).first()
if default_role:
db_user.roles.append(default_role)
session.add(db_user)
session.commit()
session.refresh(db_user)
return UserRead(**db_user.model_dump(), roles=[r.name for r in db_user.roles])
@router.get(
"/{user_id}",
response_model=UserRead,
summary="Получить пользователя",
)
def get_user(
user_id: int,
current_user: RequireStaff,
session: Session = Depends(get_session),
):
"""Возвращает пользователя по ID"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.put(
"/{user_id}",
response_model=UserRead,
summary="Обновить пользователя",
)
def update_user(
user_id: int,
user_data: UserUpdateByAdmin,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Обновляет данные пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user_data.email and user_data.email != user.email:
existing = session.exec(
select(User).where(User.email == user_data.email)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.password:
user.hashed_password = get_password_hash(user_data.password)
if user_data.is_active is not None:
user.is_active = user_data.is_active
if user_data.roles is not None:
user.roles.clear()
for role_name in user_data.roles:
role = session.exec(select(Role).where(Role.name == role_name)).first()
if role:
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/{user_id}",
response_model=UserRead,
summary="Удалить пользователя",
)
def delete_user(
user_id: int,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Деактивирует пользователя, при повторном вызове — удаляет физически"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user.is_active:
user.is_active = False
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
else:
user_read = UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
session.delete(user)
session.commit()
return user_read
@router.post(
"/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Назначить роль пользователю",
)
def add_role_to_user(
user_id: int,
role_name: str,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Добавляет роль пользователю"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role",
)
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Удалить роль у пользователя",
)
def remove_role_from_user(
user_id: int,
role_name: str,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Удаляет роль у пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role not in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have this role",
)
user.roles.remove(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
+1
View File
@@ -60,6 +60,7 @@ OPENAPI_TAGS = [
{"name": "genres", "description": "Действия с жанрами."},
{"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."},
{"name": "misc", "description": "Прочие."},
]
-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", {
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);
}
@@ -19,8 +19,7 @@ $(document).ready(() => {
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();
@@ -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>
@@ -230,8 +237,7 @@ $(document).ready(() => {
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";
@@ -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/users/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("Сменить");
}
});
});
@@ -5,18 +5,11 @@ $(document).ready(() => {
);
return;
}
setTimeout(() => {
if (!window.isAdmin()) {
$("#users-container").html(
document.getElementById("access-denied-template").innerHTML,
);
}
}, 100);
let allRoles = [];
let users = [];
let currentPage = 1;
let pageSize = 20;
const pageSize = 20;
let totalUsers = 0;
let searchQuery = "";
let selectedFilterRoles = new Set();
@@ -28,8 +21,8 @@ $(document).ready(() => {
showLoadingState();
Promise.all([
Api.get("/api/auth/users?skip=0&limit=100"),
Api.get("/api/auth/roles"),
Api.get("/api/users?skip=0&limit=100"),
Api.get("/api/users/roles"),
])
.then(([usersData, rolesData]) => {
users = usersData.users;
@@ -139,13 +132,11 @@ $(document).ready(() => {
}
function loadUsers() {
const params = new URLSearchParams();
params.append("skip", (currentPage - 1) * pageSize);
params.append("limit", pageSize);
const skip = (currentPage - 1) * pageSize;
showLoadingState();
Api.get(`/api/auth/users?${params.toString()}`)
Api.get(`/api/users?skip=${skip}&limit=${pageSize}`)
.then((data) => {
users = data.users;
totalUsers = data.total;
@@ -220,6 +211,8 @@ $(document).ready(() => {
}
const rolesContainer = clone.querySelector(".user-roles");
let totalPayroll = 0;
if (user.roles && user.roles.length > 0) {
user.roles.forEach((roleName) => {
const badge = roleBadgeTpl.content.cloneNode(true);
@@ -238,12 +231,25 @@ $(document).ready(() => {
removeBtn.dataset.userId = user.id;
removeBtn.dataset.roleName = roleName;
rolesContainer.appendChild(badge);
const fullRole = allRoles.find((r) => r.name === roleName);
if (fullRole && fullRole.payroll) {
totalPayroll += fullRole.payroll;
}
});
} else {
rolesContainer.innerHTML =
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
}
if (totalPayroll > 0) {
const payrollBadge = clone.querySelector(".user-payroll");
const payrollAmount = clone.querySelector(".user-payroll-amount");
payrollBadge.classList.remove("hidden");
payrollAmount.textContent = totalPayroll.toLocaleString("ru-RU");
}
const addRoleBtn = clone.querySelector(".add-role-btn");
addRoleBtn.dataset.userId = user.id;
@@ -457,12 +463,9 @@ $(document).ready(() => {
}
function addRoleToUser(userId, roleName) {
Api.request(
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
{
Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
method: "POST",
},
)
})
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
@@ -485,12 +488,9 @@ $(document).ready(() => {
return;
}
Api.request(
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
{
Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
method: "DELETE",
},
)
})
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
@@ -514,7 +514,6 @@ $(document).ready(() => {
$("#edit-user-fullname").val(user.full_name || "");
$("#edit-user-password").val("");
$("#edit-user-active").prop("checked", user.is_active);
$("#edit-user-verified").prop("checked", user.is_verified);
$("#edit-user-modal").removeClass("hidden");
}
@@ -529,6 +528,7 @@ $(document).ready(() => {
const email = $("#edit-user-email").val().trim();
const fullName = $("#edit-user-fullname").val().trim();
const password = $("#edit-user-password").val();
const isActive = $("#edit-user-active").prop("checked");
if (!email) {
Utils.showToast("Email обязателен", "error");
@@ -538,40 +538,26 @@ $(document).ready(() => {
const updateData = {
email: email,
full_name: fullName || null,
is_active: isActive,
};
if (password) {
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)
Api.put(`/api/users/${userId}`, updateData)
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], ...updatedUser };
users[userIndex] = updatedUser;
}
renderUsers();
closeEditModal();
Utils.showToast("Пользователь обновлён", "success");
})
.catch((error) => {
console.warn("API update failed, updating locally:", error);
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex].email = email;
users[userIndex].full_name = fullName || null;
users[userIndex].is_active = $("#edit-user-active").prop("checked");
users[userIndex].is_verified = $("#edit-user-verified").prop(
"checked",
);
}
renderUsers();
closeEditModal();
Utils.showToast("Изменения сохранены локально", "info");
console.error(error);
Utils.showToast(error.message || "Ошибка обновления", "error");
});
}
@@ -586,7 +572,16 @@ $(document).ready(() => {
}
userToDelete = user;
const actionText = user.is_active ? "деактивировать" : "удалить навсегда";
$("#delete-user-name").text(user.full_name || user.username);
$("#delete-user-modal .text-sm.text-gray-500").html(
`Вы уверены, что хотите <strong>${actionText}</strong> пользователя <strong>${Utils.escapeHtml(user.full_name || user.username)}</strong>?` +
(user.is_active
? ""
: " <span class='text-red-600'>Это действие необратимо!</span>"),
);
$("#delete-user-modal").removeClass("hidden");
}
@@ -598,23 +593,27 @@ $(document).ready(() => {
function confirmDeleteUser() {
if (!userToDelete) return;
Utils.showToast("Удаление пользователей не поддерживается API", "error");
Api.delete(`/api/users/${userToDelete.id}`)
.then((deletedUser) => {
if (deletedUser.is_active === false) {
const userIndex = users.findIndex((u) => u.id === userToDelete.id);
if (userIndex !== -1) {
users[userIndex] = deletedUser;
}
Utils.showToast("Пользователь деактивирован", "success");
} else {
users = users.filter((u) => u.id !== userToDelete.id);
totalUsers--;
$("#total-users-count").text(totalUsers);
Utils.showToast("Пользователь удалён", "success");
}
renderUsers();
closeDeleteModal();
// When API supports deletion:
// Api.delete(`/api/auth/users/${userToDelete.id}`)
// .then(() => {
// users = users.filter(u => u.id !== userToDelete.id);
// totalUsers--;
// $("#total-users-count").text(totalUsers);
// renderUsers();
// closeDeleteModal();
// Utils.showToast("Пользователь удалён", "success");
// })
// .catch((error) => {
// console.error(error);
// Utils.showToast(error.message || "Ошибка удаления", "error");
// });
})
.catch((error) => {
console.error(error);
Utils.showToast(error.message || "Ошибка удаления", "error");
});
}
$("#users-container").on("click", ".add-role-btn", function (e) {
-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%;
}
+4 -4
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} {%
block content %}
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %}
{% block content %}
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
<div
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
@@ -11,7 +11,7 @@ block content %}
Настройка 2FA
</h2>
<p class="text-sm text-gray-500 text-center mb-6">
Отсканируйте код в Google Authenticator
Отсканируйте код в приложении Аутентификатора
</p>
<div
id="qr-container"
@@ -155,5 +155,5 @@ block content %}
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/2fa.js"></script>
<script src="/static/page/2fa.js"></script>
{% endblock %}
+3 -7
View File
@@ -1,11 +1,11 @@
{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Аналитика{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-7xl">
<div class="mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
</div>
<!-- Период анализа -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
@@ -22,7 +22,6 @@
</div>
</div>
<!-- Общая статистика -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
@@ -109,7 +108,6 @@
</div>
</div>
<!-- Графики -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Выдачи по дням</h2>
@@ -126,7 +124,6 @@
</div>
</div>
<!-- Топ книг -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Топ книг по выдачам</h2>
<div id="top-books-container" class="space-y-2">
@@ -137,6 +134,5 @@
{% endblock %} {% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %} {% block scripts %}
<script src="/static/analytics.js"></script>
<script src="/static/page/analytics.js"></script>
{% endblock %}
+257 -256
View File
@@ -1,323 +1,324 @@
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {%
block content %}
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %}
{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="flex border-b border-gray-200">
<button
type="button"
id="login-tab"
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500"
>
<button type="button" id="login-tab"
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">
Вход
</button>
<button
type="button"
id="register-tab"
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600"
>
<button type="button" id="register-tab"
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600">
Регистрация
</button>
</div>
<form id="login-form" class="p-6">
<div id="credentials-section">
<div class="mb-4">
<label
for="login-username"
class="block text-sm font-medium text-gray-700 mb-2"
>Имя пользователя</label
>
<input
type="text"
id="login-username"
name="username"
<label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">
Имя пользователя
</label>
<input type="text" id="login-username" name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите имя пользователя"
required
/>
placeholder="Введите имя пользователя" required />
</div>
<div class="mb-4">
<label
for="login-password"
class="block text-sm font-medium text-gray-700 mb-2"
>Пароль</label
>
<label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
<div class="relative">
<input
type="password"
id="login-password"
name="password"
<input type="password" id="login-password" name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите пароль"
required
/>
<button
type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg
class="eye-open w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
placeholder="Введите пароль" required />
<button type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
</path>
</svg>
<svg
class="eye-closed w-5 h-5 hidden"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
</path>
</svg>
</button>
</div>
</div>
<div class="flex items-center justify-between mb-6">
<label
class="custom-checkbox flex items-center text-sm text-gray-600"
>
<label class="custom-checkbox flex items-center text-sm text-gray-600">
<input type="checkbox" id="remember-me" />
<span class="checkmark"></span>Запомнить меня
</label>
<a
href="#"
class="text-sm text-gray-500 hover:text-gray-700 transition"
>Забыли пароль?</a
>
<button type="button" id="forgot-password-btn"
class="text-sm text-gray-500 hover:text-gray-700 transition">
Забыли пароль?
</button>
</div>
<div
id="login-error"
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
></div>
<button
type="submit"
id="login-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
>
</div>
<div id="totp-section" class="hidden">
<div class="text-center mb-4">
<div class="w-20 h-20 mx-auto relative flex items-center justify-center mb-3">
<svg class="absolute inset-0 w-full h-full -rotate-90" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="38" fill="none" stroke="#e5e7eb" stroke-width="2" />
<circle id="lock-progress-circle" cx="40" cy="40" r="38" fill="none" stroke="#000000"
stroke-width="2" stroke-linecap="round"
style="stroke-dasharray: 238.761; stroke-dashoffset: 238.761;" />
</svg>
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center z-10">
<svg class="w-8 h-8 text-gray-600" 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>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-800">Двухфакторная аутентификация</h3>
<p class="text-sm text-gray-500 mt-1">
Введите код из приложения аутентификатора
</p>
</div>
<div class="mb-6">
<input type="text" id="login-totp" name="totp_code"
class="w-full px-4 py-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center text-3xl tracking-[0.5em] font-mono"
placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div>
<button type="button" id="back-to-credentials-btn"
class="w-full mb-4 text-gray-500 hover:text-gray-700 text-sm flex items-center justify-center gap-1">
<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="M15 19l-7-7 7-7"></path>
</svg>
Назад
</button>
</div>
<button type="submit" id="login-submit"
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>
<form
id="register-form"
class="p-6 hidden"
onsubmit="return handleRegister(event);"
>
<form id="register-form" class="p-6 hidden">
<div class="mb-4">
<label
for="register-username"
class="block text-sm font-medium text-gray-700 mb-2"
>Имя пользователя</label
>
<input
type="text"
id="register-username"
name="username"
<label for="register-username" class="block text-sm font-medium text-gray-700 mb-2">
Имя пользователя
</label>
<input type="text" id="register-username" name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Придумайте имя пользователя (мин. 3 символа)"
required
minlength="3"
maxlength="50"
/>
placeholder="Придумайте имя пользователя" required minlength="3" maxlength="50" />
</div>
<div class="mb-4">
<label
for="register-email"
class="block text-sm font-medium text-gray-700 mb-2"
>Email</label
>
<input
type="email"
id="register-email"
name="email"
<label for="register-email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
<input type="email" id="register-email" name="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="example@mail.com"
required
/>
placeholder="example@mail.com" required />
</div>
<div class="mb-4">
<label
for="register-fullname"
class="block text-sm font-medium text-gray-700 mb-2"
>Полное имя
<span class="text-gray-400"
>(необязательно)</span
></label
>
<input
type="text"
id="register-fullname"
name="full_name"
<label for="register-fullname" class="block text-sm font-medium text-gray-700 mb-2">
Полное имя <span class="text-gray-400">(необязательно)</span>
</label>
<input type="text" id="register-fullname" name="full_name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Иван Иванов"
maxlength="100"
/>
placeholder="Иван Иванов" maxlength="100" />
</div>
<div class="mb-4">
<label
for="register-password"
class="block text-sm font-medium text-gray-700 mb-2"
>Пароль</label
>
<label for="register-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
<div class="relative">
<input
type="password"
id="register-password"
name="password"
<input type="password" id="register-password" name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Минимум 8 символов, A-Z, a-z, 0-9"
required
minlength="8"
maxlength="100"
/>
<button
type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg
class="eye-open w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
<button type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
</path>
</svg>
<svg
class="eye-closed w-5 h-5 hidden"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
</path>
</svg>
</button>
</div>
<div class="mt-2">
<div
class="h-1 w-full bg-gray-200 rounded-full overflow-hidden"
>
<div
id="password-strength-bar"
class="h-full w-0 transition-all duration-300"
></div>
<div class="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
<div id="password-strength-bar" class="h-full w-0 transition-all duration-300"></div>
</div>
<p
id="password-strength-text"
class="text-xs mt-1 text-gray-500"
></p>
<p id="password-strength-text" class="text-xs mt-1 text-gray-500"></p>
</div>
</div>
<div class="mb-4">
<label
for="register-password-confirm"
class="block text-sm font-medium text-gray-700 mb-2"
>Подтвердите пароль</label
>
<label for="register-password-confirm" class="block text-sm font-medium text-gray-700 mb-2">
Подтвердите пароль
</label>
<div class="relative">
<input
type="password"
id="register-password-confirm"
name="password_confirm"
<input type="password" id="register-password-confirm" name="password_confirm"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Повторите пароль"
required
/>
<button
type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg
class="eye-open w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
placeholder="Повторите пароль" required />
<button type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
</path>
</svg>
<svg
class="eye-closed w-5 h-5 hidden"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
</path>
</svg>
</button>
</div>
<p
id="password-match-error"
class="text-xs mt-1 text-red-500 hidden"
>
<p id="password-match-error" class="text-xs mt-1 text-red-500 hidden">
Пароли не совпадают
</p>
</div>
<div
id="register-error"
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
></div>
<div
id="register-success"
class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm"
></div>
<button
type="submit"
id="register-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"
>
<button type="submit" id="register-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Зарегистрироваться
</button>
</form>
<form id="reset-password-form" class="p-6 hidden">
<div class="mb-4 text-center">
<h3 class="text-lg font-semibold text-gray-800">Сброс пароля</h3>
<p class="text-sm text-gray-500 mt-1">
Используйте один из резервных кодов, полученных при регистрации
</p>
</div>
<div class="mb-4">
<label for="reset-username" class="block text-sm font-medium text-gray-700 mb-2">
Имя пользователя
</label>
<input type="text" id="reset-username" name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите имя пользователя" required />
</div>
<div class="mb-4">
<label for="reset-recovery-code" class="block text-sm font-medium text-gray-700 mb-2">
Резервный код
</label>
<input type="text" id="reset-recovery-code" name="recovery_code"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center font-mono uppercase"
placeholder="XXXX-XXXX-XXXX-XXXX" maxlength="19" required
pattern="[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}" />
</div>
<div class="mb-4">
<label for="reset-new-password" class="block text-sm font-medium text-gray-700 mb-2">
Новый пароль
</label>
<div class="relative">
<input type="password" id="reset-new-password" name="new_password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
<button type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
</path>
</svg>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
</path>
</svg>
</button>
</div>
</div>
<div class="mb-6">
<label for="reset-confirm-password" class="block text-sm font-medium text-gray-700 mb-2">
Подтвердите новый пароль
</label>
<input type="password" id="reset-confirm-password" name="confirm_password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Повторите новый пароль" required />
<p id="reset-password-match-error" class="text-xs mt-1 text-red-500 hidden">
Пароли не совпадают
</p>
</div>
<button type="submit" id="reset-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Сбросить пароль
</button>
<button type="button" id="back-to-login-btn"
class="w-full mt-3 text-gray-500 hover:text-gray-700 text-sm">
← Вернуться к входу
</button>
</form>
</div>
</div>
</div>
<div id="recovery-codes-modal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-yellow-100 rounded-full mb-4">
<svg class="w-6 h-6 text-yellow-600" 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>
</div>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
Сохраните резервные коды!
</h3>
<p class="text-sm text-gray-500 text-center mb-4">
Эти коды понадобятся для восстановления доступа к аккаунту.
<strong class="text-red-600">Сохраните их в надёжном месте!</strong>
</p>
<div id="recovery-codes-list"
class="bg-gray-50 rounded-lg p-4 font-mono text-sm text-center space-y-2 mb-4">
</div>
<div class="flex gap-2 mb-4">
<button type="button" id="copy-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
</path>
</svg>
Копировать
</button>
<button type="button" id="download-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Скачать
</button>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 mb-4">
<input type="checkbox" id="codes-saved-checkbox" class="rounded" />
Я сохранил(а) коды в надёжном месте
</label>
<button type="button" id="close-recovery-modal-btn" disabled
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Продолжить
</button>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/auth.js"></script>
<script src="/static/page/auth.js"></script>
{% endblock %}
+3 -2
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-4xl">
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
@@ -115,5 +116,5 @@
</div>
</template>
{% endblock %} {% block scripts %}
<script src="/static/author.js"></script>
<script src="/static/page/author.js"></script>
{% endblock %}
+3 -2
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %}
{% block content %}
<div class="container mx-auto p-4">
<div
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
@@ -119,5 +120,5 @@
</div>
</template>
{% endblock %} {% block scripts %}
<script src="/static/authors.js"></script>
<script src="/static/page/authors.js"></script>
{% endblock %}
+3 -1
View File
@@ -238,7 +238,9 @@
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2025 LiB Library. All rights reserved.</p>
<p>&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
Код открыт под лицензией <a href="https://github.com/wowlikon/LibraryAPI/blob/main/LICENSE">MIT</a>.
</p>
</div>
</footer>
{% block scripts %}{% endblock %}
+3 -4
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
@@ -143,7 +144,6 @@
</div>
</div>
<!-- Секция выдачи для библиотекарей и администраторов -->
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
@@ -175,7 +175,6 @@
</div>
</div>
<!-- Модальное окно для выдачи книги -->
<div
id="loan-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
@@ -296,5 +295,5 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/book.js"></script>
<script src="/static/page/book.js"></script>
{% endblock %}
+3 -2
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
{% block content %}
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<aside class="w-full md:w-1/4">
<div
@@ -186,5 +187,5 @@
</div>
</template>
{% endblock %} {% block scripts %}
<script src="/static/books.js"></script>
<script src="/static/page/books.js"></script>
{% endblock %}
+2 -2
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% block title %}Создание автора | LiB{% endblock %}
{% extends "base.html" %} {% block title %}LiB - Создание автора{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
@@ -158,5 +158,5 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_author.js"></script>
<script src="/static/page/create_author.js"></script>
{% endblock %}
+3 -3
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {%
block content %}
{% extends "base.html" %} {% block title %}LiB - Создание книги{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
@@ -225,5 +225,5 @@ block content %}
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_book.js"></script>
<script src="/static/page/create_book.js"></script>
{% endblock %}
+3 -3
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {%
block content %}
{% extends "base.html" %} {% block title %}LiB - Создание жанра{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
@@ -158,5 +158,5 @@ block content %}
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_genre.js"></script>
<script src="/static/page/create_genre.js"></script>
{% endblock %}
+3 -3
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Редактирование автора | LiB{%
endblock %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Редактирование автора{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
@@ -312,5 +312,5 @@ endblock %} {% block content %}
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_author.js"></script>
<script src="/static/page/edit_author.js"></script>
{% endblock %}
+3 -3
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock
%} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Редактирование книги{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
@@ -390,5 +390,5 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_book.js"></script>
<script src="/static/page/edit_book.js"></script>
{% endblock %}
+3 -3
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock
%} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Редактирование жанра{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
@@ -313,5 +313,5 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_genre.js"></script>
<script src="/static/page/edit_genre.js"></script>
{% endblock %}
+4 -4
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
block content %}
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %}
{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-4xl">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
@@ -186,10 +186,10 @@ block content %}
</div>
</div>
<div class="mt-6 text-center text-gray-400 text-sm">
<p>LiB — Библиотека. Создано с ❤️</p>
<p>LiB — Библиотека. Красиво, функционально, безопасно.</p>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/index.js"></script>
<script src="/static/page/index.js"></script>
{% endblock %}
+3 -6
View File
@@ -1,11 +1,11 @@
{% extends "base.html" %} {% block content %}
{% extends "base.html" %} {% block title %}LiB - Мои книги{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
</div>
<!-- Бронирования -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
@@ -18,7 +18,6 @@
</div>
</div>
<!-- Активные выдачи -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
@@ -31,7 +30,6 @@
</div>
</div>
<!-- Возвращенные книги -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
@@ -45,6 +43,5 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/my_books.js"></script>
<script src="/static/page/my_books.js"></script>
{% endblock %}
+258 -123
View File
@@ -1,159 +1,294 @@
{% extends "base.html" %} {% block content %}
<div
class="container mx-auto p-4 max-w-2xl"
x-data="{ showPasswordModal: false }"
@close-modal.window="showPasswordModal = false"
>
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-2xl"
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
@update-2fa.window="is2FAEnabled = $event.detail"
@update-recovery-codes.window="recoveryCodesRemaining = $event.detail"
@close-password-modal.window="showPasswordModal = false"
@close-disable-2fa-modal.window="showDisable2FAModal = false"
@close-recovery-codes-modal.window="showRecoveryCodesModal = false"
@open-recovery-codes-modal.window="showRecoveryCodesModal = true">
<div id="profile-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="animate-pulse flex items-center">
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
<div class="h-6 bg-gray-200 w-48 rounded"></div>
</div>
</div>
<div
id="account-section"
class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
>
<div id="account-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
<h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2>
<div id="account-info" class="space-y-4"></div>
</div>
<div
id="roles-section"
class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
>
<div id="roles-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
<h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2>
<div id="roles-container" class="space-y-3"></div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-xl font-bold mb-4 border-b pb-2">Безопасность</h2>
<div class="space-y-3">
<button
@click="showPasswordModal = true"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors"
>
<button type="button"
@click="is2FAEnabled ? showDisable2FAModal = true : window.location.href = '/2fa'"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-500" 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" />
</svg>
<span class="text-gray-700 font-medium">Двухфакторная аутентификация</span>
</div>
<span x-show="is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
Включена
</span>
<span x-show="!is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-600">
Выключена
</span>
</button>
<button type="button" id="recovery-codes-btn"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
<span class="text-gray-700 font-medium">Резервные коды</span>
</div>
<div class="flex items-center gap-2">
<template x-if="recoveryCodesRemaining !== null">
<span :class="{
'bg-green-100 text-green-800': recoveryCodesRemaining > 5,
'bg-yellow-100 text-yellow-800': recoveryCodesRemaining > 2 && recoveryCodesRemaining <= 5,
'bg-red-100 text-red-800': recoveryCodesRemaining <= 2
}" class="px-2 py-1 text-xs font-medium rounded-full">
<span x-text="recoveryCodesRemaining"></span> / 10
</span>
</template>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
<button @click="showPasswordModal = true"
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<span class="text-gray-700 font-medium">Сменить пароль</span>
<svg
class="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onclick="Auth.logout()"
class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
>
<button onclick="Auth.logout()"
class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
<svg
class="w-5 h-5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</div>
</button>
</div>
</div>
<div
x-show="showPasswordModal"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none"
>
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"
>
<div
x-show="showPasswordModal"
x-transition.opacity
class="fixed inset-0 transition-opacity"
aria-hidden="true"
>
<div
class="absolute inset-0 bg-gray-500 opacity-75"
@click="showPasswordModal = false"
></div>
<div x-show="showPasswordModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showPasswordModal" x-transition.opacity class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75" @click="showPasswordModal = false"></div>
</div>
<div
x-show="showPasswordModal"
x-transition.scale
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3
class="text-lg leading-6 font-medium text-gray-900 mb-4"
>
Смена пароля
</h3>
<form id="change-password-form">
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Текущий пароль</label
>
<input
type="password"
id="current-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showPasswordModal" x-transition
class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
<div class="bg-white px-6 pt-6 pb-4">
<h3 class="text-lg leading-6 font-semibold text-gray-900 mb-4">Смена пароля</h3>
<form id="change-password-form" class="space-y-4">
<div>
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
<input type="password" id="new-password"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
placeholder="Минимум 8 символов" />
</div>
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Новый пароль</label
>
<input
type="password"
id="new-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div>
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Подтвердите пароль</label
>
<input
type="password"
id="confirm-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
<div>
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите пароль</label>
<input type="password" id="confirm-password"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
placeholder="Повторите пароль" />
</div>
</form>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
type="button"
id="submit-password-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 sm:ml-3 sm:w-auto sm:text-sm"
>
<div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
<button type="button" id="submit-password-btn"
class="px-5 py-2.5 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition">
Сменить
</button>
<button
type="button"
@click="showPasswordModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<button type="button" @click="showPasswordModal = false"
class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
Отмена
</button>
</div>
</div>
</div>
</div>
<div x-show="showDisable2FAModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showDisable2FAModal" x-transition.opacity class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75" @click="showDisable2FAModal = false"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showDisable2FAModal" x-transition
class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
<div class="bg-white px-6 pt-6 pb-4">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" 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" />
</svg>
</div>
<h3 class="text-lg leading-6 font-semibold text-gray-900">Отключить 2FA</h3>
</div>
<p class="text-sm text-gray-500 mb-4">
Это снизит безопасность вашего аккаунта. Для подтверждения введите пароль.
</p>
<form id="disable-2fa-form">
<div>
<label class="block text-gray-700 text-sm font-medium mb-2">Пароль</label>
<input type="password" id="disable-2fa-password"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
placeholder="Введите ваш пароль" />
</div>
</form>
</div>
<div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
<button type="button" id="submit-disable-2fa-btn"
class="px-5 py-2.5 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition">
Отключить
</button>
<button type="button" @click="showDisable2FAModal = false"
class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
Отмена
</button>
</div>
</div>
</div>
</div>
<div x-show="showRecoveryCodesModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showRecoveryCodesModal" x-transition.opacity class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showRecoveryCodesModal" x-transition
class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md w-full">
<div class="bg-white px-6 pt-6 pb-4">
<div id="recovery-codes-loading" class="text-center py-8">
<div class="animate-spin w-8 h-8 border-2 border-gray-300 border-t-gray-600 rounded-full mx-auto mb-4"></div>
<p class="text-gray-500">Загрузка...</p>
</div>
<div id="recovery-codes-status" class="hidden">
<div class="flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4" id="status-icon-container">
</div>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
Резервные коды
</h3>
<div id="codes-status-summary" class="text-center mb-4"></div>
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<p class="text-xs text-gray-500 mb-2 text-center">Статус кодов:</p>
<div id="codes-status-list" class="space-y-1 max-h-48 overflow-y-auto"></div>
</div>
<div id="codes-warning" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p class="text-sm text-yellow-800 flex items-start gap-2">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" 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" />
</svg>
<span id="warning-text"></span>
</p>
</div>
<p id="codes-generated-at" class="text-xs text-gray-400 text-center mb-4"></p>
<button type="button" id="regenerate-codes-btn"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium mb-3">
Сгенерировать новые коды
</button>
<button type="button" id="close-status-modal-btn"
class="w-full text-gray-500 hover:text-gray-700 text-sm py-2">
Закрыть
</button>
</div>
<div id="recovery-codes-display" class="hidden">
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-green-100 rounded-full mb-4">
<svg class="w-6 h-6 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" />
</svg>
</div>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
Новые резервные коды
</h3>
<p class="text-sm text-gray-500 text-center mb-4">
<strong class="text-red-600">Сохраните эти коды!</strong>
Они понадобятся для восстановления доступа.
</p>
<div id="recovery-codes-list"
class="bg-gray-50 rounded-lg p-4 font-mono text-sm text-center space-y-2 mb-4 max-h-64 overflow-y-auto">
</div>
<p id="recovery-codes-generated-at" class="text-xs text-gray-400 text-center mb-4"></p>
<div class="flex gap-2 mb-4">
<button type="button" id="copy-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Копировать</span>
</button>
<button type="button" id="download-codes-btn"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Скачать</span>
</button>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 mb-4 cursor-pointer">
<input type="checkbox" id="codes-saved-checkbox" class="rounded border-gray-300 text-gray-600 focus:ring-gray-500" />
<span>Я сохранил(а) коды в надёжном месте</span>
</label>
<button type="button" id="close-recovery-modal-btn" disabled
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Закрыть
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/profile.js"></script>
{% endblock %}
{% block scripts %}
<script src="/static/page/profile.js"></script>
{% endblock %}
+15 -18
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {%
block content %}
{% extends "base.html" %} {% block title %}LiB - Пользователи{% endblock %}
{% block content %}
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">
@@ -38,7 +38,7 @@ block content %}
type="text"
id="role-filter-input"
placeholder="Фильтр по роли..."
class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
class="w-full md:w-56 border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
@@ -150,7 +150,7 @@ block content %}
</button>
</div>
</div>
<div class="mt-3">
<div class="mt-3 flex flex-col sm:flex-row sm:items-end justify-between gap-3">
<div class="flex items-center flex-wrap gap-2">
<span class="text-sm font-medium text-gray-700"
>Роли:</span
@@ -177,6 +177,15 @@ block content %}
</button>
</div>
</div>
<div class="user-payroll hidden shrink-0 flex items-center gap-1.5 bg-emerald-50 text-emerald-700 px-3 py-1 rounded-lg border border-emerald-100 shadow-sm">
<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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xs font-semibold uppercase tracking-wider text-emerald-600">Оклад:</div>
<div class="font-bold font-mono text-lg leading-none user-payroll-amount"></div>
<div class="text-xs font-medium"></div>
</div>
</div>
</div>
</div>
</div>
@@ -312,7 +321,7 @@ block content %}
placeholder="••••••••"
/>
</div>
<div class="flex items-center gap-4">
<div>
<label
class="flex items-center gap-2 cursor-pointer"
>
@@ -325,18 +334,6 @@ block content %}
>Активен</span
>
</label>
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-verified"
class="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<span class="text-sm text-gray-700"
>Подтверждён</span
>
</label>
</div>
</div>
</div>
@@ -429,5 +426,5 @@ block content %}
</div>
{% endblock %} {% block scripts %}
<script src="/static/users.js"></script>
<script src="/static/page/users.js"></script>
{% endblock %}
@@ -0,0 +1,53 @@
"""recovery codes and totp
Revision ID: a585fd97b88c
Revises: a8e40ab24138
Create Date: 2026-01-18 15:09:58.721180
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "a585fd97b88c"
down_revision: Union[str, None] = "a8e40ab24138"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("is_2fa_enabled", sa.Boolean(), nullable=False))
op.add_column(
"users",
sa.Column(
"totp_secret", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
),
)
op.add_column(
"users",
sa.Column(
"recovery_code_hashes",
sqlmodel.sql.sqltypes.AutoString(length=1500),
nullable=True,
),
)
op.add_column(
"users", sa.Column("recovery_codes_generated_at", sa.DateTime(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "recovery_codes_generated_at")
op.drop_column("users", "recovery_code_hashes")
op.drop_column("users", "totp_secret")
op.drop_column("users", "is_2fa_enabled")
# ### end Alembic commands ###
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "LibraryAPI"
version = "0.4.0"
version = "0.5.0"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }]
readme = "README.md"
Generated
+1 -1
View File
@@ -619,7 +619,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]]
name = "libraryapi"
version = "0.4.0"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },