Security commit

This commit is contained in:
TronoSfera 2026-02-23 18:39:36 +03:00
parent 96649f8cc7
commit e66abf3fb9
21 changed files with 2324 additions and 2014 deletions

6
.gitignore vendored
View file

@ -1,4 +1,8 @@
/tmp/ /tmp/
*.idea *.idea
.env .env
/reports
node_modules/
e2e/node_modules/
e2e/playwright-report/
e2e/test-results/

View file

@ -9,6 +9,7 @@ from functools import lru_cache
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.inspection import inspect as sa_inspect from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -146,6 +147,31 @@ def _is_lawyer(admin: dict) -> bool:
return str(admin.get("role") or "").upper() == "LAWYER" return str(admin.get("role") or "").upper() == "LAWYER"
def _lawyer_actor_id_or_401(admin: dict) -> str:
actor_id = str(admin.get("sub") or "").strip()
if not actor_id:
raise HTTPException(status_code=401, detail="Некорректный токен")
return actor_id
def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None:
if not _is_lawyer(admin):
return
actor_id = _lawyer_actor_id_or_401(admin)
assigned = str(req.assigned_lawyer_id or "").strip()
if assigned and assigned != actor_id:
raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки")
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
if not _is_lawyer(admin):
return
actor_id = _lawyer_actor_id_or_401(admin)
assigned = str(req.assigned_lawyer_id or "").strip()
if not assigned or assigned != actor_id:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
def _serialize_value(value: Any) -> Any: def _serialize_value(value: Any) -> Any:
if isinstance(value, dict): if isinstance(value, dict):
return {key: _serialize_value(val) for key, val in value.items()} return {key: _serialize_value(val) for key, val in value.items()}
@ -364,7 +390,6 @@ def _column_label(table_name: str, column_name: str) -> str:
"phone": "Телефон", "phone": "Телефон",
"verified_at": "Подтверждено", "verified_at": "Подтверждено",
"expires_at": "Истекает", "expires_at": "Истекает",
"jwt_token": "JWT-токен",
"action": "Действие", "action": "Действие",
"entity": "Сущность", "entity": "Сущность",
"entity_id": "ID сущности", "entity_id": "ID сущности",
@ -969,7 +994,16 @@ def query_table(
): ):
normalized, model = _resolve_table_model(table_name) normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "query") _require_table_action(admin, normalized, "query")
query = apply_universal_query(db.query(model), model, uq) base_query = db.query(model)
if normalized == "requests" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.filter(
or_(
Request.assigned_lawyer_id == actor_id,
Request.assigned_lawyer_id.is_(None),
)
)
query = apply_universal_query(base_query, model, uq)
total = query.count() total = query.count()
rows = query.offset(uq.page.offset).limit(uq.page.limit).all() rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total} return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total}
@ -988,6 +1022,7 @@ def get_row(
if normalized == "requests": if normalized == "requests":
req = row if isinstance(row, Request) else None req = row if isinstance(row, Request) else None
if req is not None: if req is not None:
_ensure_lawyer_can_view_request_or_403(admin, req)
changed = False changed = False
if _is_lawyer(admin) and clear_unread_for_lawyer(req): if _is_lawyer(admin) and clear_unread_for_lawyer(req):
changed = True changed = True
@ -1093,6 +1128,8 @@ def update_row(
if forbidden_fields: if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
row = _load_row_or_404(db, model, row_id) row = _load_row_or_404(db, model, row_id)
if normalized == "requests" and isinstance(row, Request):
_ensure_lawyer_can_manage_request_or_403(admin, row)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования") raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
prepared = dict(payload) prepared = dict(payload)
@ -1197,6 +1234,8 @@ def delete_row(
if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id): if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id):
raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись") raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись")
row = _load_row_or_404(db, model, row_id) row = _load_row_or_404(db, model, row_id)
if normalized == "requests" and isinstance(row, Request):
_ensure_lawyer_can_manage_request_or_403(admin, row)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления") raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления")

View file

