From 263c29ebe8f406f929e19634f0dbde80bd44474f Mon Sep 17 00:00:00 2001
From: dmitei
Date: Tue, 10 Nov 2026 23:18:00 +0300
Subject: [PATCH] add login and registration
---
.env.example | 2 +
README.md | 19 ++-
.../versions/1395cb8114bd_add_users.py | 38 +++++
backend/app/api/auth.py | 53 +++++++
backend/app/core/config.py | 3 +
backend/app/core/security.py | 40 ++++++
backend/app/models/__init__.py | 4 +-
backend/app/models/users.py | 12 ++
backend/app/schemas/auth.py | 35 +++++
backend/app/services/auth_service.py | 31 ++++
backend/main.py | 15 +-
backend/requirements.txt | 4 +-
backend/tests/conftest.py | 19 ++-
backend/tests/test_auth.py | 132 ++++++++++++++++++
frontend/src/App.tsx | 48 +++++--
frontend/src/api.ts | 48 +++++--
frontend/src/index.css | 50 +++++++
frontend/src/pages/Auth.tsx | 97 +++++++++++++
frontend/src/session.ts | 56 ++++++++
scripts/api_smoke.py | 32 ++++-
20 files changed, 704 insertions(+), 34 deletions(-)
create mode 100644 backend/alembic/versions/1395cb8114bd_add_users.py
create mode 100644 backend/app/api/auth.py
create mode 100644 backend/app/core/security.py
create mode 100644 backend/app/models/users.py
create mode 100644 backend/app/schemas/auth.py
create mode 100644 backend/app/services/auth_service.py
create mode 100644 backend/tests/test_auth.py
create mode 100644 frontend/src/pages/Auth.tsx
create mode 100644 frontend/src/session.ts
diff --git a/.env.example b/.env.example
index ecde501..a8da963 100644
--- a/.env.example
+++ b/.env.example
@@ -14,3 +14,5 @@ WEATHER_LATITUDE=55.7558
WEATHER_LONGITUDE=37.6173
BOT_TOKEN=
CHAT_ID=
+JWT_EXPIRE_MINUTES=1440
+JWT_SECRET=
diff --git a/README.md b/README.md
index 2661cab..7bf8ee5 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,16 @@ api/v1 (эндпоинты, валидация) → services (бизнес-ло
Все эндпоинты — с префиксом `/api/v1`. Документация OpenAPI: `/docs`.
+### Авторизация
+
+Все эндпоинты, кроме `/auth/register`, `/auth/login` и `/health`, требуют заголовок `Authorization: Bearer <токен>`, без него отвечают `401`. Токен — JWT, его выдают регистрация и вход; живёт `JWT_EXPIRE_MINUTES` минут (по умолчанию сутки). Подписывается ключом `JWT_SECRET` из `.env`: сгенерировать — `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Если ключ не задан, backend создаёт временный, и после его перезапуска войти придётся заново. Пароли хранятся только как bcrypt-хэш.
+
+| Метод | Эндпоинт | Описание | Ожидаемый latency |
+| ----- | ---------------- | ------------------------------------------------------------- | ----------------- |
+| POST | `/auth/register` | Регистрация по email и паролю (от 8 символов), сразу выдаёт токен; `409`, если email занят | < 500 мс |
+| POST | `/auth/login` | Вход: токен; `401` при неверном email или пароле | < 500 мс |
+| GET | `/auth/me` | Текущий пользователь | < 100 мс |
+
### Оборудование
| Метод | Эндпоинт | Описание | Ожидаемый latency |
@@ -228,9 +238,16 @@ erDiagram
varchar severity "info warning critical"
text message
}
+
+ users {
+ int id PK
+ text email "уникальный, в нижнем регистре"
+ text password_hash "bcrypt"
+ timestamp created_at
+ }
```
-Ограничения и индексы: `UNIQUE (dataset, unit_number)` в `equipment`; `UNIQUE (equipment_id, cycle)` в `sensor_readings`, `physics_predictions`, `ml_predictions`; индекс `alerts (equipment_id, triggered_at DESC)`. Показания хранятся широкой таблицей: набор из 14 датчиков фиксирован, так запросы истории не требуют `PIVOT`. Для других наборов (FD002–FD004) добавляются колонки миграцией; полный список датчиков и признак `is_used` лежат в `sensor_catalog`.
+Ограничения и индексы: `UNIQUE (dataset, unit_number)` в `equipment`; `UNIQUE (equipment_id, cycle)` в `sensor_readings`, `physics_predictions`, `ml_predictions`; индекс `alerts (equipment_id, triggered_at DESC)`; `UNIQUE (email)` в `users`. Показания хранятся широкой таблицей: набор из 14 датчиков фиксирован, так запросы истории не требуют `PIVOT`. Для других наборов (FD002–FD004) добавляются колонки миграцией; полный список датчиков и признак `is_used` лежат в `sensor_catalog`.
При записи (40 записей/сек) все вставки идут в разные таблицы от фоновых процессов. 40 INSERT/сек — незначительная нагрузка для PostgreSQL.
При чтении (~10-20 RPS) PostgreSQL будет находить строки за O(log n). Проблем не возникнет.
diff --git a/backend/alembic/versions/1395cb8114bd_add_users.py b/backend/alembic/versions/1395cb8114bd_add_users.py
new file mode 100644
index 0000000..ba08f3c
--- /dev/null
+++ b/backend/alembic/versions/1395cb8114bd_add_users.py
@@ -0,0 +1,38 @@
+"""add users
+
+Revision ID: 1395cb8114bd
+Revises: e74d5734bc65
+Create Date: 2026-11-10 23:10:55.971571
+
+"""
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = '1395cb8114bd'
+down_revision: str | Sequence[str] | None = 'e74d5734bc65'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('email', sa.Text(), nullable=False),
+ sa.Column('password_hash', sa.Text(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('email')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
new file mode 100644
index 0000000..f1b411c
--- /dev/null
+++ b/backend/app/api/auth.py
@@ -0,0 +1,53 @@
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from app.core.db import DB
+from app.core.security import create_token, user_id_from_token
+from app.schemas.auth import AuthOut, LoginIn, RegisterIn, UserOut
+from app.services import auth_service
+
+router = APIRouter(prefix="/auth", tags=["Авторизация"])
+
+bearer = HTTPBearer(auto_error=False, description="Токен из /auth/login или /auth/register")
+
+UNAUTHORIZED = {401: {"description": "Нет токена, токен неверен или просрочен"}}
+
+
+def _unauthorized(detail: str) -> HTTPException:
+ return HTTPException(status.HTTP_401_UNAUTHORIZED, detail, headers={"WWW-Authenticate": "Bearer"})
+
+
+def get_current_user(db: DB, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> dict:
+ user_id = user_id_from_token(credentials.credentials) if credentials else None
+ user = auth_service.get_user(db, user_id) if user_id is not None else None
+ if user is None:
+ raise _unauthorized("Требуется вход в систему")
+ return user
+
+
+def _session(user: dict) -> dict:
+ return {"access_token": create_token(user["id"]), "token_type": "bearer", "user": user}
+
+
+@router.post("/register", response_model=AuthOut, status_code=status.HTTP_201_CREATED,
+ responses={409: {"description": "Email уже зарегистрирован"}}, summary="Регистрация")
+def register(body: RegisterIn, db: DB):
+ user = auth_service.create_user(db, body.email, body.password)
+ if user is None:
+ raise HTTPException(status.HTTP_409_CONFLICT, "Пользователь с таким email уже зарегистрирован")
+ return _session(user)
+
+
+@router.post("/login", response_model=AuthOut, responses=UNAUTHORIZED, summary="Вход по email и паролю")
+def login(body: LoginIn, db: DB):
+ user = auth_service.authenticate(db, body.email, body.password)
+ if user is None:
+ raise _unauthorized("Неверный email или пароль")
+ return _session(user)
+
+
+@router.get("/me", response_model=UserOut, responses=UNAUTHORIZED, summary="Текущий пользователь")
+def me(user: Annotated[dict, Depends(get_current_user)]):
+ return user
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 2c4cbd0..739b497 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -9,3 +9,6 @@
RUL_WARNING = int(os.getenv("RUL_WARNING"))
RUL_CRITICAL = int(os.getenv("RUL_CRITICAL"))
RUL_CLIP = int(os.getenv("RUL_CLIP"))
+
+JWT_SECRET = os.getenv("JWT_SECRET", "")
+JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "1440"))
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..eef5e28
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,40 @@
+import logging
+import secrets
+from datetime import UTC, datetime, timedelta
+
+import bcrypt
+import jwt
+
+from app.core.config import JWT_EXPIRE_MINUTES, JWT_SECRET
+
+logger = logging.getLogger("security")
+
+ALGORITHM = "HS256"
+
+if JWT_SECRET:
+ _secret = JWT_SECRET
+else:
+ _secret = secrets.token_urlsafe(32)
+ logger.warning("JWT_SECRET не задан: сгенерирован временный ключ, после перезапуска backend войти придётся заново")
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
+
+
+def verify_password(password: str, password_hash: str) -> bool:
+ return bcrypt.checkpw(password.encode(), password_hash.encode())
+
+
+def create_token(user_id: int) -> str:
+ expires = datetime.now(UTC) + timedelta(minutes=JWT_EXPIRE_MINUTES)
+ return jwt.encode({"sub": str(user_id), "exp": expires}, _secret, algorithm=ALGORITHM)
+
+
+def user_id_from_token(token: str) -> int | None:
+ """id пользователя из токена; None, если токен неверен или просрочен."""
+ try:
+ payload = jwt.decode(token, _secret, algorithms=[ALGORITHM], options={"require": ["exp", "sub"]})
+ return int(payload["sub"])
+ except (jwt.InvalidTokenError, ValueError):
+ return None
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 9041430..f153bad 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -4,6 +4,7 @@
from .physics_predictions import PhysicsPrediction
from .sensor_catalog import SensorCatalog
from .sensor_readings import SensorReading
+from .users import User
__all__ = [
"Alert",
@@ -12,4 +13,5 @@
"PhysicsPrediction",
"SensorCatalog",
"SensorReading",
-]
\ No newline at end of file
+ "User",
+]
diff --git a/backend/app/models/users.py b/backend/app/models/users.py
new file mode 100644
index 0000000..dd90531
--- /dev/null
+++ b/backend/app/models/users.py
@@ -0,0 +1,12 @@
+from sqlalchemy import Column, DateTime, Integer, Text, func
+
+from app.core.db import Base
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True)
+ email = Column(Text, nullable=False, unique=True)
+ password_hash = Column(Text, nullable=False)
+ created_at = Column(DateTime, nullable=False, server_default=func.now())
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
new file mode 100644
index 0000000..8506d6d
--- /dev/null
+++ b/backend/app/schemas/auth.py
@@ -0,0 +1,35 @@
+from pydantic import BaseModel, Field, field_validator
+
+PASSWORD_MAX_BYTES = 72 # bcrypt не принимает пароли длиннее
+
+
+class LoginIn(BaseModel):
+ email: str = Field(max_length=254, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", examples=["operator@example.com"])
+ password: str = Field(min_length=1)
+
+ @field_validator("email", mode="before")
+ @classmethod
+ def normalize_email(cls, value):
+ return value.strip().lower() if isinstance(value, str) else value
+
+ @field_validator("password")
+ @classmethod
+ def fits_bcrypt(cls, value: str) -> str:
+ if len(value.encode()) > PASSWORD_MAX_BYTES:
+ raise ValueError(f"пароль длиннее {PASSWORD_MAX_BYTES} байт")
+ return value
+
+
+class RegisterIn(LoginIn):
+ password: str = Field(min_length=8, description="От 8 символов, не длиннее 72 байт в UTF-8")
+
+
+class UserOut(BaseModel):
+ id: int
+ email: str
+
+
+class AuthOut(BaseModel):
+ access_token: str = Field(description="JWT для заголовка Authorization: Bearer")
+ token_type: str = "bearer"
+ user: UserOut
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
new file mode 100644
index 0000000..b826dbf
--- /dev/null
+++ b/backend/app/services/auth_service.py
@@ -0,0 +1,31 @@
+from sqlalchemy import Connection, select
+from sqlalchemy.dialects.postgresql import insert
+
+from app.core.security import hash_password, verify_password
+from app.models.users import User
+
+
+def create_user(db: Connection, email: str, password: str) -> dict | None:
+ """Новый пользователь; None, если email уже занят."""
+ query = (
+ insert(User)
+ .values(email=email, password_hash=hash_password(password))
+ .on_conflict_do_nothing(index_elements=[User.email])
+ .returning(User.id, User.email)
+ )
+ row = db.execute(query).mappings().first()
+ db.commit()
+ return dict(row) if row else None
+
+
+def authenticate(db: Connection, email: str, password: str) -> dict | None:
+ query = select(User.id, User.email, User.password_hash).where(User.email == email)
+ row = db.execute(query).mappings().first()
+ if row is None or not verify_password(password, row["password_hash"]):
+ return None
+ return {"id": row["id"], "email": row["email"]}
+
+
+def get_user(db: Connection, user_id: int) -> dict | None:
+ row = db.execute(select(User.id, User.email).where(User.id == user_id)).mappings().first()
+ return dict(row) if row else None
diff --git a/backend/main.py b/backend/main.py
index b3376de..b687a2a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,6 +1,7 @@
-from app.api import alerts, equipment, predict
+from app.api import alerts, auth, equipment, predict
+from app.api.auth import UNAUTHORIZED, get_current_user
from app.core.config import CORS_ORIGINS
-from fastapi import FastAPI
+from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="OFF-Tech: оценка состояния оборудования", version="0.1.0")
@@ -8,6 +9,10 @@
app.add_middleware(CORSMiddleware, allow_origins=CORS_ORIGINS,
allow_methods=["*"], allow_headers=["*"])
-app.include_router(equipment.router)
-app.include_router(alerts.router)
-app.include_router(predict.router)
+# Данные — только с токеном; /auth/register и /auth/login открыты.
+PROTECTED = [Depends(get_current_user)]
+
+app.include_router(auth.router)
+app.include_router(equipment.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
+app.include_router(alerts.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
+app.include_router(predict.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 8b88dc1..4388894 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -8,4 +8,6 @@ httpx==0.27.2
alembic==1.20.0
psycopg[binary]==3.3.6
alembic==1.20.0
-python-dotenv==1.0.1
\ No newline at end of file
+python-dotenv==1.0.1
+PyJWT==2.15.1
+bcrypt==5.0.0
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 13285c2..ecdc160 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -1,4 +1,5 @@
import os
+import secrets
from datetime import date
import pytest
@@ -11,8 +12,9 @@
os.environ["RUL_WARNING"] = "75"
os.environ["RUL_CRITICAL"] = "30"
os.environ["RUL_CLIP"] = "125"
+os.environ["JWT_SECRET"] = secrets.token_urlsafe(32)
-TABLES = "alerts, ml_predictions, physics_predictions, sensor_readings, equipment"
+TABLES = "alerts, ml_predictions, physics_predictions, sensor_readings, equipment, users"
@pytest.fixture
@@ -26,12 +28,25 @@ def db():
@pytest.fixture
-def client(db):
+def anon_client(db):
from main import app
return TestClient(app)
+@pytest.fixture
+def client(db):
+ """Клиент вошедшего пользователя: все эндпоинты, кроме /auth, требуют токен."""
+ from app.core.security import create_token
+ from app.models import User
+ from main import app
+
+ statement = insert(User).values(email="operator@example.com", password_hash="-").returning(User.id)
+ user_id = db.execute(statement).scalar_one()
+ db.commit()
+ return TestClient(app, headers={"Authorization": f"Bearer {create_token(user_id)}"})
+
+
@pytest.fixture
def add_equipment(db):
from app.models import Equipment
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
new file mode 100644
index 0000000..86d7645
--- /dev/null
+++ b/backend/tests/test_auth.py
@@ -0,0 +1,132 @@
+import re
+import secrets
+from datetime import UTC, datetime, timedelta
+
+import bcrypt
+import jwt
+import pytest
+from fastapi.routing import APIRoute
+from sqlalchemy import text
+
+OPEN_PATHS = {"/auth/register", "/auth/login", "/health"}
+CREDENTIALS = {"email": "operator@example.com", "password": "correct-horse"}
+
+
+def protected_routes() -> list[tuple[str, str]]:
+ from main import app
+
+ return sorted(
+ (method, re.sub(r"\{[^}]+\}", "1", route.path))
+ for route in app.routes
+ if isinstance(route, APIRoute) and route.path not in OPEN_PATHS
+ for method in route.methods
+ )
+
+
+def bearer(token: str) -> dict[str, str]:
+ return {"Authorization": f"Bearer {token}"}
+
+
+def test_register_returns_working_token(anon_client):
+ response = anon_client.post("/auth/register", json={**CREDENTIALS, "email": " Operator@Example.COM "})
+
+ assert response.status_code == 201
+ body = response.json()
+ assert body["token_type"] == "bearer"
+ assert body["user"]["email"] == "operator@example.com"
+
+ me = anon_client.get("/auth/me", headers=bearer(body["access_token"]))
+ assert me.status_code == 200
+ assert me.json() == body["user"]
+
+
+def test_password_is_stored_as_bcrypt_hash(anon_client, db):
+ anon_client.post("/auth/register", json=CREDENTIALS)
+
+ stored = db.execute(text("SELECT password_hash FROM users")).scalar_one()
+
+ assert CREDENTIALS["password"] not in stored
+ assert bcrypt.checkpw(CREDENTIALS["password"].encode(), stored.encode())
+
+
+def test_register_same_email_conflicts(anon_client):
+ assert anon_client.post("/auth/register", json=CREDENTIALS).status_code == 201
+
+ response = anon_client.post("/auth/register", json={**CREDENTIALS, "email": "OPERATOR@example.com"})
+
+ assert response.status_code == 409
+
+
+@pytest.mark.parametrize("payload", [
+ {"email": "not-an-email", "password": "correct-horse"},
+ {"email": "operator@example.com", "password": "short"},
+ {"email": "operator@example.com", "password": "я" * 37}, # 74 байта — больше лимита bcrypt
+ {"email": "operator@example.com"},
+])
+def test_register_validates_input(anon_client, payload):
+ assert anon_client.post("/auth/register", json=payload).status_code == 422
+
+
+def test_login_returns_working_token(anon_client):
+ anon_client.post("/auth/register", json=CREDENTIALS)
+
+ response = anon_client.post("/auth/login", json={**CREDENTIALS, "email": "Operator@example.com"})
+
+ assert response.status_code == 200
+ assert anon_client.get("/equipment", headers=bearer(response.json()["access_token"])).status_code == 200
+
+
+@pytest.mark.parametrize("credentials", [
+ {**CREDENTIALS, "password": "wrong-password"},
+ {**CREDENTIALS, "email": "nobody@example.com"},
+])
+def test_login_rejects_wrong_credentials(anon_client, credentials):
+ anon_client.post("/auth/register", json=CREDENTIALS)
+
+ response = anon_client.post("/auth/login", json=credentials)
+
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Неверный email или пароль"
+
+
+@pytest.mark.parametrize(("method", "path"), protected_routes())
+def test_api_requires_token(anon_client, method, path):
+ response = anon_client.request(method, path)
+
+ assert response.status_code == 401
+ assert response.headers["WWW-Authenticate"] == "Bearer"
+
+
+def test_protected_routes_cover_api():
+ routes = protected_routes()
+
+ assert ("GET", "/equipment") in routes
+ assert ("GET", "/auth/me") in routes
+
+
+def test_signed_in_client_reaches_api(client):
+ assert client.get("/equipment").status_code == 200
+ assert client.get("/auth/me").json()["email"] == "operator@example.com"
+
+
+def _token(secret: str, sub: str = "1", minutes: int = 5) -> str:
+ expires = datetime.now(UTC) + timedelta(minutes=minutes)
+ return jwt.encode({"sub": sub, "exp": expires}, secret, algorithm="HS256")
+
+
+def test_rejects_bad_tokens(client):
+ from app.core.config import JWT_SECRET
+
+ user_token = client.headers["Authorization"].removeprefix("Bearer ")
+ user_id = jwt.decode(user_token, JWT_SECRET, algorithms=["HS256"])["sub"]
+ bad = {
+ "garbage": bearer("garbage"),
+ "foreign secret": bearer(_token(secrets.token_urlsafe(32), user_id)),
+ "expired": bearer(_token(JWT_SECRET, user_id, minutes=-1)),
+ "unknown user": bearer(_token(JWT_SECRET, "999")),
+ "basic scheme": {"Authorization": f"Basic {user_token}"},
+ }
+
+ for name, headers in bad.items():
+ response = client.get("/equipment", headers=headers)
+ assert response.status_code == 401, name
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index cb460a0..564e879 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,30 +1,56 @@
-import { NavLink, Route, Routes } from "react-router-dom";
+import { Navigate, NavLink, Outlet, Route, Routes, useLocation } from "react-router-dom";
import { Alerts } from "./pages/Alerts";
+import { AuthPage } from "./pages/Auth";
import { EquipmentCard } from "./pages/EquipmentCard";
import { EquipmentList } from "./pages/EquipmentList";
+import { setSession, useSession } from "./session";
export function App() {
+ const session = useSession();
+
return (
<>
- } />
- } />
- } />
- Страница не найдена
} />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ Страница не найдена} />
+
>
);
}
+
+function RequireAuth() {
+ const session = useSession();
+ const location = useLocation();
+
+ if (!session) return ;
+ return ;
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index b1cda21..eb8f3ae 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,3 +1,5 @@
+import { getToken, setSession } from "./session";
+
export type Status = "normal" | "warning" | "critical";
export interface Equipment {
@@ -56,21 +58,53 @@ export interface Alert {
message: string;
}
+export interface User {
+ id: number;
+ email: string;
+}
+
+export interface AuthResponse {
+ access_token: string;
+ token_type: string;
+ user: User;
+}
+
+export class ApiError extends Error {
+ status: number;
+ detail: string;
+
+ constructor(status: number, detail: string) {
+ super(`${status}: ${detail}`);
+ this.status = status;
+ this.detail = detail;
+ }
+}
+
const BASE = "/api";
async function request(path: string, init?: RequestInit): Promise {
- const res = await fetch(BASE + path, init);
+ const token = getToken();
+ const headers = new Headers(init?.headers);
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ const res = await fetch(BASE + path, { ...init, headers });
if (!res.ok) {
+ // Токен просрочен или пользователь удалён: выходим, RequireAuth откроет страницу входа.
+ if (res.status === 401 && token && token === getToken()) setSession(null);
let detail = res.statusText;
try {
const body = await res.json();
if (typeof body.detail === "string") detail = body.detail;
+ else if (Array.isArray(body.detail)) detail = body.detail.map((d: { msg: string }) => d.msg).join("; ");
} catch {}
- throw new Error(`${res.status}: ${detail}`);
+ throw new ApiError(res.status, detail);
}
return res.json() as Promise;
}
+function postJson(body: unknown): RequestInit {
+ return { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
+}
+
export const api = {
listEquipment: () => request("/equipment"),
getEquipment: (id: number) => request(`/equipment/${id}`),
@@ -79,10 +113,8 @@ export const api = {
getPhysics: (id: number, limit = 500) => request(`/equipment/${id}/physics?limit=${limit}`),
listAlerts: (limit = 100) => request(`/alerts?limit=${limit}`),
alertsByEquipment: (id: number, limit = 50) => request(`/alerts/${id}?limit=${limit}`),
- predict: (id: number) =>
- request("/predict", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ equipment_id: id }),
- }),
+ predict: (id: number) => request("/predict", postJson({ equipment_id: id })),
+ login: (email: string, password: string) => request("/auth/login", postJson({ email, password })),
+ register: (email: string, password: string) =>
+ request("/auth/register", postJson({ email, password })),
};
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 843f5c4..0df2f77 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -237,3 +237,53 @@ h2 {
color: #2563eb;
font-weight: 500;
}
+
+.user {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+ color: #d1d5db;
+ font-size: 14px;
+}
+.user-email {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.auth {
+ max-width: 380px;
+ margin: 48px auto;
+ padding: 24px;
+}
+.auth form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ margin: 20px 0 16px;
+}
+.auth label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 14px;
+ font-weight: 500;
+}
+.auth input {
+ padding: 8px 10px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font: inherit;
+}
+.auth input:focus {
+ outline: 2px solid #93c5fd;
+ border-color: #2563eb;
+}
+.auth .error {
+ margin: 0;
+}
+.auth .muted {
+ text-align: center;
+}
diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx
new file mode 100644
index 0000000..fc51e61
--- /dev/null
+++ b/frontend/src/pages/Auth.tsx
@@ -0,0 +1,97 @@
+import { type FormEvent, useState } from "react";
+import { Link, Navigate, useLocation } from "react-router-dom";
+
+import { ApiError, api } from "../api";
+import { setSession, useSession } from "../session";
+
+const PASSWORD_MIN = 8;
+const PASSWORD_MAX_BYTES = 72;
+
+const MODES = {
+ login: {
+ title: "Вход",
+ submit: "Войти",
+ send: api.login,
+ other: { question: "Нет аккаунта?", link: "Зарегистрироваться", to: "/register" },
+ },
+ register: {
+ title: "Регистрация",
+ submit: "Зарегистрироваться",
+ send: api.register,
+ other: { question: "Уже есть аккаунт?", link: "Войти", to: "/login" },
+ },
+};
+
+export function AuthPage({ mode }: { mode: keyof typeof MODES }) {
+ const session = useSession();
+ const location = useLocation();
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const page = MODES[mode];
+ const register = mode === "register";
+
+ if (session) {
+ const from = (location.state as { from?: string } | null)?.from ?? "/";
+ return ;
+ }
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ const form = new FormData(e.currentTarget);
+ const email = String(form.get("email"));
+ const password = String(form.get("password"));
+ if (register && password !== form.get("confirm")) return setError("Пароли не совпадают");
+ if (register && new TextEncoder().encode(password).length > PASSWORD_MAX_BYTES) {
+ return setError(`Пароль слишком длинный: не больше ${PASSWORD_MAX_BYTES} байт`);
+ }
+
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await page.send(email, password);
+ setSession({ token: res.access_token, email: res.user.email });
+ } catch (err) {
+ setError(err instanceof ApiError ? err.detail : "Нет связи с сервером");
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ {page.title}
+
+
+ {page.other.question}{" "}
+
+ {page.other.link}
+
+
+
+ );
+}
diff --git a/frontend/src/session.ts b/frontend/src/session.ts
new file mode 100644
index 0000000..bd7fa76
--- /dev/null
+++ b/frontend/src/session.ts
@@ -0,0 +1,56 @@
+import { useSyncExternalStore } from "react";
+
+export interface Session {
+ token: string;
+ email: string;
+}
+
+const KEY = "offtech.session";
+const listeners = new Set<() => void>();
+
+function read(): Session | null {
+ try {
+ const value = JSON.parse(localStorage.getItem(KEY) ?? "null");
+ return typeof value?.token === "string" && typeof value?.email === "string" ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+let session = read();
+
+function notify(): void {
+ listeners.forEach((l) => l());
+}
+
+export function getToken(): string | null {
+ return session?.token ?? null;
+}
+
+export function setSession(next: Session | null): void {
+ session = next;
+ try {
+ if (next) localStorage.setItem(KEY, JSON.stringify(next));
+ else localStorage.removeItem(KEY);
+ } catch {
+ }
+ notify();
+}
+
+// Вход и выход в соседней вкладке.
+window.addEventListener("storage", (e) => {
+ if (e.key !== KEY && e.key !== null) return;
+ session = read();
+ notify();
+});
+
+export function useSession(): Session | null {
+ return useSyncExternalStore(
+ (cb) => {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ () => session,
+ () => null,
+ );
+}
diff --git a/scripts/api_smoke.py b/scripts/api_smoke.py
index 20f3a2e..5f029b2 100644
--- a/scripts/api_smoke.py
+++ b/scripts/api_smoke.py
@@ -1,5 +1,6 @@
import json
import re
+import secrets
import sys
import time
import urllib.error
@@ -17,11 +18,20 @@
("GET", "/alerts"),
("GET", "/alerts/{id}"),
("POST", "/predict"),
+ ("POST", "/auth/register"),
+ ("POST", "/auth/login"),
+ ("GET", "/auth/me"),
]
-def call(method, path):
- req = urllib.request.Request(BASE + path, method=method, headers={"Accept": "application/json"})
+def call(method, path, body=None, token=None):
+ headers = {"Accept": "application/json"}
+ if body is not None:
+ headers["Content-Type"] = "application/json"
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ data = json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(BASE + path, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, json.loads(r.read().decode() or "null")
@@ -65,8 +75,20 @@ def main():
fail("в API нет эндпоинтов из контракта README:\n " + "\n ".join(missing))
print(f"OK контракт README: {len(CONTRACT)} эндпоинтов найдено (префикс {prefix or '—'!r})")
+ code, _ = call("GET", prefix + "/equipment")
+ if code != 401:
+ fail(f"без токена GET {prefix}/equipment: ожидали 401, получили {code}")
+ print("OK без токена -> 401")
+
+ credentials = {"email": f"smoke-{int(time.time())}@example.com", "password": secrets.token_urlsafe(16)}
+ code, session = call("POST", prefix + "/auth/register", credentials)
+ if code != 201 or not isinstance(session, dict) or not session.get("access_token"):
+ fail(f"регистрация: ожидали 201 с токеном, получили {code}")
+ token = session["access_token"]
+ print(f"OK POST {prefix}/auth/register -> 201, токен получен")
+
items = wait_for(lambda: (lambda r: r[1] if r[0] == 200 and isinstance(r[1], list) and r[1] else None)(
- call("GET", prefix + "/equipment")), "непустого списка оборудования (работают ли симулятор и воркер?)")
+ call("GET", prefix + "/equipment", token=token)), "непустого списка оборудования (работают ли симулятор и воркер?)")
if not isinstance(items[0], dict) or "id" not in items[0]:
fail("у элементов /equipment нет поля id")
eq_id = items[0]["id"]
@@ -76,10 +98,10 @@ def main():
if method != "GET" or path == "/equipment":
continue
real = prefix + path.replace("{id}", str(eq_id))
- wait_for(lambda real=real: True if call("GET", real)[0] == 200 else None, f"ответа 200 от GET {real}")
+ wait_for(lambda real=real: True if call("GET", real, token=token)[0] == 200 else None, f"ответа 200 от GET {real}")
print(f"OK GET {real}")
- code, _ = call("GET", f"{prefix}/equipment/999999999")
+ code, _ = call("GET", f"{prefix}/equipment/999999999", token=token)
if code != 404:
fail(f"несуществующее оборудование: ожидали 404, получили {code}")
print("OK несуществующее оборудование -> 404")