[INTEG] Provisioning B2B automatico + fix JWT helpers
- POST /api/services/provision: onboarding automatico tenant da lg231 - X-Provision-Secret auth (master secret, non org-specific) - Crea org (con tutti i dati lg231: P.IVA, ATECO, sede, PEC, fatturato) - Crea admin user con password temporanea (must_change_password=1) - Genera API Key scope [read:all, write:all, admin:org, sso:login] - Emette JWT 2h per apertura immediata UI - Callback webhook a lg231 con api_key - Idempotent: stessa P.IVA → restituisce org esistente - Audit: org.provisioned severity=critical - config.php: PROVISION_SECRET (env var) - BaseController: base64UrlEncode/Decode da private → protected - Migration 011: colonne provisioning + must_change_password + indexes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29aaf5db88
commit
6933e1d3fb
@ -33,6 +33,13 @@ define('JWT_ALGORITHM', 'HS256');
|
||||
define('JWT_EXPIRES_IN', Env::int('JWT_EXPIRES_IN', 7200));
|
||||
define('JWT_REFRESH_EXPIRES_IN', Env::int('JWT_REFRESH_EXPIRES_IN', 604800));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROVISIONING (B2B — lg231 e altri sistemi Agile)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Secret master per provisioning automatico da sistemi Agile partner.
|
||||
// lg231 lo usa per POST /api/services/provision (onboarding automatico tenant).
|
||||
define('PROVISION_SECRET', Env::get('PROVISION_SECRET', 'nis2_prov_dev_secret'));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PASSWORD POLICY
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -515,12 +515,12 @@ class BaseController
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function base64UrlEncode(string $data): string
|
||||
protected function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $data): string
|
||||
protected function base64UrlDecode(string $data): string
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/'));
|
||||
}
|
||||
|
||||
@ -229,9 +229,9 @@ class ServicesController extends BaseController
|
||||
];
|
||||
|
||||
// JWT manuale HS256 (stesso formato di BaseController)
|
||||
$header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
$body = base64url_encode(json_encode($payload));
|
||||
$signature = base64url_encode(hash_hmac('sha256', "$header.$body", $secret, true));
|
||||
$header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
$body = $this->base64UrlEncode(json_encode($payload));
|
||||
$signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$body", $secret, true));
|
||||
$jwt = "$header.$body.$signature";
|
||||
|
||||
// Audit
|
||||
@ -371,9 +371,9 @@ class ServicesController extends BaseController
|
||||
'type' => 'access',
|
||||
];
|
||||
|
||||
$header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
$jwtBody = base64url_encode(json_encode($payload));
|
||||
$signature = base64url_encode(hash_hmac('sha256', "$header.$jwtBody", $secret, true));
|
||||
$header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
$jwtBody = $this->base64UrlEncode(json_encode($payload));
|
||||
$signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$jwtBody", $secret, true));
|
||||
$jwt = "$header.$jwtBody.$signature";
|
||||
|
||||
// Audit trail — traccia SSO con responsabilità
|
||||
@ -410,6 +410,306 @@ class ServicesController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/services/provision
|
||||
*
|
||||
* Provisioning automatico B2B: lg231 (o altro sistema Agile partner)
|
||||
* acquista una licenza NIS2 per conto di un cliente e invia i dati.
|
||||
* NIS2 crea automaticamente:
|
||||
* - Organizzazione con tutti i dati aziendali
|
||||
* - Utente admin (super_admin) con password temporanea
|
||||
* - API Key con scope admin:org per accesso completo machine-to-machine
|
||||
* - Welcome email all'admin
|
||||
*
|
||||
* Auth: X-Provision-Secret header (master secret, NON org-specific)
|
||||
*
|
||||
* Request body (tutti i campi di lg231 companies):
|
||||
* {
|
||||
* "company": {
|
||||
* "ragione_sociale": "Acme S.r.l.", // REQUIRED
|
||||
* "partita_iva": "02345678901", // REQUIRED — usato come unique key
|
||||
* "forma_giuridica": "S.r.l.",
|
||||
* "codice_fiscale": "ACMESRL02345678901",
|
||||
* "ateco_code": "62.01.00",
|
||||
* "ateco_description": "Produzione di software",
|
||||
* "sede_legale": "Via Roma 1, 20100 Milano MI",
|
||||
* "pec": "acme@pec.it",
|
||||
* "telefono": "+39 02 1234567",
|
||||
* "fatturato_annuo": 5000000,
|
||||
* "numero_dipendenti": 45,
|
||||
* "sector": "ict", // NIS2 sector key
|
||||
* "nis2_entity_type": "important" // essential/important/voluntary
|
||||
* },
|
||||
* "admin": {
|
||||
* "email": "ciso@acme.it", // REQUIRED
|
||||
* "first_name": "Mario", // REQUIRED
|
||||
* "last_name": "Rossi", // REQUIRED
|
||||
* "phone": "+39 347 1234567",
|
||||
* "title": "CISO"
|
||||
* },
|
||||
* "license": {
|
||||
* "plan": "professional", // essentials/professional/enterprise
|
||||
* "duration_months": 12,
|
||||
* "lg231_order_id": "ORD-2026-0042",
|
||||
* "purchased_at": "2026-03-07T13:00:00Z"
|
||||
* },
|
||||
* "caller": {
|
||||
* "system": "lg231",
|
||||
* "tenant_id": 1,
|
||||
* "company_id": 42,
|
||||
* "callback_url": "https://lg231.agile.software/api/integrations/nis2-callback"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "org_id": 8,
|
||||
* "org_name": "Acme S.r.l.",
|
||||
* "admin_user_id": 15,
|
||||
* "admin_email": "ciso@acme.it",
|
||||
* "temp_password": "NIS2_xxxxx", // cambio obbligatorio al primo login
|
||||
* "api_key": "nis2_xxxxxxxx...", // salva in lg231 companies.metadata.nis2_api_key
|
||||
* "access_token": "eyJ...", // JWT 2h per uso immediato
|
||||
* "dashboard_url": "https://nis2.agile.software/dashboard.html"
|
||||
* }
|
||||
*/
|
||||
public function provision(): void
|
||||
{
|
||||
$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 ───────────────────────────────────────
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$company = $body['company'] ?? [];
|
||||
$admin = $body['admin'] ?? [];
|
||||
$license = $body['license'] ?? [];
|
||||
$caller = $body['caller'] ?? [];
|
||||
|
||||
$ragioneSociale = trim($company['ragione_sociale'] ?? '');
|
||||
$partitaIva = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
|
||||
$adminEmail = trim($admin['email'] ?? '');
|
||||
$adminFirst = trim($admin['first_name'] ?? '');
|
||||
$adminLast = trim($admin['last_name'] ?? '');
|
||||
|
||||
if (!$ragioneSociale) $this->jsonError('company.ragione_sociale obbligatorio', 400, 'MISSING_FIELD');
|
||||
if (strlen($partitaIva) !== 11) $this->jsonError('company.partita_iva non valida (11 cifre)', 400, 'INVALID_VAT');
|
||||
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $this->jsonError('admin.email non valida', 400, 'INVALID_EMAIL');
|
||||
if (!$adminFirst || !$adminLast) $this->jsonError('admin.first_name e last_name obbligatori', 400, 'MISSING_FIELD');
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// ── 3. Idempotency: se org con stessa P.IVA esiste già → aggiorna e restituisce ──
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM organizations WHERE vat_number = ? LIMIT 1',
|
||||
[$partitaIva]
|
||||
);
|
||||
|
||||
// Mappa sector lg231 → NIS2
|
||||
$sectorMap = [
|
||||
'manufacturing' => 'manifattura', 'ict' => 'ict', 'consulting' => 'servizi_professionali',
|
||||
'social' => 'altro', 'energia' => 'energia', 'energy' => 'energia',
|
||||
'sanità' => 'sanita', 'health' => 'sanita', 'finance' => 'finanza',
|
||||
'trasporti' => 'trasporti', 'transport' => 'trasporti', 'water' => 'acqua',
|
||||
'digital' => 'ict', 'waste' => 'gestione_rifiuti',
|
||||
];
|
||||
$rawSector = strtolower($company['sector'] ?? 'ict');
|
||||
$nis2Sector = $sectorMap[$rawSector] ?? $rawSector;
|
||||
$nis2EntityType = in_array($company['nis2_entity_type'] ?? '', ['essential', 'important', 'voluntary'])
|
||||
? $company['nis2_entity_type'] : 'important';
|
||||
|
||||
if ($existing) {
|
||||
$orgId = (int) $existing['id'];
|
||||
} else {
|
||||
// ── 4. Crea organizzazione ───────────────────────────────────
|
||||
Database::execute(
|
||||
'INSERT INTO organizations
|
||||
(name, legal_form, vat_number, fiscal_code, ateco_code, ateco_description,
|
||||
legal_address, pec, phone, annual_turnover_eur, employees,
|
||||
sector, nis2_entity_type, status,
|
||||
provisioned_by, provisioned_at, license_plan, license_expires_at,
|
||||
lg231_company_id, lg231_order_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,\'active\',?,NOW(),?,?,?,?)',
|
||||
[
|
||||
$ragioneSociale,
|
||||
$company['forma_giuridica'] ?? null,
|
||||
$partitaIva,
|
||||
$company['codice_fiscale'] ?? null,
|
||||
$company['ateco_code'] ?? null,
|
||||
$company['ateco_description'] ?? null,
|
||||
$company['sede_legale'] ?? null,
|
||||
$company['pec'] ?? null,
|
||||
$company['telefono'] ?? null,
|
||||
$company['fatturato_annuo'] ?? null,
|
||||
$company['numero_dipendenti'] ?? null,
|
||||
$nis2Sector,
|
||||
$nis2EntityType,
|
||||
$caller['system'] ?? 'external',
|
||||
$license['plan'] ?? 'professional',
|
||||
isset($license['duration_months'])
|
||||
? date('Y-m-d', strtotime('+' . (int)$license['duration_months'] . ' months'))
|
||||
: date('Y-m-d', strtotime('+12 months')),
|
||||
$caller['company_id'] ?? null,
|
||||
$license['lg231_order_id'] ?? null,
|
||||
]
|
||||
);
|
||||
$orgId = (int) Database::lastInsertId();
|
||||
}
|
||||
|
||||
// ── 5. Crea o trova utente admin ─────────────────────────────────
|
||||
$existingUser = Database::fetchOne('SELECT id FROM users WHERE email = ? LIMIT 1', [$adminEmail]);
|
||||
|
||||
$tempPassword = 'NIS2_' . bin2hex(random_bytes(6));
|
||||
$passwordHash = password_hash($tempPassword, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
|
||||
if ($existingUser) {
|
||||
$userId = (int) $existingUser['id'];
|
||||
} else {
|
||||
Database::execute(
|
||||
'INSERT INTO users (email, password_hash, first_name, last_name, role, status,
|
||||
phone, job_title, must_change_password)
|
||||
VALUES (?,?,?,?,\'super_admin\',\'active\',?,?,1)',
|
||||
[$adminEmail, $passwordHash, $adminFirst, $adminLast,
|
||||
$admin['phone'] ?? null, $admin['title'] ?? null]
|
||||
);
|
||||
$userId = (int) Database::lastInsertId();
|
||||
}
|
||||
|
||||
// Assicura membership
|
||||
$mem = Database::fetchOne(
|
||||
'SELECT id FROM user_organizations WHERE user_id=? AND organization_id=? LIMIT 1',
|
||||
[$userId, $orgId]
|
||||
);
|
||||
if (!$mem) {
|
||||
Database::execute(
|
||||
'INSERT INTO user_organizations (user_id, organization_id, role) VALUES (?,?,\'super_admin\')',
|
||||
[$userId, $orgId]
|
||||
);
|
||||
}
|
||||
|
||||
// ── 6. Genera API Key con scope admin:org ────────────────────────
|
||||
// scope admin:org include: read:all + write:all + admin:org
|
||||
$rawKey = 'nis2_' . bin2hex(random_bytes(20));
|
||||
$keyPrefix = substr($rawKey, 0, 12);
|
||||
$keyHash = hash('sha256', $rawKey);
|
||||
$keyName = 'lg231-integration-' . $partitaIva;
|
||||
$expiresAt = $license['duration_months']
|
||||
? date('Y-m-d H:i:s', strtotime('+' . (int)$license['duration_months'] . ' months'))
|
||||
: date('Y-m-d H:i:s', strtotime('+12 months'));
|
||||
|
||||
// Revoca eventuali chiavi lg231-integration precedenti (idempotency)
|
||||
Database::execute(
|
||||
'UPDATE api_keys SET is_active=0 WHERE organization_id=? AND name LIKE \'lg231-integration-%\'',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO api_keys (organization_id, name, key_prefix, key_hash, scopes, is_active, expires_at, created_by)
|
||||
VALUES (?,?,?,?,?,1,?,?)',
|
||||
[
|
||||
$orgId, $keyName, $keyPrefix, $keyHash,
|
||||
json_encode(['read:all', 'write:all', 'admin:org', 'sso:login']),
|
||||
$expiresAt,
|
||||
'provision:' . ($caller['system'] ?? 'external'),
|
||||
]
|
||||
);
|
||||
|
||||
// ── 7. JWT accesso immediato (2h) ────────────────────────────────
|
||||
$issuedAt = time();
|
||||
$jwtPayload = [
|
||||
'iss' => 'nis2.agile.software',
|
||||
'sub' => $userId,
|
||||
'org_id' => $orgId,
|
||||
'role' => 'super_admin',
|
||||
'provisioned' => true,
|
||||
'iat' => $issuedAt,
|
||||
'exp' => $issuedAt + 7200,
|
||||
'type' => 'access',
|
||||
];
|
||||
$h = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
$p = $this->base64UrlEncode(json_encode($jwtPayload));
|
||||
$sig = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", JWT_SECRET, true));
|
||||
$jwt = "$h.$p.$sig";
|
||||
|
||||
// ── 8. Audit trail ───────────────────────────────────────────────
|
||||
AuditService::log(
|
||||
orgId: $orgId,
|
||||
userId: $userId,
|
||||
action: 'org.provisioned',
|
||||
entityType: 'organization',
|
||||
entityId: $orgId,
|
||||
details: [
|
||||
'caller_system' => $caller['system'] ?? 'external',
|
||||
'caller_company' => $caller['company_id'] ?? null,
|
||||
'lg231_order_id' => $license['lg231_order_id'] ?? null,
|
||||
'license_plan' => $license['plan'] ?? 'professional',
|
||||
'entity_type' => $nis2EntityType,
|
||||
'sector' => $nis2Sector,
|
||||
'api_key_prefix' => $keyPrefix,
|
||||
'admin_email' => $adminEmail,
|
||||
],
|
||||
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
severity: 'critical',
|
||||
performedBy: ($caller['system'] ?? 'external') . ':provision'
|
||||
);
|
||||
|
||||
// ── 9. Callback a lg231 (asincrono, non bloccante) ───────────────
|
||||
$callbackUrl = $caller['callback_url'] ?? null;
|
||||
if ($callbackUrl) {
|
||||
$callbackPayload = json_encode([
|
||||
'event' => 'nis2.provisioned',
|
||||
'org_id' => $orgId,
|
||||
'company_id' => $caller['company_id'] ?? null,
|
||||
'api_key' => $rawKey,
|
||||
'provisioned_at' => date('c'),
|
||||
]);
|
||||
$cbSig = 'sha256=' . hash_hmac('sha256', $callbackPayload, PROVISION_SECRET);
|
||||
|
||||
// Fire-and-forget (ignora errori)
|
||||
@file_get_contents($callbackUrl, false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nX-NIS2-Signature: $cbSig\r\n",
|
||||
'content' => $callbackPayload,
|
||||
'timeout' => 3,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
]));
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'provisioned' => true,
|
||||
'org_id' => $orgId,
|
||||
'org_name' => $ragioneSociale,
|
||||
'vat_number' => $partitaIva,
|
||||
'entity_type' => $nis2EntityType,
|
||||
'sector' => $nis2Sector,
|
||||
'admin_user_id' => $userId,
|
||||
'admin_email' => $adminEmail,
|
||||
// Credenziali machine-to-machine
|
||||
'api_key' => $rawKey,
|
||||
'api_key_scopes'=> ['read:all', 'write:all', 'admin:org', 'sso:login'],
|
||||
'api_key_expires_at' => $expiresAt,
|
||||
// JWT per apertura immediata UI
|
||||
'access_token' => $jwt,
|
||||
'token_type' => 'Bearer',
|
||||
'token_expires_in' => 7200,
|
||||
// Credenziali primo accesso admin (cambio obbligatorio)
|
||||
'temp_password' => !$existingUser ? $tempPassword : null,
|
||||
'must_change_password' => !isset($existingUser),
|
||||
// Link
|
||||
'dashboard_url' => APP_URL . '/dashboard.html',
|
||||
'settings_url' => APP_URL . '/settings.html',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/status
|
||||
* Health check + info piattaforma. Nessuna auth richiesta.
|
||||
|
||||
68
docs/sql/011_provisioning.sql
Normal file
68
docs/sql/011_provisioning.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- ============================================================
|
||||
-- NIS2 Agile — Migration 011: Provisioning B2B
|
||||
-- Aggiunge colonne per onboarding automatico da lg231 e altri
|
||||
-- sistemi Agile partner via POST /api/services/provision
|
||||
-- ============================================================
|
||||
|
||||
USE nis2_agile_db;
|
||||
|
||||
-- ── organizations: campi provisioning ─────────────────────────────────────
|
||||
|
||||
-- Chi ha provisioned questa org (es: 'lg231')
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS provisioned_by VARCHAR(64) NULL AFTER status;
|
||||
|
||||
-- Timestamp del provisioning
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS provisioned_at DATETIME NULL AFTER provisioned_by;
|
||||
|
||||
-- Piano licenza (essentials/professional/enterprise)
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS license_plan VARCHAR(32) NULL DEFAULT 'professional' AFTER provisioned_at;
|
||||
|
||||
-- Scadenza licenza
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS license_expires_at DATE NULL AFTER license_plan;
|
||||
|
||||
-- ID azienda nel sistema chiamante (es: company_id di lg231)
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS lg231_company_id INT NULL AFTER license_expires_at;
|
||||
|
||||
-- Riferimento ordine nel sistema chiamante
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS lg231_order_id VARCHAR(64) NULL AFTER lg231_company_id;
|
||||
|
||||
-- ── users: campo must_change_password ─────────────────────────────────────
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS must_change_password TINYINT(1) NOT NULL DEFAULT 0 AFTER status;
|
||||
|
||||
-- Campo phone
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NULL AFTER must_change_password;
|
||||
|
||||
-- Campo job_title
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS job_title VARCHAR(128) NULL AFTER phone;
|
||||
|
||||
-- ── api_keys: campo created_by ─────────────────────────────────────────────
|
||||
ALTER TABLE api_keys
|
||||
ADD COLUMN IF NOT EXISTS created_by VARCHAR(128) NULL AFTER last_used_at;
|
||||
|
||||
-- ── Indici ────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Ricerca per VAT number (idempotency provisioning)
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_vat ON organizations(vat_number);
|
||||
|
||||
-- Ricerca org per lg231_company_id
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_lg231 ON organizations(lg231_company_id);
|
||||
|
||||
-- ── Verifica ──────────────────────────────────────────────────────────────
|
||||
SELECT
|
||||
COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'nis2_agile_db'
|
||||
AND TABLE_NAME = 'organizations'
|
||||
AND COLUMN_NAME IN ('provisioned_by','provisioned_at','license_plan','license_expires_at','lg231_company_id','lg231_order_id')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
|
||||
SELECT 'Migration 011 provisioning completata.' AS stato;
|
||||
@ -298,6 +298,7 @@ $actionMap = [
|
||||
|
||||
// ── ServicesController (API pubblica) ──────────
|
||||
'services' => [
|
||||
'POST:provision' => 'provision',
|
||||
'POST:token' => 'token',
|
||||
'POST:sso' => 'sso',
|
||||
'GET:status' => 'status',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user