@ -4,7 +4,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import case, update from sqlalchemy import case, or_, update
from app.db.session import get_db from app.db.session import get_db
from app.core.deps import require_role from app.core.deps import require_role
@ -60,11 +60,25 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
if role != "LAWYER": if role != "LAWYER":
return return
actor = str(admin.get("sub") or "").strip() actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
assigned = str(req.assigned_lawyer_id or "").strip() assigned = str(req.assigned_lawyer_id or "").strip()
if not actor or not assigned or actor != assigned: if not actor or not assigned or actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper()
if role != "LAWYER":
return
actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
assigned = str(req.assigned_lawyer_id or "").strip()
if assigned and actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки")
def _request_data_requirement_row(row: RequestDataRequirement) -> dict: def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
return { return {
"id": str(row.id), "id": str(row.id),
@ -81,7 +95,20 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
@router.post("/query") @router.post("/query")
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
q = apply_universal_query(db.query(Request), Request, uq) base_query = db.query(Request)
role = str(admin.get("role") or "").upper()
if role == "LAWYER":
actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
base_query = base_query.filter(
or_(
Request.assigned_lawyer_id == actor,
Request.assigned_lawyer_id.is_(None),
)
)
q = apply_universal_query(base_query, Request, uq)
total = q.count() total = q.count()
rows = q.offset(uq.page.offset).limit(uq.page.limit).all() rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
return { return {
@ -166,6 +193,7 @@ def update_request(
row = db.get(Request, request_uuid) row = db.get(Request, request_uuid)
if not row: if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, row)
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
actor_role = str(admin.get("role") or "").upper() actor_role = str(admin.get("role") or "").upper()
if actor_role == "LAWYER": if actor_role == "LAWYER":
@ -238,6 +266,7 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends
row = db.get(Request, request_uuid) row = db.get(Request, request_uuid)
if not row: if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, row)
db.delete(row) db.delete(row)
db.commit() db.commit()
return {"status": "удалено"} return {"status": "удалено"}
@ -248,6 +277,7 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
req = db.get(Request, request_uuid) req = db.get(Request, request_uuid)
if not req: if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_view_request_or_403(admin, req)
changed = False changed = False
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req): if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
changed = True changed = True

View file

@ -17,6 +17,10 @@ SECURITY_HEADERS = {
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
"X-Permitted-Cross-Domain-Policies": "none", "X-Permitted-Cross-Domain-Policies": "none",
"Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "credentialless",
"Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
} }
@ -40,6 +44,11 @@ def install_http_hardening(app: FastAPI) -> None:
for key, value in SECURITY_HEADERS.items(): for key, value in SECURITY_HEADERS.items():
response.headers[key] = value response.headers[key] = value
# Backend serves application data and operational endpoints only.
# Keep responses non-cacheable to avoid stale or sensitive data reuse.
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
response.headers[REQUEST_ID_HEADER] = request_id response.headers[REQUEST_ID_HEADER] = request_id
duration_ms = (perf_counter() - started_at) * 1000.0 duration_ms = (perf_counter() - started_at) * 1000.0

View file

@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from jose import jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
@ -14,7 +14,8 @@ def create_jwt(payload: dict, secret: str, expires_delta: timedelta) -> str:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
data = payload.copy() data = payload.copy()
data.update({"iat": int(now.timestamp()), "exp": int((now + expires_delta).timestamp())}) data.update({"iat": int(now.timestamp()), "exp": int((now + expires_delta).timestamp())})
return jwt.encode(data, secret, algorithm="HS256") token = jwt.encode(data, secret, algorithm="HS256")
return str(token)
def decode_jwt(token: str, secret: str) -> dict: def decode_jwt(token: str, secret: str) -> dict:
return jwt.decode(token, secret, algorithms=["HS256"]) return jwt.decode(token, secret, algorithms=["HS256"], options={"require": ["iat", "exp"]})

View file

@ -1,16 +1,12 @@
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import JSONResponse
from app.core.config import settings from app.core.config import settings
from app.core.http_hardening import install_http_hardening from app.core.http_hardening import install_http_hardening
from app.api.public.router import router as public_router from app.api.public.router import router as public_router
from app.api.admin.router import router as admin_router from app.api.admin.router import router as admin_router
app = FastAPI(title=settings.APP_NAME, version="0.1.0") app = FastAPI(title=settings.APP_NAME, version="0.1.0")
BASE_DIR = Path(__file__).resolve().parent
LANDING_FILE = BASE_DIR / "web" / "landing.html"
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
@ -25,7 +21,7 @@ app.include_router(admin_router, prefix="/api/admin")
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
def landing(): def landing():
return FileResponse(LANDING_FILE) return JSONResponse({"service": settings.APP_NAME, "status": "ok"})
@app.get("/health") @app.get("/health")
def health(): def health():

View file

@ -14,8 +14,12 @@ _VERSION = b"v1"
def _key() -> bytes: def _key() -> bytes:
secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip() secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
if not secret or secret == "change_me_data_encryption": if not secret:
secret = str(settings.ADMIN_JWT_SECRET or "change_me_admin") secret = str(settings.ADMIN_JWT_SECRET or "").strip()
if not secret:
secret = str(settings.PUBLIC_JWT_SECRET or "").strip()
if not secret:
raise ValueError("Не задан секрет шифрования")
return hashlib.sha256(secret.encode("utf-8")).digest() return hashlib.sha256(secret.encode("utf-8")).digest()

View file

@ -126,4 +126,4 @@ def record_file_security_event(
try: try:
db.rollback() db.rollback()
except Exception: except Exception:
pass logger.debug("security_audit_rollback_failed", exc_info=True)

View file

@ -10,7 +10,8 @@ from app.core.config import settings
def _telegram_enabled() -> bool: def _telegram_enabled() -> bool:
token = str(settings.TELEGRAM_BOT_TOKEN or "").strip() token = str(settings.TELEGRAM_BOT_TOKEN or "").strip()
chat_id = str(settings.TELEGRAM_CHAT_ID or "").strip() chat_id = str(settings.TELEGRAM_CHAT_ID or "").strip()
if not token or token == "change_me": # Telegram bot token is expected in "<bot_id>:<secret>" format.
if not token or ":" not in token or len(token) < 20:
return False return False
if not chat_id or chat_id == "0": if not chat_id or chat_id == "0":
return False return False

717
app/web/admin.css Normal file
View file

@ -0,0 +1,717 @@
:root {
--bg: #0b1015;
--bg-soft: #111922;
--surface: #15202b;
--surface-2: #1d2a37;
--line: rgba(196, 210, 228, 0.2);
--text: #f3f7fc;
--muted: #9db0c5;
--brand: #d4a86a;
--brand-soft: rgba(212, 168, 106, 0.14);
--ok: #4dbe93;
--danger: #ff7f7f;
--radius: 16px;
--shadow: 0 24px 58px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 12% 2%, #1a2532, var(--bg) 50%), var(--bg);
color: var(--text);
font-family: "Manrope", sans-serif;
}
body.modal-open { overflow: hidden; }
.layout {
display: grid;
grid-template-columns: 272px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(19, 29, 39, 0.94), rgba(13, 20, 27, 0.94));
padding: 1rem;
position: sticky;
top: 0;
height: 100vh;
}
.logo {
font-weight: 800;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #ebf2fb;
margin-bottom: 1.2rem;
}
.logo a {
color: inherit;
text-decoration: none;
}
.menu {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.menu button {
width: 100%;
text-align: left;
border: 1px solid transparent;
background: transparent;
color: #d4deec;
padding: 0.72rem 0.78rem;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 0.92rem;
}
.menu button:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.03);
}
.menu button.active {
border-color: rgba(212, 168, 106, 0.45);
background: var(--brand-soft);
color: #fde5c2;
}
.menu-tree {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding-left: 0.6rem;
border-left: 1px dashed rgba(212, 168, 106, 0.3);
margin: 0.2rem 0 0.1rem 0.2rem;
}
.menu-tree button {
font-size: 0.85rem;
padding: 0.52rem 0.62rem;
color: #c8d8ea;
}
.auth-box {
margin-top: 1.2rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 0.75rem;
font-size: 0.86rem;
color: var(--muted);
line-height: 1.45;
}
.auth-box b {
color: #f4f7fc;
font-weight: 700;
}
.main {
padding: 1.2rem;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(130deg, rgba(22, 33, 45, 0.94), rgba(15, 23, 31, 0.97));
padding: 0.85rem 1rem;
box-shadow: var(--shadow);
}
.topbar h1 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.5rem;
letter-spacing: 0.01em;
}
.badge {
border: 1px solid rgba(212, 168, 106, 0.35);
border-radius: 999px;
background: var(--brand-soft);
color: #fedfb1;
font-size: 0.76rem;
font-weight: 700;
padding: 0.32rem 0.55rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.btn {
border: 1px solid transparent;
border-radius: 999px;
background: linear-gradient(120deg, #d9b57f, #c59048);
color: #1b2430;
padding: 0.64rem 1rem;
font-weight: 700;
font-size: 0.88rem;
cursor: pointer;
}
.btn:hover { filter: brightness(1.05); }
.btn.secondary {
border-color: var(--line);
background: rgba(255, 255, 255, 0.04);
color: #dbe6f5;
}
.btn.danger {
border-color: rgba(255, 127, 127, 0.3);
background: rgba(255, 127, 127, 0.13);
color: #ffd3d3;
}
.section {
display: none;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(160deg, rgba(20, 30, 40, 0.93), rgba(14, 22, 30, 0.96));
box-shadow: var(--shadow);
padding: 1rem;
}
.section.active { display: block; }
.section h2 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.35rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.9rem;
flex-wrap: wrap;
}
.muted {
color: var(--muted);
margin: 0.45rem 0 0;
line-height: 1.55;
font-size: 0.94rem;
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.card {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
padding: 0.8rem;
}
.card p {
margin: 0;
color: var(--muted);
font-size: 0.83rem;
}
.card b {
display: block;
margin-top: 0.3rem;
font-size: 1.2rem;
color: #f6dab0;
}
.filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 0.75rem;
}
.filter-toolbar {
display: flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.filter-chips {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
flex: 1 1 auto;
min-height: 34px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.5rem;
border: 1px solid rgba(212, 168, 106, 0.3);
border-radius: 999px;
background: var(--brand-soft);
color: #f9dfb5;
font-size: 0.76rem;
line-height: 1.2;
cursor: pointer;
}
.filter-chip button {
border: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #fce3bd;
cursor: pointer;
font-size: 0.82rem;
line-height: 1;
padding: 0;
}
.chip-placeholder {
color: var(--muted);
font-size: 0.84rem;
}
.filter-action {
margin-left: auto;
flex-shrink: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field label {
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9fb3c8;
font-size: 0.73rem;
font-weight: 700;
}
.field-inline {
display: flex;
align-items: center;
gap: 0.45rem;
}
.btn-sm {
min-height: 38px;
padding: 0.5rem 0.68rem;
font-size: 0.82rem;
}
input, textarea, select {
width: 100%;
border: 1px solid #3c4d62;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
font: inherit;
padding: 0.58rem 0.68rem;
min-height: 38px;
}
textarea {
min-height: 108px;
resize: vertical;
}
.table-wrap {
border: 1px solid var(--line);
border-radius: 12px;
overflow: auto;
background: rgba(255, 255, 255, 0.015);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 840px;
}
th, td {
padding: 0.63rem 0.65rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
font-size: 0.89rem;
}
th {
text-align: left;
color: #9fb3c8;
font-size: 0.77rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sortable-th {
cursor: pointer;
user-select: none;
}
.sortable-head {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.sort-indicator {
color: #6d7f96;
font-weight: 700;
font-size: 0.8rem;
line-height: 1;
}
.sort-indicator.active {
color: #f1d3a3;
}
td code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #f7dfb8;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #f5f8ff;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.24);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.user-identity {
display: inline-flex;
align-items: center;
gap: 0.52rem;
min-width: 0;
}
.user-identity-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-identity-text b {
font-size: 0.88rem;
font-weight: 700;
color: #eaf2fd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 230px;
}
.table-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.request-updates-stack {
display: inline-flex;
flex-direction: column;
gap: 0.24rem;
align-items: flex-start;
}
.request-update-chip {
display: inline-flex;
align-items: center;
gap: 0.32rem;
border: 1px solid rgba(95, 182, 145, 0.34);
border-radius: 999px;
background: rgba(77, 190, 147, 0.14);
color: #bef5df;
font-size: 0.74rem;
line-height: 1.1;
padding: 0.18rem 0.45rem;
white-space: nowrap;
}
.request-update-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3ed692;
box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18);
flex-shrink: 0;
}
.request-update-empty {
color: #8ea1b8;
font-size: 0.8rem;
}
.icon-btn {
border: 1px solid var(--line);
border-radius: 9px;
background: rgba(255, 255, 255, 0.03);
color: #d8e4f4;
cursor: pointer;
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
font-size: 0.95rem;
line-height: 1;
position: relative;
}
.icon-btn:hover {
border-color: rgba(212, 168, 106, 0.42);
background: rgba(212, 168, 106, 0.16);
color: #fce0b6;
}
.icon-btn.danger:hover {
border-color: rgba(255, 127, 127, 0.45);
background: rgba(255, 127, 127, 0.16);
color: #ffd9d9;
}
.icon-btn::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 7px);
transform: translate(-50%, 2px);
background: #081018;
border: 1px solid var(--line);
color: #dce6f5;
font-size: 0.72rem;
white-space: nowrap;
border-radius: 7px;
padding: 0.24rem 0.4rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease;
z-index: 3;
}
.icon-btn:hover::after,
.icon-btn:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
margin-top: 0.72rem;
color: var(--muted);
font-size: 0.86rem;
flex-wrap: wrap;
}
.status {
margin: 0.6rem 0 0;
min-height: 1.1rem;
color: var(--muted);
font-size: 0.87rem;
}
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
.stack {
display: grid;
gap: 0.9rem;
}
.triple {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.config-layout {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
.config-panel {
min-width: 0;
}
.block {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.02);
}
.block h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.block .table-wrap table {
min-width: 640px;
}
.json {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem;
background: #0e151d;
color: #ccdef4;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.83rem;
overflow: auto;
max-height: 380px;
white-space: pre-wrap;
word-break: break-word;
}
.overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 40;
background: rgba(6, 10, 14, 0.78);
backdrop-filter: blur(4px);
}
.overlay.open { display: flex; }
.modal {
width: min(680px, 100%);
max-height: 90vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(21, 31, 42, 0.95), rgba(14, 21, 28, 0.98));
padding: 0.9rem;
box-shadow: var(--shadow);
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
margin-bottom: 0.65rem;
}
.modal-head h3 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.2rem;
}
.close {
border: 1px solid var(--line);
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: #d7e4f5;
cursor: pointer;
font-size: 1.05rem;
}
.login-screen {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(7, 11, 15, 0.86);
backdrop-filter: blur(5px);
display: grid;
place-items: center;
padding: 1rem;
}
.login-card {
width: min(420px, 100%);
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(24, 36, 48, 0.95), rgba(15, 24, 32, 0.98));
box-shadow: var(--shadow);
padding: 1rem;
}
.login-card h2 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.4rem;
}
@media (max-width: 1160px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.triple { grid-template-columns: 1fr; }
.config-layout { grid-template-columns: 1fr; }
}
@media (max-width: 920px) {
.layout { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
}
}
@media (max-width: 620px) {
.cards,
.filters {
grid-template-columns: 1fr;
}
.filter-toolbar {
flex-direction: column;
align-items: stretch;
}
.filter-action {
margin-left: 0;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
}

View file

@ -4,734 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title> <title>Административная панель • Правовой трекер</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/admin.css" integrity="sha384-ob5ClyWT89HFMlY1xFaLvCa0+FaL5KHhc//V2owTg+iFay2Lx0Y2U7fuGnRozMzD" crossorigin="anonymous">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Prata&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b1015;
--bg-soft: #111922;
--surface: #15202b;
--surface-2: #1d2a37;
--line: rgba(196, 210, 228, 0.2);
--text: #f3f7fc;
--muted: #9db0c5;
--brand: #d4a86a;
--brand-soft: rgba(212, 168, 106, 0.14);
--ok: #4dbe93;
--danger: #ff7f7f;
--radius: 16px;
--shadow: 0 24px 58px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 12% 2%, #1a2532, var(--bg) 50%), var(--bg);
color: var(--text);
font-family: "Manrope", sans-serif;
}
body.modal-open { overflow: hidden; }
.layout {
display: grid;
grid-template-columns: 272px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(19, 29, 39, 0.94), rgba(13, 20, 27, 0.94));
padding: 1rem;
position: sticky;
top: 0;
height: 100vh;
}
.logo {
font-weight: 800;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #ebf2fb;
margin-bottom: 1.2rem;
}
.logo a {
color: inherit;
text-decoration: none;
}
.menu {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.menu button {
width: 100%;
text-align: left;
border: 1px solid transparent;
background: transparent;
color: #d4deec;
padding: 0.72rem 0.78rem;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 0.92rem;
}
.menu button:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.03);
}
.menu button.active {
border-color: rgba(212, 168, 106, 0.45);
background: var(--brand-soft);
color: #fde5c2;
}
.menu-tree {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding-left: 0.6rem;
border-left: 1px dashed rgba(212, 168, 106, 0.3);
margin: 0.2rem 0 0.1rem 0.2rem;
}
.menu-tree button {
font-size: 0.85rem;
padding: 0.52rem 0.62rem;
color: #c8d8ea;
}
.auth-box {
margin-top: 1.2rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 0.75rem;
font-size: 0.86rem;
color: var(--muted);
line-height: 1.45;
}
.auth-box b {
color: #f4f7fc;
font-weight: 700;
}
.main {
padding: 1.2rem;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(130deg, rgba(22, 33, 45, 0.94), rgba(15, 23, 31, 0.97));
padding: 0.85rem 1rem;
box-shadow: var(--shadow);
}
.topbar h1 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.5rem;
letter-spacing: 0.01em;
}
.badge {
border: 1px solid rgba(212, 168, 106, 0.35);
border-radius: 999px;
background: var(--brand-soft);
color: #fedfb1;
font-size: 0.76rem;
font-weight: 700;
padding: 0.32rem 0.55rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.btn {
border: 1px solid transparent;
border-radius: 999px;
background: linear-gradient(120deg, #d9b57f, #c59048);
color: #1b2430;
padding: 0.64rem 1rem;
font-weight: 700;
font-size: 0.88rem;
cursor: pointer;
}
.btn:hover { filter: brightness(1.05); }
.btn.secondary {
border-color: var(--line);
background: rgba(255, 255, 255, 0.04);
color: #dbe6f5;
}
.btn.danger {
border-color: rgba(255, 127, 127, 0.3);
background: rgba(255, 127, 127, 0.13);
color: #ffd3d3;
}
.section {
display: none;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(160deg, rgba(20, 30, 40, 0.93), rgba(14, 22, 30, 0.96));
box-shadow: var(--shadow);
padding: 1rem;
}
.section.active { display: block; }
.section h2 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.35rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.9rem;
flex-wrap: wrap;
}
.muted {
color: var(--muted);
margin: 0.45rem 0 0;
line-height: 1.55;
font-size: 0.94rem;
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.card {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
padding: 0.8rem;
}
.card p {
margin: 0;
color: var(--muted);
font-size: 0.83rem;
}
.card b {
display: block;
margin-top: 0.3rem;
font-size: 1.2rem;
color: #f6dab0;
}
.filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 0.75rem;
}
.filter-toolbar {
display: flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.filter-chips {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
flex: 1 1 auto;
min-height: 34px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.5rem;
border: 1px solid rgba(212, 168, 106, 0.3);
border-radius: 999px;
background: var(--brand-soft);
color: #f9dfb5;
font-size: 0.76rem;
line-height: 1.2;
cursor: pointer;
}
.filter-chip button {
border: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #fce3bd;
cursor: pointer;
font-size: 0.82rem;
line-height: 1;
padding: 0;
}
.chip-placeholder {
color: var(--muted);
font-size: 0.84rem;
}
.filter-action {
margin-left: auto;
flex-shrink: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field label {
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9fb3c8;
font-size: 0.73rem;
font-weight: 700;
}
.field-inline {
display: flex;
align-items: center;
gap: 0.45rem;
}
.btn-sm {
min-height: 38px;
padding: 0.5rem 0.68rem;
font-size: 0.82rem;
}
input, textarea, select {
width: 100%;
border: 1px solid #3c4d62;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
font: inherit;
padding: 0.58rem 0.68rem;
min-height: 38px;
}
textarea {
min-height: 108px;
resize: vertical;
}
.table-wrap {
border: 1px solid var(--line);
border-radius: 12px;
overflow: auto;
background: rgba(255, 255, 255, 0.015);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 840px;
}
th, td {
padding: 0.63rem 0.65rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
font-size: 0.89rem;
}
th {
text-align: left;
color: #9fb3c8;
font-size: 0.77rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sortable-th {
cursor: pointer;
user-select: none;
}
.sortable-head {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.sort-indicator {
color: #6d7f96;
font-weight: 700;
font-size: 0.8rem;
line-height: 1;
}
.sort-indicator.active {
color: #f1d3a3;
}
td code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #f7dfb8;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #f5f8ff;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.24);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.user-identity {
display: inline-flex;
align-items: center;
gap: 0.52rem;
min-width: 0;
}
.user-identity-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-identity-text b {
font-size: 0.88rem;
font-weight: 700;
color: #eaf2fd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 230px;
}
.table-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.request-updates-stack {
display: inline-flex;
flex-direction: column;
gap: 0.24rem;
align-items: flex-start;
}
.request-update-chip {
display: inline-flex;
align-items: center;
gap: 0.32rem;
border: 1px solid rgba(95, 182, 145, 0.34);
border-radius: 999px;
background: rgba(77, 190, 147, 0.14);
color: #bef5df;
font-size: 0.74rem;
line-height: 1.1;
padding: 0.18rem 0.45rem;
white-space: nowrap;
}
.request-update-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3ed692;
box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18);
flex-shrink: 0;
}
.request-update-empty {
color: #8ea1b8;
font-size: 0.8rem;
}
.icon-btn {
border: 1px solid var(--line);
border-radius: 9px;
background: rgba(255, 255, 255, 0.03);
color: #d8e4f4;
cursor: pointer;
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
font-size: 0.95rem;
line-height: 1;
position: relative;
}
.icon-btn:hover {
border-color: rgba(212, 168, 106, 0.42);
background: rgba(212, 168, 106, 0.16);
color: #fce0b6;
}
.icon-btn.danger:hover {
border-color: rgba(255, 127, 127, 0.45);
background: rgba(255, 127, 127, 0.16);
color: #ffd9d9;
}
.icon-btn::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 7px);
transform: translate(-50%, 2px);
background: #081018;
border: 1px solid var(--line);
color: #dce6f5;
font-size: 0.72rem;
white-space: nowrap;
border-radius: 7px;
padding: 0.24rem 0.4rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease;
z-index: 3;
}
.icon-btn:hover::after,
.icon-btn:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
margin-top: 0.72rem;
color: var(--muted);
font-size: 0.86rem;
flex-wrap: wrap;
}
.status {
margin: 0.6rem 0 0;
min-height: 1.1rem;
color: var(--muted);
font-size: 0.87rem;
}
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
.stack {
display: grid;
gap: 0.9rem;
}
.triple {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.config-layout {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
.config-panel {
min-width: 0;
}
.block {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.02);
}
.block h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.block .table-wrap table {
min-width: 640px;
}
.json {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem;
background: #0e151d;
color: #ccdef4;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.83rem;
overflow: auto;
max-height: 380px;
white-space: pre-wrap;
word-break: break-word;
}
.overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 40;
background: rgba(6, 10, 14, 0.78);
backdrop-filter: blur(4px);
}
.overlay.open { display: flex; }
.modal {
width: min(680px, 100%);
max-height: 90vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(21, 31, 42, 0.95), rgba(14, 21, 28, 0.98));
padding: 0.9rem;
box-shadow: var(--shadow);
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
margin-bottom: 0.65rem;
}
.modal-head h3 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.2rem;
}
.close {
border: 1px solid var(--line);
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: #d7e4f5;
cursor: pointer;
font-size: 1.05rem;
}
.login-screen {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(7, 11, 15, 0.86);
backdrop-filter: blur(5px);
display: grid;
place-items: center;
padding: 1rem;
}
.login-card {
width: min(420px, 100%);
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(24, 36, 48, 0.95), rgba(15, 24, 32, 0.98));
box-shadow: var(--shadow);
padding: 1rem;
}
.login-card h2 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.4rem;
}
@media (max-width: 1160px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.triple { grid-template-columns: 1fr; }
.config-layout { grid-template-columns: 1fr; }
}
@media (max-width: 920px) {
.layout { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
}
}
@media (max-width: 620px) {
.cards,
.filters {
grid-template-columns: 1fr;
}
.filter-toolbar {
flex-direction: column;
align-items: stretch;
}
.filter-action {
margin-left: 0;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head> </head>
<body> <body>
<div id="admin-root"></div> <div id="admin-root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <script crossorigin="anonymous" integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z" src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> <script crossorigin="anonymous" integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1" src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <script crossorigin="anonymous" integrity="sha384-Fo0OdKhdnE7y2WmzjOMW4PYjHkkANeu1501pWTqKrzAPeJMFQb4ZTdAA9dtrVUJV" src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel" data-presets="env,react" src="/admin.jsx"></script> <script type="text/babel" data-presets="env,react" src="/admin.jsx"></script>
</body> </body>
</html> </html>

757
app/web/landing.css Normal file
View file

@ -0,0 +1,757 @@
:root {
--bg: #0d1217;
--bg-soft: #121a22;
--surface: #171f29;
--surface-2: #1f2a37;
--text: #f4f7fb;
--muted: #a8b2c2;
--accent: #d4a968;
--accent-soft: rgba(212, 169, 104, 0.15);
--line: rgba(207, 217, 231, 0.18);
--ok: #49b68e;
--danger: #ff7b7b;
--radius: 18px;
--shadow: 0 30px 70px rgba(0, 0, 0, 0.32);
--maxw: 1180px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 12% 0%, #1a2430 0, var(--bg) 48%), var(--bg);
color: var(--text);
font-family: "Manrope", sans-serif;
scroll-behavior: smooth;
overflow-x: hidden;
}
body.modal-open { overflow: hidden; }
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(600px 320px at 90% 8%, rgba(212, 169, 104, 0.1), transparent 70%),
radial-gradient(600px 360px at 10% 76%, rgba(94, 147, 227, 0.1), transparent 72%);
z-index: -1;
}
.wrap {
width: min(var(--maxw), calc(100% - 1.5rem));
margin: 0 auto;
}
section { scroll-margin-top: 84px; }
.topbar {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(10px);
background: rgba(13, 18, 23, 0.78);
border-bottom: 1px solid var(--line);
}
.topbar-inner {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
font-size: 0.84rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
max-width: 390px;
color: #eef4ff;
}
.nav {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
color: #d6deea;
font-size: 0.93rem;
font-weight: 600;
opacity: 0.92;
}
.btn {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.82rem 1.25rem;
font-family: inherit;
font-size: 0.93rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary {
background: linear-gradient(120deg, #d8b27b, #c6914a);
color: #17212d;
box-shadow: 0 16px 28px rgba(198, 145, 74, 0.3);
}
.btn-ghost {
border-color: var(--line);
color: #dde6f2;
background: rgba(255, 255, 255, 0.04);
}
.hero {
padding: 5.2rem 0 3rem;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1.1rem;
align-items: stretch;
}
.hero h1 {
margin: 0;
font-family: "Prata", serif;
font-size: clamp(2.05rem, 5.6vw, 4.2rem);
line-height: 1.08;
max-width: 13ch;
letter-spacing: 0.01em;
}
.hero p {
margin: 1.1rem 0 0;
color: var(--muted);
line-height: 1.66;
font-size: 1.05rem;
max-width: 66ch;
}
.hero-actions {
margin-top: 1.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(160deg, rgba(35, 48, 63, 0.92), rgba(21, 29, 39, 0.95));
box-shadow: var(--shadow);
padding: 1.3rem;
position: relative;
overflow: hidden;
}
.panel::before {
content: "";
position: absolute;
right: -40px;
top: -40px;
width: 140px;
height: 140px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212, 169, 104, 0.2), transparent 70%);
}
.panel small {
display: block;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
color: #90a2b7;
font-weight: 800;
margin-bottom: 0.6rem;
}
.panel strong {
display: block;
font-size: 1.06rem;
line-height: 1.45;
margin-bottom: 0.9rem;
}
.stats {
margin-top: 0.95rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
.stat {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.7rem;
}
.stat b {
display: block;
font-size: 1.15rem;
color: #f7dbb1;
margin-bottom: 0.15rem;
}
.stat span {
font-size: 0.78rem;
color: #a7b3c5;
line-height: 1.35;
}
section { padding: 1.3rem 0 2.2rem; }
.section-head {
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
}
h2 {
margin: 0;
font-family: "Prata", serif;
font-size: clamp(1.65rem, 4vw, 2.7rem);
letter-spacing: 0.01em;
}
.subtitle {
margin: 0.65rem 0 0;
color: var(--muted);
line-height: 1.6;
max-width: 70ch;
}
.grid { display: grid; gap: 0.9rem; }
.practices { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(25, 34, 45, 0.88), rgba(19, 26, 34, 0.95));
padding: 1.05rem;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(12px);
animation: rise 0.6s ease forwards;
}
.card:nth-child(2) { animation-delay: 0.06s; }
.card:nth-child(3) { animation-delay: 0.12s; }
.card:nth-child(4) { animation-delay: 0.18s; }
.card:nth-child(5) { animation-delay: 0.24s; }
.card:nth-child(6) { animation-delay: 0.3s; }
.card h3 {
margin: 0 0 0.52rem;
font-size: 1.03rem;
color: #f1f5fb;
}
.card p {
margin: 0;
color: #aab5c4;
line-height: 1.57;
font-size: 0.95rem;
}
.approach {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 0.9rem;
}
.timeline {
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
background: linear-gradient(160deg, rgba(22, 30, 40, 0.88), rgba(16, 23, 30, 0.95));
}
.timeline .step {
position: relative;
padding-left: 2.2rem;
margin-bottom: 1rem;
}
.timeline .step:last-child { margin-bottom: 0; }
.timeline .step::before {
content: attr(data-step);
position: absolute;
left: 0;
top: 0;
width: 1.55rem;
height: 1.55rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.74rem;
font-weight: 800;
color: #1b2634;
background: linear-gradient(130deg, #e3c08f, #c5914b);
}
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
.quote {
border: 1px solid #4b5b71;
border-radius: 16px;
background: linear-gradient(160deg, #1e2b3c, #1a2432);
padding: 1rem;
}
.quote p {
margin: 0;
min-height: 5.3rem;
line-height: 1.6;
color: #dbe6f5;
}
.quote-meta {
margin-top: 0.7rem;
color: #98adc7;
font-size: 0.86rem;
}
.expert {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(26, 36, 48, 0.92), rgba(20, 27, 36, 0.95));
padding: 1.1rem;
}
.expert strong {
display: block;
margin-bottom: 0.5rem;
font-size: 1.08rem;
}
.expert p {
margin: 0;
color: var(--muted);
line-height: 1.62;
}
.tags {
margin-top: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.38rem 0.62rem;
border-radius: 999px;
border: 1px solid rgba(212, 169, 104, 0.3);
background: var(--accent-soft);
color: #f6d7a8;
font-size: 0.8rem;
font-weight: 700;
}
.cta-band {
margin: 1.4rem 0 2.4rem;
border: 1px solid rgba(212, 169, 104, 0.35);
border-radius: 18px;
background: linear-gradient(120deg, rgba(212, 169, 104, 0.14), rgba(66, 99, 145, 0.18));
padding: 1.15rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.cta-band p {
margin: 0;
color: #d8e2f2;
line-height: 1.52;
max-width: 60ch;
}
footer {
border-top: 1px solid var(--line);
margin-top: 1.1rem;
padding: 1.8rem 0;
color: #94a6bc;
text-align: center;
font-size: 0.9rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(7, 10, 14, 0.72);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 40;
}
.modal-backdrop.open { display: flex; }
.modal {
width: min(620px, 100%);
max-height: 92vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(160deg, #18222e, #121a23);
box-shadow: var(--shadow);
padding: 1.15rem;
}
.modal-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.9rem;
}
.modal-head h3 {
margin: 0;
font-size: 1.28rem;
font-family: "Prata", serif;
line-height: 1.2;
}
.modal-head p {
margin: 0.5rem 0 0;
color: var(--muted);
line-height: 1.54;
font-size: 0.93rem;
}
.close {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: #dce5f2;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 1.1rem;
cursor: pointer;
}
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.34rem;
}
.field.full { grid-column: 1 / -1; }
label {
font-size: 0.76rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9fb0c6;
font-weight: 700;
}
input, textarea {
width: 100%;
border-radius: 12px;
border: 1px solid #3b4b5f;
background: rgba(255, 255, 255, 0.03);
color: #ecf2fb;
font: inherit;
font-size: 16px;
padding: 0.72rem 0.8rem;
}
textarea {
min-height: 108px;
resize: vertical;
}
.form-foot {
margin-top: 0.9rem;
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
}
.status {
margin: 0;
color: #9bafc8;
font-size: 0.9rem;
min-height: 1.2rem;
}
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
.cabinet-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
.cabinet-card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
padding: 1rem;
}
.cabinet-card h3 {
margin: 0 0 0.65rem;
font-size: 1.03rem;
}
.cabinet-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.7rem;
}
.meta-row {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.meta-row small {
display: block;
color: #9fb0c6;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.2rem;
}
.meta-row b {
display: block;
color: #eaf2ff;
font-size: 0.9rem;
font-weight: 700;
line-height: 1.4;
}
.simple-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
max-height: 280px;
overflow: auto;
}
.simple-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.simple-item p {
margin: 0.24rem 0 0;
color: #d8e3f3;
line-height: 1.5;
font-size: 0.92rem;
overflow-wrap: anywhere;
}
.simple-item time {
color: #9eb1ca;
font-size: 0.78rem;
}
.chat-form {
margin-top: 0.7rem;
display: grid;
gap: 0.55rem;
}
.chat-form textarea {
min-height: 84px;
}
.file-row {
margin-top: 0.7rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.file-row input[type="file"] {
max-width: 100%;
}
.brand,
.meta-row b {
overflow-wrap: anywhere;
}
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 980px) {
.hero,
.approach,
.practices {
grid-template-columns: 1fr;
}
}
@media (max-width: 740px) {
.topbar-inner {
flex-direction: column;
align-items: flex-start;
padding: 0.72rem 0;
}
.nav {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.nav a,
.nav .btn {
width: 100%;
text-align: center;
}
.hero {
padding-top: 2.7rem;
}
.stats {
grid-template-columns: 1fr;
}
.form {
grid-template-columns: 1fr;
}
.cabinet-layout {
grid-template-columns: 1fr;
}
.cabinet-meta {
grid-template-columns: 1fr;
}
.hero-actions .btn {
width: 100%;
}
.simple-list {
max-height: 220px;
}
}
@media (max-width: 520px) {
.wrap {
width: calc(100% - 1rem);
}
.topbar {
position: static;
}
section {
scroll-margin-top: 0;
}
.brand {
font-size: 0.78rem;
max-width: none;
}
.nav {
grid-template-columns: 1fr;
}
.hero {
padding-top: 1.4rem;
}
.panel,
.card,
.expert,
.cabinet-card {
padding: 0.85rem;
}
.file-row {
flex-direction: column;
align-items: stretch;
}
.file-row .btn {
width: 100%;
}
.modal-backdrop {
padding: 0;
}
.modal {
width: 100%;
max-height: 100vh;
min-height: 100vh;
border-radius: 0;
border: none;
padding: 0.95rem;
}
.modal-head {
position: sticky;
top: 0;
z-index: 2;
background: linear-gradient(160deg, #18222e, #121a23);
padding-bottom: 0.5rem;
margin-bottom: 0.7rem;
}
.close {
width: 38px;
height: 38px;
}
.form-foot .btn {
width: 100%;
}
}

File diff suppressed because it is too large Load diff

503
app/web/landing.js Normal file
View file

@ -0,0 +1,503 @@
(function () {
const modal = document.getElementById("request-modal");
const openButtons = document.querySelectorAll("[data-open-modal]");
const closeButtons = document.querySelectorAll("[data-close-modal]");
const form = document.getElementById("request-form");
const status = document.getElementById("form-status");
const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta");
const cabinetTrackInput = document.getElementById("cabinet-track");
const cabinetOpenButton = document.getElementById("cabinet-open");
const cabinetStatus = document.getElementById("cabinet-status");
const cabinetSummary = document.getElementById("cabinet-summary");
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
const cabinetMessages = document.getElementById("cabinet-messages");
const cabinetFiles = document.getElementById("cabinet-files");
const cabinetInvoices = document.getElementById("cabinet-invoices");
const cabinetTimeline = document.getElementById("cabinet-timeline");
const cabinetChatForm = document.getElementById("cabinet-chat-form");
const cabinetChatBody = document.getElementById("cabinet-chat-body");
const cabinetChatSend = document.getElementById("cabinet-chat-send");
const cabinetFileInput = document.getElementById("cabinet-file-input");
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
let activeTrack = "";
let activeRequestId = "";
function openModal() {
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
}
function closeModal() {
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
}
openButtons.forEach((button) => button.addEventListener("click", openModal));
closeButtons.forEach((button) => button.addEventListener("click", closeModal));
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("open")) closeModal();
});
function formatDate(value) {
if (!value) return "-";
try {
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return value;
return dt.toLocaleString("ru-RU");
} catch (_) {
return value;
}
}
function setStatus(el, message, kind) {
el.className = "status";
if (kind === "ok") el.classList.add("ok");
if (kind === "error") el.classList.add("error");
el.textContent = message;
}
async function parseJsonSafe(response) {
try {
return await response.json();
} catch (_) {
return null;
}
}
function apiErrorDetail(data, fallbackMessage) {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
return fallbackMessage;
}
function setCabinetEnabled(enabled) {
cabinetChatBody.disabled = !enabled;
cabinetChatSend.disabled = !enabled;
cabinetFileInput.disabled = !enabled;
cabinetFileUpload.disabled = !enabled;
}
function clearList(node, emptyMessage) {
node.innerHTML = "";
const li = document.createElement("li");
li.className = "simple-item";
const p = document.createElement("p");
p.textContent = emptyMessage;
li.appendChild(p);
node.appendChild(li);
}
function renderMessages(items) {
cabinetMessages.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetMessages, "Сообщений пока нет.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const author = item.author_name || item.author_type || "Участник";
p.textContent = author + ": " + (item.body || "");
li.appendChild(p);
cabinetMessages.appendChild(li);
});
}
function renderFiles(items) {
cabinetFiles.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetFiles, "Файлы пока не загружены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetFiles.appendChild(li);
});
}
function renderInvoices(items) {
cabinetInvoices.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetInvoices, "Счета пока не выставлены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = "Сформирован: " + formatDate(item.issued_at);
li.appendChild(time);
const p = document.createElement("p");
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
p.textContent =
(item.invoice_number || "Счет") +
" • " +
(item.status_label || item.status || "-") +
" • " +
amount +
" " +
(item.currency || "RUB");
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать PDF";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetInvoices.appendChild(li);
});
}
function renderTimeline(items) {
cabinetTimeline.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetTimeline, "История пока пуста.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
if (item.type === "status_change") {
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
} else if (item.type === "message") {
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
} else if (item.type === "attachment") {
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
} else {
p.textContent = "Событие";
}
li.appendChild(p);
cabinetTimeline.appendChild(li);
});
}
async function loadQuotes() {
try {
const response = await fetch("/api/public/quotes?limit=8&order=random");
if (!response.ok) throw new Error("quotes fetch failed");
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) throw new Error("quotes empty");
let index = 0;
const render = () => {
const quote = items[index % items.length];
quoteText.textContent = quote.text;
quoteMeta.textContent = [quote.author, quote.source].filter(Boolean).join(" • ");
index += 1;
};
render();
if (items.length > 1) setInterval(render, 5500);
} catch (error) {
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
quoteMeta.textContent = "Команда компании";
}
}
async function fetchRequestByTrack(trackNumber) {
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
const data = await parseJsonSafe(response);
return { response, data };
}
async function ensureViewAccess(trackNumber) {
let { response, data } = await fetchRequestByTrack(trackNumber);
if (response.ok) return data;
if (response.status !== 401 && response.status !== 403) {
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
}
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
const sendResponse = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber
})
});
const sendData = await parseJsonSafe(sendResponse);
if (!sendResponse.ok) {
throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP"));
}
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) {
throw new Error("Код OTP не введен");
}
setStatus(cabinetStatus, "Проверяем OTP...", null);
const verifyResponse = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber,
code: String(code).trim()
})
});
const verifyData = await parseJsonSafe(verifyResponse);
if (!verifyResponse.ok) {
throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден"));
}
({ response, data } = await fetchRequestByTrack(trackNumber));
if (!response.ok) {
throw new Error(apiErrorDetail(data, "Нет доступа к заявке"));
}
return data;
}
async function refreshCabinetData() {
if (!activeTrack) return;
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
]);
const messagesData = await parseJsonSafe(messagesRes);
const filesData = await parseJsonSafe(filesRes);
const invoicesData = await parseJsonSafe(invoicesRes);
const timelineData = await parseJsonSafe(timelineRes);
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
renderMessages(messagesData);
renderFiles(filesData);
renderInvoices(invoicesData);
renderTimeline(timelineData);
}
async function openCabinetByTrack() {
const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase();
if (!trackNumber) {
setStatus(cabinetStatus, "Введите номер заявки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Открываем кабинет...", null);
const requestData = await ensureViewAccess(trackNumber);
activeTrack = trackNumber;
activeRequestId = requestData.id;
cabinetRequestStatus.textContent = requestData.status_code || "-";
cabinetRequestTopic.textContent = requestData.topic_code || "Не указана";
cabinetRequestCreated.textContent = formatDate(requestData.created_at);
cabinetRequestUpdated.textContent = formatDate(requestData.updated_at);
cabinetSummary.hidden = false;
setCabinetEnabled(true);
await refreshCabinetData();
setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error");
}
}
cabinetOpenButton.addEventListener("click", () => {
openCabinetByTrack();
});
cabinetChatForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeTrack) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const body = String(cabinetChatBody.value || "").trim();
if (!body) return;
try {
setStatus(cabinetStatus, "Отправляем сообщение...", null);
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
cabinetChatBody.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
}
});
cabinetFileUpload.addEventListener("click", async () => {
if (!activeTrack || !activeRequestId) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const file = cabinetFileInput.files && cabinetFileInput.files[0];
if (!file) {
setStatus(cabinetStatus, "Выберите файл для загрузки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null);
const initResponse = await fetch("/api/public/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
cabinetFileInput.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Файл загружен.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error");
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
setStatus(status, "Отправляем заявку...", null);
const payload = {
client_name: document.getElementById("name").value.trim(),
client_phone: document.getElementById("phone").value.trim(),
topic_code: "consulting",
description: document.getElementById("description").value.trim(),
extra_fields: {
referral_name: document.getElementById("referral").value.trim()
}
};
try {
setStatus(status, "Отправляем OTP-код...", null);
const otpSend = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone
})
});
if (!otpSend.ok) throw new Error("otp send failed");
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) throw new Error("otp code required");
setStatus(status, "Проверяем OTP...", null);
const otpVerify = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone,
code: String(code).trim()
})
});
if (!otpVerify.ok) throw new Error("otp verify failed");
setStatus(status, "Создаем заявку...", null);
const response = await fetch("/api/public/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("create request failed");
const data = await response.json();
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
cabinetTrackInput.value = data.track_number;
form.reset();
setTimeout(closeModal, 1200);
} catch (error) {
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
}
});
loadQuotes();
setCabinetEnabled(false);
clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetInvoices, "Счета пока не выставлены.");
clearList(cabinetTimeline, "История пока пуста.");
})();

