feat(web): self-service cheie/creds + admin web + email signup (PRD 3.3b)

US-007: rute web proprii /cont/roteste-cheie + /cont/rar-creds scoped pe
sesiune (C13), sectiune "Contul meu" cu cheie afisata o data.
US-010: rol admin (users.is_admin) + require_admin->403 + CLI set-admin +
bootstrap primul cont=admin (count_admins in BEGIN IMMEDIATE, anti-race).
US-011: panou /admin (activare/dezactivare conturi, CSRF + PRG), link admin
+ logout pe dashboard.
US-012: app/email.py notify_signup best-effort degradat fara SMTP + config smtp_*.
Fix: migrare defensiva users.is_admin/email_verified in _migrate.

VERIFY x2 context curat (PASS) + /code-review high. 393 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-18 17:19:06 +00:00
parent 504b490d3b
commit b92055eb01
21 changed files with 1766 additions and 10 deletions

129
app/web/admin_routes.py Normal file
View File

@@ -0,0 +1,129 @@
"""Panou admin web /admin. US-011 PRD 3.3b.
Rute:
GET /admin — listeaza conturi in asteptare + active (require_admin)
POST /admin/activate — activeaza un cont (require_admin + CSRF, PRG)
POST /admin/deactivate — dezactiveaza un cont, nu permite id=1 (require_admin + CSRF, PRG)
"""
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 list_accounts, set_active
from ..config import get_settings
from ..db import get_connection
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_admin
router = APIRouter()
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
def _ctx(request: Request, **extra) -> dict:
settings = get_settings()
return {"rar_env": settings.rar_env, "version": __version__, **extra}
def _emails_by_account(conn) -> dict[int, str | None]:
"""Intoarce primul email per account_id, intr-un singur query (fara N+1)."""
rows = conn.execute(
"SELECT account_id, email FROM users ORDER BY id"
).fetchall()
result: dict[int, str | None] = {}
for row in rows:
acc_id = int(row["account_id"])
if acc_id not in result:
result[acc_id] = row["email"]
return result
def _render_admin(request: Request, conn, *, error: str | None = None, status_code: int = 200):
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
accounts = list_accounts(conn)
emails = _emails_by_account(conn)
for acct in accounts:
acct["email"] = emails.get(acct["id"])
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
active = [a for a in accounts if a["active"] and a["id"] != 1]
default = next((a for a in accounts if a["id"] == 1), None)
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
request,
csrf_token=get_csrf_token(request),
pending=pending,
active=active,
default_account=default,
error=error,
), status_code=status_code)
@router.get("/admin", response_class=HTMLResponse)
async def admin_get(request: Request):
"""Panou admin: conturi in asteptare + active."""
require_admin(request)
conn = get_connection()
try:
return _render_admin(request, conn)
finally:
conn.close()
@router.post("/admin/activate", response_class=HTMLResponse)
async def admin_activate(
request: Request,
account_id: int = Form(...),
csrf_token: str = Form(default=""),
):
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
require_admin(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
try:
set_active(conn, account_id, True)
except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422)
finally:
conn.close()
return RedirectResponse("/admin", status_code=303)
@router.post("/admin/deactivate", response_class=HTMLResponse)
async def admin_deactivate(
request: Request,
account_id: int = Form(...),
csrf_token: str = Form(default=""),
):
"""Dezactiveaza un cont. Nu permite dezactivarea contului default id=1. PRG: redirect 303."""
require_admin(request)
verify_csrf(request, csrf_token)
if account_id == 1:
conn = get_connection()
try:
return _render_admin(
request, conn,
error="Contul default (id=1) nu poate fi dezactivat.",
status_code=422,
)
finally:
conn.close()
conn = get_connection()
try:
try:
set_active(conn, account_id, False)
except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422)
finally:
conn.close()
return RedirectResponse("/admin", status_code=303)

View File

@@ -13,7 +13,8 @@ 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 ..email import notify_signup
from ..users import count_admins, create_user, list_admin_emails, 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
@@ -68,12 +69,15 @@ async def signup_post(
name=name, cui=cui, email=email,
), status_code=422)
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
try:
is_first = count_admins(conn) == 0
account_id = create_account(conn, name, cui.strip() or None, active=False)
user_id = create_user(conn, account_id, email, parola)
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
api_key = create_api_key(conn, account_id)
conn.execute("COMMIT")
except Exception as exc:
@@ -90,6 +94,17 @@ async def signup_post(
set_session(request, account_id, user_id)
print(f"SIGNUP cont={account_id} email={email}", flush=True)
# Notificare email admin (best-effort, nu blocheaza signup-ul)
try:
conn2 = get_connection()
try:
admins = list_admin_emails(conn2)
finally:
conn2.close()
notify_signup(admins, account_id, email)
except Exception as exc_notify:
print(f"SIGNUP-NOTIFY exceptie neasteptata cont={account_id}: {type(exc_notify).__name__}", flush=True)
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),

View File

