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/
*.idea
.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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session
@ -146,6 +147,31 @@ def _is_lawyer(admin: dict) -> bool:
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:
if isinstance(value, dict):
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": "Телефон",
"verified_at": "Подтверждено",
"expires_at": "Истекает",
"jwt_token": "JWT-токен",
"action": "Действие",
"entity": "Сущность",
"entity_id": "ID сущности",
@ -969,7 +994,16 @@ def query_table(
):
normalized, model = _resolve_table_model(table_name)
_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()
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}
@ -988,6 +1022,7 @@ def get_row(
if normalized == "requests":
req = row if isinstance(row, Request) else None
if req is not None:
_ensure_lawyer_can_view_request_or_403(admin, req)
changed = False
if _is_lawyer(admin) and clear_unread_for_lawyer(req):
changed = True
@ -1093,6 +1128,8 @@ def update_row(
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
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)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
prepared = dict(payload)
@ -1197,6 +1234,8 @@ def delete_row(
if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id):
raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись")
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)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления")

View file

@ -4,7 +4,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
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.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":
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 not actor or not assigned or actor != assigned:
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:
return {
"id": str(row.id),
@ -81,7 +95,20 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
@router.post("/query")
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()
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
return {
@ -166,6 +193,7 @@ def update_request(
row = db.get(Request, request_uuid)
if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, row)
changes = payload.model_dump(exclude_unset=True)
actor_role = str(admin.get("role") or "").upper()
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)
if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, row)
db.delete(row)
db.commit()
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)
if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_view_request_or_403(admin, req)
changed = False
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
changed = True

View file

@ -17,6 +17,10 @@ SECURITY_HEADERS = {
"Referrer-Policy": "no-referrer",
"X-Permitted-Cross-Domain-Policies": "none",
"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():
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
duration_ms = (perf_counter() - started_at) * 1000.0

View file

@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from passlib.context import CryptContext
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)
data = payload.copy()
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:
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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.http_hardening import install_http_hardening
from app.api.public.router import router as public_router
from app.api.admin.router import router as admin_router
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(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
@ -25,7 +21,7 @@ app.include_router(admin_router, prefix="/api/admin")
@app.get("/", include_in_schema=False)
def landing():
return FileResponse(LANDING_FILE)
return JSONResponse({"service": settings.APP_NAME, "status": "ok"})
@app.get("/health")
def health():

View file

@ -14,8 +14,12 @@ _VERSION = b"v1"
def _key() -> bytes:
secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
if not secret or secret == "change_me_data_encryption":
secret = str(settings.ADMIN_JWT_SECRET or "change_me_admin")
if not secret:
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()

View file

@ -126,4 +126,4 @@ def record_file_security_event(
try:
db.rollback()
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:
token = str(settings.TELEGRAM_BOT_TOKEN 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
if not chat_id or chat_id == "0":
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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
<link rel="stylesheet" href="/admin.css" integrity="sha384-ob5ClyWT89HFMlY1xFaLvCa0+FaL5KHhc//V2owTg+iFay2Lx0Y2U7fuGnRozMzD" crossorigin="anonymous">
</head>
<body>
<div id="admin-root"></div>
<script crossorigin 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 src="https://unpkg.com/@babel/standalone/babel.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="anonymous" integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1" src="https://unpkg.com/react-dom@18/umd/react-dom.production.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>
</body>
</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` |
| 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. Выполнить миграции (если были изменения схемы).
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 {
listen 80;
server_name _;
server_tokens off;
root /usr/share/nginx/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 {
expires 10m;
return 302 /admin.html;
}
location ~* \.jsx$ {
expires 10m;
default_type application/javascript;
try_files $uri =404;
}
location / {
expires 10m;
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
pydantic==2.10.3
pydantic-settings==2.6.1
SQLAlchemy==2.0.36
alembic==1.14.0
psycopg[binary]==3.2.3
python-jose==3.3.0
PyJWT==2.10.1
passlib[bcrypt]==1.7.4
redis==5.2.0
celery==5.4.0
boto3==1.35.70
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)
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):
with self.SessionLocal() as db:
lawyer = AdminUser(

View file

@ -19,7 +19,7 @@ os.environ.setdefault("S3_BUCKET", "test")
from app.main import app
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.models.notification import Notification
from app.models.otp_session import OtpSession
@ -244,3 +244,33 @@ class PublicRequestCreateTests(unittest.TestCase):
)
self.assertEqual(created.status_code, 201)
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())