mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
Security commit
This commit is contained in:
parent
96649f8cc7
commit
e66abf3fb9
21 changed files with 2324 additions and 2014 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,4 +1,8 @@
|
|||
/tmp/
|
||||
*.idea
|
||||
.env
|
||||
|
||||
/reports
|
||||
node_modules/
|
||||
e2e/node_modules/
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
|
|
|
|||
|
|
@ -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="Запись зафиксирована и недоступна для удаления")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]})
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -126,4 +126,4 @@ def record_file_security_event(
|
|||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("security_audit_rollback_failed", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -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
717
app/web/admin.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
757
app/web/landing.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
1269
app/web/landing.html
1269
app/web/landing.html
File diff suppressed because it is too large
Load diff
503
app/web/landing.js
Normal file
503
app/web/landing.js
Normal 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.
|
|
@ -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
14
e2e/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue