[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:
co-authored by
Claude Sonnet 4.6
parent
6933e1d3fb
commit
612befd66d
@@ -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();
|
||||
|
||||
// ── 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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user