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:
129
app/web/admin_routes.py
Normal file
129
app/web/admin_routes.py
Normal 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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
76
app/web/templates/_cont.html
Normal file
76
app/web/templates/_cont.html
Normal 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>
|
||||
105
app/web/templates/admin.html
Normal file
105
app/web/templates/admin.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user