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

@@ -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>