mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e0c3478a1 | |||
| e507896b7a | |||
| d6ecd4066f |
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
@@ -5,6 +5,7 @@ POSTGRES_USER="postgres"
|
|||||||
POSTGRES_PASSWORD="postgres"
|
POSTGRES_PASSWORD="postgres"
|
||||||
POSTGRES_DB="lib"
|
POSTGRES_DB="lib"
|
||||||
|
|
||||||
|
# Default admin account
|
||||||
# DEFAULT_ADMIN_USERNAME="admin"
|
# DEFAULT_ADMIN_USERNAME="admin"
|
||||||
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||||
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
|
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
|
||||||
@@ -13,7 +14,8 @@ POSTGRES_DB="lib"
|
|||||||
ALGORITHM="HS256"
|
ALGORITHM="HS256"
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS="7"
|
REFRESH_TOKEN_EXPIRE_DAYS="7"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES="15"
|
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
|
# Hash
|
||||||
ARGON2_TYPE="id"
|
ARGON2_TYPE="id"
|
||||||
@@ -21,3 +23,15 @@ ARGON2_TIME_COST="3"
|
|||||||
ARGON2_MEMORY_COST="65536"
|
ARGON2_MEMORY_COST="65536"
|
||||||
ARGON2_PARALLELISM="4"
|
ARGON2_PARALLELISM="4"
|
||||||
ARGON2_SALT_LENGTH="16"
|
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"
|
||||||
|
|||||||
@@ -68,88 +68,107 @@
|
|||||||
#### **Аутентификация** (`/api/auth`)
|
#### **Аутентификация** (`/api/auth`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|-----------------------------------------------|----------------|------------------------------------------|
|
|--------|------------------------------|----------------|------------------------------------------|
|
||||||
| POST | `/api/auth/register` | Публичный | Регистрация нового пользователя |
|
| POST | `/register` | Публичный | Регистрация нового пользователя |
|
||||||
| POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) |
|
| POST | `/token` | Публичный | Получение JWT токенов (access + refresh) |
|
||||||
| POST | `/api/auth/refresh` | Публичный | Обновление пары токенов |
|
| POST | `/refresh` | Публичный | Обновление пары токенов |
|
||||||
| GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе |
|
| GET | `/me` | Авторизованный | Информация о текущем пользователе |
|
||||||
| PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя |
|
| PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя |
|
||||||
| GET | `/api/auth/users` | Сотрудник | Список всех пользователей |
|
| GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA |
|
||||||
| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
|
| POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA |
|
||||||
| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
|
| POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию |
|
||||||
| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе |
|
| POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию |
|
||||||
|
| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления |
|
||||||
|
| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля |
|
||||||
|
| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода |
|
||||||
|
|
||||||
#### **Авторы** (`/api/authors`)
|
#### **Авторы** (`/api/authors`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|---------------------|-----------|---------------------------------|
|
|--------|----------|-----------|---------------------------------|
|
||||||
| POST | `/api/authors` | Сотрудник | Создать нового автора |
|
| POST | `/` | Сотрудник | Создать нового автора |
|
||||||
| GET | `/api/authors` | Публичный | Получить список всех авторов |
|
| GET | `/` | Публичный | Получить список всех авторов |
|
||||||
| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами |
|
| GET | `/{id}` | Публичный | Получить автора по ID с книгами |
|
||||||
| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID |
|
| PUT | `/{id}` | Сотрудник | Обновить автора по ID |
|
||||||
| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID |
|
| DELETE | `/{id}` | Сотрудник | Удалить автора по ID |
|
||||||
|
|
||||||
#### **Книги** (`/api/books`)
|
#### **Книги** (`/api/books`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|---------------------|-----------|-----------------------------------------------------------|
|
|--------|-----------|-----------|----------------------------------------------|
|
||||||
| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией |
|
| POST | `/` | Сотрудник | Создать новую книгу |
|
||||||
| POST | `/api/books` | Сотрудник | Создать новую книгу |
|
| GET | `/` | Публичный | Получить список всех книг |
|
||||||
| GET | `/api/books` | Публичный | Получить список всех книг |
|
| GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
|
||||||
| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
|
| PUT | `/{id}` | Сотрудник | Обновить книгу по ID |
|
||||||
| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID |
|
| DELETE | `/{id}` | Сотрудник | Удалить книгу по ID |
|
||||||
| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID |
|
| GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам |
|
||||||
|
|
||||||
#### **Жанры** (`/api/genres`)
|
#### **Жанры** (`/api/genres`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|--------------------|-----------|-------------------------------|
|
|--------|----------|-----------|-------------------------------|
|
||||||
| POST | `/api/genres` | Сотрудник | Создать новый жанр |
|
| POST | `/` | Сотрудник | Создать новый жанр |
|
||||||
| GET | `/api/genres` | Публичный | Получить список всех жанров |
|
| GET | `/` | Публичный | Получить список всех жанров |
|
||||||
| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами |
|
| GET | `/{id}` | Публичный | Получить жанр по ID с книгами |
|
||||||
| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID |
|
| PUT | `/{id}` | Сотрудник | Обновить жанр по ID |
|
||||||
| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID |
|
| DELETE | `/{id}` | Сотрудник | Удалить жанр по ID |
|
||||||
|
|
||||||
#### **Выдачи** (`/api/loans`)
|
#### **Выдачи** (`/api/loans`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|------------------------------------|----------------|--------------------------------------------------------------|
|
|--------|-------------------------|----------------|------------------------------------------------------------|
|
||||||
| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) |
|
| POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) |
|
||||||
| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
|
| GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
|
||||||
| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов |
|
| GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
|
||||||
| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
|
| PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
|
||||||
| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
|
| DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
|
||||||
| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
|
| POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
|
||||||
| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
|
| POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
|
||||||
| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
|
| GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
|
||||||
| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
|
| POST | `issue` | Админ | Выдать книгу напрямую без бронирования |
|
||||||
| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования |
|
| GET | `analytics` | Админ | Аналитика выдач и возвратов |
|
||||||
|
|
||||||
#### **Связи** (`/api`)
|
#### **Связи** (`/api`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|--------|----------------------------------|-----------|-------------------------------|
|
|--------|------------------------------|-----------|-------------------------------|
|
||||||
| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу |
|
| POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу |
|
||||||
| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
|
| DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
|
||||||
| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора |
|
| GET | `/authors/{id}/books` | Публичный | Получить список книг автора |
|
||||||
| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги |
|
| GET | `/books/{id}/authors` | Публичный | Получить список авторов книги |
|
||||||
| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
|
| POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
|
||||||
| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
|
| DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
|
||||||
| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра |
|
| GET | `/genres/{id}/books` | Публичный | Получить список книг жанра |
|
||||||
| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги |
|
| 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`)
|
#### **Прочее** (`/api`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|-------|--------------|-----------|----------------------|
|
|-------|----------|-----------|----------------------|
|
||||||
| GET | `/api/info` | Публичный | Информация о сервисе |
|
| GET | `/info` | Публичный | Информация о сервисе |
|
||||||
| GET | `/api/stats` | Публичный | Статистика системы |
|
| GET | `/stats` | Публичный | Статистика системы |
|
||||||
|
|
||||||
### **Веб-страницы**
|
### **Веб-страницы**
|
||||||
|
|
||||||
| Путь | Доступ | Описание |
|
| Путь | Доступ | Описание |
|
||||||
|---------------------|----------------|-----------------------------------------|
|
|---------------------|----------------|-----------------------------|
|
||||||
| `/` | Публичный | Главная страница |
|
| `/` | Публичный | Главная страница |
|
||||||
|
| `/api` | Публичный | Ссылки на документацию |
|
||||||
| `/auth` | Публичный | Страница авторизации |
|
| `/auth` | Публичный | Страница авторизации |
|
||||||
| `/profile` | Авторизованный | Профиль пользователя |
|
| `/profile` | Авторизованный | Профиль пользователя |
|
||||||
| `/books` | Публичный | Каталог книг с фильтрацией |
|
| `/books` | Публичный | Каталог книг с фильтрацией |
|
||||||
@@ -163,9 +182,9 @@
|
|||||||
| `/genre/create` | Сотрудник | Создание жанра |
|
| `/genre/create` | Сотрудник | Создание жанра |
|
||||||
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
|
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
|
||||||
| `/my-books` | Авторизованный | Мои выдачи |
|
| `/my-books` | Авторизованный | Мои выдачи |
|
||||||
| `/users` | Сотрудник | Управление пользователями |
|
| `/users` | Админ | Управление пользователями |
|
||||||
| `/analytics` | Админ | Аналитика выдач и возвратов |
|
| `/analytics` | Админ | Аналитика выдач и возвратов |
|
||||||
| `/api` | Публичный | Страница с ссылками на документацию API |
|
|
||||||
|
|
||||||
### **Схема базы данных**
|
### **Схема базы данных**
|
||||||
|
|
||||||
|
|||||||
@@ -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 os
|
||||||
import base64
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -11,10 +10,8 @@ from fastapi.security import OAuth2PasswordBearer
|
|||||||
from jose import jwt, JWTError, ExpiredSignatureError
|
from jose import jwt, JWTError, ExpiredSignatureError
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlmodel import Session, select
|
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.models.dto import TokenData
|
||||||
from library_service.settings import get_session, get_logger
|
from library_service.settings import get_session, get_logger
|
||||||
|
|
||||||
@@ -22,15 +19,24 @@ from library_service.settings import get_session, get_logger
|
|||||||
# Конфигурация JWT из переменных окружения
|
# Конфигурация JWT из переменных окружения
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
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"))
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||||
|
|
||||||
# Конфигурация хэширования паролей из переменных окружения
|
# Конфигурация хэширования из переменных окружения
|
||||||
ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id")
|
ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id")
|
||||||
ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3"))
|
ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3"))
|
||||||
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "65536"))
|
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "131072"))
|
||||||
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "4"))
|
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "2"))
|
||||||
ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16"))
|
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()
|
logger = get_logger()
|
||||||
@@ -51,6 +57,7 @@ pwd_context = CryptContext(
|
|||||||
argon2__memory_cost=ARGON2_MEMORY_COST,
|
argon2__memory_cost=ARGON2_MEMORY_COST,
|
||||||
argon2__parallelism=ARGON2_PARALLELISM,
|
argon2__parallelism=ARGON2_PARALLELISM,
|
||||||
argon2__salt_len=ARGON2_SALT_LENGTH,
|
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)
|
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)
|
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":
|
if token_type == "refresh":
|
||||||
to_encode.update({"jti": str(uuid4())})
|
to_encode.update({"jti": str(uuid4())})
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
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:
|
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||||
"""Создает JWT access токен"""
|
"""Создает JWT access токен"""
|
||||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
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")
|
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 токен"""
|
"""Декодирует и проверяет JWT токен"""
|
||||||
token_error = HTTPException(
|
token_error = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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")
|
username: str | None = payload.get("sub")
|
||||||
user_id: int | None = payload.get("user_id")
|
user_id: int | None = payload.get("user_id")
|
||||||
token_type: str | None = payload.get("type")
|
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}"
|
token_error.detail = f"Invalid token type. Expected {expected_type}"
|
||||||
raise token_error
|
raise token_error
|
||||||
|
|
||||||
if username is None or user_id is None:
|
if username is None or user_id is None:
|
||||||
token_error.detail = "Could not validate credentials"
|
token_error.detail = "Could not validate credentials"
|
||||||
raise token_error
|
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:
|
except ExpiredSignatureError:
|
||||||
token_error.detail = "Token expired"
|
token_error.detail = "Token expired"
|
||||||
raise token_error
|
raise token_error
|
||||||
@@ -147,6 +183,29 @@ def get_current_active_user(
|
|||||||
return current_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):
|
def require_role(role_name: str):
|
||||||
"""Создает dependency для проверки наличия определенной роли"""
|
"""Создает dependency для проверки наличия определенной роли"""
|
||||||
|
|
||||||
@@ -182,6 +241,7 @@ RequireAuth = Annotated[User, Depends(get_current_active_user)]
|
|||||||
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
||||||
RequireMember = Annotated[User, Depends(require_role("member"))]
|
RequireMember = Annotated[User, Depends(require_role("member"))]
|
||||||
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
|
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"]))]
|
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}
|
roles = {role.name for role in user.roles}
|
||||||
return "admin" in 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}
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль DB-моделей книг"""
|
"""Модуль DB-моделей книг"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlalchemy import Column, String
|
from sqlalchemy import Column, String
|
||||||
@@ -15,10 +16,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class Book(BookBase, table=True):
|
class Book(BookBase, table=True):
|
||||||
"""Модель книги в базе данных"""
|
"""Модель книги в базе данных"""
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||||
status: BookStatus = Field(
|
status: BookStatus = Field(
|
||||||
default=BookStatus.ACTIVE,
|
default=BookStatus.ACTIVE,
|
||||||
sa_column=Column(String, nullable=False, default="active")
|
sa_column=Column(String, nullable=False, default="active"),
|
||||||
)
|
)
|
||||||
authors: List["Author"] = Relationship(
|
authors: List["Author"] = Relationship(
|
||||||
back_populates="books", link_model=AuthorBookLink
|
back_populates="books", link_model=AuthorBookLink
|
||||||
@@ -26,4 +28,6 @@ class Book(BookBase, table=True):
|
|||||||
genres: List["Genre"] = Relationship(
|
genres: List["Genre"] = Relationship(
|
||||||
back_populates="books", link_model=GenreBookLink
|
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"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Модуль связей между сущностями в БД"""
|
"""Модуль связей между сущностями в БД"""
|
||||||
from datetime import datetime
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from sqlmodel import SQLModel, Field
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
class AuthorBookLink(SQLModel, table=True):
|
class AuthorBookLink(SQLModel, table=True):
|
||||||
"""Модель связи автора и книги"""
|
"""Модель связи автора и книги"""
|
||||||
|
|
||||||
author_id: int | None = Field(
|
author_id: int | None = Field(
|
||||||
default=None, foreign_key="author.id", primary_key=True
|
default=None, foreign_key="author.id", primary_key=True
|
||||||
)
|
)
|
||||||
@@ -13,12 +15,14 @@ class AuthorBookLink(SQLModel, table=True):
|
|||||||
|
|
||||||
class GenreBookLink(SQLModel, table=True):
|
class GenreBookLink(SQLModel, table=True):
|
||||||
"""Модель связи жанра и книги"""
|
"""Модель связи жанра и книги"""
|
||||||
|
|
||||||
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=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)
|
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
class UserRoleLink(SQLModel, table=True):
|
class UserRoleLink(SQLModel, table=True):
|
||||||
"""Модель связи роли и пользователя"""
|
"""Модель связи роли и пользователя"""
|
||||||
|
|
||||||
__tablename__ = "user_roles"
|
__tablename__ = "user_roles"
|
||||||
|
|
||||||
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
||||||
@@ -30,6 +34,7 @@ class BookUserLink(SQLModel, table=True):
|
|||||||
Модель истории выдачи книг (Loan).
|
Модель истории выдачи книг (Loan).
|
||||||
Связывает книгу и пользователя с фиксацией времени.
|
Связывает книгу и пользователя с фиксацией времени.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "book_loans"
|
__tablename__ = "book_loans"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
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")
|
book_id: int = Field(foreign_key="book.id")
|
||||||
user_id: int = Field(foreign_key="users.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
|
due_date: datetime
|
||||||
returned_at: datetime | None = Field(default=None)
|
returned_at: datetime | None = Field(default=None)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Модуль DB-моделей пользователей"""
|
"""Модуль DB-моделей пользователей"""
|
||||||
from datetime import datetime
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship
|
from sqlmodel import Field, Relationship
|
||||||
@@ -13,17 +14,58 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class User(UserBase, table=True):
|
class User(UserBase, table=True):
|
||||||
"""Модель пользователя в базе данных"""
|
"""Модель пользователя в базе данных"""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||||
hashed_password: str = Field(nullable=False)
|
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_active: bool = Field(default=True)
|
||||||
is_verified: bool = Field(default=False)
|
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(
|
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)
|
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}
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
"""Модуль DTO-моделей"""
|
"""Модуль DTO-моделей"""
|
||||||
|
|
||||||
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
|
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
|
||||||
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
|
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
|
||||||
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
|
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
|
||||||
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
|
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
|
||||||
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
|
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
|
||||||
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
|
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
|
||||||
from .token import Token, TokenData
|
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
|
||||||
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
|
from .token import Token, TokenData, PartialToken
|
||||||
BookWithAuthorsAndGenres, BookFilteredList, BookStatusUpdate, LoanWithBook)
|
from .misc import (
|
||||||
|
AuthorWithBooks,
|
||||||
|
GenreWithBooks,
|
||||||
|
BookWithAuthors,
|
||||||
|
BookWithGenres,
|
||||||
|
BookWithAuthorsAndGenres,
|
||||||
|
BookFilteredList,
|
||||||
|
BookStatusUpdate,
|
||||||
|
LoanWithBook,
|
||||||
|
LoginResponse,
|
||||||
|
RegisterResponse,
|
||||||
|
UserCreateByAdmin,
|
||||||
|
UserUpdateByAdmin,
|
||||||
|
TOTPSetupResponse,
|
||||||
|
TOTPVerifyRequest,
|
||||||
|
TOTPDisableRequest,
|
||||||
|
PasswordResetResponse,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthorBase",
|
"AuthorBase",
|
||||||
@@ -46,4 +64,16 @@ __all__ = [
|
|||||||
"RoleList",
|
"RoleList",
|
||||||
"Token",
|
"Token",
|
||||||
"TokenData",
|
"TokenData",
|
||||||
|
"PartialToken",
|
||||||
|
"TOTPSetupResponse",
|
||||||
|
"TOTPVerifyRequest",
|
||||||
|
"TOTPDisableRequest",
|
||||||
|
"RecoveryCodeUse",
|
||||||
|
"UserCreateByAdmin",
|
||||||
|
"UserUpdateByAdmin",
|
||||||
|
"LoginResponse",
|
||||||
|
"RegisterResponse",
|
||||||
|
"RecoveryCodesStatus",
|
||||||
|
"PasswordResetResponse",
|
||||||
|
"RecoveryCodesResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
"""Модуль DTO-моделей токенов"""
|
"""Модуль DTO-моделей токенов"""
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class Token(SQLModel):
|
class Token(SQLModel):
|
||||||
"""Модель токена"""
|
"""Модель токена"""
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
refresh_token: str | None = None
|
refresh_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartialToken(SQLModel):
|
||||||
|
"""Частичный токен — для подтверждения 2FA"""
|
||||||
|
|
||||||
|
partial_token: str
|
||||||
|
token_type: str = "partial"
|
||||||
|
requires_2fa: bool = True
|
||||||
|
|
||||||
|
|
||||||
class TokenData(SQLModel):
|
class TokenData(SQLModel):
|
||||||
"""Модель содержимого токена"""
|
"""Модель содержимого токена"""
|
||||||
|
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
user_id: int | None = None
|
user_id: int | None = None
|
||||||
|
is_partial: bool = False
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль DTO-моделей пользователей"""
|
"""Модуль DTO-моделей пользователей"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ from sqlmodel import Field, SQLModel
|
|||||||
|
|
||||||
class UserBase(SQLModel):
|
class UserBase(SQLModel):
|
||||||
"""Базовая модель пользователя"""
|
"""Базовая модель пользователя"""
|
||||||
|
|
||||||
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
||||||
email: EmailStr = Field(index=True, unique=True)
|
email: EmailStr = Field(index=True, unique=True)
|
||||||
full_name: str | None = Field(default=None, max_length=100)
|
full_name: str | None = Field(default=None, max_length=100)
|
||||||
@@ -25,6 +27,7 @@ class UserBase(SQLModel):
|
|||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
"""Модель пользователя для создания"""
|
"""Модель пользователя для создания"""
|
||||||
|
|
||||||
password: str = Field(min_length=8, max_length=100)
|
password: str = Field(min_length=8, max_length=100)
|
||||||
|
|
||||||
@field_validator("password")
|
@field_validator("password")
|
||||||
@@ -42,20 +45,24 @@ class UserCreate(UserBase):
|
|||||||
|
|
||||||
class UserLogin(SQLModel):
|
class UserLogin(SQLModel):
|
||||||
"""Модель аутентификации для пользователя"""
|
"""Модель аутентификации для пользователя"""
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserRead(UserBase):
|
class UserRead(UserBase):
|
||||||
"""Модель пользователя для чтения"""
|
"""Модель пользователя для чтения"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_verified: bool
|
is_verified: bool
|
||||||
|
is_2fa_enabled: bool
|
||||||
roles: List[str] = []
|
roles: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
class UserUpdate(SQLModel):
|
||||||
"""Модель пользователя для обновления"""
|
"""Модель пользователя для обновления"""
|
||||||
|
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
full_name: str | None = None
|
full_name: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
@@ -63,5 +70,6 @@ class UserUpdate(SQLModel):
|
|||||||
|
|
||||||
class UserList(SQLModel):
|
class UserList(SQLModel):
|
||||||
"""Список пользователей"""
|
"""Список пользователей"""
|
||||||
|
|
||||||
users: List[UserRead]
|
users: List[UserRead]
|
||||||
total: int
|
total: int
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль объединения роутеров"""
|
"""Модуль объединения роутеров"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .auth import router as auth_router
|
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 .genres import router as genres_router
|
||||||
from .loans import router as loans_router
|
from .loans import router as loans_router
|
||||||
from .relationships import router as relationships_router
|
from .relationships import router as relationships_router
|
||||||
|
from .users import router as users_router
|
||||||
from .misc import router as misc_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(books_router, prefix="/api")
|
||||||
api_router.include_router(genres_router, prefix="/api")
|
api_router.include_router(genres_router, prefix="/api")
|
||||||
api_router.include_router(loans_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")
|
api_router.include_router(relationships_router, prefix="/api")
|
||||||
|
|||||||
+213
-138
@@ -3,10 +3,9 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Annotated
|
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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
import pyotp
|
|
||||||
|
|
||||||
from library_service.models.db import Role, User
|
from library_service.models.db import Role, User
|
||||||
from library_service.models.dto import (
|
from library_service.models.dto import (
|
||||||
@@ -17,7 +16,19 @@ from library_service.models.dto import (
|
|||||||
UserList,
|
UserList,
|
||||||
RoleRead,
|
RoleRead,
|
||||||
RoleList,
|
RoleList,
|
||||||
|
Token,
|
||||||
|
PartialToken,
|
||||||
|
LoginResponse,
|
||||||
|
RecoveryCodeUse,
|
||||||
|
RegisterResponse,
|
||||||
|
RecoveryCodesStatus,
|
||||||
|
RecoveryCodesResponse,
|
||||||
|
PasswordResetResponse,
|
||||||
|
TOTPSetupResponse,
|
||||||
|
TOTPVerifyRequest,
|
||||||
|
TOTPDisableRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
from library_service.settings import get_session
|
from library_service.settings import get_session
|
||||||
from library_service.auth import (
|
from library_service.auth import (
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
@@ -29,21 +40,28 @@ from library_service.auth import (
|
|||||||
decode_token,
|
decode_token,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_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,
|
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 = APIRouter(prefix="/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/register",
|
"/register",
|
||||||
response_model=UserRead,
|
response_model=RegisterResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Регистрация нового пользователя",
|
summary="Регистрация нового пользователя",
|
||||||
description="Создает нового пользователя в системе",
|
description="Создает нового пользователя и возвращает резервные коды",
|
||||||
)
|
)
|
||||||
def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
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()
|
).first()
|
||||||
if existing_email:
|
if existing_email:
|
||||||
raise HTTPException(
|
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(
|
db_user = User(
|
||||||
@@ -77,14 +96,25 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_user)
|
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(
|
@router.post(
|
||||||
"/token",
|
"/token",
|
||||||
response_model=Token,
|
response_model=LoginResponse,
|
||||||
summary="Получение токена",
|
summary="Получение токена",
|
||||||
description="Аутентификация и получение JWT токена",
|
description="Аутентификация и получение токенов",
|
||||||
)
|
)
|
||||||
def login(
|
def login(
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
@@ -99,17 +129,23 @@ def login(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
token_data = {"sub": user.username, "user_id": user.id}
|
||||||
access_token = create_access_token(
|
|
||||||
data={"sub": user.username, "user_id": user.id},
|
if user.is_2fa_enabled:
|
||||||
expires_delta=access_token_expires,
|
return LoginResponse(
|
||||||
)
|
partial_token=create_partial_token(token_data),
|
||||||
refresh_token = create_refresh_token(
|
token_type="partial",
|
||||||
data={"sub": user.username, "user_id": user.id}
|
requires_2fa=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Token(
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
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",
|
"/refresh",
|
||||||
response_model=Token,
|
response_model=Token,
|
||||||
summary="Обновление токена",
|
summary="Обновление токена",
|
||||||
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен",
|
description="Получение новой пары токенов, используя действующий Refresh токен",
|
||||||
)
|
)
|
||||||
def refresh_token(
|
def refresh_token(
|
||||||
refresh_token: str = Body(..., embed=True),
|
refresh_token: str = Body(..., embed=True),
|
||||||
@@ -204,144 +240,183 @@ def update_user_me(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/users",
|
"/2fa",
|
||||||
response_model=UserList,
|
response_model=TOTPSetupResponse,
|
||||||
summary="Список пользователей",
|
summary="Создание QR-кода TOTP 2FA",
|
||||||
description="Получить список всех пользователей (только для админов)",
|
description="Генерирует секрет и QR-код для настройки TOTP",
|
||||||
)
|
)
|
||||||
def read_users(
|
def get_totp_qr_bitmap(auth: RequireAuth):
|
||||||
current_user: RequireStaff,
|
"""Возвращает данные для настройки TOTP"""
|
||||||
skip: int = 0,
|
return TOTPSetupResponse(**generate_totp_setup(auth.username))
|
||||||
limit: int = 100,
|
|
||||||
|
|
||||||
|
@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),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Возвращает список всех пользователей"""
|
"""Включает 2FA после проверки кода"""
|
||||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
if current_user.is_2fa_enabled:
|
||||||
return UserList(
|
raise HTTPException(
|
||||||
users=[
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
detail="2FA already enabled",
|
||||||
for user in users
|
)
|
||||||
],
|
|
||||||
total=len(users),
|
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(
|
@router.post(
|
||||||
"/users/{user_id}/roles/{role_name}",
|
"/password/reset",
|
||||||
response_model=UserRead,
|
response_model=PasswordResetResponse,
|
||||||
summary="Назначить роль пользователю",
|
summary="Сброс пароля через резервный код",
|
||||||
description="Добавить указанную роль пользователю",
|
description="Устанавливает новый пароль используя резервный код",
|
||||||
)
|
)
|
||||||
def add_role_to_user(
|
def reset_password(
|
||||||
user_id: int,
|
data: RecoveryCodeUse,
|
||||||
role_name: str,
|
|
||||||
admin: RequireAdmin,
|
|
||||||
session: Session = Depends(get_session),
|
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:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="User not found",
|
detail="Invalid username or recovery code",
|
||||||
)
|
)
|
||||||
|
|
||||||
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
if not user.is_active:
|
||||||
if not role:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Role '{role_name}' not found",
|
detail="Account is deactivated",
|
||||||
)
|
)
|
||||||
|
|
||||||
if role in user.roles:
|
if not verify_and_use_code(session, user, data.recovery_code):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
|
||||||
|
|
||||||
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
return PasswordResetResponse(**get_codes_status(user))
|
||||||
|
|
||||||
|
|
||||||
@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}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Модуль работы с книгами"""
|
"""Модуль работы с книгами"""
|
||||||
from datetime import datetime
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
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.auth import RequireStaff
|
||||||
from library_service.settings import get_session
|
from library_service.settings import get_session
|
||||||
from library_service.models.enums import BookStatus
|
from library_service.models.enums import BookStatus
|
||||||
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink
|
from library_service.models.db import (
|
||||||
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
|
Author,
|
||||||
from library_service.models.dto.combined import (
|
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,
|
BookWithAuthorsAndGenres,
|
||||||
BookFilteredList
|
BookFilteredList,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +43,7 @@ def close_active_loan(session: Session, book_id: int) -> None:
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if active_loan:
|
if active_loan:
|
||||||
active_loan.returned_at = datetime.utcnow()
|
active_loan.returned_at = datetime.now(timezone.utc)
|
||||||
session.add(active_loan)
|
session.add(active_loan)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +51,7 @@ def close_active_loan(session: Session, book_id: int) -> None:
|
|||||||
"/filter",
|
"/filter",
|
||||||
response_model=BookFilteredList,
|
response_model=BookFilteredList,
|
||||||
summary="Фильтрация книг",
|
summary="Фильтрация книг",
|
||||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
|
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией",
|
||||||
)
|
)
|
||||||
def filter_books(
|
def filter_books(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -55,10 +70,18 @@ def filter_books(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if author_ids:
|
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:
|
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_statement = select(func.count()).select_from(statement.subquery())
|
||||||
total = session.exec(total_statement).one()
|
total = session.exec(total_statement).one()
|
||||||
@@ -73,7 +96,7 @@ def filter_books(
|
|||||||
BookWithAuthorsAndGenres(
|
BookWithAuthorsAndGenres(
|
||||||
**db_book.model_dump(),
|
**db_book.model_dump(),
|
||||||
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
|
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(
|
def create_book(
|
||||||
book: BookCreate,
|
book: BookCreate,
|
||||||
current_user: RequireStaff,
|
current_user: RequireStaff,
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Создает новую книгу в системе"""
|
"""Создает новую книгу в системе"""
|
||||||
db_book = Book(**book.model_dump())
|
db_book = Book(**book.model_dump())
|
||||||
@@ -168,7 +191,7 @@ def update_book(
|
|||||||
if book_update.status == BookStatus.BORROWED:
|
if book_update.status == BookStatus.BORROWED:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Статус 'borrowed' устанавливается только через выдачу книги"
|
detail="Статус 'borrowed' устанавливается только через выдачу книги",
|
||||||
)
|
)
|
||||||
|
|
||||||
if db_book.status == BookStatus.BORROWED:
|
if db_book.status == BookStatus.BORROWED:
|
||||||
@@ -205,7 +228,10 @@ def delete_book(
|
|||||||
if not book:
|
if not book:
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
raise HTTPException(status_code=404, detail="Book not found")
|
||||||
book_read = BookRead(
|
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.delete(book)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Модуль работы с выдачей и бронированием книг"""
|
"""Модуль работы с выдачей и бронированием книг"""
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
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:
|
if not is_staff and loan.user_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
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)
|
book = session.get(Book, loan.book_id)
|
||||||
@@ -44,7 +45,7 @@ def create_loan(
|
|||||||
if book.status != BookStatus.ACTIVE:
|
if book.status != BookStatus.ACTIVE:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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)
|
target_user = session.get(User, loan.user_id)
|
||||||
@@ -55,7 +56,7 @@ def create_loan(
|
|||||||
book_id=loan.book_id,
|
book_id=loan.book_id,
|
||||||
user_id=loan.user_id,
|
user_id=loan.user_id,
|
||||||
due_date=loan.due_date,
|
due_date=loan.due_date,
|
||||||
borrowed_at=datetime.utcnow()
|
borrowed_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
book.status = BookStatus.RESERVED
|
book.status = BookStatus.RESERVED
|
||||||
@@ -109,8 +110,7 @@ def read_loans(
|
|||||||
loans = session.exec(statement).all()
|
loans = session.exec(statement).all()
|
||||||
|
|
||||||
return LoanList(
|
return LoanList(
|
||||||
loans=[LoanRead(**loan.model_dump()) for loan in loans],
|
loans=[LoanRead(**loan.model_dump()) for loan in loans], total=total
|
||||||
total=total
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,11 +125,12 @@ def get_loans_analytics(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Возвращает аналитику по выдачам и возвратам книг"""
|
"""Возвращает аналитику по выдачам и возвратам книг"""
|
||||||
end_date = datetime.utcnow()
|
end_date = datetime.now(timezone.utc)
|
||||||
start_date = end_date - timedelta(days=days)
|
start_date = end_date - timedelta(days=days)
|
||||||
total_loans = session.exec(
|
total_loans = session.exec(
|
||||||
select(func.count(BookUserLink.id))
|
select(func.count(BookUserLink.id)).where(
|
||||||
.where(BookUserLink.borrowed_at >= start_date)
|
BookUserLink.borrowed_at >= start_date
|
||||||
|
)
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
active_loans = session.exec(
|
active_loans = session.exec(
|
||||||
@@ -156,7 +157,7 @@ def get_loans_analytics(
|
|||||||
loans_by_date = session.exec(
|
loans_by_date = session.exec(
|
||||||
select(
|
select(
|
||||||
cast(BookUserLink.borrowed_at, Date).label("date"),
|
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)
|
.where(BookUserLink.borrowed_at >= start_date)
|
||||||
.group_by(cast(BookUserLink.borrowed_at, Date))
|
.group_by(cast(BookUserLink.borrowed_at, Date))
|
||||||
@@ -166,9 +167,11 @@ def get_loans_analytics(
|
|||||||
returns_by_date = session.exec(
|
returns_by_date = session.exec(
|
||||||
select(
|
select(
|
||||||
cast(BookUserLink.returned_at, Date).label("date"),
|
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
|
.where(BookUserLink.returned_at != None) # noqa: E711
|
||||||
.group_by(cast(BookUserLink.returned_at, Date))
|
.group_by(cast(BookUserLink.returned_at, Date))
|
||||||
.order_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
|
daily_returns[date_str] = count
|
||||||
|
|
||||||
top_books = session.exec(
|
top_books = session.exec(
|
||||||
select(
|
select(BookUserLink.book_id, func.count(BookUserLink.id).label("loan_count"))
|
||||||
BookUserLink.book_id,
|
|
||||||
func.count(BookUserLink.id).label("loan_count")
|
|
||||||
)
|
|
||||||
.where(BookUserLink.borrowed_at >= start_date)
|
.where(BookUserLink.borrowed_at >= start_date)
|
||||||
.group_by(BookUserLink.book_id)
|
.group_by(BookUserLink.book_id)
|
||||||
.order_by(func.count(BookUserLink.id).desc())
|
.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
|
loan_count = row[1] if isinstance(row, tuple) else row.loan_count
|
||||||
book = session.get(Book, book_id)
|
book = session.get(Book, book_id)
|
||||||
if book:
|
if book:
|
||||||
top_books_data.append({
|
top_books_data.append(
|
||||||
"book_id": book_id,
|
{"book_id": book_id, "title": book.title, "loan_count": loan_count}
|
||||||
"title": book.title,
|
)
|
||||||
"loan_count": loan_count
|
|
||||||
})
|
|
||||||
|
|
||||||
reserved_count = session.exec(
|
reserved_count = session.exec(
|
||||||
select(func.count(Book.id))
|
select(func.count(Book.id)).where(Book.status == BookStatus.RESERVED)
|
||||||
.where(Book.status == BookStatus.RESERVED)
|
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
borrowed_count = session.exec(
|
borrowed_count = session.exec(
|
||||||
select(func.count(Book.id))
|
select(func.count(Book.id)).where(Book.status == BookStatus.BORROWED)
|
||||||
.where(Book.status == BookStatus.BORROWED)
|
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
return JSONResponse(content={
|
return JSONResponse(
|
||||||
|
content={
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_loans": total_loans,
|
"total_loans": total_loans,
|
||||||
"active_loans": active_loans,
|
"active_loans": active_loans,
|
||||||
@@ -232,7 +229,8 @@ def get_loans_analytics(
|
|||||||
"period_days": days,
|
"period_days": days,
|
||||||
"start_date": start_date.isoformat(),
|
"start_date": start_date.isoformat(),
|
||||||
"end_date": end_date.isoformat(),
|
"end_date": end_date.isoformat(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -256,8 +254,7 @@ def get_loan(
|
|||||||
|
|
||||||
if not is_staff and loan.user_id != current_user.id:
|
if not is_staff and loan.user_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this loan"
|
||||||
detail="Access denied to this loan"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return LoanRead(**loan.model_dump())
|
return LoanRead(**loan.model_dump())
|
||||||
@@ -285,7 +282,7 @@ def update_loan(
|
|||||||
if not is_staff and db_loan.user_id != current_user.id:
|
if not is_staff and db_loan.user_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
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)
|
book = session.get(Book, db_loan.book_id)
|
||||||
@@ -296,7 +293,7 @@ def update_loan(
|
|||||||
if not is_staff:
|
if not is_staff:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
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)
|
new_user = session.get(User, loan_update.user_id)
|
||||||
if not new_user:
|
if not new_user:
|
||||||
@@ -308,10 +305,7 @@ def update_loan(
|
|||||||
|
|
||||||
if loan_update.returned_at is not None:
|
if loan_update.returned_at is not None:
|
||||||
if db_loan.returned_at is not None:
|
if db_loan.returned_at is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||||
status_code=400,
|
|
||||||
detail="Loan is already returned"
|
|
||||||
)
|
|
||||||
db_loan.returned_at = loan_update.returned_at
|
db_loan.returned_at = loan_update.returned_at
|
||||||
book.status = BookStatus.ACTIVE
|
book.status = BookStatus.ACTIVE
|
||||||
|
|
||||||
@@ -349,7 +343,7 @@ def confirm_loan(
|
|||||||
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
|
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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
|
book.status = BookStatus.BORROWED
|
||||||
@@ -381,7 +375,7 @@ def return_loan(
|
|||||||
if loan.returned_at:
|
if loan.returned_at:
|
||||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
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)
|
book = session.get(Book, loan.book_id)
|
||||||
if book:
|
if book:
|
||||||
@@ -416,7 +410,7 @@ def delete_loan(
|
|||||||
if not is_staff and loan.user_id != current_user.id:
|
if not is_staff and loan.user_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
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)
|
book = session.get(Book, loan.book_id)
|
||||||
@@ -424,7 +418,7 @@ def delete_loan(
|
|||||||
if book and book.status != BookStatus.RESERVED:
|
if book and book.status != BookStatus.RESERVED:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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())
|
loan_read = LoanRead(**loan.model_dump())
|
||||||
@@ -481,8 +475,7 @@ def issue_book_directly(
|
|||||||
|
|
||||||
if book.status != BookStatus.ACTIVE:
|
if book.status != BookStatus.ACTIVE:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400, detail=f"Book is not available (status: {book.status})"
|
||||||
detail=f"Book is not available (status: {book.status})"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_user = session.get(User, loan.user_id)
|
target_user = session.get(User, loan.user_id)
|
||||||
@@ -493,7 +486,7 @@ def issue_book_directly(
|
|||||||
book_id=loan.book_id,
|
book_id=loan.book_id,
|
||||||
user_id=loan.user_id,
|
user_id=loan.user_id,
|
||||||
due_date=loan.due_date,
|
due_date=loan.due_date,
|
||||||
borrowed_at=datetime.utcnow()
|
borrowed_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
book.status = BookStatus.BORROWED
|
book.status = BookStatus.BORROWED
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def auth(request: Request):
|
|||||||
return templates.TemplateResponse(request, "auth.html")
|
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):
|
async def set2fa(request: Request):
|
||||||
"""Рендерит страницу установки двухфакторной аутентификации"""
|
"""Рендерит страницу установки двухфакторной аутентификации"""
|
||||||
return templates.TemplateResponse(request, "2fa.html")
|
return templates.TemplateResponse(request, "2fa.html")
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -60,6 +60,7 @@ OPENAPI_TAGS = [
|
|||||||
{"name": "genres", "description": "Действия с жанрами."},
|
{"name": "genres", "description": "Действия с жанрами."},
|
||||||
{"name": "loans", "description": "Действия с выдачами."},
|
{"name": "loans", "description": "Действия с выдачами."},
|
||||||
{"name": "relations", "description": "Действия со связями."},
|
{"name": "relations", "description": "Действия со связями."},
|
||||||
|
{"name": "users", "description": "Действия с пользователями."},
|
||||||
{"name": "misc", "description": "Прочие."},
|
{"name": "misc", "description": "Прочие."},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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]");
|
$msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Api.post("/api/auth/2fa/verify", {
|
await Api.post("/api/auth/2fa/enable", {
|
||||||
|
data: {
|
||||||
code: code,
|
code: code,
|
||||||
|
},
|
||||||
secret: secretKey,
|
secret: secretKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
if (!window.isAdmin()) {
|
if (!window.isAdmin()) {
|
||||||
$(".container").html(
|
$(".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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,21 +45,28 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCharts(data) {
|
function renderCharts(data) {
|
||||||
// Подготовка данных для графиков
|
|
||||||
const startDate = new Date(data.start_date);
|
const startDate = new Date(data.start_date);
|
||||||
const endDate = new Date(data.end_date);
|
const endDate = new Date(data.end_date);
|
||||||
const dates = [];
|
const dates = [];
|
||||||
const loansData = [];
|
const loansData = [];
|
||||||
const returnsData = [];
|
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];
|
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);
|
loansData.push(data.daily_loans[dateStr] || 0);
|
||||||
returnsData.push(data.daily_returns[dateStr] || 0);
|
returnsData.push(data.daily_returns[dateStr] || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// График выдач
|
|
||||||
const loansCtx = document.getElementById("loans-chart");
|
const loansCtx = document.getElementById("loans-chart");
|
||||||
if (loansChart) {
|
if (loansChart) {
|
||||||
loansChart.destroy();
|
loansChart.destroy();
|
||||||
@@ -141,7 +148,6 @@ $(document).ready(() => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// График возвратов
|
|
||||||
const returnsCtx = document.getElementById("returns-chart");
|
const returnsCtx = document.getElementById("returns-chart");
|
||||||
if (returnsChart) {
|
if (returnsChart) {
|
||||||
returnsChart.destroy();
|
returnsChart.destroy();
|
||||||
@@ -230,7 +236,7 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
if (!topBooks || topBooks.length === 0) {
|
if (!topBooks || topBooks.length === 0) {
|
||||||
$container.html(
|
$container.html(
|
||||||
'<div class="text-center text-gray-500 py-8">Нет данных</div>'
|
'<div class="text-center text-gray-500 py-8">Нет данных</div>',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,4 +265,3 @@ $(document).ready(() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 {
|
try {
|
||||||
const data = await Api.get(
|
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;
|
activeLoan = data.loans.length > 0 ? data.loans[0] : null;
|
||||||
renderLoans(data.loans);
|
renderLoans(data.loans);
|
||||||
@@ -128,7 +128,7 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
loans.forEach((loan) => {
|
loans.forEach((loan) => {
|
||||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
|
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
|
||||||
"ru-RU"
|
"ru-RU",
|
||||||
);
|
);
|
||||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||||
const isOverdue =
|
const isOverdue =
|
||||||
@@ -531,11 +531,9 @@ $(document).ready(() => {
|
|||||||
due_date: new Date(dueDate).toISOString(),
|
due_date: new Date(dueDate).toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Используем прямой эндпоинт выдачи для администраторов
|
|
||||||
if (window.isAdmin()) {
|
if (window.isAdmin()) {
|
||||||
await Api.post("/api/loans/issue", payload);
|
await Api.post("/api/loans/issue", payload);
|
||||||
} else {
|
} else {
|
||||||
// Для библиотекарей создаем бронь, которую потом нужно подтвердить
|
|
||||||
await Api.post("/api/loans/", payload);
|
await Api.post("/api/loans/", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +19,7 @@ $(document).ready(() => {
|
|||||||
const data = await Api.get("/api/loans/?page=1&size=100");
|
const data = await Api.get("/api/loans/?page=1&size=100");
|
||||||
allLoans = data.loans;
|
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);
|
await loadBooks(bookIds);
|
||||||
|
|
||||||
renderLoans();
|
renderLoans();
|
||||||
@@ -46,12 +45,12 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
function renderLoans() {
|
function renderLoans() {
|
||||||
const reservations = allLoans.filter(
|
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(
|
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);
|
renderReservations(reservations);
|
||||||
renderActiveLoans(activeLoans);
|
renderActiveLoans(activeLoans);
|
||||||
@@ -70,7 +69,7 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
if (reservations.length === 0) {
|
if (reservations.length === 0) {
|
||||||
$container.html(
|
$container.html(
|
||||||
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>'
|
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,7 +78,9 @@ $(document).ready(() => {
|
|||||||
const book = booksCache.get(loan.book_id);
|
const book = booksCache.get(loan.book_id);
|
||||||
if (!book) return;
|
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 dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||||
|
|
||||||
const $card = $(`
|
const $card = $(`
|
||||||
@@ -90,7 +91,7 @@ $(document).ready(() => {
|
|||||||
${Utils.escapeHtml(book.title)}
|
${Utils.escapeHtml(book.title)}
|
||||||
</a>
|
</a>
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||||
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
|
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
|
||||||
@@ -130,7 +131,7 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
if (activeLoans.length === 0) {
|
if (activeLoans.length === 0) {
|
||||||
$container.html(
|
$container.html(
|
||||||
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>'
|
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,9 @@ $(document).ready(() => {
|
|||||||
const book = booksCache.get(loan.book_id);
|
const book = booksCache.get(loan.book_id);
|
||||||
if (!book) return;
|
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 dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||||
const isOverdue = new Date(loan.due_date) < new Date();
|
const isOverdue = new Date(loan.due_date) < new Date();
|
||||||
|
|
||||||
@@ -151,7 +154,7 @@ $(document).ready(() => {
|
|||||||
${Utils.escapeHtml(book.title)}
|
${Utils.escapeHtml(book.title)}
|
||||||
</a>
|
</a>
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||||
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
||||||
@@ -179,7 +182,7 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
if (returned.length === 0) {
|
if (returned.length === 0) {
|
||||||
$container.html(
|
$container.html(
|
||||||
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>'
|
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,8 +191,12 @@ $(document).ready(() => {
|
|||||||
const book = booksCache.get(loan.book_id);
|
const book = booksCache.get(loan.book_id);
|
||||||
if (!book) return;
|
if (!book) return;
|
||||||
|
|
||||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
|
||||||
const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU");
|
"ru-RU",
|
||||||
|
);
|
||||||
|
const returnedDate = new Date(loan.returned_at).toLocaleDateString(
|
||||||
|
"ru-RU",
|
||||||
|
);
|
||||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||||
|
|
||||||
const $card = $(`
|
const $card = $(`
|
||||||
@@ -200,7 +207,7 @@ $(document).ready(() => {
|
|||||||
${Utils.escapeHtml(book.title)}
|
${Utils.escapeHtml(book.title)}
|
||||||
</a>
|
</a>
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||||
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
||||||
@@ -230,8 +237,7 @@ $(document).ready(() => {
|
|||||||
await Api.delete(`/api/loans/${loanId}`);
|
await Api.delete(`/api/loans/${loanId}`);
|
||||||
Utils.showToast("Бронирование отменено", "success");
|
Utils.showToast("Бронирование отменено", "success");
|
||||||
|
|
||||||
// Удаляем из кэша и перезагружаем
|
allLoans = allLoans.filter((loan) => loan.id !== loanId);
|
||||||
allLoans = allLoans.filter(loan => loan.id !== loanId);
|
|
||||||
const book = booksCache.get(bookId);
|
const book = booksCache.get(bookId);
|
||||||
if (book) {
|
if (book) {
|
||||||
book.status = "active";
|
book.status = "active";
|
||||||
@@ -245,4 +251,3 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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;
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
|
||||||
if (!window.isAdmin()) {
|
|
||||||
$("#users-container").html(
|
|
||||||
document.getElementById("access-denied-template").innerHTML,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
let allRoles = [];
|
let allRoles = [];
|
||||||
let users = [];
|
let users = [];
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let pageSize = 20;
|
const pageSize = 20;
|
||||||
let totalUsers = 0;
|
let totalUsers = 0;
|
||||||
let searchQuery = "";
|
let searchQuery = "";
|
||||||
let selectedFilterRoles = new Set();
|
let selectedFilterRoles = new Set();
|
||||||
@@ -28,8 +21,8 @@ $(document).ready(() => {
|
|||||||
showLoadingState();
|
showLoadingState();
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
Api.get("/api/auth/users?skip=0&limit=100"),
|
Api.get("/api/users?skip=0&limit=100"),
|
||||||
Api.get("/api/auth/roles"),
|
Api.get("/api/users/roles"),
|
||||||
])
|
])
|
||||||
.then(([usersData, rolesData]) => {
|
.then(([usersData, rolesData]) => {
|
||||||
users = usersData.users;
|
users = usersData.users;
|
||||||
@@ -139,13 +132,11 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadUsers() {
|
function loadUsers() {
|
||||||
const params = new URLSearchParams();
|
const skip = (currentPage - 1) * pageSize;
|
||||||
params.append("skip", (currentPage - 1) * pageSize);
|
|
||||||
params.append("limit", pageSize);
|
|
||||||
|
|
||||||
showLoadingState();
|
showLoadingState();
|
||||||
|
|
||||||
Api.get(`/api/auth/users?${params.toString()}`)
|
Api.get(`/api/users?skip=${skip}&limit=${pageSize}`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
users = data.users;
|
users = data.users;
|
||||||
totalUsers = data.total;
|
totalUsers = data.total;
|
||||||
@@ -220,6 +211,8 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rolesContainer = clone.querySelector(".user-roles");
|
const rolesContainer = clone.querySelector(".user-roles");
|
||||||
|
let totalPayroll = 0;
|
||||||
|
|
||||||
if (user.roles && user.roles.length > 0) {
|
if (user.roles && user.roles.length > 0) {
|
||||||
user.roles.forEach((roleName) => {
|
user.roles.forEach((roleName) => {
|
||||||
const badge = roleBadgeTpl.content.cloneNode(true);
|
const badge = roleBadgeTpl.content.cloneNode(true);
|
||||||
@@ -238,12 +231,25 @@ $(document).ready(() => {
|
|||||||
removeBtn.dataset.userId = user.id;
|
removeBtn.dataset.userId = user.id;
|
||||||
removeBtn.dataset.roleName = roleName;
|
removeBtn.dataset.roleName = roleName;
|
||||||
rolesContainer.appendChild(badge);
|
rolesContainer.appendChild(badge);
|
||||||
|
|
||||||
|
const fullRole = allRoles.find((r) => r.name === roleName);
|
||||||
|
if (fullRole && fullRole.payroll) {
|
||||||
|
totalPayroll += fullRole.payroll;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
rolesContainer.innerHTML =
|
rolesContainer.innerHTML =
|
||||||
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
|
'<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");
|
const addRoleBtn = clone.querySelector(".add-role-btn");
|
||||||
addRoleBtn.dataset.userId = user.id;
|
addRoleBtn.dataset.userId = user.id;
|
||||||
|
|
||||||
@@ -457,12 +463,9 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addRoleToUser(userId, roleName) {
|
function addRoleToUser(userId, roleName) {
|
||||||
Api.request(
|
Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
|
||||||
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.then((updatedUser) => {
|
.then((updatedUser) => {
|
||||||
const userIndex = users.findIndex((u) => u.id === userId);
|
const userIndex = users.findIndex((u) => u.id === userId);
|
||||||
if (userIndex !== -1) {
|
if (userIndex !== -1) {
|
||||||
@@ -485,12 +488,9 @@ $(document).ready(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Api.request(
|
Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
|
||||||
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.then((updatedUser) => {
|
.then((updatedUser) => {
|
||||||
const userIndex = users.findIndex((u) => u.id === userId);
|
const userIndex = users.findIndex((u) => u.id === userId);
|
||||||
if (userIndex !== -1) {
|
if (userIndex !== -1) {
|
||||||
@@ -514,7 +514,6 @@ $(document).ready(() => {
|
|||||||
$("#edit-user-fullname").val(user.full_name || "");
|
$("#edit-user-fullname").val(user.full_name || "");
|
||||||
$("#edit-user-password").val("");
|
$("#edit-user-password").val("");
|
||||||
$("#edit-user-active").prop("checked", user.is_active);
|
$("#edit-user-active").prop("checked", user.is_active);
|
||||||
$("#edit-user-verified").prop("checked", user.is_verified);
|
|
||||||
|
|
||||||
$("#edit-user-modal").removeClass("hidden");
|
$("#edit-user-modal").removeClass("hidden");
|
||||||
}
|
}
|
||||||
@@ -529,6 +528,7 @@ $(document).ready(() => {
|
|||||||
const email = $("#edit-user-email").val().trim();
|
const email = $("#edit-user-email").val().trim();
|
||||||
const fullName = $("#edit-user-fullname").val().trim();
|
const fullName = $("#edit-user-fullname").val().trim();
|
||||||
const password = $("#edit-user-password").val();
|
const password = $("#edit-user-password").val();
|
||||||
|
const isActive = $("#edit-user-active").prop("checked");
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
Utils.showToast("Email обязателен", "error");
|
Utils.showToast("Email обязателен", "error");
|
||||||
@@ -538,40 +538,26 @@ $(document).ready(() => {
|
|||||||
const updateData = {
|
const updateData = {
|
||||||
email: email,
|
email: email,
|
||||||
full_name: fullName || null,
|
full_name: fullName || null,
|
||||||
|
is_active: isActive,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
updateData.password = password;
|
updateData.password = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: This uses the /api/auth/me endpoint structure
|
Api.put(`/api/users/${userId}`, updateData)
|
||||||
// For admin editing other users, you might need a different endpoint
|
|
||||||
// Here we'll simulate by updating local data
|
|
||||||
|
|
||||||
Api.put(`/api/auth/me`, updateData)
|
|
||||||
.then((updatedUser) => {
|
.then((updatedUser) => {
|
||||||
const userIndex = users.findIndex((u) => u.id === userId);
|
const userIndex = users.findIndex((u) => u.id === userId);
|
||||||
if (userIndex !== -1) {
|
if (userIndex !== -1) {
|
||||||
users[userIndex] = { ...users[userIndex], ...updatedUser };
|
users[userIndex] = updatedUser;
|
||||||
}
|
}
|
||||||
renderUsers();
|
renderUsers();
|
||||||
closeEditModal();
|
closeEditModal();
|
||||||
Utils.showToast("Пользователь обновлён", "success");
|
Utils.showToast("Пользователь обновлён", "success");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn("API update failed, updating locally:", error);
|
console.error(error);
|
||||||
const userIndex = users.findIndex((u) => u.id === userId);
|
Utils.showToast(error.message || "Ошибка обновления", "error");
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +572,16 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userToDelete = user;
|
userToDelete = user;
|
||||||
|
|
||||||
|
const actionText = user.is_active ? "деактивировать" : "удалить навсегда";
|
||||||
$("#delete-user-name").text(user.full_name || user.username);
|
$("#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");
|
$("#delete-user-modal").removeClass("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,23 +593,27 @@ $(document).ready(() => {
|
|||||||
function confirmDeleteUser() {
|
function confirmDeleteUser() {
|
||||||
if (!userToDelete) return;
|
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();
|
closeDeleteModal();
|
||||||
|
})
|
||||||
// When API supports deletion:
|
.catch((error) => {
|
||||||
// Api.delete(`/api/auth/users/${userToDelete.id}`)
|
console.error(error);
|
||||||
// .then(() => {
|
Utils.showToast(error.message || "Ошибка удаления", "error");
|
||||||
// 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");
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#users-container").on("click", ".add-role-btn", function (e) {
|
$("#users-container").on("click", ".add-role-btn", function (e) {
|
||||||
@@ -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("Сменить");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -242,3 +242,29 @@ button:disabled {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} {%
|
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %}
|
||||||
block content %}
|
{% block content %}
|
||||||
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
|
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
|
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
|
Настройка 2FA
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-500 text-center mb-6">
|
<p class="text-sm text-gray-500 text-center mb-6">
|
||||||
Отсканируйте код в Google Authenticator
|
Отсканируйте код в приложении Аутентификатора
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="qr-container"
|
id="qr-container"
|
||||||
@@ -155,5 +155,5 @@ block content %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/2fa.js"></script>
|
<script src="/static/page/2fa.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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="container mx-auto p-4 max-w-7xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
|
||||||
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
|
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Период анализа -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
|
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
|
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Общая статистика -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
<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="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -109,7 +108,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Графики -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<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">
|
<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>
|
<h2 class="text-base font-medium text-gray-700 mb-6">Выдачи по дням</h2>
|
||||||
@@ -126,7 +124,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Топ книг -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
<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>
|
<h2 class="text-base font-medium text-gray-700 mb-6">Топ книг по выдачам</h2>
|
||||||
<div id="top-books-container" class="space-y-2">
|
<div id="top-books-container" class="space-y-2">
|
||||||
@@ -137,6 +134,5 @@
|
|||||||
{% endblock %} {% block extra_head %}
|
{% endblock %} {% block extra_head %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/analytics.js"></script>
|
<script src="/static/page/analytics.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
+257
-256
@@ -1,323 +1,324 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {%
|
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %}
|
||||||
block content %}
|
{% block content %}
|
||||||
<div class="flex flex-1 items-center justify-center p-4">
|
<div class="flex flex-1 items-center justify-center p-4">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<div class="flex border-b border-gray-200">
|
<div class="flex border-b border-gray-200">
|
||||||
<button
|
<button type="button" id="login-tab"
|
||||||
type="button"
|
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">
|
||||||
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>
|
||||||
<button
|
<button type="button" id="register-tab"
|
||||||
type="button"
|
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600">
|
||||||
id="register-tab"
|
|
||||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
Регистрация
|
Регистрация
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form" class="p-6">
|
<form id="login-form" class="p-6">
|
||||||
|
<div id="credentials-section">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
for="login-username"
|
Имя пользователя
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
</label>
|
||||||
>Имя пользователя</label
|
<input type="text" id="login-username" name="username"
|
||||||
>
|
|
||||||
<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"
|
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="Введите имя пользователя"
|
placeholder="Введите имя пользователя" required />
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
||||||
for="login-password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>Пароль</label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input type="password" id="login-password" name="password"
|
||||||
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"
|
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="Введите пароль"
|
placeholder="Введите пароль" required />
|
||||||
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">
|
||||||
<button
|
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="button"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<svg
|
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">
|
||||||
class="eye-open w-5 h-5"
|
</path>
|
||||||
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>
|
||||||
<svg
|
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
class="eye-closed w-5 h-5 hidden"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
fill="none"
|
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">
|
||||||
stroke="currentColor"
|
</path>
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<label
|
<label class="custom-checkbox flex items-center text-sm text-gray-600">
|
||||||
class="custom-checkbox flex items-center text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
<input type="checkbox" id="remember-me" />
|
<input type="checkbox" id="remember-me" />
|
||||||
<span class="checkmark"></span>Запомнить меня
|
<span class="checkmark"></span>Запомнить меня
|
||||||
</label>
|
</label>
|
||||||
<a
|
<button type="button" id="forgot-password-btn"
|
||||||
href="#"
|
class="text-sm text-gray-500 hover:text-gray-700 transition">
|
||||||
class="text-sm text-gray-500 hover:text-gray-700 transition"
|
Забыли пароль?
|
||||||
>Забыли пароль?</a
|
</button>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<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 id="totp-section" class="hidden">
|
||||||
></div>
|
<div class="text-center mb-4">
|
||||||
<button
|
<div class="w-20 h-20 mx-auto relative flex items-center justify-center mb-3">
|
||||||
type="submit"
|
<svg class="absolute inset-0 w-full h-full -rotate-90" viewBox="0 0 80 80">
|
||||||
id="login-submit"
|
<circle cx="40" cy="40" r="38" fill="none" stroke="#e5e7eb" stroke-width="2" />
|
||||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form
|
|
||||||
id="register-form"
|
<form id="register-form" class="p-6 hidden">
|
||||||
class="p-6 hidden"
|
|
||||||
onsubmit="return handleRegister(event);"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="register-username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
for="register-username"
|
Имя пользователя
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
</label>
|
||||||
>Имя пользователя</label
|
<input type="text" id="register-username" name="username"
|
||||||
>
|
|
||||||
<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"
|
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 символа)"
|
placeholder="Придумайте имя пользователя" required minlength="3" maxlength="50" />
|
||||||
required
|
|
||||||
minlength="3"
|
|
||||||
maxlength="50"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="register-email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||||
for="register-email"
|
<input type="email" id="register-email" name="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"
|
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"
|
placeholder="example@mail.com" required />
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="register-fullname" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
for="register-fullname"
|
Полное имя <span class="text-gray-400">(необязательно)</span>
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
</label>
|
||||||
>Полное имя
|
<input type="text" id="register-fullname" name="full_name"
|
||||||
<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"
|
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="Иван Иванов"
|
placeholder="Иван Иванов" maxlength="100" />
|
||||||
maxlength="100"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="register-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
||||||
for="register-password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>Пароль</label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input type="password" id="register-password" name="password"
|
||||||
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"
|
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"
|
placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
|
||||||
required
|
<button type="button"
|
||||||
minlength="8"
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||||
maxlength="100"
|
<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"
|
||||||
<button
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
type="button"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
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
|
|
||||||
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>
|
||||||
<svg
|
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
class="eye-closed w-5 h-5 hidden"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
fill="none"
|
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">
|
||||||
stroke="currentColor"
|
</path>
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div
|
<div class="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
|
||||||
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
|
|
||||||
id="password-strength-bar"
|
|
||||||
class="h-full w-0 transition-all duration-300"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p id="password-strength-text" class="text-xs mt-1 text-gray-500"></p>
|
||||||
id="password-strength-text"
|
|
||||||
class="text-xs mt-1 text-gray-500"
|
|
||||||
></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label for="register-password-confirm" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
for="register-password-confirm"
|
Подтвердите пароль
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
</label>
|
||||||
>Подтвердите пароль</label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input type="password" id="register-password-confirm" name="password_confirm"
|
||||||
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"
|
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="Повторите пароль"
|
placeholder="Повторите пароль" required />
|
||||||
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">
|
||||||
<button
|
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="button"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<svg
|
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">
|
||||||
class="eye-open w-5 h-5"
|
</path>
|
||||||
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>
|
||||||
<svg
|
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
class="eye-closed w-5 h-5 hidden"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
fill="none"
|
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">
|
||||||
stroke="currentColor"
|
</path>
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p id="password-match-error" class="text-xs mt-1 text-red-500 hidden">
|
||||||
id="password-match-error"
|
|
||||||
class="text-xs mt-1 text-red-500 hidden"
|
|
||||||
>
|
|
||||||
Пароли не совпадают
|
Пароли не совпадают
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="register-error"
|
<button type="submit" id="register-submit"
|
||||||
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
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">
|
||||||
></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>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/auth.js"></script>
|
<script src="/static/page/auth.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 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 id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -115,5 +116,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/author.js"></script>
|
<script src="/static/page/author.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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="container mx-auto p-4">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
|
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
|
||||||
@@ -119,5 +120,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/authors.js"></script>
|
<script src="/static/page/authors.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -238,7 +238,9 @@
|
|||||||
|
|
||||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||||
<div class="container mx-auto text-center">
|
<div class="container mx-auto text-center">
|
||||||
<p>© 2025 LiB Library. All rights reserved.</p>
|
<p>© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||||
|
Код открыт под лицензией <a href="https://github.com/wowlikon/LibraryAPI/blob/main/LICENSE">MIT</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|||||||
@@ -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 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 id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -143,7 +144,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Секция выдачи для библиотекарей и администраторов -->
|
|
||||||
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
|
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
|
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
|
||||||
@@ -175,7 +175,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Модальное окно для выдачи книги -->
|
|
||||||
<div
|
<div
|
||||||
id="loan-modal"
|
id="loan-modal"
|
||||||
class="hidden fixed inset-0 z-50 overflow-y-auto"
|
class="hidden fixed inset-0 z-50 overflow-y-auto"
|
||||||
@@ -296,5 +295,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/book.js"></script>
|
<script src="/static/page/book.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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">
|
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
|
||||||
<aside class="w-full md:w-1/4">
|
<aside class="w-full md:w-1/4">
|
||||||
<div
|
<div
|
||||||
@@ -186,5 +187,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/books.js"></script>
|
<script src="/static/page/books.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %} {% block title %}Создание автора | LiB{% endblock %}
|
{% extends "base.html" %} {% block title %}LiB - Создание автора{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4 max-w-xl">
|
<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="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||||
@@ -158,5 +158,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/create_author.js"></script>
|
<script src="/static/page/create_author.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {%
|
{% extends "base.html" %} {% block title %}LiB - Создание книги{% endblock %}
|
||||||
block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4 max-w-3xl">
|
<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="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||||
@@ -225,5 +225,5 @@ block content %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/create_book.js"></script>
|
<script src="/static/page/create_book.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {%
|
{% extends "base.html" %} {% block title %}LiB - Создание жанра{% endblock %}
|
||||||
block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4 max-w-xl">
|
<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="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||||
@@ -158,5 +158,5 @@ block content %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/create_genre.js"></script>
|
<script src="/static/page/create_genre.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Редактирование автора | LiB{%
|
{% extends "base.html" %} {% block title %}LiB - Редактирование автора{% endblock %}
|
||||||
endblock %} {% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4 max-w-2xl">
|
<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="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||||
@@ -312,5 +312,5 @@ endblock %} {% block content %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/edit_author.js"></script>
|
<script src="/static/page/edit_author.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock
|
{% extends "base.html" %} {% block title %}LiB - Редактирование книги{% endblock %}
|
||||||
%} {% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4 max-w-3xl">
|
<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="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||||
@@ -390,5 +390,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/edit_book.js"></script>
|
<script src="/static/page/edit_book.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock
|
{% extends "base.html" %} {% block title %}LiB - Редактирование жанра{% endblock %}
|
||||||
%} {% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4 max-w-2xl">
|
<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="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||||
@@ -313,5 +313,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/edit_genre.js"></script>
|
<script src="/static/page/edit_genre.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
|
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %}
|
||||||
block content %}
|
{% block content %}
|
||||||
<div class="flex flex-1 items-center justify-center p-4">
|
<div class="flex flex-1 items-center justify-center p-4">
|
||||||
<div class="w-full max-w-4xl">
|
<div class="w-full max-w-4xl">
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
@@ -186,10 +186,10 @@ block content %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 text-center text-gray-400 text-sm">
|
<div class="mt-6 text-center text-gray-400 text-sm">
|
||||||
<p>LiB — Библиотека. Создано с ❤️</p>
|
<p>LiB — Библиотека. Красиво, функционально, безопасно.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/index.js"></script>
|
<script src="/static/page/index.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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="container mx-auto p-4 max-w-6xl">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
|
||||||
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
|
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Бронирования -->
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
|
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Активные выдачи -->
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
|
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Возвращенные книги -->
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
|
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
|
||||||
@@ -45,6 +43,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/my_books.js"></script>
|
<script src="/static/page/my_books.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,159 +1,294 @@
|
|||||||
{% extends "base.html" %} {% block content %}
|
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %}
|
||||||
<div
|
{% block content %}
|
||||||
class="container mx-auto p-4 max-w-2xl"
|
<div class="container mx-auto p-4 max-w-2xl"
|
||||||
x-data="{ showPasswordModal: false }"
|
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
|
||||||
@close-modal.window="showPasswordModal = false"
|
@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 id="profile-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div class="animate-pulse flex items-center">
|
<div class="animate-pulse flex items-center">
|
||||||
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
<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 class="h-6 bg-gray-200 w-48 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="account-section"
|
<div id="account-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
|
||||||
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>
|
<h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2>
|
||||||
<div id="account-info" class="space-y-4"></div>
|
<div id="account-info" class="space-y-4"></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="roles-section"
|
<div id="roles-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
|
||||||
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>
|
<h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2>
|
||||||
<div id="roles-container" class="space-y-3"></div>
|
<div id="roles-container" class="space-y-3"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
<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">
|
<div class="space-y-3">
|
||||||
<button
|
<button type="button"
|
||||||
@click="showPasswordModal = true"
|
@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"
|
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>
|
<span class="text-gray-700 font-medium">Сменить пароль</span>
|
||||||
<svg
|
</div>
|
||||||
class="w-5 h-5 text-gray-400"
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
fill="none"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onclick="Auth.logout()"
|
<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"
|
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>
|
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
|
||||||
<svg
|
</div>
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
x-show="showPasswordModal"
|
<div x-show="showPasswordModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto">
|
||||||
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">
|
||||||
style="display: none"
|
<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
|
|
||||||
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>
|
</div>
|
||||||
<div
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||||
x-show="showPasswordModal"
|
<div x-show="showPasswordModal" x-transition
|
||||||
x-transition.scale
|
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">
|
||||||
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-6 pt-6 pb-4">
|
||||||
>
|
<h3 class="text-lg leading-6 font-semibold text-gray-900 mb-4">Смена пароля</h3>
|
||||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<form id="change-password-form" class="space-y-4">
|
||||||
<h3
|
<div>
|
||||||
class="text-lg leading-6 font-medium text-gray-900 mb-4"
|
<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"
|
||||||
</h3>
|
placeholder="Минимум 8 символов" />
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<label
|
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите пароль</label>
|
||||||
class="block text-gray-700 text-sm font-bold mb-2"
|
<input type="password" id="confirm-password"
|
||||||
>Новый пароль</label
|
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="Повторите пароль" />
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
|
||||||
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="px-5 py-2.5 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition">
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Сменить
|
Сменить
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" @click="showPasswordModal = false"
|
||||||
type="button"
|
class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
|
||||||
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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">​</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">​</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>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %}
|
||||||
<script src="/static/profile.js"></script>
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/page/profile.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {%
|
{% extends "base.html" %} {% block title %}LiB - Пользователи{% endblock %}
|
||||||
block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-bold text-gray-800">
|
<h1 class="text-2xl font-bold text-gray-800">
|
||||||
@@ -38,7 +38,7 @@ block content %}
|
|||||||
type="text"
|
type="text"
|
||||||
id="role-filter-input"
|
id="role-filter-input"
|
||||||
placeholder="Фильтр по роли..."
|
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"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -150,7 +150,7 @@ block content %}
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<span class="text-sm font-medium text-gray-700"
|
<span class="text-sm font-medium text-gray-700"
|
||||||
>Роли:</span
|
>Роли:</span
|
||||||
@@ -177,6 +177,15 @@ block content %}
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +321,7 @@ block content %}
|
|||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div>
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -325,18 +334,6 @@ block content %}
|
|||||||
>Активен</span
|
>Активен</span
|
||||||
>
|
>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,5 +426,5 @@ block content %}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script src="/static/users.js"></script>
|
<script src="/static/page/users.js"></script>
|
||||||
{% endblock %}
|
{% 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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "LibraryAPI"
|
name = "LibraryAPI"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||||
authors = [{ name = "wowlikon" }]
|
authors = [{ name = "wowlikon" }]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libraryapi"
|
name = "libraryapi"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
|
|||||||
Reference in New Issue
Block a user