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) ?? []; } }