[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:
parent
6933e1d3fb
commit
612befd66d
376
application/controllers/InviteController.php
Normal file
376
application/controllers/InviteController.php
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile — Invite Controller
|
||||||
|
*
|
||||||
|
* Sistema inviti / licenze a tempo per provisioning automatico B2B.
|
||||||
|
* Gli inviti vengono generati da NIS2 Admin (o via API admin),
|
||||||
|
* distribuiti dall'e-commerce, usati da lg231 e altri sistemi Agile.
|
||||||
|
*
|
||||||
|
* Endpoints admin (richiedono JWT super_admin):
|
||||||
|
* POST /api/invites → genera invito
|
||||||
|
* GET /api/invites → lista inviti
|
||||||
|
* GET /api/invites/{id} → dettaglio invito
|
||||||
|
* DELETE /api/invites/{id} → revoca invito
|
||||||
|
* POST /api/invites/{id}/regenerate → rigenera token mantenendo config
|
||||||
|
*
|
||||||
|
* Endpoints pubblici (nessuna auth):
|
||||||
|
* GET /api/invites/validate/{token} → valida invito (preview piano, scadenza)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class InviteController extends BaseController
|
||||||
|
{
|
||||||
|
// Prefisso token invito
|
||||||
|
private const TOKEN_PREFIX = 'inv_';
|
||||||
|
// Lunghezza parte random del token (bytes → hex)
|
||||||
|
private const TOKEN_BYTES = 24;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// ADMIN — Generazione e gestione inviti
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/invites
|
||||||
|
* Genera uno o più inviti. Solo super_admin.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* "plan": "professional", // essentials/professional/enterprise
|
||||||
|
* "duration_months": 12, // durata licenza attivata dall'invito
|
||||||
|
* "invite_expires_days": 30, // giorni di validità dell'invito stesso (default 30)
|
||||||
|
* "max_uses": 1, // 1=one-shot, N=multi-use (es. batch lg231)
|
||||||
|
* "quantity": 1, // genera N inviti in batch (default 1)
|
||||||
|
* "label": "lg231 Partner Q1 2026", // etichetta descrittiva
|
||||||
|
* "channel": "lg231", // 'lg231'|'ecommerce'|'direct'|'manual'
|
||||||
|
* "issued_to": "partner@lg231.it", // destinatario (facoltativo)
|
||||||
|
* "restrict_vat": "02345678901", // limita a P.IVA specifica (facoltativo)
|
||||||
|
* "restrict_email": "admin@co.it", // limita a email admin specifica (facoltativo)
|
||||||
|
* "notes": "Ordine OPP-2026-042" // note interne
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Response: { invites: [ {id, token, token_prefix, plan, expires_at, ...} ] }
|
||||||
|
*/
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->requireRole(['super_admin']);
|
||||||
|
|
||||||
|
$body = $this->getBody();
|
||||||
|
$plan = in_array($body['plan'] ?? '', ['essentials','professional','enterprise'])
|
||||||
|
? $body['plan'] : 'professional';
|
||||||
|
$durationMonths = max(1, min(60, (int)($body['duration_months'] ?? 12)));
|
||||||
|
$expiresDays = max(1, min(365, (int)($body['invite_expires_days'] ?? 30)));
|
||||||
|
$maxUses = max(1, min(100, (int)($body['max_uses'] ?? 1)));
|
||||||
|
$quantity = max(1, min(50, (int)($body['quantity'] ?? 1)));
|
||||||
|
$label = substr(trim($body['label'] ?? ''), 0, 255) ?: null;
|
||||||
|
$channel = substr(trim($body['channel'] ?? 'manual'), 0, 64);
|
||||||
|
$issuedTo = substr(trim($body['issued_to'] ?? ''), 0, 255) ?: null;
|
||||||
|
$restrictVat = preg_replace('/[^0-9]/', '', $body['restrict_vat'] ?? '');
|
||||||
|
$restrictEmail = filter_var($body['restrict_email'] ?? '', FILTER_VALIDATE_EMAIL) ?: null;
|
||||||
|
$notes = trim($body['notes'] ?? '') ?: null;
|
||||||
|
$issuedBy = $this->getCurrentUserId();
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', strtotime("+{$expiresDays} days"));
|
||||||
|
|
||||||
|
$created = [];
|
||||||
|
for ($i = 0; $i < $quantity; $i++) {
|
||||||
|
$rawToken = self::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES));
|
||||||
|
$tokenHash = hash('sha256', $rawToken);
|
||||||
|
$prefix = substr($rawToken, 0, 10) . '...';
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO invites
|
||||||
|
(token_prefix, token_hash, plan, duration_months, label, notes,
|
||||||
|
max_uses, expires_at, channel, issued_to, issued_by,
|
||||||
|
restrict_vat, restrict_email)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
[
|
||||||
|
$prefix, $tokenHash, $plan, $durationMonths,
|
||||||
|
$label, $notes, $maxUses, $expiresAt,
|
||||||
|
$channel, $issuedTo, $issuedBy,
|
||||||
|
$restrictVat ?: null, $restrictEmail,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$inviteId = (int) Database::lastInsertId();
|
||||||
|
|
||||||
|
$created[] = [
|
||||||
|
'id' => $inviteId,
|
||||||
|
'token' => $rawToken, // SOLO qui viene restituito in chiaro
|
||||||
|
'token_prefix' => $prefix,
|
||||||
|
'plan' => $plan,
|
||||||
|
'duration_months' => $durationMonths,
|
||||||
|
'max_uses' => $maxUses,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'channel' => $channel,
|
||||||
|
'issued_to' => $issuedTo,
|
||||||
|
'label' => $label,
|
||||||
|
'invite_url' => APP_URL . '/onboarding.html?invite=' . urlencode($rawToken),
|
||||||
|
'provision_hint' => 'POST /api/services/provision con invite_token: "' . $rawToken . '"',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
$this->logAudit('invite.created', 'invite', $inviteId, [
|
||||||
|
'plan' => $plan, 'channel' => $channel,
|
||||||
|
'expires_at' => $expiresAt, 'max_uses' => $maxUses,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'invites' => $created,
|
||||||
|
'count' => count($created),
|
||||||
|
'warning' => 'Salva i token subito — non saranno più visibili in chiaro.',
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/invites
|
||||||
|
* Lista inviti con filtri. Solo super_admin.
|
||||||
|
*/
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->requireRole(['super_admin']);
|
||||||
|
|
||||||
|
$status = $_GET['status'] ?? null; // pending/used/expired/revoked
|
||||||
|
$channel = $_GET['channel'] ?? null;
|
||||||
|
$limit = min(100, max(1, (int)($_GET['limit'] ?? 50)));
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status) { $where[] = 'status = ?'; $params[] = $status; }
|
||||||
|
if ($channel) { $where[] = 'channel = ?'; $params[] = $channel; }
|
||||||
|
|
||||||
|
// Auto-scaduti: aggiorna status se expires_at passato
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE invites SET status='expired' WHERE status='pending' AND expires_at < NOW()"
|
||||||
|
);
|
||||||
|
|
||||||
|
$sql = 'SELECT id, token_prefix, plan, duration_months, label, max_uses, used_count,
|
||||||
|
expires_at, channel, issued_to, status, used_at, used_by_org_id,
|
||||||
|
restrict_vat, restrict_email, notes, created_at
|
||||||
|
FROM invites
|
||||||
|
WHERE ' . implode(' AND ', $where) .
|
||||||
|
' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||||
|
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
|
||||||
|
$rows = Database::fetchAll($sql, $params);
|
||||||
|
$total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM invites WHERE ' . implode(' AND ', array_slice($where, 0, -0)), array_slice($params, 0, -2))['c'];
|
||||||
|
|
||||||
|
// Aggiungi org_name per inviti usati
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
if ($row['used_by_org_id']) {
|
||||||
|
$org = Database::fetchOne('SELECT name FROM organizations WHERE id=? LIMIT 1', [$row['used_by_org_id']]);
|
||||||
|
$row['used_by_org_name'] = $org['name'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jsonSuccess(['invites' => $rows, 'total' => $total, 'limit' => $limit, 'offset' => $offset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/invites/{id}
|
||||||
|
* Dettaglio singolo invito. Solo super_admin.
|
||||||
|
*/
|
||||||
|
public function show(int $id = 0): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->requireRole(['super_admin']);
|
||||||
|
|
||||||
|
$row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
|
||||||
|
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
|
||||||
|
|
||||||
|
unset($row['token_hash']); // non esporre mai l'hash
|
||||||
|
|
||||||
|
if ($row['used_by_org_id']) {
|
||||||
|
$org = Database::fetchOne('SELECT name, sector, nis2_entity_type FROM organizations WHERE id=?', [$row['used_by_org_id']]);
|
||||||
|
$row['used_by_org'] = $org;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jsonSuccess($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/invites/{id}
|
||||||
|
* Revoca invito (non cancella — solo status=revoked). Solo super_admin.
|
||||||
|
*/
|
||||||
|
public function revoke(int $id = 0): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->requireRole(['super_admin']);
|
||||||
|
$row = Database::fetchOne('SELECT id, status FROM invites WHERE id=? LIMIT 1', [$id]);
|
||||||
|
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
|
||||||
|
if ($row['status'] === 'used') $this->jsonError('Invito già usato — non revocabile', 422, 'ALREADY_USED');
|
||||||
|
|
||||||
|
Database::execute("UPDATE invites SET status='revoked', updated_at=NOW() WHERE id=?", [$id]);
|
||||||
|
$this->logAudit('invite.revoked', 'invite', $id);
|
||||||
|
$this->jsonSuccess(['revoked' => true, 'id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/invites/{id}/regenerate
|
||||||
|
* Rigenera il token mantenendo config (piano, durata, scadenza). Solo super_admin.
|
||||||
|
* Utile se il token è stato compromesso PRIMA dell'uso.
|
||||||
|
*/
|
||||||
|
public function regenerate(int $id = 0): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->requireRole(['super_admin']);
|
||||||
|
$row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
|
||||||
|
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
|
||||||
|
if ($row['status'] === 'used') $this->jsonError('Invito già usato — impossibile rigenerare', 422, 'ALREADY_USED');
|
||||||
|
|
||||||
|
$rawToken = self::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES));
|
||||||
|
$tokenHash = hash('sha256', $rawToken);
|
||||||
|
$prefix = substr($rawToken, 0, 10) . '...';
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE invites SET token_prefix=?, token_hash=?, status='pending', updated_at=NOW() WHERE id=?",
|
||||||
|
[$prefix, $tokenHash, $id]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logAudit('invite.regenerated', 'invite', $id);
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'id' => $id,
|
||||||
|
'token' => $rawToken,
|
||||||
|
'token_prefix' => $prefix,
|
||||||
|
'warning' => 'Nuovo token generato. Vecchio token invalidato.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// PUBBLICO — Validazione invito (anteprima)
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/invites/validate/{token}
|
||||||
|
* Valida un invito e restituisce il piano incluso.
|
||||||
|
* Nessuna auth richiesta — usato da lg231 prima del provision
|
||||||
|
* e dalla pagina onboarding.html per mostrare l'anteprima.
|
||||||
|
*/
|
||||||
|
public function validate(): void
|
||||||
|
{
|
||||||
|
$token = $_GET['token'] ?? '';
|
||||||
|
if (!$token) $this->jsonError('Token mancante', 400, 'MISSING_TOKEN');
|
||||||
|
|
||||||
|
$result = self::resolveInvite($token);
|
||||||
|
if (!$result['valid']) {
|
||||||
|
$this->jsonError($result['error'], 422, $result['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inv = $result['invite'];
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'valid' => true,
|
||||||
|
'plan' => $inv['plan'],
|
||||||
|
'duration_months' => (int) $inv['duration_months'],
|
||||||
|
'expires_at' => $inv['expires_at'],
|
||||||
|
'remaining_uses' => (int)$inv['max_uses'] - (int)$inv['used_count'],
|
||||||
|
'channel' => $inv['channel'],
|
||||||
|
'label' => $inv['label'],
|
||||||
|
'plan_features' => self::planFeatures($inv['plan']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// HELPERS STATICI (usati anche da ServicesController::provision)
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida e restituisce l'invito dal token raw.
|
||||||
|
* @return array { valid: bool, invite: array|null, error: string|null, code: string|null }
|
||||||
|
*/
|
||||||
|
public static function resolveInvite(string $rawToken): array
|
||||||
|
{
|
||||||
|
if (!str_starts_with($rawToken, self::TOKEN_PREFIX)) {
|
||||||
|
return ['valid' => false, 'invite' => null, 'error' => 'Formato token non valido', 'code' => 'INVALID_FORMAT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = hash('sha256', $rawToken);
|
||||||
|
$invite = Database::fetchOne('SELECT * FROM invites WHERE token_hash=? LIMIT 1', [$hash]);
|
||||||
|
|
||||||
|
if (!$invite) {
|
||||||
|
return ['valid' => false, 'invite' => null, 'error' => 'Invito non trovato', 'code' => 'NOT_FOUND'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invite['status'] === 'revoked') {
|
||||||
|
return ['valid' => false, 'invite' => $invite, 'error' => 'Invito revocato', 'code' => 'REVOKED'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invite['status'] === 'used' && (int)$invite['used_count'] >= (int)$invite['max_uses']) {
|
||||||
|
return ['valid' => false, 'invite' => $invite, 'error' => 'Invito già utilizzato', 'code' => 'ALREADY_USED'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtotime($invite['expires_at']) < time()) {
|
||||||
|
Database::execute("UPDATE invites SET status='expired' WHERE id=?", [$invite['id']]);
|
||||||
|
return ['valid' => false, 'invite' => $invite, 'error' => 'Invito scaduto il ' . $invite['expires_at'], 'code' => 'EXPIRED'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => true, 'invite' => $invite, 'error' => null, 'code' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segna l'invito come usato (chiamato dopo provisioning riuscito).
|
||||||
|
*/
|
||||||
|
public static function markUsed(int $inviteId, int $orgId, string $ip): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE invites
|
||||||
|
SET used_count = used_count + 1,
|
||||||
|
status = CASE WHEN used_count + 1 >= max_uses THEN 'used' ELSE 'pending' END,
|
||||||
|
used_at = COALESCE(used_at, NOW()),
|
||||||
|
used_by_org_id = COALESCE(used_by_org_id, ?),
|
||||||
|
used_by_ip = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?",
|
||||||
|
[$orgId, $ip, $inviteId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature list per piano (usata nella preview invito).
|
||||||
|
*/
|
||||||
|
public static function planFeatures(string $plan): array
|
||||||
|
{
|
||||||
|
$features = [
|
||||||
|
'essentials' => [
|
||||||
|
'Gap Analysis Art.21 (80 domande)',
|
||||||
|
'Registro Rischi (ISO 27005)',
|
||||||
|
'Gestione Incidenti Art.23',
|
||||||
|
'Audit Trail SHA-256',
|
||||||
|
'Max 3 utenti',
|
||||||
|
'API read-only',
|
||||||
|
],
|
||||||
|
'professional' => [
|
||||||
|
'Tutto Essentials',
|
||||||
|
'Policy Management + AI',
|
||||||
|
'Supply Chain Assessment',
|
||||||
|
'Formazione & Compliance',
|
||||||
|
'Whistleblowing anonimo',
|
||||||
|
'Webhook outbound',
|
||||||
|
'API read+write',
|
||||||
|
'Max 10 utenti',
|
||||||
|
'SSO federato',
|
||||||
|
],
|
||||||
|
'enterprise' => [
|
||||||
|
'Tutto Professional',
|
||||||
|
'Organizzazioni illimitate',
|
||||||
|
'Utenti illimitati',
|
||||||
|
'API admin:org completa',
|
||||||
|
'Provisioning automatico',
|
||||||
|
'Benchmark settoriale',
|
||||||
|
'SLA prioritario',
|
||||||
|
'White-label disponibile',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
return $features[$plan] ?? $features['professional'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helper ───────────────────────────────────────────────────────────
|
||||||
|
private function getBody(): array
|
||||||
|
{
|
||||||
|
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -477,22 +477,51 @@ class ServicesController extends BaseController
|
|||||||
{
|
{
|
||||||
$this->setServiceHeaders();
|
$this->setServiceHeaders();
|
||||||
|
|
||||||
// ── 1. Auth via PROVISION_SECRET ────────────────────────────────
|
// ── 1. Leggi body (serve per invite_token) ───────────────────────
|
||||||
$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 ───────────────────────────────────────
|
|
||||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
$company = $body['company'] ?? [];
|
$company = $body['company'] ?? [];
|
||||||
$admin = $body['admin'] ?? [];
|
$admin = $body['admin'] ?? [];
|
||||||
$license = $body['license'] ?? [];
|
$license = $body['license'] ?? [];
|
||||||
$caller = $body['caller'] ?? [];
|
$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'] ?? '');
|
$ragioneSociale = trim($company['ragione_sociale'] ?? '');
|
||||||
$partitaIva = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
|
$partitaIva = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
|
||||||
$adminEmail = trim($admin['email'] ?? '');
|
$adminEmail = trim($admin['email'] ?? '');
|
||||||
@ -660,7 +689,16 @@ class ServicesController extends BaseController
|
|||||||
performedBy: ($caller['system'] ?? 'external') . ':provision'
|
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;
|
$callbackUrl = $caller['callback_url'] ?? null;
|
||||||
if ($callbackUrl) {
|
if ($callbackUrl) {
|
||||||
$callbackPayload = json_encode([
|
$callbackPayload = json_encode([
|
||||||
@ -707,6 +745,10 @@ class ServicesController extends BaseController
|
|||||||
// Link
|
// Link
|
||||||
'dashboard_url' => APP_URL . '/dashboard.html',
|
'dashboard_url' => APP_URL . '/dashboard.html',
|
||||||
'settings_url' => APP_URL . '/settings.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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
docs/sql/012_invites.sql
Normal file
58
docs/sql/012_invites.sql
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- NIS2 Agile — Migration 012: Sistema Inviti / Licenze
|
||||||
|
-- Inviti a tempo per provisioning automatico da e-commerce,
|
||||||
|
-- lg231 e altri sistemi Agile partner.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
USE nis2_agile_db;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
||||||
|
-- Token (visibile al chiamante, formato: inv_XXXX...)
|
||||||
|
token_prefix VARCHAR(16) NOT NULL, -- es: inv_a1b2c3
|
||||||
|
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 del token completo
|
||||||
|
|
||||||
|
-- Configurazione licenza
|
||||||
|
plan ENUM('essentials','professional','enterprise') NOT NULL DEFAULT 'professional',
|
||||||
|
duration_months TINYINT UNSIGNED NOT NULL DEFAULT 12,
|
||||||
|
label VARCHAR(255) NULL, -- es: "lg231 Partner Q1 2026"
|
||||||
|
notes TEXT NULL,
|
||||||
|
|
||||||
|
-- Limiti utilizzo
|
||||||
|
max_uses TINYINT UNSIGNED NOT NULL DEFAULT 1, -- 1 = one-shot, N = multi-use
|
||||||
|
used_count TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Scadenza invito (indipendente dalla scadenza licenza)
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
|
||||||
|
-- Canale di distribuzione
|
||||||
|
channel VARCHAR(64) NULL, -- 'lg231', 'ecommerce', 'direct', 'manual'
|
||||||
|
issued_to VARCHAR(255) NULL, -- email o nome destinatario
|
||||||
|
issued_by INT UNSIGNED NULL, -- user_id NIS2 admin che ha generato
|
||||||
|
|
||||||
|
-- Utilizzo
|
||||||
|
status ENUM('pending','used','expired','revoked') NOT NULL DEFAULT 'pending',
|
||||||
|
used_at DATETIME NULL,
|
||||||
|
used_by_org_id INT UNSIGNED NULL, -- org creata dall'invito
|
||||||
|
used_by_ip VARCHAR(45) NULL,
|
||||||
|
|
||||||
|
-- Restrizioni facoltative
|
||||||
|
restrict_vat VARCHAR(11) NULL, -- solo per questa P.IVA
|
||||||
|
restrict_email VARCHAR(255) NULL, -- solo per questo admin email
|
||||||
|
|
||||||
|
-- Metadata extra (JSON)
|
||||||
|
metadata JSON NULL, -- dati extra (es: lg231 order_id)
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
INDEX idx_invites_token_hash (token_hash),
|
||||||
|
INDEX idx_invites_status (status),
|
||||||
|
INDEX idx_invites_channel (channel),
|
||||||
|
INDEX idx_invites_expires (expires_at),
|
||||||
|
INDEX idx_invites_org (used_by_org_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SELECT 'Migration 012 invites completata.' AS stato;
|
||||||
@ -99,6 +99,7 @@ $controllerMap = [
|
|||||||
'onboarding' => 'OnboardingController',
|
'onboarding' => 'OnboardingController',
|
||||||
'ncr' => 'NonConformityController',
|
'ncr' => 'NonConformityController',
|
||||||
'services' => 'ServicesController',
|
'services' => 'ServicesController',
|
||||||
|
'invites' => 'InviteController',
|
||||||
'webhooks' => 'WebhookController',
|
'webhooks' => 'WebhookController',
|
||||||
'whistleblowing'=> 'WhistleblowingController',
|
'whistleblowing'=> 'WhistleblowingController',
|
||||||
'normative' => 'NormativeController',
|
'normative' => 'NormativeController',
|
||||||
@ -296,6 +297,17 @@ $actionMap = [
|
|||||||
'POST:webhook' => 'webhook',
|
'POST:webhook' => 'webhook',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ── InviteController (licenze/inviti B2B) ──────
|
||||||
|
'invites' => [
|
||||||
|
'POST:create' => 'create',
|
||||||
|
'GET:index' => 'index',
|
||||||
|
'GET:list' => 'index',
|
||||||
|
'GET:{id}' => 'show',
|
||||||
|
'DELETE:{id}' => 'revoke',
|
||||||
|
'POST:{id}/regenerate' => 'regenerate',
|
||||||
|
'GET:validate' => 'validate', // ?token=inv_...
|
||||||
|
],
|
||||||
|
|
||||||
// ── ServicesController (API pubblica) ──────────
|
// ── ServicesController (API pubblica) ──────────
|
||||||
'services' => [
|
'services' => [
|
||||||
'POST:provision' => 'provision',
|
'POST:provision' => 'provision',
|
||||||
|
|||||||
@ -85,6 +85,13 @@
|
|||||||
padding: .15rem .4rem; border-radius: 3px;
|
padding: .15rem .4rem; border-radius: 3px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
.method-delete {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(239,68,68,.15); color: #ef4444;
|
||||||
|
font-size: .65rem; font-weight: 700;
|
||||||
|
padding: .15rem .4rem; border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
@ -247,6 +254,7 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tab-nav">
|
<div class="tab-nav">
|
||||||
<button class="tab-btn active" onclick="showTab('api')">Services API</button>
|
<button class="tab-btn active" onclick="showTab('api')">Services API</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('invites')">Inviti & Licenze</button>
|
||||||
<button class="tab-btn" onclick="showTab('lg231')">Guida lg231</button>
|
<button class="tab-btn" onclick="showTab('lg231')">Guida lg231</button>
|
||||||
<button class="tab-btn" onclick="showTab('webhook')">Webhook</button>
|
<button class="tab-btn" onclick="showTab('webhook')">Webhook</button>
|
||||||
<button class="tab-btn" onclick="showTab('quickstart')">Quick Start</button>
|
<button class="tab-btn" onclick="showTab('quickstart')">Quick Start</button>
|
||||||
@ -310,6 +318,153 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB: Inviti & Licenze -->
|
||||||
|
<div id="tab-invites" class="tab-pane">
|
||||||
|
<div class="callout">
|
||||||
|
<strong>Sistema Inviti / Licenze B2B:</strong> NIS2 Agile genera token di invito che abilitano
|
||||||
|
il provisioning automatico di organizzazioni e utenti. L'e-commerce o il partner riceve l'invito,
|
||||||
|
lo consegna al cliente finale (es. lg231), che lo usa per attivarsi automaticamente.
|
||||||
|
L'accesso si interrompe alla scadenza dell'invito.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Flusso completo</div>
|
||||||
|
<div class="code-block" style="font-family:monospace;line-height:1.8;font-size:.82rem">
|
||||||
|
<span style="color:#22c55e">1. GENERAZIONE</span> NIS2 Admin (super_admin)
|
||||||
|
POST /api/invites → { token: "inv_abc123...", expires_at, plan, max_uses }
|
||||||
|
<span class="cmt">Il token è visibile UNA SOLA VOLTA. Solo hash SHA-256 in DB.</span>
|
||||||
|
|
||||||
|
<span style="color:#06b6d4">2. DISTRIBUZIONE</span> NIS2 → E-commerce → lg231
|
||||||
|
NIS2 Admin passa inv_abc123... all'e-commerce
|
||||||
|
E-commerce lo consegna al cliente con l'ordine
|
||||||
|
lg231 lo riceve (es. in metadata ordine)
|
||||||
|
|
||||||
|
<span style="color:#a78bfa">3. VALIDAZIONE</span> lg231 prima del provisioning
|
||||||
|
GET /api/invites/validate?token=inv_abc123...
|
||||||
|
→ { valid: true, plan: "professional", expires_at, remaining_uses, plan_features }
|
||||||
|
|
||||||
|
<span style="color:#f59e0b">4. PROVISIONING</span> lg231 attiva automaticamente
|
||||||
|
POST /api/services/provision (body include "invite_token": "inv_abc123...")
|
||||||
|
→ { org_id, api_key, access_token, license_expires_at, temp_password }
|
||||||
|
NIS2 marca l'invito come usato (used_count++)
|
||||||
|
|
||||||
|
<span style="color:#ef4444">5. SCADENZA</span> Accesso automaticamente revocato
|
||||||
|
Quando expires_at raggiunto:
|
||||||
|
- API key rimane ma licenza_expires_at è nel passato
|
||||||
|
- GET /api/invites/validate → { valid: false, code: "EXPIRED" }
|
||||||
|
- Rinnovo: nuovo invito dal NIS2 Admin → nuovo provisioning
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">API Inviti (solo super_admin)</div>
|
||||||
|
<table class="api-table">
|
||||||
|
<thead><tr><th>Metodo</th><th>Endpoint</th><th>Auth</th><th>Descrizione</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><span class="method-post">POST</span></td><td><code>/api/invites/create</code></td><td>JWT super_admin</td><td>Genera 1..50 inviti in batch</td></tr>
|
||||||
|
<tr><td><span class="method-get">GET</span></td><td><code>/api/invites/list</code></td><td>JWT super_admin</td><td>Lista con filtri status/channel, auto-scade pending</td></tr>
|
||||||
|
<tr><td><span class="method-get">GET</span></td><td><code>/api/invites/{id}</code></td><td>JWT super_admin</td><td>Dettaglio singolo + org usante</td></tr>
|
||||||
|
<tr><td><span class="method-delete">DELETE</span></td><td><code>/api/invites/{id}</code></td><td>JWT super_admin</td><td>Revoca (non cancella — solo status=revoked)</td></tr>
|
||||||
|
<tr><td><span class="method-post">POST</span></td><td><code>/api/invites/{id}/regenerate</code></td><td>JWT super_admin</td><td>Nuovo token, stessa configurazione</td></tr>
|
||||||
|
<tr><td><span class="method-get">GET</span></td><td><code>/api/invites/validate?token=inv_...</code></td><td>—</td><td>Anteprima pubblica: piano, scadenza, usi rimasti</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-title">Generare un invito (NIS2 Admin)</div>
|
||||||
|
<div class="code-block"><span class="method-post">POST</span> https://nis2.agile.software/api/invites/create
|
||||||
|
Authorization: Bearer {jwt_super_admin}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
<span class="key">"plan"</span>: <span class="str">"professional"</span>, <span class="cmt">// essentials | professional | enterprise</span>
|
||||||
|
<span class="key">"duration_months"</span>: 12, <span class="cmt">// durata licenza dopo attivazione</span>
|
||||||
|
<span class="key">"invite_expires_days"</span>: 30, <span class="cmt">// giorni di validità dell'invito stesso</span>
|
||||||
|
<span class="key">"max_uses"</span>: 1, <span class="cmt">// 1=one-shot, N=batch (es: 10 per reseller)</span>
|
||||||
|
<span class="key">"label"</span>: <span class="str">"lg231 Partner Q1 2026"</span>,
|
||||||
|
<span class="key">"channel"</span>: <span class="str">"lg231"</span>, <span class="cmt">// lg231 | ecommerce | direct | manual</span>
|
||||||
|
<span class="key">"issued_to"</span>: <span class="str">"partner@lg231.it"</span>,
|
||||||
|
<span class="key">"notes"</span>: <span class="str">"Ordine OPP-2026-042"</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="cmt">// Risposta — token visibile UNA SOLA VOLTA:</span>
|
||||||
|
{
|
||||||
|
<span class="key">"invites"</span>: [{
|
||||||
|
<span class="key">"id"</span>: 7,
|
||||||
|
<span class="key">"token"</span>: <span class="str">"inv_a1b2c3d4e5f6..."</span>, <span class="cmt">← SALVARE SUBITO, non recuperabile</span>
|
||||||
|
<span class="key">"plan"</span>: <span class="str">"professional"</span>,
|
||||||
|
<span class="key">"expires_at"</span>: <span class="str">"2026-04-06 12:00:00"</span>,
|
||||||
|
<span class="key">"invite_url"</span>: <span class="str">"https://nis2.agile.software/onboarding.html?invite=inv_..."</span>
|
||||||
|
}],
|
||||||
|
<span class="key">"warning"</span>: <span class="str">"Salva i token subito — non saranno più visibili in chiaro."</span>
|
||||||
|
}</div>
|
||||||
|
|
||||||
|
<div class="section-title">Provisioning con invito (lg231 → NIS2)</div>
|
||||||
|
<div class="code-block"><span class="cmt">// lg231 usa l'invite_token ricevuto per attivare automaticamente il cliente:</span>
|
||||||
|
<span class="method-post">POST</span> https://nis2.agile.software/api/services/provision
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
<span class="key">"invite_token"</span>: <span class="str">"inv_a1b2c3d4..."</span>, <span class="cmt">← sostituisce X-Provision-Secret</span>
|
||||||
|
<span class="key">"company"</span>: {
|
||||||
|
<span class="key">"ragione_sociale"</span>: <span class="str">"Acme S.r.l."</span>,
|
||||||
|
<span class="key">"partita_iva"</span>: <span class="str">"02345678901"</span>,
|
||||||
|
<span class="key">"ateco_code"</span>: <span class="str">"62.01.00"</span>,
|
||||||
|
<span class="key">"sector"</span>: <span class="str">"ict"</span>
|
||||||
|
},
|
||||||
|
<span class="key">"admin"</span>: {
|
||||||
|
<span class="key">"email"</span>: <span class="str">"ciso@acme.it"</span>,
|
||||||
|
<span class="key">"first_name"</span>: <span class="str">"Marco"</span>,
|
||||||
|
<span class="key">"last_name"</span>: <span class="str">"Rossi"</span>
|
||||||
|
},
|
||||||
|
<span class="key">"caller"</span>: {
|
||||||
|
<span class="key">"system"</span>: <span class="str">"lg231"</span>,
|
||||||
|
<span class="key">"company_id"</span>: 142,
|
||||||
|
<span class="key">"callback_url"</span>: <span class="str">"https://lg231.agile.software/api/integrations/nis2-callback"</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="cmt">// Risposta (il piano e la durata vengono dall'invito, non dal body):</span>
|
||||||
|
{
|
||||||
|
<span class="key">"provisioned"</span>: <span class="kw">true</span>,
|
||||||
|
<span class="key">"org_id"</span>: 8,
|
||||||
|
<span class="key">"api_key"</span>: <span class="str">"nis2_abcdef..."</span>, <span class="cmt">← salvare in lg231 metadata</span>
|
||||||
|
<span class="key">"access_token"</span>: <span class="str">"eyJ..."</span>, <span class="cmt">← JWT 2h per apertura immediata</span>
|
||||||
|
<span class="key">"temp_password"</span>: <span class="str">"NIS2_xxxxxx"</span>, <span class="cmt">← cambio obbligatorio al primo login</span>
|
||||||
|
<span class="key">"invite_plan"</span>: <span class="str">"professional"</span>,
|
||||||
|
<span class="key">"license_expires_at"</span>: <span class="str">"2027-03-07 12:00:00"</span>,
|
||||||
|
<span class="key">"dashboard_url"</span>: <span class="str">"https://nis2.agile.software/dashboard.html"</span>
|
||||||
|
}</div>
|
||||||
|
|
||||||
|
<div class="section-title">Validazione preventiva (opzionale)</div>
|
||||||
|
<div class="code-block"><span class="cmt"># lg231 può validare l'invito prima del provisioning:</span>
|
||||||
|
curl "https://nis2.agile.software/api/invites/validate?token=inv_a1b2c3..."
|
||||||
|
|
||||||
|
<span class="cmt">// Se valido:</span>
|
||||||
|
{ <span class="key">"valid"</span>: <span class="kw">true</span>, <span class="key">"plan"</span>: <span class="str">"professional"</span>, <span class="key">"duration_months"</span>: 12,
|
||||||
|
<span class="key">"expires_at"</span>: <span class="str">"2026-04-06T12:00:00"</span>, <span class="key">"remaining_uses"</span>: 1,
|
||||||
|
<span class="key">"plan_features"</span>: [<span class="str">"Policy Management + AI"</span>, <span class="str">"Supply Chain Assessment"</span>, ...] }
|
||||||
|
|
||||||
|
<span class="cmt">// Se scaduto:</span>
|
||||||
|
{ <span class="key">"valid"</span>: <span class="kw">false</span>, <span class="key">"error"</span>: <span class="str">"Invito scaduto il 2026-03-01 00:00:00"</span>, <span class="key">"code"</span>: <span class="str">"EXPIRED"</span> }
|
||||||
|
|
||||||
|
<span class="cmt">// Se già usato:</span>
|
||||||
|
{ <span class="key">"valid"</span>: <span class="kw">false</span>, <span class="key">"error"</span>: <span class="str">"Invito già utilizzato"</span>, <span class="key">"code"</span>: <span class="str">"ALREADY_USED"</span> }</div>
|
||||||
|
|
||||||
|
<div class="section-title">Gestione ciclo di vita</div>
|
||||||
|
<table class="api-table">
|
||||||
|
<thead><tr><th>Stato invito</th><th>Significato</th><th>Azione</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>pending</code></td><td>Valido, non ancora usato</td><td>Usabile per provision</td></tr>
|
||||||
|
<tr><td><code>used</code></td><td>Raggiunto max_uses</td><td>Provisioning bloccato</td></tr>
|
||||||
|
<tr><td><code>expired</code></td><td>expires_at superato</td><td>Provisioning bloccato, rinnovare</td></tr>
|
||||||
|
<tr><td><code>revoked</code></td><td>Revocato da admin</td><td>Provisioning bloccato permanente</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<strong>Sicurezza token:</strong> il token <code>inv_</code> viene restituito in chiaro UNA SOLA VOLTA
|
||||||
|
alla creazione. Nel DB è conservato solo il hash SHA-256. Il <code>token_prefix</code> (es: <code>inv_a1b2c3...</code>)
|
||||||
|
è visibile nell'admin panel per identificazione visiva.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TAB: Guida lg231 -->
|
<!-- TAB: Guida lg231 -->
|
||||||
<div id="tab-lg231" class="tab-pane">
|
<div id="tab-lg231" class="tab-pane">
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
@ -427,10 +582,32 @@ $ssoResp = Nis2Client::post(<span class="str">'/sso'</span>, $apiKey, [
|
|||||||
<code>if (ssoToken) { localStorage.setItem('nis2_token', ssoToken); location.hash = ''; }</code>
|
<code>if (ssoToken) { localStorage.setItem('nis2_token', ssoToken); location.hash = ''; }</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">5. Provisioning automatico dal portale lg231</div>
|
||||||
|
<div class="callout">
|
||||||
|
Quando un cliente lg231 acquista una licenza NIS2 Agile, lg231 riceve un <strong>invite_token</strong>
|
||||||
|
dall'e-commerce. Con questo token lg231 chiama <code>POST /api/services/provision</code> passando
|
||||||
|
i dati aziendali del cliente. NIS2 crea automaticamente org, admin e API key. Il piano e la
|
||||||
|
durata sono definiti dall'invito (non modificabili dal chiamante).
|
||||||
|
</div>
|
||||||
|
<div class="code-block"><span class="cmt">// Salva in lg231 companies.metadata dopo provisioning:</span>
|
||||||
|
$meta[<span class="str">'nis2_api_key'</span>] = $response[<span class="str">'api_key'</span>];
|
||||||
|
$meta[<span class="str">'nis2_org_id'</span>] = $response[<span class="str">'org_id'</span>];
|
||||||
|
$meta[<span class="str">'nis2_invite_id'</span>] = $response[<span class="str">'invite_id'</span>];
|
||||||
|
$meta[<span class="str">'nis2_license_plan'</span>] = $response[<span class="str">'invite_plan'</span>];
|
||||||
|
$meta[<span class="str">'nis2_expires_at'</span>] = $response[<span class="str">'license_expires_at'</span>];
|
||||||
|
$meta[<span class="str">'nis2_enabled'</span>] = <span class="kw">true</span>;
|
||||||
|
|
||||||
|
<span class="cmt">// Controlla scadenza prima di ogni chiamata:</span>
|
||||||
|
<span class="kw">if</span> (strtotime($meta[<span class="str">'nis2_expires_at'</span>] ?? <span class="str">'1970-01-01'</span>) < time()) {
|
||||||
|
<span class="cmt">// licenza scaduta → mostrare banner rinnovo, non chiamare NIS2</span>
|
||||||
|
<span class="kw">return</span> [<span class="str">'success'</span> => <span class="kw">false</span>, <span class="str">'reason'</span> => <span class="str">'license_expired'</span>];
|
||||||
|
}</div>
|
||||||
|
|
||||||
<div class="section-title">Checklist implementazione lg231</div>
|
<div class="section-title">Checklist implementazione lg231</div>
|
||||||
<ul class="checklist">
|
<ul class="checklist">
|
||||||
<li>company-ms: aggiungere <code>nis2_api_key</code>, <code>nis2_org_id</code>, <code>nis2_enabled</code> a provider-config</li>
|
<li>company-ms: aggiungere <code>nis2_api_key</code>, <code>nis2_org_id</code>, <code>nis2_enabled</code>, <code>nis2_invite_id</code>, <code>nis2_license_plan</code>, <code>nis2_expires_at</code> a provider-config</li>
|
||||||
<li>shared: creare <code>Nis2Client</code> (curl wrapper leggero)</li>
|
<li>shared: creare <code>Nis2Client</code> (curl wrapper leggero) con check scadenza licenza</li>
|
||||||
|
<li>company-ms: endpoint provisioning — ricevi invite_token, chiama <code>POST /api/services/provision</code>, salva risposta in metadata</li>
|
||||||
<li>UI: widget NIS2 score nella company detail (gauge + badge + rischi high)</li>
|
<li>UI: widget NIS2 score nella company detail (gauge + badge + rischi high)</li>
|
||||||
<li>risk-ms: import rischi cyber durante assessment (categoria art.24-bis)</li>
|
<li>risk-ms: import rischi cyber durante assessment (categoria art.24-bis)</li>
|
||||||
<li>monitoring-ms: escalation incidenti significativi → odv_activity</li>
|
<li>monitoring-ms: escalation incidenti significativi → odv_activity</li>
|
||||||
@ -571,7 +748,7 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
|
|||||||
<script>
|
<script>
|
||||||
function showTab(id) {
|
function showTab(id) {
|
||||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||||
const ids = ['api', 'lg231', 'webhook', 'quickstart'];
|
const ids = ['api', 'invites', 'lg231', 'webhook', 'quickstart'];
|
||||||
b.classList.toggle('active', ids[i] === id);
|
b.classList.toggle('active', ids[i] === id);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.tab-pane').forEach(p => {
|
document.querySelectorAll('.tab-pane').forEach(p => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user