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

View File

@@ -46,6 +46,15 @@ class Settings(BaseSettings):
# False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False
# --- Notificare email admin la signup (US-012, PRD 3.3b) ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP);
# follow-up cand exista SMTP real configurat in .env.
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
# --- 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

View File

@@ -68,6 +68,17 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
)
# Coloane users (DB cu users creata inaintea acestor coloane)
user_tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
).fetchone()
if user_tbl:
user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "is_admin" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
if "email_verified" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
# Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"

59
app/email.py Normal file
View File

@@ -0,0 +1,59 @@
"""Helper notificare email admin la signup (US-012, PRD 3.3b).
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.
"""
from __future__ import annotations
import smtplib
import textwrap
from email.message import EmailMessage
from .config import get_settings
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
"""Notifica adminii despre un cont nou in asteptare (best-effort).
Daca smtp_host e None SAU admin_emails e gol -> log si return (degradat).
Daca SMTP ridica exceptie -> log eroare si return (NU se propaga).
Timeout mic (5s) pe conexiunea SMTP.
"""
settings = get_settings()
if not settings.smtp_host or not admin_emails:
print(
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
f"email={email} admins={len(admin_emails)}",
flush=True,
)
return
try:
msg = EmailMessage()
expeditor = settings.smtp_from or settings.smtp_user or "autopass@localhost"
msg["From"] = expeditor
msg["To"] = ", ".join(admin_emails)
msg["Subject"] = f"AutoPass: cont nou {account_id} in asteptare"
msg.set_content(textwrap.dedent(f"""\
Cont nou inregistrat si in asteptare de activare.
ID cont: {account_id}
Email: {email}
Actioneaza din panoul admin /admin sau din CLI:
python3 -m tools.account activate --account {account_id}
"""))
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=5) as smtp:
if settings.smtp_user and settings.smtp_password:
smtp.starttls()
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(msg)
except Exception as exc:
print(
f"SIGNUP-NOTIFY esuat cont={account_id}: {type(exc).__name__}",
flush=True,
)

View File

@@ -28,8 +28,9 @@ 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.admin_routes import router as admin_router
from .web.csrf import CsrfError
from .web.session import LoginRequired
from .web.session import AdminRequired, LoginRequired
@asynccontextmanager
@@ -57,6 +58,11 @@ async def login_required_handler(request: Request, exc: LoginRequired) -> Redire
return RedirectResponse("/login", status_code=303)
@app.exception_handler(AdminRequired)
async def admin_required_handler(request: Request, exc: AdminRequired) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
@app.exception_handler(CsrfError)
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
@@ -86,6 +92,7 @@ app.include_router(api_v1_router)
app.include_router(import_v1_router)
app.include_router(web_router)
app.include_router(auth_router)
app.include_router(admin_router)
@app.get("/healthz")

View File

@@ -46,12 +46,21 @@ def _scrypt_hash(password: str, salt: bytes, n: int = _N, r: int = _R, p: int =
)
def create_user(conn: sqlite3.Connection, account_id: int, email: str, password: str) -> int:
def create_user(
conn: sqlite3.Connection,
account_id: int,
email: str,
password: str,
is_admin: bool = False,
) -> 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.
is_admin: daca True, userul e marcat ca admin (is_admin=1). Apelantul decide
logica de bootstrap (count_admins==0 -> primul cont devine admin).
"""
email = email.strip()
@@ -69,9 +78,9 @@ def create_user(conn: sqlite3.Connection, account_id: int, email: str, password:
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),
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params, is_admin) "
"VALUES (?, ?, ?, ?, ?, ?)",
(account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS, 1 if is_admin else 0),
)
except sqlite3.IntegrityError:
raise ValueError("email deja folosit")
@@ -79,6 +88,44 @@ def create_user(conn: sqlite3.Connection, account_id: int, email: str, password:
return int(cur.lastrowid or 0)
def count_admins(conn: sqlite3.Connection) -> int:
"""Numara userii cu is_admin=1 din intreaga baza."""
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()
return int(row["n"]) if row else 0
def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True) -> None:
"""Seteaza/sterge rolul admin pe toti userii contului dat.
Ridica ValueError daca contul nu exista.
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010).
"""
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct:
raise ValueError(f"cont inexistent: {account_id}")
conn.execute(
"UPDATE users SET is_admin=? WHERE account_id=?",
(1 if is_admin else 0, account_id),
)
def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
"""Returneaza True daca cel putin un user al contului are is_admin=1."""
row = conn.execute(
"SELECT 1 FROM users WHERE account_id=? AND is_admin=1 LIMIT 1",
(account_id,),
).fetchone()
return row is not None
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012)."""
rows = conn.execute(
"SELECT email FROM users WHERE is_admin=1"
).fetchall()
return [row["email"] for row in rows]
def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int | None:
"""Verifica parola pentru email. Intoarce account_id la potrivire, None altfel.

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>