Binary file not shown.

View file

@ -54,6 +54,32 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` | | P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон | | P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
### PUBLIC (клиент)
- Лендинг и клиентский контур (ручной e2e через `http://localhost:8081`): открыть лендинг, создать заявку, открыть кабинет.
- OTP create/view + 7-day cookie + rate-limit: `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py`.
- Просмотр статуса/истории/чата/файлов/таймлайна по `track_number`: `tests/test_public_cabinet.py`.
- Переписка клиент -> юрист и маркеры непрочитанного: `tests/test_public_cabinet.py`, `tests/test_notifications.py`.
- Загрузка и скачивание файлов + лимиты 25MB/250MB + контроль доступа: `tests/test_uploads_s3.py`, `tests/test_public_cabinet.py`.
- Публичные счета и PDF в кабинете: `tests/test_invoices.py`.
### LAWYER (юрист)
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
- Смена статуса и завершение только своих заявок: `tests/test_admin_universal_crud.py`.
- Оповещения (алерты): список/прочтение и генерация по событиям: `tests/test_notifications.py`.
- Сообщения/файлы по заявке и непрочитанные маркеры: `tests/test_notifications.py`, `tests/test_uploads_s3.py`, `tests/test_admin_universal_crud.py`.
- Счета: видимость только своих, запрет ставить `PAID`: `tests/test_invoices.py`, `tests/test_billing_flow.py`.
### ADMIN (администратор)
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`.
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`.
- Счета и оплаты (создание, статусы, подтверждение оплаты, multiple cycles): `tests/test_invoices.py`, `tests/test_billing_flow.py`.
- Дашборд портала и загрузка юристов + финансовые метрики: `tests/test_dashboard_finance.py`, `tests/test_admin_universal_crud.py`.
- Безопасность: security-audit по S3 доступам, RBAC и шифрование реквизитов: `tests/test_security_audit.py`, `tests/test_uploads_s3.py`, `tests/test_invoices.py`.
## Минимальный чеклист закрытия пункта ## Минимальный чеклист закрытия пункта
1. Выполнить миграции (если были изменения схемы). 1. Выполнить миграции (если были изменения схемы).
2. Выполнить целевые тесты пункта по матрице выше. 2. Выполнить целевые тесты пункта по матрице выше.

