Compare commits

...

3 Commits

Author SHA1 Message Date
jze9
5cbc1e127b feat(profile): редактирование персональных данных и смена пароля
Бэкенд:
- PATCH /auth/me — изменение имени и/или email. Смена email проверяет
  уникальность, сбрасывает is_verified и отправляет новое письмо
  подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша
  не привязан к сессии), кэш инвалидируется после изменения.
- POST /auth/change-password — смена пароля с подтверждением текущего;
  отклоняет неверный текущий и совпадение нового со старым.

Фронтенд:
- Страница /settings: карточка персональных данных (имя, email со статусом
  подтверждения и предупреждением о повторной верификации) и карточка смены
  пароля с проверкой совпадения.
- Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:22:05 +05:00
jze9
3af79e9d03 fix(auth): сбрасывать Redis-кэш пользователя после подтверждения email
get_current_user кэширует пользователя в Redis на 5 минут. verify_email
не сбрасывал этот кэш после user.is_verified = True — если пользователь
до подтверждения хотя бы раз дёрнул защищённый эндпоинт (закешировался
как неверифицированный), он получал "Необходимо подтвердить email адрес"
ещё до 5 минут после реального подтверждения по ссылке из письма.
2026-07-27 12:22:37 +05:00
jze9
673e72a15a feat(infra): переход прод/test на общую инфраструктуру вместо self-hosted
Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env),
локально в докере остаётся только Elasticsearch + app-сервисы. Старый
docker-compose.yml (полностью автономный стек) сохранён как
docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в
будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS,
собирающий фронтенд в статику). Makefile переписан под три режима
(dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
2026-07-27 12:22:36 +05:00
31 changed files with 613 additions and 79 deletions

View File

@@ -1,11 +1,18 @@
.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean .PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps
# ─── Переменные ──────────────────────────────────────────────────────────────── # ─── Переменные ────────────────────────────────────────────────────────────────
COMPOSE = docker compose # Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
COMPOSE_DEV = docker compose -f docker-compose.yml -f docker-compose.dev.yml # (postgres/redis/rabbitmq/minio/ollama) уже существует и берётся из .env.
# Dev (полностью локальный sandbox, свои postgres/redis/rabbitmq/minio/ollama +
# hot reload) — поверх self-hosted примера, используется редко.
# Test (то, что реально гоняется на машине разработчика день в день) — hot
# reload против той же общей инфры, что и прод.
COMPOSE_PROD = docker compose -f docker-compose.prod.yml
COMPOSE_DEV = docker compose -f docker-compose.selfhosted.yml.example -f docker-compose.dev.yml
COMPOSE_TEST = docker compose -f docker-compose.test.yml
PROJECT = anti-plagiarism PROJECT = anti-plagiarism
# ─── Разработка ──────────────────────────────────────────────────────────────── # ─── Разработка (полностью локальный sandbox, редко используется) ─────────────
dev: dev:
@cp -n .env.example .env 2>/dev/null || true @cp -n .env.example .env 2>/dev/null || true
$(COMPOSE_DEV) up --build $(COMPOSE_DEV) up --build
@@ -14,99 +21,112 @@ dev-d:
@cp -n .env.example .env 2>/dev/null || true @cp -n .env.example .env 2>/dev/null || true
$(COMPOSE_DEV) up --build -d $(COMPOSE_DEV) up --build -d
# ─── Test (hot reload против общей инфры — повседневная разработка) ──────────
test-up:
$(COMPOSE_TEST) up --build -d
test-down:
$(COMPOSE_TEST) down
test-logs:
$(COMPOSE_TEST) logs -f
test-ps:
$(COMPOSE_TEST) ps
# ─── Продакшн ───────────────────────────────────────────────────────────────── # ─── Продакшн ─────────────────────────────────────────────────────────────────
build: build:
$(COMPOSE) build $(COMPOSE_PROD) build
up: up:
$(COMPOSE) up -d $(COMPOSE_PROD) up -d
down: down:
$(COMPOSE) down $(COMPOSE_PROD) down
restart: restart:
$(COMPOSE) restart $(COMPOSE_PROD) restart
ps: ps:
$(COMPOSE) ps $(COMPOSE_PROD) ps
# ─── Миграции ───────────────────────────────────────────────────────────────── # ─── Миграции ─────────────────────────────────────────────────────────────────
migrate: migrate:
$(COMPOSE) exec api alembic upgrade head $(COMPOSE_PROD) exec api alembic upgrade head
migrate-dev: migrate-dev:
$(COMPOSE_DEV) exec api alembic upgrade head $(COMPOSE_DEV) exec api alembic upgrade head
makemigration: makemigration:
@read -p "Migration name: " name; \ @read -p "Migration name: " name; \
$(COMPOSE) exec api alembic revision --autogenerate -m "$$name" $(COMPOSE_PROD) exec api alembic revision --autogenerate -m "$$name"
downgrade: downgrade:
$(COMPOSE) exec api alembic downgrade -1 $(COMPOSE_PROD) exec api alembic downgrade -1
# ─── Логи ───────────────────────────────────────────────────────────────────── # ─── Логи ─────────────────────────────────────────────────────────────────────
logs: logs:
$(COMPOSE) logs -f $(COMPOSE_PROD) logs -f
logs-api: logs-api:
$(COMPOSE) logs -f api $(COMPOSE_PROD) logs -f api
logs-gpu: logs-gpu:
$(COMPOSE) logs -f worker-gpu $(COMPOSE_PROD) logs -f worker-gpu
logs-indexer: logs-indexer:
$(COMPOSE) logs -f worker-indexer $(COMPOSE_PROD) logs -f worker-indexer
logs-notifier: logs-notifier:
$(COMPOSE) logs -f worker-notifier $(COMPOSE_PROD) logs -f worker-notifier
logs-gost: logs-gost:
$(COMPOSE) logs -f worker-gost $(COMPOSE_PROD) logs -f worker-gost
# ─── Шеллы ──────────────────────────────────────────────────────────────────── # ─── Шеллы ────────────────────────────────────────────────────────────────────
shell-api: shell-api:
$(COMPOSE) exec api bash $(COMPOSE_PROD) exec api bash
shell-gpu: shell-gpu:
$(COMPOSE) exec worker-gpu bash $(COMPOSE_PROD) exec worker-gpu bash
shell-indexer: shell-indexer:
$(COMPOSE) exec worker-indexer bash $(COMPOSE_PROD) exec worker-indexer bash
shell-db: shell-db:
$(COMPOSE) exec postgres psql -U antiplagiator antiplagiator @echo "Postgres общий (192.168.1.11) — подключайся напрямую: psql -h 192.168.1.11 -U antiplagiator antiplagiator"
shell-redis: shell-redis:
$(COMPOSE) exec redis redis-cli @echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
# ─── Линтинг и тесты ────────────────────────────────────────────────────────── # ─── Линтинг и тесты ──────────────────────────────────────────────────────────
lint: lint:
$(COMPOSE) exec api ruff check . --fix $(COMPOSE_PROD) exec api ruff check . --fix
$(COMPOSE) exec api mypy app/ $(COMPOSE_PROD) exec api mypy app/
lint-frontend: lint-frontend:
cd services/frontend && npm run lint cd services/frontend && npm run lint
test: test:
$(COMPOSE) exec api pytest tests/ -v $(COMPOSE_PROD) exec api pytest tests/ -v
test-cov: test-cov:
$(COMPOSE) exec api pytest tests/ -v --cov=app --cov-report=html $(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
# ─── Утилиты ────────────────────────────────────────────────────────────────── # ─── Утилиты ──────────────────────────────────────────────────────────────────
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
# общие postgres/redis/rabbitmq/minio не в Compose, их не затронет.
clean: clean:
$(COMPOSE) down -v --remove-orphans $(COMPOSE_PROD) down -v --remove-orphans
docker system prune -f docker system prune -f
# Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000") # Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000")
parse: parse:
$(COMPOSE) exec api python /scripts/run_parser.py $(PARSER) $(ARGS) $(COMPOSE_PROD) exec api python /scripts/run_parser.py $(PARSER) $(ARGS)
# Создать MinIO бакеты # Создать MinIO бакеты (запускать один раз на общем MinIO 192.168.1.21, не локально)
minio-init: minio-init:
$(COMPOSE) exec minio mc alias set local http://localhost:9000 minioadmin changeme @echo "MinIO общий (192.168.1.21) — бакеты documents/backups создаются на нём, см. .env MINIO_*"
$(COMPOSE) exec minio mc mb local/documents || true
$(COMPOSE) exec minio mc mb local/backups || true
# Создать ES индекс # Создать ES индекс
es-init: es-init:
@@ -115,13 +135,14 @@ es-init:
# Статистика Celery # Статистика Celery
flower: flower:
@echo "Flower доступен по адресу http://localhost:5555" @echo "Flower доступен по адресу http://localhost:5555"
@$(COMPOSE) ps flower @$(COMPOSE_PROD) ps flower
help: help:
@echo "Академический помощник — команды make:" @echo "Академический помощник — команды make:"
@echo "" @echo ""
@echo " make dev — запустить в режиме разработки (hot reload)" @echo " make test-up — запустить тестовый стек (hot reload, общая инфра)"
@echo " make buildсобрать Docker образы" @echo " make dev запустить полностью локальный sandbox (редко нужно)"
@echo " make build — собрать прод-образы"
@echo " make up — запустить в продакшн режиме" @echo " make up — запустить в продакшн режиме"
@echo " make down — остановить все сервисы" @echo " make down — остановить все сервисы"
@echo " make migrate — применить миграции Alembic" @echo " make migrate — применить миграции Alembic"
@@ -129,4 +150,4 @@ help:
@echo " make shell-api — открыть shell в контейнере api" @echo " make shell-api — открыть shell в контейнере api"
@echo " make lint — запустить линтер" @echo " make lint — запустить линтер"
@echo " make test — запустить тесты" @echo " make test — запустить тесты"
@echo " make clean — удалить все контейнеры и volumes" @echo " make clean — удалить прод-контейнеры и ЛОКАЛЬНЫЕ volume"

View File

@@ -134,21 +134,31 @@ python scripts/run_parser.py arxiv \
Смотри `.env.example` для полного списка переменных. Смотри `.env.example` для полного списка переменных.
Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`.
## Три docker-compose файла
| Файл | Назначение |
|------|-----------|
| `docker-compose.prod.yml` | **Реальный прод.** app-сервисы + nginx (собранный фронтенд + TLS) + локальный Elasticsearch. Postgres/Redis/RabbitMQ/MinIO/Ollama — уже существующие общие серверы, адреса в `.env`. |
| `docker-compose.test.yml` | Повседневная разработка — hot reload против той же общей инфры, что и прод (`make test-up`). |
| `docker-compose.selfhosted.yml.example` | Не используется. Полностью автономный вариант (свои Postgres/Redis/RabbitMQ/ES/MinIO/Ollama + GPU passthrough) — на случай отдельного выделенного сервера в будущем. |
## Продакшн деплой ## Продакшн деплой
```bash ```bash
# На сервере # На сервере
cp .env.example .env cp .env.example .env
nano .env # Настроить все пароли и ключи nano .env # Настроить все пароли и ключи, указать реальные адреса общих Postgres/Redis/RabbitMQ/MinIO/Ollama
# Сертификат ДО первого запуска nginx-контейнера (standalone, порт 80 должен быть свободен)
certbot certonly --standalone -d academic.jze9.ru
make build make build
make up make up
make migrate make migrate
# SSL сертификат (Let's Encrypt)
certbot --nginx -d academic.jze9.ru
``` ```
Nginx работает внутри контейнера (`docker-compose.prod.yml`, сервис `nginx`, Dockerfile в `infra/nginx/Dockerfile`) — собирает `services/frontend` в статику и отдаёт её вместе с проксированием `/api/`, `/ws/` на `api:8000` по конфигу `infra/nginx/nginx.conf`. Контейнер монтирует `/etc/letsencrypt` с хоста как read-only — сертификат обновляется на хосте (`certbot renew`), контейнер просто читает его.
## Лицензия ## Лицензия
MIT MIT

129
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,129 @@
# ═══════════════════════════════════════════════════════════════════════════════
# ПРОД стек: локально — nginx (static+proxy), api, воркеры, elasticsearch.
# Удалённо (через .env) — postgres, redis, minio, ollama, rabbitmq.
# Тот же принцип, что и docker-compose.test.yml (не дублировать общую инфру),
# но без bind-mount'ов исходников, без --reload, с собранным фронтендом и nginx/TLS.
# Запуск: docker compose -f docker-compose.prod.yml up -d --build
# ═══════════════════════════════════════════════════════════════════════════════
networks:
antiplagiator:
driver: bridge
volumes:
es_prod_data:
faiss_index_prod:
x-app-env: &app-env
env_file: .env
networks:
- antiplagiator
restart: unless-stopped
services:
# ─── Локальная инфраструктура ──────────────────────────────────────────────
elasticsearch:
image: elasticsearch:8.13.0
container_name: antiplagiator-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- xpack.ml.enabled=false
- ES_JAVA_OPTS=-Xms2g -Xmx2g
- bootstrap.memory_lock=true
- cluster.name=antiplagiator-prod
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es_prod_data:/usr/share/elasticsearch/data
networks:
- antiplagiator
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -qv '\"status\":\"red\"'"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
# ─── Приложение ────────────────────────────────────────────────────────────
# RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env)
api:
build:
context: ./services/api
dockerfile: Dockerfile
container_name: antiplagiator-api
<<: *app-env
depends_on:
elasticsearch:
condition: service_healthy
worker-gpu:
build:
context: ./services/worker-gpu
dockerfile: Dockerfile
container_name: antiplagiator-worker-gpu
<<: *app-env
volumes:
- faiss_index_prod:/data/index
depends_on:
elasticsearch:
condition: service_healthy
worker-indexer:
build:
context: ./services/worker-indexer
dockerfile: Dockerfile
container_name: antiplagiator-worker-indexer
<<: *app-env
volumes:
- ./scripts/parsers:/parsers:ro
depends_on:
elasticsearch:
condition: service_healthy
worker-notifier:
build:
context: ./services/worker-notifier
dockerfile: Dockerfile
container_name: antiplagiator-worker-notifier
<<: *app-env
worker-gost:
build:
context: ./services/worker-gost
dockerfile: Dockerfile
container_name: antiplagiator-worker-gost
<<: *app-env
flower:
image: mher/flower:2.0
container_name: antiplagiator-flower
command: celery --broker=${RABBITMQ_URL} flower --port=5555
environment:
CELERY_BROKER_URL: ${RABBITMQ_URL}
CELERY_RESULT_BACKEND: ${REDIS_URL}
networks:
- antiplagiator
restart: unless-stopped
# Flower не публикуется наружу напрямую — доступ через ssh-туннель при необходимости
# ─── Nginx: собранный фронтенд (static) + proxy /api, /ws → api:8000, TLS ──
nginx:
build:
context: .
dockerfile: infra/nginx/Dockerfile
container_name: antiplagiator-nginx
networks:
- antiplagiator
ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
restart: unless-stopped

View File

@@ -1,5 +1,13 @@
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# Академический помощник — Production Docker Compose # Академический помощник — self-hosted вариант (свои Postgres/Redis/RabbitMQ/
# ES/MinIO/Ollama + GPU passthrough для ollama/worker-gpu).
#
# НЕ используется сейчас — реальный прод разворачивается через
# docker-compose.prod.yml поверх уже существующей общей инфры (Postgres .11,
# Redis .19, RabbitMQ .82, MinIO .21, Ollama CT108). Держим этот файл на случай,
# если когда-нибудь появится отдельный GPU-сервер под этот проект — тогда
# у worker-indexer нужно будет ещё добавить volume ./scripts/parsers:/parsers:ro
# (в этом файле его сейчас нет, в отличие от prod/test).
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
networks: networks:
@@ -13,7 +21,7 @@ volumes:
elasticsearch_data: elasticsearch_data:
minio_data: minio_data:
ollama_data: ollama_data:
faiss_index: faiss_index_selfhosted:
# ─── Общие переменные окружения для app-сервисов ──────────────────────────── # ─── Общие переменные окружения для app-сервисов ────────────────────────────
x-app-env: &app-env x-app-env: &app-env
@@ -177,7 +185,7 @@ services:
<<: *app-env <<: *app-env
command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info
volumes: volumes:
- faiss_index:/data/index - faiss_index_selfhosted:/data/index
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

10
infra/nginx/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-slim AS build
WORKDIR /app
COPY services/frontend/package.json services/frontend/package-lock.json* ./
RUN npm install
COPY services/frontend/ .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /var/www/academic
COPY infra/nginx/nginx.conf /etc/nginx/nginx.conf

View File

@@ -72,6 +72,12 @@ http {
add_header X-Frame-Options DENY; add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
# ── Health check (не под /api/ в самом приложении) ─────────────────────
location = /health {
proxy_pass http://api/health;
proxy_set_header Host $host;
}
# ── API proxy ────────────────────────────────────────────────────────── # ── API proxy ──────────────────────────────────────────────────────────
location /api/ { location /api/ {
proxy_pass http://api/api/; proxy_pass http://api/api/;

View File

@@ -12,11 +12,19 @@ from app.core.security import (
create_access_token, create_access_token,
get_current_user, get_current_user,
hash_password, hash_password,
invalidate_user_cache,
verify_password, verify_password,
) )
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserInToken from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
UpdateProfileRequest,
UserInToken,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -98,6 +106,96 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
return UserInToken.model_validate(current_user) return UserInToken.model_validate(current_user)
@router.patch("/me", response_model=UserInToken)
async def update_profile(
data: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> UserInToken:
"""
Изменить персональные данные (имя и/или email).
Смена email сбрасывает подтверждение и отправляет новое письмо верификации.
"""
# Загружаем "живого" пользователя из БД: объект из Redis-кэша не привязан
# к сессии и не содержит части полей.
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
email_changed = False
if data.name is not None:
user.name = data.name
if data.email is not None:
new_email = data.email.lower()
if new_email != user.email:
existing = await db.execute(
select(User).where(User.email == new_email, User.id != user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Этот email уже используется другим аккаунтом",
)
user.email = new_email
user.is_verified = False
user.verification_token = secrets.token_urlsafe(32)
email_changed = True
await db.commit()
await db.refresh(user)
await invalidate_user_cache(user.id)
# При смене email — отправить новое письмо подтверждения на новый адрес
if email_changed:
try:
celery_app.send_task(
"notify.send_verification",
args=[user.email, user.name, user.verification_token],
queue="queue.notify",
)
except Exception as e:
logger.warning(f"Не удалось отправить письмо верификации при смене email: {e}")
return UserInToken.model_validate(user)
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Сменить пароль. Требует текущий пароль для подтверждения."""
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
if not verify_password(data.current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Текущий пароль указан неверно",
)
if verify_password(data.new_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль совпадает с текущим",
)
user.hashed_password = hash_password(data.new_password)
await db.commit()
await invalidate_user_cache(user.id)
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT) @router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
async def resend_verification( async def resend_verification(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -146,5 +244,6 @@ async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
await db.commit() await db.commit()
await invalidate_user_cache(user.id)
return {"message": "Email успешно подтверждён"} return {"message": "Email успешно подтверждён"}

View File

@@ -18,6 +18,20 @@ class LoginRequest(BaseModel):
password: str password: str
class UpdateProfileRequest(BaseModel):
"""Изменение персональных данных (имя и/или email)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
email: EmailStr | None = None
class ChangePasswordRequest(BaseModel):
"""Смена пароля: нужен текущий пароль для подтверждения."""
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class UserInToken(BaseModel): class UserInToken(BaseModel):
"""Минимальная информация о пользователе в JWT токене.""" """Минимальная информация о пользователе в JWT токене."""

View File

@@ -38,6 +38,12 @@ export const authApi = {
me: () => api.get('/auth/me'), me: () => api.get('/auth/me'),
updateProfile: (data: { name?: string; email?: string }) =>
api.patch('/auth/me', data),
changePassword: (data: { current_password: string; new_password: string }) =>
api.post('/auth/change-password', data),
verifyEmail: (token: string) => verifyEmail: (token: string) =>
api.post(`/auth/verify-email/${token}`), api.post(`/auth/verify-email/${token}`),

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { Upload, FileText, X } from 'lucide-react'; import { Upload, FileText, X } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom'; import { Link, NavLink, useNavigate } from 'react-router-dom';
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react'; import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { EmailVerificationModal } from './EmailVerificationModal'; import { EmailVerificationModal } from './EmailVerificationModal';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'; import { AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { PlagiarismResultData } from '../types'; import type { PlagiarismResultData } from '../types';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react'; import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { TaskStatus } from '../types'; import type { TaskStatus } from '../types';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';

View File

@@ -10,6 +10,7 @@ import { Search } from './pages/Search';
import { Check } from './pages/Check'; import { Check } from './pages/Check';
import { Bibliography } from './pages/Bibliography'; import { Bibliography } from './pages/Bibliography';
import { Cabinet } from './pages/Cabinet'; import { Cabinet } from './pages/Cabinet';
import { Settings } from './pages/Settings';
import { Task } from './pages/Task'; import { Task } from './pages/Task';
import { Pricing } from './pages/Pricing'; import { Pricing } from './pages/Pricing';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
@@ -45,6 +46,7 @@ function PublicApp() {
<Route path="/check" element={<Check />} /> <Route path="/check" element={<Check />} />
<Route path="/bibliography" element={<Bibliography />} /> <Route path="/bibliography" element={<Bibliography />} />
<Route path="/cabinet" element={<Cabinet />} /> <Route path="/cabinet" element={<Cabinet />} />
<Route path="/settings" element={<Settings />} />
<Route path="/tasks/:taskId" element={<Task />} /> <Route path="/tasks/:taskId" element={<Task />} />
<Route path="/pricing" element={<Pricing />} /> <Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />

View File

@@ -1,16 +1,14 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react'; import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { GostCitation } from '../components/GostCitation'; import { GostCitation } from '../components/GostCitation';
import { useBibliographyStore } from '../store/bibliography'; import { useBibliographyStore } from '../store/bibliography';
import { useAuthStore } from '../store/auth';
import { api } from '../api/client'; import { api } from '../api/client';
import type { GostResultData } from '../types'; import type { GostResultData } from '../types';
export function Bibliography() { export function Bibliography() {
const { sources, removeSource, clearSources } = useBibliographyStore(); const { sources, removeSource, clearSources } = useBibliographyStore();
const { isAuthenticated } = useAuthStore();
const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1'); const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1');
const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null); const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null);

View File

@@ -1,7 +1,6 @@
import React from 'react'; import { Navigate, useNavigate, Link } from 'react-router-dom';
import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Crown, Search, Shield, BookOpen, ShieldAlert } from 'lucide-react'; import { Crown, Search, Shield, BookOpen, ShieldAlert, Settings as SettingsIcon } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { TaskCard } from '../components/TaskCard'; import { TaskCard } from '../components/TaskCard';
import { tasksApi, adminApi } from '../api/client'; import { tasksApi, adminApi } from '../api/client';
@@ -61,6 +60,13 @@ export function Cabinet() {
<p className="text-gray-500 text-sm">{user?.email}</p> <p className="text-gray-500 text-sm">{user?.email}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link
to="/settings"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Настройки
</Link>
{user?.is_admin && ( {user?.is_admin && (
<button <button
onClick={openAdmin} onClick={openAdmin}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search, Shield, BookOpen, Zap } from 'lucide-react'; import { Search, Shield, BookOpen, Zap } from 'lucide-react';
import { SearchBar } from '../components/SearchBar'; import { SearchBar } from '../components/SearchBar';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { PLAN_LIMITS } from '../types'; import { PLAN_LIMITS } from '../types';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { Filter, Loader2 } from 'lucide-react'; import { Filter, Loader2 } from 'lucide-react';
@@ -41,16 +41,13 @@ export function Search() {
}); });
// Поллинг задачи // Поллинг задачи
const { const { data: task } = useQuery({
data: task,
isLoading: isPolling,
} = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task), queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task),
enabled: !!taskId, enabled: !!taskId,
refetchInterval: (data) => { refetchInterval: (query) => {
if (!data) return 3000; const status = query.state.data?.status;
if (data.status === 'done' || data.status === 'failed') return false; if (status === 'done' || status === 'failed') return false;
return 3000; return 3000;
}, },
}); });

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Navigate, Link } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { ArrowLeft, User as UserIcon, Lock, Mail, AlertCircle, CheckCircle2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { authApi } from '../api/client';
import { useAuthStore } from '../store/auth';
import type { User } from '../types';
export function Settings() {
const { isAuthenticated, user, updateUser } = useAuthStore();
// Персональные данные
const [name, setName] = useState(user?.name ?? '');
const [email, setEmail] = useState(user?.email ?? '');
// Смена пароля
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (!isAuthenticated) return <Navigate to="/login" replace />;
const emailChanged = email.trim().toLowerCase() !== (user?.email ?? '').toLowerCase();
const profileDirty = name.trim() !== (user?.name ?? '') || emailChanged;
const saveProfile = useMutation({
mutationFn: () => {
const payload: { name?: string; email?: string } = {};
if (name.trim() !== user?.name) payload.name = name.trim();
if (emailChanged) payload.email = email.trim();
return authApi.updateProfile(payload);
},
onSuccess: (response) => {
const updated: User = response.data;
updateUser(updated);
setEmail(updated.email);
setName(updated.name);
toast.success(
emailChanged
? 'Данные сохранены. Мы отправили письмо для подтверждения нового email.'
: 'Данные сохранены'
);
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Не удалось сохранить данные');
},
});
const changePassword = useMutation({
mutationFn: () =>
authApi.changePassword({ current_password: currentPassword, new_password: newPassword }),
onSuccess: () => {
toast.success('Пароль изменён');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Не удалось сменить пароль');
},
});
const passwordMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
const canChangePassword =
currentPassword.length > 0 &&
newPassword.length >= 8 &&
newPassword === confirmPassword &&
!changePassword.isPending;
const inputClass =
'w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-shadow';
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Шапка */}
<div className="flex items-center gap-3">
<Link
to="/cabinet"
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl font-semibold text-gray-900">Настройки профиля</h1>
<p className="text-sm text-gray-400">Персональные данные и безопасность</p>
</div>
</div>
{/* Персональные данные */}
<form
onSubmit={(e) => {
e.preventDefault();
if (profileDirty) saveProfile.mutate();
}}
className="bg-white rounded-2xl border border-gray-100 p-6"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
<UserIcon className="w-4 h-4 text-brand-600" />
</div>
<h2 className="text-base font-semibold text-gray-900">Персональные данные</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Имя</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
maxLength={255}
placeholder="Иван Иванов"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Email</label>
<div className="relative">
<Mail className="w-4 h-4 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="ivan@example.com"
className={`${inputClass} pl-10`}
/>
</div>
{/* Статус подтверждения / предупреждение о смене */}
{emailChanged ? (
<div className="flex items-start gap-2 mt-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
После сохранения потребуется подтвердить новый адрес мы пришлём письмо со ссылкой.
</span>
</div>
) : user?.is_verified ? (
<div className="flex items-center gap-1.5 mt-2 text-xs text-green-600">
<CheckCircle2 className="w-3.5 h-3.5" />
Email подтверждён
</div>
) : (
<div className="flex items-center gap-1.5 mt-2 text-xs text-amber-600">
<AlertCircle className="w-3.5 h-3.5" />
Email не подтверждён
</div>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={!profileDirty || saveProfile.isPending}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saveProfile.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
</button>
</div>
</form>
{/* Смена пароля */}
<form
onSubmit={(e) => {
e.preventDefault();
if (canChangePassword) changePassword.mutate();
}}
className="bg-white rounded-2xl border border-gray-100 p-6"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
<Lock className="w-4 h-4 text-brand-600" />
</div>
<h2 className="text-base font-semibold text-gray-900">Смена пароля</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Текущий пароль</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••••••"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Новый пароль</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
placeholder="Минимум 8 символов"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">
Повторите новый пароль
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="••••••••"
className={`${inputClass} ${passwordMismatch ? 'border-red-300 focus:ring-red-400' : ''}`}
/>
{passwordMismatch && (
<p className="text-xs text-red-500 mt-1.5">Пароли не совпадают</p>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={!canChangePassword}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{changePassword.isPending ? 'Меняем...' : 'Изменить пароль'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useParams, Navigate } from 'react-router-dom'; import { useParams, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2, ArrowLeft } from 'lucide-react'; import { Loader2, ArrowLeft } from 'lucide-react';
@@ -22,9 +21,9 @@ export function Task() {
const { data: task, isLoading } = useQuery({ const { data: task, isLoading } = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType), queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType),
refetchInterval: (data) => { refetchInterval: (query) => {
if (!data) return 3000; const status = query.state.data?.status;
return (data.status === 'done' || data.status === 'failed') ? false : 3000; return (status === 'done' || status === 'failed') ? false : 3000;
}, },
}); });

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, Search } from 'lucide-react'; import { Trash2, Search } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react'; import { Play, Trash2, Plus } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, X, Eye } from 'lucide-react'; import { Check, X, Eye } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { HardDrive } from 'lucide-react'; import { HardDrive } from 'lucide-react';
import { adminApi } from '../../api/client'; import { adminApi } from '../../api/client';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, Search } from 'lucide-react'; import { Trash2, Search } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Trash2 } from 'lucide-react'; import { RefreshCw, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

1
services/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />