feat(web): self-onboarding multi-tenant + auth sesiune (PRD 3.3a)
Canalul web trece de la 100% deschis (hardcodat cont 1) la autentificat si multi-tenant. Un service nou se inregistreaza din browser, primeste o cheie API (o singura data) si o sesiune; contul se creeaza "in asteptare" (active=0) si nu trimite la RAR pana la activarea de catre admin (tools/account.py activate). - users + app/users.py: parole scrypt (salt per-user, eticheta parametri onorata la verify pentru migrare cost), email unic case-insensitive - sesiune: SessionMiddleware (same_site=strict, https_only config) + app/web/session.py (current_account/web_account/require_login->LoginRequired, set_session clear-inainte) - CSRF (app/web/csrf.py) enforce in prod inclusiv pe login/signup + rate-limit in-proces (app/web/ratelimit.py) pe signup si login - signup/login/logout (app/web/auth_routes.py): signup tranzactie atomica, cheie-o-data, log SIGNUP pentru descoperire admin - dashboard + import scoped pe contul sesiunii (regula NULL->cont 1); toate rutele web care ating date sensibile sub require_login; nomenclator ramane global - banner "cont in asteptare" pentru conturi active=0 - gate worker: claim_one LEFT JOIN accounts COALESCE(active,1)=1 (account_id NULL=activ) VERIFY context curat (2 runde): leak cross-account /_fragments/mapari prins+reparat. /code-review high: csrf_token lipsa pe re-randari de eroare, scrypt_params ignorat, login fara rate-limit -- toate reparate. 361 teste pass (de la 313). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,25 @@ class Settings(BaseSettings):
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
creds_key: str | None = None
|
||||
|
||||
# --- Sesiuni web (US-002, PRD 3.3) ---
|
||||
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
|
||||
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
|
||||
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
session_secret: str | None = None
|
||||
# True (prod): rutele web fara sesiune -> redirect /login. False (dev): fara
|
||||
# sesiune -> cont implicit id=1, back-compat (C12/§5 Q5).
|
||||
web_auth_required: bool = False
|
||||
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4).
|
||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||
session_https_only: bool = False
|
||||
|
||||
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) ---
|
||||
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
|
||||
signup_rate_max: int = 5
|
||||
signup_rate_window_s: int = 3600
|
||||
# Max incercari POST /login per IP (brute-force parole). Fereastra impartita cu signup.
|
||||
login_rate_max: int = 10
|
||||
|
||||
# --- RAR ---
|
||||
rar_env: str = "test" # "test" | "prod"
|
||||
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
|
||||
|
||||
27
app/main.py
27
app/main.py
@@ -8,6 +8,7 @@ Pornire dev: uvicorn app.main:app --reload
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -16,6 +17,8 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from . import __version__
|
||||
from .api.v1.import_router import router as import_v1_router
|
||||
@@ -24,6 +27,9 @@ from .config import get_settings
|
||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||
from .security import install_log_redaction
|
||||
from .web.routes import router as web_router
|
||||
from .web.auth_routes import router as auth_router
|
||||
from .web.csrf import CsrfError
|
||||
from .web.session import LoginRequired
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -35,6 +41,26 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
|
||||
|
||||
settings = get_settings()
|
||||
_session_secret = settings.session_secret or secrets.token_hex(32)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=_session_secret,
|
||||
session_cookie="autopass_session",
|
||||
https_only=settings.session_https_only,
|
||||
same_site="strict",
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(LoginRequired)
|
||||
async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@app.exception_handler(CsrfError)
|
||||
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
@@ -59,6 +85,7 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
app.include_router(api_v1_router)
|
||||
app.include_router(import_v1_router)
|
||||
app.include_router(web_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
|
||||
@@ -133,6 +133,19 @@ CREATE TABLE IF NOT EXISTS import_attestations (
|
||||
n_confirmed INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Utilizatori web (email+parola, legati de un cont). Parola stocata doar ca scrypt hash.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL, -- hex scrypt(salt, parola)
|
||||
salt TEXT NOT NULL, -- hex secrets.token_bytes(16), per-user
|
||||
scrypt_params TEXT NOT NULL, -- eticheta versiune parametri: 'n16384_r8_p1'
|
||||
email_verified INTEGER NOT NULL DEFAULT 0, -- C19: pregatire viitor
|
||||
is_admin INTEGER NOT NULL DEFAULT 0, -- pregatire 3.3b
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
|
||||
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
|
||||
120
app/users.py
Normal file
120
app/users.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3.
|
||||
|
||||
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
|
||||
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
|
||||
migrare cost viitoare (C9).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
|
||||
SCRYPT_PARAMS = "n16384_r8_p1"
|
||||
_N = 2**14
|
||||
_R = 8
|
||||
_P = 1
|
||||
_DKLEN = 32
|
||||
_MAXMEM = 64 * 1024 * 1024
|
||||
|
||||
_PASSWORD_MIN = 10
|
||||
_PASSWORD_MAX = 128
|
||||
|
||||
|
||||
def _parse_scrypt_params(label: str) -> tuple[int, int, int] | None:
|
||||
"""Parseaza 'nN_rR_pP' -> (N, R, P). Returneaza None la format necunoscut/corupt."""
|
||||
try:
|
||||
parts = label.split("_")
|
||||
if len(parts) != 3 or parts[0][0] != "n" or parts[1][0] != "r" or parts[2][0] != "p":
|
||||
return None
|
||||
return (int(parts[0][1:]), int(parts[1][1:]), int(parts[2][1:]))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _scrypt_hash(password: str, salt: bytes, n: int = _N, r: int = _R, p: int = _P) -> bytes:
|
||||
return hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
maxmem=_MAXMEM,
|
||||
dklen=_DKLEN,
|
||||
)
|
||||
|
||||
|
||||
def create_user(conn: sqlite3.Connection, account_id: int, email: str, password: str) -> int:
|
||||
"""Creeaza un user nou si intoarce id-ul.
|
||||
|
||||
Valideaza ca: contul exista, parola intre 10 si 128 caractere, emailul nu e duplicat.
|
||||
Stocheaza DOAR hash scrypt + salt (hex), niciodata parola in clar.
|
||||
Email duplicat (case-insensitive, via UNIQUE COLLATE NOCASE) -> ValueError.
|
||||
"""
|
||||
email = email.strip()
|
||||
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
|
||||
if len(password) < _PASSWORD_MIN:
|
||||
raise ValueError(f"parola prea scurta (minim {_PASSWORD_MIN} caractere)")
|
||||
if len(password) > _PASSWORD_MAX:
|
||||
raise ValueError(f"parola prea lunga (maxim {_PASSWORD_MAX} caractere, anti-DoS)")
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
pw_hash = _scrypt_hash(password, salt)
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
raise ValueError("email deja folosit")
|
||||
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int | None:
|
||||
"""Verifica parola pentru email. Intoarce account_id la potrivire, None altfel.
|
||||
|
||||
Nu distinge intre email inexistent si parola gresita (evita enumerare useri).
|
||||
Comparatie constant-time cu hmac.compare_digest.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT account_id, password_hash, salt, scrypt_params FROM users "
|
||||
"WHERE email=? COLLATE NOCASE",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
# Executa un hash dummy pentru a evita timing oracle pe email inexistent
|
||||
_scrypt_hash(password, b"\x00" * 16)
|
||||
return None
|
||||
|
||||
salt = bytes.fromhex(row["salt"])
|
||||
expected = bytes.fromhex(row["password_hash"])
|
||||
|
||||
params = _parse_scrypt_params(row["scrypt_params"] or "")
|
||||
if params is None:
|
||||
return None
|
||||
n, r, p = params
|
||||
actual = _scrypt_hash(password, salt, n=n, r=r, p=p)
|
||||
|
||||
if hmac.compare_digest(actual, expected):
|
||||
return int(row["account_id"])
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
|
||||
"""Metadate user dupa email (FARA password_hash si salt)."""
|
||||
row = conn.execute(
|
||||
"SELECT id, account_id, email, is_admin, email_verified, created_at "
|
||||
"FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
155
app/web/auth_routes.py
Normal file
155
app/web/auth_routes.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import create_account
|
||||
from ..auth import create_api_key
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..users import create_user, verify_password
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.ratelimit import check_rate_limit
|
||||
from ..web.session import clear_session, set_session
|
||||
|
||||
router = APIRouter()
|
||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
_RATE_MSG = "Prea multe cereri. Incearca mai tarziu."
|
||||
_PASSWORD_MIN = 10
|
||||
|
||||
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
settings = get_settings()
|
||||
return {"rar_env": settings.rar_env, "version": __version__, **extra}
|
||||
|
||||
|
||||
# --- Signup ---
|
||||
|
||||
@router.get("/signup", response_class=HTMLResponse)
|
||||
async def signup_get(request: Request):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request, csrf_token=get_csrf_token(request)
|
||||
))
|
||||
|
||||
|
||||
@router.post("/signup", response_class=HTMLResponse)
|
||||
async def signup_post(
|
||||
request: Request,
|
||||
name: str = Form(default=""),
|
||||
cui: str = Form(default=""),
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
name=name, cui=cui, email=email,
|
||||
), status_code=429)
|
||||
|
||||
if len(parola) < _PASSWORD_MIN:
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
||||
name=name, cui=cui, email=email,
|
||||
), status_code=422)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
account_id = create_account(conn, name, cui.strip() or None, active=False)
|
||||
user_id = create_user(conn, account_id, email, parola)
|
||||
api_key = create_api_key(conn, account_id)
|
||||
conn.execute("COMMIT")
|
||||
except Exception as exc:
|
||||
conn.execute("ROLLBACK")
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=str(exc),
|
||||
name=name, cui=cui, email=email,
|
||||
), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
print(f"SIGNUP cont={account_id} email={email}", flush=True)
|
||||
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
api_key=api_key,
|
||||
account_id=account_id,
|
||||
))
|
||||
|
||||
|
||||
# --- Login / Logout ---
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_get(request: Request):
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request, csrf_token=get_csrf_token(request)
|
||||
))
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
async def login_post(
|
||||
request: Request,
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit("login:" + ip, settings.login_rate_max, settings.signup_rate_window_s):
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
), status_code=429)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
account_id = verify_password(conn, email, parola)
|
||||
if account_id is None:
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error="Email sau parola incorecte.",
|
||||
), status_code=401)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE email=? COLLATE NOCASE", (email.strip(),)
|
||||
).fetchone()
|
||||
user_id = int(row["id"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout", response_class=HTMLResponse)
|
||||
async def logout_post(
|
||||
request: Request,
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
clear_session(request)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
51
app/web/csrf.py
Normal file
51
app/web/csrf.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""CSRF token per-sesiune + validare. US-009 PRD 3.3.
|
||||
|
||||
Contract pentru rutele POST web:
|
||||
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
- Handler-ul POST apeleaza: verify_csrf(request, form.get("csrf_token"))
|
||||
- La nepotrivire/lipsa: CsrfError -> @app.exception_handler(CsrfError) -> 403
|
||||
|
||||
Token e per-sesiune (stabil pana la logout), generat lazy la primul acces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
class CsrfError(Exception):
|
||||
"""Token CSRF lipsa sau invalid. Prins de exception_handler in main.py -> 403."""
|
||||
|
||||
|
||||
def get_csrf_token(request: Request) -> str:
|
||||
"""Intoarce tokenul CSRF al sesiunii, generandu-l daca lipseste."""
|
||||
token = request.session.get("csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
request.session["csrf_token"] = token
|
||||
return token
|
||||
|
||||
|
||||
def verify_csrf(request: Request, submitted: str | None) -> None:
|
||||
"""Verifica tokenul CSRF trimis in formular.
|
||||
|
||||
Gateaza pe MOD, nu pe account_id:
|
||||
- prod (web_auth_required=True): enforce pe TOATE rutele POST, inclusiv /login si
|
||||
/signup unde atacatorul ar putea forta victima sa se logheze in contul sau
|
||||
(login CSRF). GET-urile de formular genereaza token in sesiune via get_csrf_token.
|
||||
- dev/test (web_auth_required=False, fara account_id): skip transparent, testele
|
||||
existente raman verzi fara sa fie nevoie de token.
|
||||
- sesiune autentificata (account_id in sesiune): enforce indiferent de mod.
|
||||
"""
|
||||
settings = get_settings()
|
||||
enforce = settings.web_auth_required or request.session.get("account_id") is not None
|
||||
if not enforce:
|
||||
return # dev fara auth: CSRF neaplicabil
|
||||
expected = request.session.get("csrf_token")
|
||||
if not expected or not submitted or not hmac.compare_digest(expected.encode(), submitted.encode()):
|
||||
raise CsrfError("token CSRF invalid")
|
||||
31
app/web/ratelimit.py
Normal file
31
app/web/ratelimit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5.
|
||||
|
||||
Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client.
|
||||
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
|
||||
_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
||||
"""Fereastra glisanta: returneaza True daca cererea e permisa, False la depasire.
|
||||
|
||||
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
|
||||
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
||||
suficient pentru un singur proces uvicorn.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - window_s
|
||||
timestamps = _hits[key]
|
||||
# Sterge intrari expirate
|
||||
_hits[key] = [t for t in timestamps if t > cutoff]
|
||||
if len(_hits[key]) >= max_hits:
|
||||
return False
|
||||
_hits[key].append(now)
|
||||
return True
|
||||
@@ -22,6 +22,8 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
_already_sent_lookup,
|
||||
_build_idempotency_key,
|
||||
@@ -55,11 +57,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
|
||||
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
||||
|
||||
|
||||
def _status_counts(conn) -> dict[str, int]:
|
||||
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
|
||||
|
||||
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
|
||||
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
|
||||
"""
|
||||
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
|
||||
|
||||
|
||||
def _status_counts(conn, account_id: int) -> dict[str, int]:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
||||
"GROUP BY status",
|
||||
(account_id, account_id),
|
||||
).fetchall()
|
||||
return {r["status"]: int(r["n"]) for r in rows}
|
||||
|
||||
|
||||
def _account_active(conn, account_id: int) -> bool:
|
||||
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
|
||||
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return bool(row["active"]) if row else True
|
||||
|
||||
|
||||
def _worker_alive(hb) -> bool:
|
||||
if hb is None or not hb["last_beat"]:
|
||||
return False
|
||||
@@ -92,9 +114,10 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn)
|
||||
counts = _status_counts(conn, account_id)
|
||||
hb = read_heartbeat(conn)
|
||||
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
worker_alive = _worker_alive(hb)
|
||||
@@ -107,6 +130,8 @@ def dashboard(request: Request) -> HTMLResponse:
|
||||
"worker_alive": worker_alive,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"rar_state": _rar_state(hb, worker_alive),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
return templates.TemplateResponse("dashboard.html", ctx)
|
||||
finally:
|
||||
@@ -130,46 +155,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse:
|
||||
|
||||
@router.get("/_fragments/banner", response_class=HTMLResponse)
|
||||
def fragment_banner(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn)
|
||||
counts = _status_counts(conn, account_id)
|
||||
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked})
|
||||
return templates.TemplateResponse("_banner.html", {
|
||||
"request": request,
|
||||
"blocked": blocked,
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
|
||||
"FROM submissions ORDER BY id DESC LIMIT 100"
|
||||
"FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
||||
"ORDER BY id DESC LIMIT 100",
|
||||
(account_id, account_id),
|
||||
).fetchall()
|
||||
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse:
|
||||
def _render_mapari(
|
||||
request: Request, conn, account_id: int, *, message: str | None = None
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
"_mapari.html",
|
||||
{
|
||||
"request": request,
|
||||
"pending": pending_unmapped(conn),
|
||||
"pending": pending_unmapped(conn, account_id),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": message,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/_fragments/mapari", response_class=HTMLResponse)
|
||||
def fragment_mapari(request: Request) -> HTMLResponse:
|
||||
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR."""
|
||||
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
|
||||
|
||||
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
return _render_mapari(request, conn)
|
||||
return _render_mapari(request, conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -179,16 +220,18 @@ def post_mapare(
|
||||
request: Request,
|
||||
cod_op_service: str = Form(...),
|
||||
cod_prestatie: str = Form(...),
|
||||
account_id: int | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
auto_send: bool = Form(False),
|
||||
) -> HTMLResponse:
|
||||
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = cod_prestatie.strip().upper()
|
||||
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
|
||||
if not exists:
|
||||
return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}")
|
||||
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
||||
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
msg = (
|
||||
@@ -196,7 +239,7 @@ def post_mapare(
|
||||
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
|
||||
f"{stats['still_blocked']} inca nemapate."
|
||||
)
|
||||
return _render_mapari(request, conn, message=msg)
|
||||
return _render_mapari(request, conn, account_id, message=msg)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -383,6 +426,7 @@ async def web_upload_import(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
sheet_name: str | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
||||
|
||||
@@ -390,7 +434,8 @@ async def web_upload_import(
|
||||
Daca nu: intoarce formularul de mapare coloane.
|
||||
Nu editeaza import_router.py — apeleaza parse_file si DB direct.
|
||||
"""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
data = await file.read()
|
||||
@@ -400,30 +445,15 @@ async def web_upload_import(
|
||||
try:
|
||||
parsed = parse_file(data, filename, sheet_name=sheet_name)
|
||||
except MultipleSheets as ms:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"sheets": ms.sheet_names,
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
||||
except FileTooLarge as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": str(e),
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
|
||||
except HeaderError as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Antet neclar: {e}",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
|
||||
except UnicodeDecodeError as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Encoding nesuportat: {e.reason}",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -467,11 +497,13 @@ async def web_upload_import(
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": batch_id_int,
|
||||
"message": "Mapare retinuta aplicata automat.",
|
||||
"csrf_token": get_csrf_token(request),
|
||||
**result,
|
||||
})
|
||||
|
||||
@@ -491,6 +523,7 @@ async def web_upload_import(
|
||||
"fuzzy_suggestions": fuzzy_suggestions,
|
||||
"canonical_fields": _CANONICAL_FIELDS,
|
||||
"format_data": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -502,13 +535,14 @@ async def web_save_mapare_coloane(
|
||||
import_id: int,
|
||||
) -> HTMLResponse:
|
||||
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
form = await request.form()
|
||||
|
||||
# Colectare perechi coloana fisier → camp canonic din form
|
||||
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
|
||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
|
||||
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
|
||||
format_data_val = str(form.get("format_data") or "").strip() or None
|
||||
@@ -539,17 +573,17 @@ async def web_save_mapare_coloane(
|
||||
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||
if sugg:
|
||||
fuzzy[col] = sugg
|
||||
return templates.TemplateResponse("_mapcoloane.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"columns": columns,
|
||||
"sample_rows": [],
|
||||
"fuzzy_suggestions": fuzzy,
|
||||
"canonical_fields": _CANONICAL_FIELDS,
|
||||
"format_data": format_data_val,
|
||||
"message": "Mapeaza cel putin un camp canonic inainte de a continua.",
|
||||
"error": True,
|
||||
})
|
||||
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
columns=columns,
|
||||
sample_rows=[],
|
||||
fuzzy_suggestions=fuzzy,
|
||||
canonical_fields=_CANONICAL_FIELDS,
|
||||
format_data=format_data_val,
|
||||
message="Mapeaza cel putin un camp canonic inainte de a continua.",
|
||||
error=True,
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -561,10 +595,9 @@ async def web_save_mapare_coloane(
|
||||
(import_id, acct),
|
||||
).fetchone()
|
||||
if not batch:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": "Batch de import inexistent sau expirat.",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error="Batch de import inexistent sau expirat."
|
||||
))
|
||||
|
||||
sig = _signature(list(json_mapare.keys()))
|
||||
|
||||
@@ -580,15 +613,10 @@ async def web_save_mapare_coloane(
|
||||
# Computa preview
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
**result,
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request, import_id=import_id, **result
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -599,7 +627,7 @@ def web_preview_import(
|
||||
import_id: int,
|
||||
) -> HTMLResponse:
|
||||
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
@@ -607,10 +635,12 @@ def web_preview_import(
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
**result,
|
||||
})
|
||||
finally:
|
||||
@@ -620,7 +650,10 @@ def web_preview_import(
|
||||
@router.get("/_import/reset", response_class=HTMLResponse)
|
||||
def web_import_reset(request: Request) -> HTMLResponse:
|
||||
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
||||
return templates.TemplateResponse("_upload.html", {"request": request})
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
|
||||
@@ -632,11 +665,14 @@ async def web_confirma_import(
|
||||
|
||||
Replica logica din import_router.commit_import dar cu input din form HTML
|
||||
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
|
||||
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
|
||||
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
|
||||
"""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
form = await request.form()
|
||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||
|
||||
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
|
||||
try:
|
||||
@@ -662,16 +698,14 @@ async def web_confirma_import(
|
||||
(import_id, acct),
|
||||
).fetchone()
|
||||
if not batch:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": "Batch de import inexistent sau expirat.",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error="Batch de import inexistent sau expirat."
|
||||
))
|
||||
|
||||
if batch["status"] == "committed":
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"message": "Acest batch a fost deja comis.",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, message="Acest batch a fost deja comis."
|
||||
))
|
||||
|
||||
# Incarca randurile cu stare ok si needs_review
|
||||
ok_rows_db = conn.execute(
|
||||
@@ -684,14 +718,14 @@ async def web_confirma_import(
|
||||
# Re-arata preview cu eroare
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"message": "Niciun rand ok de confirmat in acest batch.",
|
||||
"error": True,
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
message="Niciun rand ok de confirmat in acest batch.",
|
||||
error=True,
|
||||
**result,
|
||||
})
|
||||
))
|
||||
|
||||
# Decripteaza si construieste lista de randuri de trimis
|
||||
to_enqueue: list[dict[str, Any]] = []
|
||||
@@ -726,26 +760,22 @@ async def web_confirma_import(
|
||||
f"Verifica preview-ul si retasteaza numarul corect."
|
||||
)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {"request": request, "error": msg})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"message": msg,
|
||||
"error": True,
|
||||
**result,
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request, import_id=import_id, message=msg, error=True, **result
|
||||
))
|
||||
|
||||
if n_total_ok == 0:
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"message": "Niciun rand ok de confirmat.",
|
||||
"error": True,
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
message="Niciun rand ok de confirmat.",
|
||||
error=True,
|
||||
**result,
|
||||
})
|
||||
))
|
||||
|
||||
# Incarca maparea de coloane pentru payload
|
||||
first_row_db = conn.execute(
|
||||
@@ -867,13 +897,13 @@ async def web_confirma_import(
|
||||
|
||||
# Succes → drop zone cu mesaj de confirmare
|
||||
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"message": (
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request,
|
||||
message=(
|
||||
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
|
||||
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
|
||||
),
|
||||
})
|
||||
))
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
75
app/web/session.py
Normal file
75
app/web/session.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Helper-e sesiune web. US-002 PRD 3.3.
|
||||
|
||||
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse
|
||||
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
|
||||
- require_login() RIDICA LoginRequired
|
||||
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
|
||||
RedirectResponse('/login', 303)
|
||||
Astfel handler-ul e intrerupt imediat la raise, independent de logica FastAPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..config import get_settings
|
||||
from ..mapping import DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
"""Ridica pentru a redirectiona la /login (prinsa de exception_handler in main.py)."""
|
||||
|
||||
|
||||
def current_account(request: Request) -> int | None:
|
||||
"""account_id din sesiune sau None daca nu e logat."""
|
||||
val = request.session.get("account_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
|
||||
def current_user_id(request: Request) -> int | None:
|
||||
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
|
||||
val = request.session.get("user_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
|
||||
def web_account(request: Request) -> int | None:
|
||||
"""account_id pentru rutele web de CITIRE.
|
||||
|
||||
- sesiune activa -> contul sesiunii
|
||||
- fara sesiune + web_auth_required=False (dev) -> DEFAULT_ACCOUNT_ID (cont 1, back-compat)
|
||||
- fara sesiune + web_auth_required=True (prod) -> None
|
||||
|
||||
Rutele de SCRIERE trebuie sa foloseasca require_login() direct, nu web_account(),
|
||||
ca sa nu cada niciodata tacit pe contul 1 in prod.
|
||||
"""
|
||||
aid = current_account(request)
|
||||
if aid is not None:
|
||||
return aid
|
||||
settings = get_settings()
|
||||
if not settings.web_auth_required:
|
||||
return DEFAULT_ACCOUNT_ID
|
||||
return None
|
||||
|
||||
|
||||
def require_login(request: Request) -> int:
|
||||
"""Verifica sesiunea activa; ridica LoginRequired daca nu.
|
||||
|
||||
Intoarce account_id la succes. Aruncatorul (exception_handler din main.py)
|
||||
intercepteaza LoginRequired si intoarce RedirectResponse('/login', 303).
|
||||
"""
|
||||
aid = web_account(request)
|
||||
if aid is None:
|
||||
raise LoginRequired()
|
||||
return aid
|
||||
|
||||
|
||||
def set_session(request: Request, account_id: int, user_id: int) -> None:
|
||||
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
|
||||
request.session.clear()
|
||||
request.session["account_id"] = account_id
|
||||
request.session["user_id"] = user_id
|
||||
|
||||
|
||||
def clear_session(request: Request) -> None:
|
||||
"""Sterge sesiunea (logout)."""
|
||||
request.session.clear()
|
||||
@@ -1,5 +1,12 @@
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
{% if not account_active %}
|
||||
<div class="card banner" style="border-color:var(--warn); background:#201c0f;"
|
||||
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
{% if account_active %}hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
|
||||
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
|
||||
Plasa de siguranta pe pene RAR > 30h. Verifica coada mai jos.
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{% set top = e.suggestions[0] if e.suggestions else None %}
|
||||
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
|
||||
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="account_id" value="{{ e.account_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
|
||||
|
||||
<div class="mapcol grow">
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<label for="format-data" style="font-size:13px; color:var(--muted);">
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
hx-post="/_import/{{ import_id }}/confirma"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
{% if sheets %}
|
||||
<div style="margin-bottom:12px;">
|
||||
|
||||
28
app/web/templates/login.html
Normal file
28
app/web/templates/login.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Autentificare — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:400px;margin:40px auto;">
|
||||
<h2 style="margin-top:0;">Autentificare</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>
|
||||
<label>Email</label><br>
|
||||
<input type="email" name="email" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>Parola</label><br>
|
||||
<input type="password" name="parola" required style="width:100%;">
|
||||
</p>
|
||||
<button type="submit" style="width:100%;margin-top:8px;">Intra in cont</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||
Cont nou? <a href="/signup">Inregistrare</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
73
app/web/templates/signup.html
Normal file
73
app/web/templates/signup.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inregistrare — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:480px;margin:40px auto;">
|
||||
{% if api_key %}
|
||||
<div class="flash">Contul a fost creat. Salveaza cheia API acum — nu o vei mai putea vedea.</div>
|
||||
|
||||
<div class="card" style="font-family:monospace;word-break:break-all;font-size:14px;background:#0f1115;margin:12px 0;">
|
||||
{{ api_key }}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-key="{{ api_key }}"
|
||||
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
|
||||
Copiaza cheia
|
||||
</button>
|
||||
|
||||
<p style="font-size:13px;color:var(--warn);margin-top:12px;">
|
||||
Atentie: la refresh sau la urmatoarea vizita aceasta cheie dispare.
|
||||
Recuperare posibila doar prin rotire cheie (CLI admin).
|
||||
</p>
|
||||
|
||||
<div class="banner warn" style="margin-top:16px;">
|
||||
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">
|
||||
<input type="checkbox" id="saved-check" style="margin-top:3px;">
|
||||
Am salvat cheia in siguranta
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p id="cta-dashboard" style="display:none;margin-top:16px;">
|
||||
<a href="/">Mergi la dashboard</a> — configureaza creds RAR si pregateste importul.
|
||||
Trimiterea catre RAR porneste automat dupa activarea contului de catre admin.
|
||||
</p>
|
||||
<script>
|
||||
document.getElementById('saved-check').addEventListener('change', function() {
|
||||
document.getElementById('cta-dashboard').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<h2 style="margin-top:0;">Inregistrare cont nou</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/signup">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>
|
||||
<label>Companie <span style="color:var(--err)">*</span></label><br>
|
||||
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>CUI <span style="color:var(--muted);font-size:12px;">(optional)</span></label><br>
|
||||
<input type="text" name="cui" value="{{ cui or '' }}" style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>Email <span style="color:var(--err)">*</span></label><br>
|
||||
<input type="email" name="email" value="{{ email or '' }}" required style="width:100%;">
|
||||
</p>
|
||||
<p>
|
||||
<label>Parola <span style="color:var(--err)">*</span>
|
||||
<span style="color:var(--muted);font-size:12px;">(minim 10 caractere)</span>
|
||||
</label><br>
|
||||
<input type="password" name="parola" required style="width:100%;">
|
||||
</p>
|
||||
<button type="submit" style="width:100%;margin-top:8px;">Creeaza cont</button>
|
||||
</form>
|
||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||
Ai deja cont? <a href="/login">Autentificare</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -140,8 +140,12 @@ def claim_one(conn) -> dict | None:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, account_id, payload_json, rar_creds_enc FROM submissions WHERE status='queued' "
|
||||
"AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY id LIMIT 1",
|
||||
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc "
|
||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||
"WHERE s.status='queued' "
|
||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||
"AND COALESCE(a.active, 1) = 1 "
|
||||
"ORDER BY s.id LIMIT 1",
|
||||
(_iso(_now()),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
|
||||
Reference in New Issue
Block a user