14
e2e/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "law-e2e",
"private": true,
"version": "1.0.0",
"description": "Playwright E2E tests for Law project",
"scripts": {
"test": "playwright test --config=playwright.config.js"
},
"devDependencies": {
"@playwright/test": "^1.51.0",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2"
}
}

View file

@ -1,20 +1,33 @@
server { server {
listen 80; listen 80;
server_name _; server_name _;
server_tokens off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location = /admin { location = /admin {
expires 10m;
return 302 /admin.html; return 302 /admin.html;
} }
location ~* \.jsx$ { location ~* \.jsx$ {
expires 10m;
default_type application/javascript; default_type application/javascript;
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
expires 10m;
try_files $uri /index.html; try_files $uri /index.html;
} }

View file

@ -1,14 +1,15 @@
fastapi==0.115.6 fastapi==0.121.0
starlette==0.49.1
uvicorn[standard]==0.32.1 uvicorn[standard]==0.32.1
pydantic==2.10.3 pydantic==2.10.3
pydantic-settings==2.6.1 pydantic-settings==2.6.1
SQLAlchemy==2.0.36 SQLAlchemy==2.0.36
alembic==1.14.0 alembic==1.14.0
psycopg[binary]==3.2.3 psycopg[binary]==3.2.3
python-jose==3.3.0 PyJWT==2.10.1
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
redis==5.2.0 redis==5.2.0
celery==5.4.0 celery==5.4.0
boto3==1.35.70 boto3==1.35.70
httpx==0.27.2 httpx==0.27.2
python-multipart==0.0.12 python-multipart==0.0.22

View file

@ -243,6 +243,157 @@ class AdminUniversalCrudTests(unittest.TestCase):
) )
self.assertEqual(status_forbidden.status_code, 403) self.assertEqual(status_forbidden.status_code, 403)
def test_lawyer_can_see_own_and_unassigned_requests_and_close_only_own(self):
with self.SessionLocal() as db:
lawyer_self = AdminUser(
role="LAWYER",
name="Юрист Свой",
email="lawyer.self@example.com",
password_hash="hash",
is_active=True,
)
lawyer_other = AdminUser(
role="LAWYER",
name="Юрист Чужой",
email="lawyer.other@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_self, lawyer_other])
db.flush()
self_id = str(lawyer_self.id)
other_id = str(lawyer_other.id)
own = Request(
track_number="TRK-LAWYER-OWN",
client_name="Клиент Свой",
client_phone="+79990001011",
status_code="NEW",
description="own",
extra_fields={},
assigned_lawyer_id=self_id,
)
foreign = Request(
track_number="TRK-LAWYER-FOREIGN",
client_name="Клиент Чужой",
client_phone="+79990001012",
status_code="NEW",
description="foreign",
extra_fields={},
assigned_lawyer_id=other_id,
)
unassigned = Request(
track_number="TRK-LAWYER-UNASSIGNED",
client_name="Клиент Без назначения",
client_phone="+79990001013",
status_code="NEW",
description="unassigned",
extra_fields={},
assigned_lawyer_id=None,
)
db.add_all([own, foreign, unassigned])
db.commit()
own_id = str(own.id)
foreign_id = str(foreign.id)
unassigned_id = str(unassigned.id)
headers = self._auth_headers("LAWYER", email="lawyer.self@example.com", sub=self_id)
crud_query = self.client.post(
"/api/admin/crud/requests/query",
headers=headers,
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
)
self.assertEqual(crud_query.status_code, 200)
crud_ids = {str(row["id"]) for row in (crud_query.json().get("rows") or [])}
self.assertEqual(crud_ids, {own_id, unassigned_id})
legacy_query = self.client.post(
"/api/admin/requests/query",
headers=headers,
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
)
self.assertEqual(legacy_query.status_code, 200)
legacy_ids = {str(row["id"]) for row in (legacy_query.json().get("rows") or [])}
self.assertEqual(legacy_ids, {own_id, unassigned_id})
crud_get_foreign = self.client.get(f"/api/admin/crud/requests/{foreign_id}", headers=headers)
self.assertEqual(crud_get_foreign.status_code, 403)
legacy_get_foreign = self.client.get(f"/api/admin/requests/{foreign_id}", headers=headers)
self.assertEqual(legacy_get_foreign.status_code, 403)
crud_update_unassigned = self.client.patch(
f"/api/admin/crud/requests/{unassigned_id}",
headers=headers,
json={"status_code": "CLOSED"},
)
self.assertEqual(crud_update_unassigned.status_code, 403)
legacy_update_unassigned = self.client.patch(
f"/api/admin/requests/{unassigned_id}",
headers=headers,
json={"status_code": "CLOSED"},
)
self.assertEqual(legacy_update_unassigned.status_code, 403)
close_own = self.client.patch(
f"/api/admin/requests/{own_id}",
headers=headers,
json={"status_code": "CLOSED"},
)
self.assertEqual(close_own.status_code, 200)
with self.SessionLocal() as db:
refreshed = db.get(Request, UUID(own_id))
self.assertIsNotNone(refreshed)
self.assertEqual(refreshed.status_code, "CLOSED")
def test_topic_status_flow_supports_branching_transitions(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db:
db.add_all(
[
Topic(code="civil-branch", name="Гражданское (ветвление)", enabled=True, sort_order=1),
TopicStatusTransition(topic_code="civil-branch", from_status="NEW", to_status="IN_PROGRESS", enabled=True, sort_order=1),
TopicStatusTransition(topic_code="civil-branch", from_status="NEW", to_status="WAITING_CLIENT", enabled=True, sort_order=2),
]
)
req_in_progress = Request(
track_number="TRK-BRANCH-1",
client_name="Клиент 1",
client_phone="+79991110021",
topic_code="civil-branch",
status_code="NEW",
description="branch 1",
extra_fields={},
)
req_waiting = Request(
track_number="TRK-BRANCH-2",
client_name="Клиент 2",
client_phone="+79991110022",
topic_code="civil-branch",
status_code="NEW",
description="branch 2",
extra_fields={},
)
db.add_all([req_in_progress, req_waiting])
db.commit()
req_in_progress_id = str(req_in_progress.id)
req_waiting_id = str(req_waiting.id)
first_branch = self.client.patch(
f"/api/admin/crud/requests/{req_in_progress_id}",
headers=headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(first_branch.status_code, 200)
second_branch = self.client.patch(
f"/api/admin/crud/requests/{req_waiting_id}",
headers=headers,
json={"status_code": "WAITING_CLIENT"},
)
self.assertEqual(second_branch.status_code, 200)
def test_request_read_markers_status_update_and_lawyer_open_reset(self): def test_request_read_markers_status_update_and_lawyer_open_reset(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
lawyer = AdminUser( lawyer = AdminUser(

View file

@ -19,7 +19,7 @@ os.environ.setdefault("S3_BUCKET", "test")
from app.main import app from app.main import app
from app.core.config import settings from app.core.config import settings
from app.core.security import create_jwt from app.core.security import create_jwt, decode_jwt
from app.db.session import get_db from app.db.session import get_db
from app.models.notification import Notification from app.models.notification import Notification
from app.models.otp_session import OtpSession from app.models.otp_session import OtpSession
@ -244,3 +244,33 @@ class PublicRequestCreateTests(unittest.TestCase):
) )
self.assertEqual(created.status_code, 201) self.assertEqual(created.status_code, 201)
self.assertTrue(created.json()["track_number"].startswith("TRK-")) self.assertTrue(created.json()["track_number"].startswith("TRK-"))
def test_verify_otp_sets_public_cookie_for_configured_ttl(self):
phone = "+79990001234"
with patch("app.api.public.otp._generate_code", return_value="777777"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "777777"},
)
self.assertEqual(verified.status_code, 200)
token = verified.cookies.get(settings.PUBLIC_COOKIE_NAME)
self.assertTrue(token)
payload = decode_jwt(token, settings.PUBLIC_JWT_SECRET)
self.assertEqual(payload.get("sub"), phone)
self.assertEqual(payload.get("purpose"), "CREATE_REQUEST")
self.assertEqual(
int(payload.get("exp") or 0) - int(payload.get("iat") or 0),
settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
)
cookie_header = str(verified.headers.get("set-cookie") or "")
self.assertIn(f"{settings.PUBLIC_COOKIE_NAME}=", cookie_header)
self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
self.assertIn("httponly", cookie_header.lower())