[INVITE] Sistema inviti/licenze B2B + provisioning con invite_token

- InviteController: CRUD inviti (gen, list, show, revoke, rigenera, validate)
- Token inv_* sha256-hashed, one-shot o multi-use, canali, scadenza
- ServicesController::provision() accetta invite_token al posto di X-Provision-Secret
  Piano e durata forzati dall'invito, markUsed() chiaamto dopo provisioning riuscito
- index.php: routing /api/invites/* aggiunto (controller + action map)
- integrazioniext.html: nuovo tab "Inviti & Licenze" con flow completo, endpoints,
  esempi curl/php, guida lg231 aggiornata con sezione provisioning automatico

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-03-07 15:22:25 +01:00
co-authored by Claude Sonnet 4.6
parent 6933e1d3fb
commit 612befd66d
5 changed files with 679 additions and 14 deletions
+53 -11
View File
@@ -477,22 +477,51 @@ class ServicesController extends BaseController
{
$this->setServiceHeaders();
// ── 1. Auth via PROVISION_SECRET ────────────────────────────────
$secret = $_SERVER['HTTP_X_PROVISION_SECRET']
?? ($_SERVER['HTTP_AUTHORIZATION'] ? str_replace('Provision ', '', $_SERVER['HTTP_AUTHORIZATION']) : null)
?? null;
if (!$secret || !hash_equals(PROVISION_SECRET, $secret)) {
$this->jsonError('Provision secret mancante o non valido', 401, 'INVALID_PROVISION_SECRET');
}
// ── 2. Leggi e valida body ───────────────────────────────────────
// ── 1. Leggi body (serve per invite_token) ───────────────────────
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$company = $body['company'] ?? [];
$admin = $body['admin'] ?? [];
$license = $body['license'] ?? [];
$caller = $body['caller'] ?? [];
// ── 2. Auth: invite_token (canale B2B) O X-Provision-Secret (admin diretto) ──
$inviteToken = trim($body['invite_token'] ?? '');
$resolvedInvite = null;
if ($inviteToken) {
// Percorso B2B tramite invito (lg231, e-commerce, partner)
require_once __DIR__ . '/InviteController.php';
$result = InviteController::resolveInvite($inviteToken);
if (!$result['valid']) {
$this->jsonError('Invito non valido: ' . $result['error'], 401, 'INVALID_INVITE_' . $result['code']);
}
$resolvedInvite = $result['invite'];
// Verifica restrizione P.IVA se presente nell'invito
$partitaIvaCheck = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
if ($resolvedInvite['restrict_vat'] && $resolvedInvite['restrict_vat'] !== $partitaIvaCheck) {
$this->jsonError('Invito non valido per questa P.IVA', 403, 'INVITE_VAT_MISMATCH');
}
// Verifica restrizione email admin se presente
$adminEmailCheck = trim($admin['email'] ?? '');
if ($resolvedInvite['restrict_email'] && strcasecmp($resolvedInvite['restrict_email'], $adminEmailCheck) !== 0) {
$this->jsonError('Invito non valido per questa email admin', 403, 'INVITE_EMAIL_MISMATCH');
}
// Piano e durata vengono dall'invito (non dal body)
$license['plan'] = $resolvedInvite['plan'];
$license['duration_months'] = (int)$resolvedInvite['duration_months'];
} else {
// Percorso diretto admin/e-commerce via secret header
$secret = $_SERVER['HTTP_X_PROVISION_SECRET']
?? (isset($_SERVER['HTTP_AUTHORIZATION'])
? str_replace('Provision ', '', $_SERVER['HTTP_AUTHORIZATION'])
: null);
if (!$secret || !hash_equals(PROVISION_SECRET, $secret)) {
$this->jsonError('invite_token o X-Provision-Secret richiesti', 401, 'AUTH_REQUIRED');
}
}
$ragioneSociale = trim($company['ragione_sociale'] ?? '');
$partitaIva = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
$adminEmail = trim($admin['email'] ?? '');
@@ -660,7 +689,16 @@ class ServicesController extends BaseController
performedBy: ($caller['system'] ?? 'external') . ':provision'
);
// ── 9. Callback a lg231 (asincrono, non bloccante) ───────────────
// ── 9. Segna invito come usato (se presente) ────────────────────
if ($resolvedInvite) {
InviteController::markUsed(
(int)$resolvedInvite['id'],
$orgId,
$_SERVER['REMOTE_ADDR'] ?? ''
);
}
// ── 10. Callback a lg231 (asincrono, non bloccante) ──────────────
$callbackUrl = $caller['callback_url'] ?? null;
if ($callbackUrl) {
$callbackPayload = json_encode([
@@ -707,6 +745,10 @@ class ServicesController extends BaseController
// Link
'dashboard_url' => APP_URL . '/dashboard.html',
'settings_url' => APP_URL . '/settings.html',
// Info invito (se usato)
'invite_id' => $resolvedInvite ? (int)$resolvedInvite['id'] : null,
'invite_plan' => $resolvedInvite ? $resolvedInvite['plan'] : ($license['plan'] ?? 'professional'),
'license_expires_at' => $expiresAt,
]);
}