@@ -22,6 +22,7 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..auth import rotate_api_key
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_login
from ..api.v1.import_router import (
@@ -37,6 +38,7 @@ from ..crypto import decrypt_creds, encrypt_creds
from ..db import get_connection, read_heartbeat
from ..idempotency import build_key, canonicalize_row
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
from ..users import is_account_admin
from ..mapping import (
DEFAULT_ACCOUNT_ID,
account_or_default,
@@ -131,6 +133,7 @@ def dashboard(request: Request) -> HTMLResponse:
"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),
"is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request),
}
return templates.TemplateResponse("dashboard.html", ctx)
@@ -907,3 +910,119 @@ async def web_confirma_import(
finally:
conn.close()
# =========================================================================== #
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
# care cere cheie API; sesiunea web e suficienta ca identitate). #
# =========================================================================== #
def _render_cont(
request: Request,
*,
api_key: str | None = None,
are_creds: bool = False,
creds_mesaj: str | None = None,
creds_eroare: str | None = None,
rot_eroare: str | None = None,
) -> HTMLResponse:
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
return templates.TemplateResponse(
"_cont.html",
_ctx(
request,
api_key=api_key,
are_creds=are_creds,
creds_mesaj=creds_mesaj,
creds_eroare=creds_eroare,
rot_eroare=rot_eroare,
),
)
@router.get("/_fragments/cont", response_class=HTMLResponse)
def fragment_cont(request: Request) -> HTMLResponse:
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
account_id = require_login(request)
acct = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, are_creds=are_creds)
finally:
conn.close()
@router.post("/cont/roteste-cheie", response_class=HTMLResponse)
def cont_roteste_cheie(
request: Request,
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Revoca toate cheile active si emite una noua. Afisata O SINGURA DATA."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
new_key = rotate_api_key(conn, acct)
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, api_key=new_key, are_creds=are_creds)
finally:
conn.close()
@router.post("/cont/rar-creds", response_class=HTMLResponse)
def cont_rar_creds(
request: Request,
rar_email: str = Form(""),
rar_parola: str = Form(""),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13).
Camp parola NICIODATA re-pus in value= la re-randare.
Validare minima: email si parola negoale.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
email = rar_email.strip()
parola = rar_parola.strip()
if not email or not parola:
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
creds_eroare="Email si parola sunt obligatorii.",
)
enc = encrypt_creds({"email": email, "password": parola})
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(enc, acct),
)
return _render_cont(
request,
are_creds=True,
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
)
finally:
conn.close()

View File

@@ -20,6 +20,10 @@ class LoginRequired(Exception):
"""Ridica pentru a redirectiona la /login (prinsa de exception_handler in main.py)."""
class AdminRequired(Exception):
"""Ridica cand contul sesiunii nu are rol admin (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")
@@ -63,6 +67,26 @@ def require_login(request: Request) -> int:
return aid
def require_admin(request: Request) -> int:
"""Verifica ca userul logat are rol admin pe contul sesiunii.
Intai cheama require_login (nelogat -> LoginRequired -> /login redirect).
Daca e logat dar nu e admin -> ridica AdminRequired.
Intoarce account_id la succes.
"""
account_id = require_login(request)
from ..db import get_connection
from ..users import is_account_admin
conn = get_connection()
try:
admin = is_account_admin(conn, account_id)
finally:
conn.close()
if not admin:
raise AdminRequired()
return account_id
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()

View File

@@ -0,0 +1,76 @@
<div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
<!-- Sectiunea: Cheia mea API -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>
{% if api_key %}
<div class="flash" style="margin-bottom:12px;">Cheia a fost rotita. Salveaz-o 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:0 0 8px;">
{{ 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:10px 0 0;">
Atentie: la urmatoarea vizita aceasta cheie dispare. Daca o pierzi, roteste din nou.
</p>
{% endif %}
{% if rot_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ rot_eroare }}</div>
{% endif %}
<form hx-post="/cont/roteste-cheie"
hx-target="#card-cont"
hx-swap="outerHTML"
style="margin-top:{% if api_key %}12px{% else %}0{% endif %};">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--warn); border-color:var(--warn);">
Roteste cheia API
</button>
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Cheia veche se revoca imediat.</span>
</form>
</div>
<!-- Sectiunea: Credentiale RAR -->
<div>
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
{% if are_creds %}
<div class="flash" style="margin-bottom:12px;">Credentiale RAR configurate.</div>
{% endif %}
{% if creds_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
{% endif %}
{% if creds_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
{% endif %}
<form hx-post="/cont/rar-creds"
hx-target="#card-cont"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email RAR</label><br>
<input type="email" name="rar_email" required style="width:100%; max-width:340px;"
placeholder="email@service.ro">
</p>
<p style="margin:0 0 12px;">
<label style="font-size:13px; color:var(--muted);">Parola RAR</label><br>
<input type="password" name="rar_parola" required style="width:100%; max-width:340px;"
autocomplete="new-password">
</p>
<button type="submit">Salveaza credentiale RAR</button>
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parola stocata criptat, niciodata in clar.</span>
</form>
</div>
</div>

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
<h2 style="margin:0;">Panou admin</h2>
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
</div>
{% if error %}
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
{% endif %}
<!-- Conturi in asteptare -->
<div class="card">
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
{% if pending %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in pending %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/activate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit">Activeaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont in asteptare.</p>
{% endif %}
</div>
<!-- Conturi active -->
<div class="card">
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
{% if active %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in active %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/deactivate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
{% endif %}
</div>
<!-- Contul dev default (id=1) -->
{% if default_account %}
<div class="card" style="border-color:var(--muted);">
<p class="muted" style="margin:0;font-size:13px;">
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,15 @@
{% extends "base.html" %}
{% block content %}
<!-- Nav cont: link admin (doar pentru admini) + logout -->
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
<form method="post" action="/logout" style="display:inline; margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
</form>
</div>
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow -->
{% include '_upload.html' %}
@@ -32,6 +41,10 @@
<div class="card"><div class="empty">se incarca mapari…</div></div>
</div>
<div hx-get="/_fragments/cont" hx-trigger="load" hx-swap="outerHTML">
<div class="card"><div class="empty">se incarca cont…</div></div>
</div>
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>