[MKTG-API] Documentazione + auth API Key per licenze
- InviteController: requireLicenseAuth() accetta X-API-Key (scope admin:licenses) oppure JWT super_admin — tutti i metodi admin aggiornati - mktg-api-doc.html: risponde alle 6 domande del marketing con esempi curl, tabelle risposta, riepilogo endpoint, link Postman collection - nis2-license-api.postman.json: collection completa (login, create, list, revoke, regenerate, validate, provision) con pre-script salva JWT/invite_id Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cb0988da27
commit
d407fd0510
@ -6,15 +6,20 @@
|
||||
* 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
|
||||
* Endpoints admin (richiedono JWT super_admin OPPURE API Key con scope admin:licenses):
|
||||
* POST /api/invites/create → genera invito
|
||||
* GET /api/invites/list → 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)
|
||||
* GET /api/invites/validate?token= → valida invito (preview piano, scadenza)
|
||||
*
|
||||
* Auth da sistemi esterni (es. mktg-agile):
|
||||
* Header: X-API-Key: nis2_xxxx...
|
||||
* Scope richiesto: admin:licenses (oppure read:all per sola lettura)
|
||||
* Genera la chiave in: NIS2 Agile → Settings → API Keys
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
@ -53,8 +58,7 @@ class InviteController extends BaseController
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireRole(['super_admin']);
|
||||
$this->requireLicenseAuth(writeRequired: true);
|
||||
|
||||
$body = $this->getBody();
|
||||
$plan = in_array($body['plan'] ?? '', ['essentials','professional','enterprise'])
|
||||
@ -135,8 +139,7 @@ class InviteController extends BaseController
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireRole(['super_admin']);
|
||||
$this->requireLicenseAuth(writeRequired: false);
|
||||
|
||||
$status = $_GET['status'] ?? null; // pending/used/expired/revoked
|
||||
$channel = $_GET['channel'] ?? null;
|
||||
@ -184,8 +187,7 @@ class InviteController extends BaseController
|
||||
*/
|
||||
public function show(int $id = 0): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireRole(['super_admin']);
|
||||
$this->requireLicenseAuth(writeRequired: false);
|
||||
|
||||
$row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
|
||||
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
|
||||
@ -206,8 +208,7 @@ class InviteController extends BaseController
|
||||
*/
|
||||
public function revoke(int $id = 0): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireRole(['super_admin']);
|
||||
$this->requireLicenseAuth(writeRequired: true);
|
||||
$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');
|
||||
@ -224,8 +225,7 @@ class InviteController extends BaseController
|
||||
*/
|
||||
public function regenerate(int $id = 0): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->requireRole(['super_admin']);
|
||||
$this->requireLicenseAuth(writeRequired: true);
|
||||
$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');
|
||||
@ -376,6 +376,62 @@ class InviteController extends BaseController
|
||||
return $features[$plan] ?? $features['professional'];
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// AUTH HELPER — JWT super_admin OR API Key con scope admin:licenses
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Accetta:
|
||||
* a) JWT super_admin (header Authorization: Bearer eyJ...)
|
||||
* b) API Key con scope admin:licenses o read:all (header X-API-Key: nis2_...)
|
||||
*
|
||||
* Se nessuno dei due è valido → 401.
|
||||
* Se $writeRequired=true e la chiave ha solo read:all → 403.
|
||||
*/
|
||||
private function requireLicenseAuth(bool $writeRequired = false): void
|
||||
{
|
||||
// ── tenta API Key ────────────────────────────────────────────────
|
||||
$rawKey = $_SERVER['HTTP_X_API_KEY']
|
||||
?? (isset($_SERVER['HTTP_AUTHORIZATION']) && str_starts_with($_SERVER['HTTP_AUTHORIZATION'], 'ApiKey ')
|
||||
? substr($_SERVER['HTTP_AUTHORIZATION'], 7) : null);
|
||||
|
||||
if ($rawKey) {
|
||||
if (!str_starts_with($rawKey, 'nis2_')) {
|
||||
$this->jsonError('Formato API Key non valido', 401, 'INVALID_API_KEY_FORMAT');
|
||||
}
|
||||
$hash = hash('sha256', $rawKey);
|
||||
$key = Database::fetchOne(
|
||||
'SELECT id, organization_id, scopes, is_active, expires_at
|
||||
FROM api_keys WHERE key_hash=? LIMIT 1',
|
||||
[$hash]
|
||||
);
|
||||
if (!$key || !$key['is_active']) {
|
||||
$this->jsonError('API Key non valida o disattivata', 401, 'INVALID_API_KEY');
|
||||
}
|
||||
if ($key['expires_at'] && strtotime($key['expires_at']) < time()) {
|
||||
$this->jsonError('API Key scaduta', 401, 'EXPIRED_API_KEY');
|
||||
}
|
||||
$scopes = json_decode($key['scopes'] ?? '[]', true);
|
||||
$hasAdmin = in_array('admin:licenses', $scopes, true);
|
||||
$hasRead = in_array('read:all', $scopes, true) || in_array('admin:licenses', $scopes, true);
|
||||
|
||||
if (!$hasRead) {
|
||||
$this->jsonError('Scope insufficiente (richiesto: admin:licenses o read:all)', 403, 'INSUFFICIENT_SCOPE');
|
||||
}
|
||||
if ($writeRequired && !$hasAdmin) {
|
||||
$this->jsonError('Scope insufficiente per scrittura (richiesto: admin:licenses)', 403, 'WRITE_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
// Aggiorna last_used_at
|
||||
Database::execute('UPDATE api_keys SET last_used_at=NOW() WHERE id=?', [$key['id']]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── fallback: JWT super_admin ────────────────────────────────────
|
||||
$this->requireAuth();
|
||||
$this->requireRole(['super_admin']);
|
||||
}
|
||||
|
||||
// ── helper ───────────────────────────────────────────────────────────
|
||||
private function getBody(): array
|
||||
{
|
||||
|
||||
259
docs/integration/nis2-license-api.postman.json
Normal file
259
docs/integration/nis2-license-api.postman.json
Normal file
@ -0,0 +1,259 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "NIS2 Agile — License API",
|
||||
"description": "API per la gestione licenze NIS2 Agile da sistemi esterni (mktg-agile, e-commerce, partner).\n\nAutenticazione: API Key con scope `admin:licenses`\nHeader: `X-API-Key: nis2_xxxx`\n\nPer ottenere una chiave: login su https://nis2.agile.software → licenseExt.html → login → Settings → API Keys → scope: admin:licenses",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_postman_id": "nis2-license-api-v1"
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "base_url", "value": "https://nis2.agile.software/api", "type": "string" },
|
||||
{ "key": "api_key", "value": "nis2_LA_TUA_CHIAVE_QUI", "type": "string" },
|
||||
{ "key": "jwt", "value": "", "type": "string" },
|
||||
{ "key": "invite_id","value": "1", "type": "string" }
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "0. Auth — Ottieni JWT (alternativa all'API Key)",
|
||||
"item": [
|
||||
{
|
||||
"name": "POST /auth/login",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"const d = pm.response.json();",
|
||||
"if (d.success) {",
|
||||
" pm.collectionVariables.set('jwt', d.data.access_token);",
|
||||
" console.log('JWT salvato:', d.data.access_token.substring(0,30) + '...');",
|
||||
"}"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"cristiano.benassati@gmail.com\",\n \"password\": \"Silvia1978!@\"\n}"
|
||||
},
|
||||
"url": { "raw": "{{base_url}}/auth/login", "host": ["{{base_url}}"], "path": ["auth","login"] },
|
||||
"description": "Ottieni JWT super_admin (valido 2h). Alternativa all'API Key per test manuali.\nIl JWT viene salvato automaticamente nella variabile {{jwt}}."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "1. Crea Licenza",
|
||||
"item": [
|
||||
{
|
||||
"name": "POST /invites/create — Licenza singola professional 12m",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"const d = pm.response.json();",
|
||||
"pm.test('Status 201', () => pm.response.to.have.status(201));",
|
||||
"pm.test('Contiene token', () => pm.expect(d.data.invites[0].token).to.match(/^inv_/));",
|
||||
"if (d.success && d.data.invites.length) {",
|
||||
" pm.collectionVariables.set('invite_id', d.data.invites[0].id);",
|
||||
" console.log('TOKEN (salva subito!):', d.data.invites[0].token);",
|
||||
" console.log('URL onboarding:', d.data.invites[0].invite_url);",
|
||||
"}"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "X-API-Key", "value": "{{api_key}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"plan\": \"professional\",\n \"duration_months\": 12,\n \"invite_expires_days\": 30,\n \"max_uses\": 1,\n \"max_users_per_org\": 10,\n \"label\": \"NIS2 Professional — Ordine MKT-2026-001\",\n \"channel\": \"ecommerce\",\n \"issued_to\": \"cliente@azienda.it\",\n \"reseller_name\": \"mktg-agile S.r.l.\",\n \"price_eur\": 990.00,\n \"notes\": \"Campagna Q1 2026\"\n}"
|
||||
},
|
||||
"url": { "raw": "{{base_url}}/invites/create", "host": ["{{base_url}}"], "path": ["invites","create"] },
|
||||
"description": "Crea una licenza singola Professional 12 mesi.\n\nRESPONSE (201):\n- `invites[].token` — inv_xxx... → SALVARE SUBITO, non recuperabile\n- `invites[].invite_url` — URL per attivare da browser\n- `invites[].expires_at` — scadenza invito (entro cui attivare)\n- `warning` — promemoria sicurezza token"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "POST /invites/create — Batch 5 licenze Essentials",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "X-API-Key", "value": "{{api_key}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"plan\": \"essentials\",\n \"duration_months\": 6,\n \"invite_expires_days\": 60,\n \"max_uses\": 1,\n \"max_users_per_org\": 3,\n \"quantity\": 5,\n \"label\": \"Essentials 6m — Bundle Reseller Q1\",\n \"channel\": \"reseller\",\n \"reseller_name\": \"Partner XYZ\",\n \"price_eur\": 490.00,\n \"notes\": \"Batch 5 licenze per partner XYZ\"\n}"
|
||||
},
|
||||
"url": { "raw": "{{base_url}}/invites/create", "host": ["{{base_url}}"], "path": ["invites","create"] },
|
||||
"description": "Genera 5 token in un'unica chiamata (quantity=5).\nRisposta contiene array `invites` con 5 elementi, ciascuno con token unico."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "POST /invites/create — Enterprise con restrizione P.IVA",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "X-API-Key", "value": "{{api_key}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"plan\": \"enterprise\",\n \"duration_months\": 24,\n \"invite_expires_days\": 14,\n \"max_uses\": 1,\n \"label\": \"Enterprise 24m — Cliente Acme S.r.l.\",\n \"channel\": \"direct\",\n \"issued_to\": \"ciso@acme.it\",\n \"restrict_vat\": \"02345678901\",\n \"restrict_email\": \"ciso@acme.it\",\n \"price_eur\": 4800.00,\n \"notes\": \"Contratto enterprise diretto — approvato da DG\"\n}"
|
||||
},
|
||||
"url": { "raw": "{{base_url}}/invites/create", "host": ["{{base_url}}"], "path": ["invites","create"] },
|
||||
"description": "Licenza Enterprise riservata a una specifica P.IVA e email admin.\nSe usata da P.IVA diversa → errore 403 INVITE_VAT_MISMATCH."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2. Lista Licenze",
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /invites/list — tutte",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "X-API-Key", "value": "{{api_key}}" }],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/list?limit=50",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","list"],
|
||||
"query": [
|
||||
{ "key": "limit", "value": "50" },
|
||||
{ "key": "offset", "value": "0", "disabled": true }
|
||||
]
|
||||
},
|
||||
"description": "Lista tutte le licenze. Filtrabile per status e channel.\nCampi risposta: id, token_prefix, plan, status, used_count, max_uses, max_users_per_org, price_eur, reseller_name, expires_at, channel, label, issued_to, used_by_org_id."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET /invites/list — solo pending (attive)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "X-API-Key", "value": "{{api_key}}" }],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/list?status=pending&limit=100",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","list"],
|
||||
"query": [
|
||||
{ "key": "status", "value": "pending" },
|
||||
{ "key": "limit", "value": "100" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET /invites/list — per canale ecommerce",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "X-API-Key", "value": "{{api_key}}" }],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/list?channel=ecommerce&status=pending",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","list"],
|
||||
"query": [
|
||||
{ "key": "channel", "value": "ecommerce" },
|
||||
{ "key": "status", "value": "pending" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET /invites/{id} — dettaglio singolo",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{ "key": "X-API-Key", "value": "{{api_key}}" }],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/{{invite_id}}",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","{{invite_id}}"]
|
||||
},
|
||||
"description": "Dettaglio completo. Se usata, include used_by_org con name/sector/nis2_entity_type."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "3. Revoca / Rigenera",
|
||||
"item": [
|
||||
{
|
||||
"name": "DELETE /invites/{id} — revoca",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [{ "key": "X-API-Key", "value": "{{api_key}}" }],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/{{invite_id}}",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","{{invite_id}}"]
|
||||
},
|
||||
"description": "Revoca la licenza. Operazione non reversibile.\nLa licenza passa a status=revoked. Eventuali aziende già provisionate rimangono attive (il provisioning è già avvenuto).\nNon si può revocare una licenza già usata (status=used)."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "POST /invites/{id}/regenerate — nuovo token",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [{ "key": "X-API-Key", "value": "{{api_key}}" }],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/{{invite_id}}/regenerate",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","{{invite_id}}","regenerate"]
|
||||
},
|
||||
"description": "Genera un nuovo token invalidando il vecchio. Utile se il token è stato inviato per errore.\nRisposta: { id, token, token_prefix, warning }. Il nuovo token è visibile una sola volta."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "4. Validazione pubblica (no auth)",
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /invites/validate?token= — anteprima invito",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/invites/validate?token=inv_INSERISCI_TOKEN_QUI",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["invites","validate"],
|
||||
"query": [{ "key": "token", "value": "inv_INSERISCI_TOKEN_QUI" }]
|
||||
},
|
||||
"description": "Endpoint pubblico — nessuna auth richiesta.\nUsato da lg231 prima del provisioning e dalla pagina onboarding per mostrare l'anteprima piano.\nRisposta: { valid, plan, duration_months, expires_at, remaining_uses, max_users_per_org, plan_features[] }"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "5. Provisioning (per lg231 / e-commerce)",
|
||||
"item": [
|
||||
{
|
||||
"name": "POST /services/provision — attivazione con invite_token",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"invite_token\": \"inv_INSERISCI_TOKEN_RICEVUTO\",\n \"company\": {\n \"ragione_sociale\": \"Acme S.r.l.\",\n \"partita_iva\": \"02345678901\",\n \"ateco_code\": \"62.01.00\",\n \"sector\": \"ict\"\n },\n \"admin\": {\n \"email\": \"ciso@acme.it\",\n \"first_name\": \"Marco\",\n \"last_name\": \"Rossi\"\n },\n \"caller\": {\n \"system\": \"mktg-agile\",\n \"callback_url\": \"https://mktg.agile.software/api/webhooks/nis2-provisioned\"\n }\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/services/provision",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["services","provision"]
|
||||
},
|
||||
"description": "Attiva automaticamente una licenza usando il token ricevuto.\nNON richiede X-API-Key — l'invite_token è l'auth.\nRisposta: org_id, api_key, access_token (JWT 2h), temp_password, license_expires_at, dashboard_url."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
365
public/mktg-api-doc.html
Normal file
365
public/mktg-api-doc.html
Normal file
@ -0,0 +1,365 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NIS2 Agile — License API · Documentazione Marketing</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<style>
|
||||
.doc-layout { max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.doc-hero {
|
||||
background: linear-gradient(135deg, rgba(6,182,212,.08) 0%, rgba(168,85,247,.06) 100%);
|
||||
border: 1px solid rgba(6,182,212,.2);
|
||||
border-radius: 14px; padding: 2rem; margin-bottom: 2.5rem;
|
||||
}
|
||||
.doc-hero h1 { font-size: 1.5rem; color: var(--primary); margin-bottom: .5rem; }
|
||||
.doc-hero p { color: var(--text-secondary); line-height: 1.6; font-size: .9rem; }
|
||||
.doc-hero .tag { display: inline-block; background: rgba(6,182,212,.15); color: var(--primary); font-size: .72rem; font-weight: 700; padding: .2rem .55rem; border-radius: 5px; margin-right: .35rem; }
|
||||
|
||||
h2 { font-size: 1.1rem; font-weight: 700; margin: 2.5rem 0 1rem; padding-bottom: .5rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: .5rem; }
|
||||
h2 .num { background: var(--primary); color: #000; font-size: .75rem; font-weight: 800; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
h3 { font-size: .95rem; font-weight: 700; margin: 1.5rem 0 .6rem; color: var(--text-primary); }
|
||||
|
||||
.answer-box {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
|
||||
}
|
||||
.answer-box.highlight { border-color: rgba(6,182,212,.35); }
|
||||
|
||||
pre, .code {
|
||||
background: #0d1117; border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1rem 1.25rem;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: .8rem; line-height: 1.7;
|
||||
overflow-x: auto; margin: .75rem 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.kv { color: #79c0ff; } /* keys */
|
||||
.sv { color: #a5d6ff; } /* strings */
|
||||
.nv { color: #ffa657; } /* numbers */
|
||||
.cm { color: #8b949e; font-style: italic; } /* comments */
|
||||
.mt { color: #ff7b72; font-weight: 700; } /* method */
|
||||
.ep { color: #f0883e; } /* endpoint */
|
||||
.hd { color: #d2a8ff; } /* header */
|
||||
.ok { color: #3fb950; }
|
||||
.err { color: #f85149; }
|
||||
|
||||
.endpoint-line {
|
||||
display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.method { font-size: .75rem; font-weight: 800; padding: .2rem .5rem; border-radius: 4px; font-family: monospace; }
|
||||
.m-post { background: rgba(249,115,22,.2); color: #f97316; }
|
||||
.m-get { background: rgba(34,197,94,.2); color: #22c55e; }
|
||||
.m-del { background: rgba(239,68,68,.2); color: #ef4444; }
|
||||
.endpoint-url { font-family: monospace; font-size: .88rem; color: var(--text-primary); }
|
||||
|
||||
table.resp { width: 100%; border-collapse: collapse; font-size: .82rem; margin: .75rem 0; }
|
||||
table.resp th { background: rgba(255,255,255,.04); color: var(--text-secondary); font-weight: 600; text-align: left; padding: .55rem .75rem; border-bottom: 1px solid var(--border); }
|
||||
table.resp td { padding: .55rem .75rem; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: top; }
|
||||
table.resp tr:last-child td { border: none; }
|
||||
table.resp code { background: rgba(255,255,255,.06); padding: .1rem .3rem; border-radius: 3px; font-size: .78rem; }
|
||||
|
||||
.callout { background: rgba(6,182,212,.07); border-left: 3px solid var(--primary); border-radius: 0 8px 8px 0; padding: .75rem 1rem; margin: .75rem 0; font-size: .85rem; }
|
||||
.callout.warn { background: rgba(234,179,8,.07); border-color: #eab308; }
|
||||
.callout.danger { background: rgba(239,68,68,.07); border-color: #ef4444; }
|
||||
|
||||
.download-btn {
|
||||
display: inline-flex; align-items: center; gap: .5rem;
|
||||
background: rgba(6,182,212,.12); border: 1px solid rgba(6,182,212,.3);
|
||||
color: var(--primary); border-radius: 8px;
|
||||
padding: .5rem 1rem; font-size: .82rem; font-weight: 600;
|
||||
text-decoration: none; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.download-btn:hover { background: rgba(6,182,212,.2); }
|
||||
|
||||
.badge-scope {
|
||||
display: inline-block; font-size: .65rem; font-weight: 700;
|
||||
padding: .15rem .4rem; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
.sc-rw { background: rgba(168,85,247,.15); color: #a855f7; }
|
||||
.sc-ro { background: rgba(34,197,94,.15); color: #22c55e; }
|
||||
.sc-no { background: rgba(156,163,175,.15); color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc-layout">
|
||||
|
||||
<div class="doc-hero">
|
||||
<h1>NIS2 Agile — License API</h1>
|
||||
<p>Documentazione completa per l'integrazione da <strong>mktg-agile</strong> e sistemi e-commerce.<br>
|
||||
Risponde alle domande del team marketing su endpoint, autenticazione e risposta API.</p>
|
||||
<div style="margin-top:1rem;display:flex;gap:.75rem;flex-wrap:wrap;align-items:center">
|
||||
<span class="tag">REST / JSON</span>
|
||||
<span class="tag">HTTPS</span>
|
||||
<span class="tag">API Key</span>
|
||||
<span class="tag">v1.0</span>
|
||||
<a class="download-btn" href="/docs/integration/nis2-license-api.postman.json" download>
|
||||
⬇ Postman Collection (JSON)
|
||||
</a>
|
||||
<a class="download-btn" href="/licenseExt.html" style="background:rgba(168,85,247,.12);border-color:rgba(168,85,247,.3);color:#a855f7">
|
||||
🖥 Pannello Licenze (web)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Q1 ── -->
|
||||
<h2><span class="num">1</span> API di creazione licenza</h2>
|
||||
<div class="answer-box highlight">
|
||||
<div class="endpoint-line">
|
||||
<span class="method m-post">POST</span>
|
||||
<span class="endpoint-url">https://nis2.agile.software/api/invites/create</span>
|
||||
</div>
|
||||
|
||||
<pre><span class="cm">// Header richiesti:</span>
|
||||
<span class="hd">Content-Type</span>: application/json
|
||||
<span class="hd">X-API-Key</span>: nis2_LA_TUA_CHIAVE <span class="cm">← vedi sezione 2</span>
|
||||
|
||||
<span class="cm">// Body (tutti i campi disponibili):</span>
|
||||
{
|
||||
<span class="kv">"plan"</span>: <span class="sv">"professional"</span>, <span class="cm">// essentials | professional | enterprise</span>
|
||||
<span class="kv">"duration_months"</span>: <span class="nv">12</span>, <span class="cm">// durata licenza dopo attivazione</span>
|
||||
<span class="kv">"invite_expires_days"</span>: <span class="nv">30</span>, <span class="cm">// giorni per attivare l'invito</span>
|
||||
<span class="kv">"max_uses"</span>: <span class="nv">1</span>, <span class="cm">// n. aziende che possono usarlo (1=singolo)</span>
|
||||
<span class="kv">"max_users_per_org"</span>: <span class="nv">10</span>, <span class="cm">// max utenti per azienda (ometti = illimitato)</span>
|
||||
<span class="kv">"label"</span>: <span class="sv">"NIS2 Pro — Ordine MKT-001"</span>, <span class="cm">// etichetta interna</span>
|
||||
<span class="kv">"channel"</span>: <span class="sv">"ecommerce"</span>, <span class="cm">// ecommerce|lg231|direct|reseller|manual</span>
|
||||
<span class="kv">"issued_to"</span>: <span class="sv">"cliente@co.it"</span>, <span class="cm">// email o nome destinatario</span>
|
||||
<span class="kv">"reseller_name"</span>: <span class="sv">"Partner XYZ"</span>, <span class="cm">// riferimento reseller (solo vostro)</span>
|
||||
<span class="kv">"price_eur"</span>: <span class="nv">990.00</span>, <span class="cm">// prezzo applicato (solo riferimento)</span>
|
||||
<span class="kv">"notes"</span>: <span class="sv">"Campagna Q1 2026"</span>, <span class="cm">// note interne marketing</span>
|
||||
<span class="kv">"quantity"</span>: <span class="nv">1</span>, <span class="cm">// genera N token in batch (default 1, max 50)</span>
|
||||
<span class="cm">// OPZIONALE — restrizioni di sicurezza:</span>
|
||||
<span class="kv">"restrict_vat"</span>: <span class="sv">"02345678901"</span>, <span class="cm">// solo questa P.IVA può usarlo</span>
|
||||
<span class="kv">"restrict_email"</span>: <span class="sv">"ciso@co.it"</span> <span class="cm">// solo questo admin può usarlo</span>
|
||||
}</pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Campi obbligatori:</strong> <code>plan</code>, <code>label</code>. Tutti gli altri sono opzionali con valori di default ragionevoli.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Q2 ── -->
|
||||
<h2><span class="num">2</span> Autenticazione</h2>
|
||||
<div class="answer-box">
|
||||
<p style="margin-bottom:1rem;color:var(--text-secondary);font-size:.88rem">
|
||||
Ogni chiamata da mktg-agile deve includere l'header <code>X-API-Key</code> con una chiave dedicata.
|
||||
</p>
|
||||
|
||||
<h3>Come ottenere la chiave API</h3>
|
||||
<ol style="font-size:.85rem;color:var(--text-secondary);line-height:2">
|
||||
<li>Vai su <a href="/licenseExt.html" style="color:var(--primary)">nis2.agile.software/licenseExt.html</a> → login con account super_admin</li>
|
||||
<li>Naviga su <strong>Settings → API Keys → Nuova API Key</strong></li>
|
||||
<li>Nome: <code>mktg-agile integration</code> → Scope: <code>admin:licenses</code></li>
|
||||
<li>Copia la chiave — formato <code>nis2_xxxx...</code> — visibile una sola volta</li>
|
||||
<li>Salvala in mktg-agile come variabile d'ambiente: <code>NIS2_API_KEY=nis2_xxxx...</code></li>
|
||||
</ol>
|
||||
|
||||
<pre><span class="cm"># Chiamata da mktg-agile (curl):</span>
|
||||
curl -X POST https://nis2.agile.software/api/invites/create \
|
||||
-H <span class="sv">"X-API-Key: nis2_LA_TUA_CHIAVE"</span> \
|
||||
-H <span class="sv">"Content-Type: application/json"</span> \
|
||||
-d <span class="sv">'{"plan":"professional","duration_months":12,"label":"Test"}'</span></pre>
|
||||
|
||||
<table class="resp">
|
||||
<thead><tr><th>Scope</th><th>Tipo</th><th>Permessi</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>admin:licenses</code> <span class="badge-scope sc-rw">CONSIGLIATO</span></td><td>Lettura + Scrittura</td><td>Crea, lista, revoca, rigenera licenze</td></tr>
|
||||
<tr><td><code>read:all</code> <span class="badge-scope sc-ro">SOLA LETTURA</span></td><td>Solo lettura</td><td>Lista e dettaglio licenze, nessuna modifica</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="callout">
|
||||
<strong>Alternativa JWT:</strong> se preferisci usare un JWT (es. per test manuali), fai prima
|
||||
<code>POST /api/auth/login</code> con le credenziali super_admin e usa il token restituito
|
||||
come <code>Authorization: Bearer eyJ...</code>. Valido 2 ore.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Q3 ── -->
|
||||
<h2><span class="num">3</span> Response alla creazione</h2>
|
||||
<div class="answer-box">
|
||||
<p style="margin-bottom:.75rem;color:var(--text-secondary);font-size:.85rem">HTTP 201. JSON. Array <code>invites</code> — uno per token generato.</p>
|
||||
|
||||
<pre>{
|
||||
<span class="kv">"success"</span>: <span class="ok">true</span>,
|
||||
<span class="kv">"data"</span>: {
|
||||
<span class="kv">"invites"</span>: [
|
||||
{
|
||||
<span class="kv">"id"</span>: <span class="nv">42</span>,
|
||||
<span class="kv">"token"</span>: <span class="sv">"inv_a1b2c3d4e5f6..."</span>, <span class="cm">← SALVA SUBITO — non recuperabile</span>
|
||||
<span class="kv">"token_prefix"</span>: <span class="sv">"inv_a1b2c3..."</span>, <span class="cm">← riferimento visivo (non il token completo)</span>
|
||||
<span class="kv">"plan"</span>: <span class="sv">"professional"</span>,
|
||||
<span class="kv">"duration_months"</span>: <span class="nv">12</span>,
|
||||
<span class="kv">"max_uses"</span>: <span class="nv">1</span>,
|
||||
<span class="kv">"max_users_per_org"</span>: <span class="nv">10</span>,
|
||||
<span class="kv">"price_eur"</span>: <span class="nv">990.00</span>,
|
||||
<span class="kv">"expires_at"</span>: <span class="sv">"2026-04-07 14:00:00"</span>, <span class="cm">← entro cui attivare</span>
|
||||
<span class="kv">"channel"</span>: <span class="sv">"ecommerce"</span>,
|
||||
<span class="kv">"label"</span>: <span class="sv">"NIS2 Pro — Ordine MKT-001"</span>,
|
||||
<span class="kv">"issued_to"</span>: <span class="sv">"cliente@co.it"</span>,
|
||||
<span class="kv">"invite_url"</span>: <span class="sv">"https://nis2.agile.software/onboarding.html?invite=inv_a1b2..."</span>,
|
||||
<span class="kv">"provision_hint"</span>: <span class="sv">"POST /api/services/provision con invite_token: ..."</span>
|
||||
}
|
||||
],
|
||||
<span class="kv">"count"</span>: <span class="nv">1</span>,
|
||||
<span class="kv">"warning"</span>: <span class="sv">"Salva i token subito — non saranno più visibili in chiaro."</span>
|
||||
}
|
||||
}</pre>
|
||||
|
||||
<table class="resp">
|
||||
<thead><tr><th>Campo</th><th>Uso</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>token</code></td><td>Il token completo <code>inv_xxx</code> — consegnarlo al cliente / lg231. <strong>Non viene mai restituito di nuovo.</strong></td></tr>
|
||||
<tr><td><code>invite_url</code></td><td>URL diretto per onboarding browser — il cliente apre questo link e si registra</td></tr>
|
||||
<tr><td><code>expires_at</code></td><td>Entro quando il cliente deve attivare. Dopo questa data il token non funziona.</td></tr>
|
||||
<tr><td><code>id</code></td><td>ID numerico per revoca / dettaglio successivo</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:1rem">
|
||||
<strong style="font-size:.85rem">Export CSV</strong>
|
||||
<p style="font-size:.83rem;color:var(--text-secondary);margin:.3rem 0 0">
|
||||
Il pannello <a href="/licenseExt.html" style="color:var(--primary)">licenseExt.html</a> ha un bottone
|
||||
<strong>Esporta CSV</strong> che scarica tutte le licenze generate nella sessione.
|
||||
In alternativa, esporta programmaticamente da <code>GET /api/invites/list</code> e converti.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Q4 ── -->
|
||||
<h2><span class="num">4</span> Lista licenze</h2>
|
||||
<div class="answer-box">
|
||||
<div class="endpoint-line">
|
||||
<span class="method m-get">GET</span>
|
||||
<span class="endpoint-url">https://nis2.agile.software/api/invites/list</span>
|
||||
</div>
|
||||
|
||||
<pre><span class="cm"># Parametri query (tutti opzionali):</span>
|
||||
?status=pending <span class="cm">// pending | used | expired | revoked</span>
|
||||
&channel=ecommerce <span class="cm">// ecommerce | lg231 | direct | reseller | manual</span>
|
||||
&limit=50 <span class="cm">// default 50, max 100</span>
|
||||
&offset=0 <span class="cm">// paginazione</span>
|
||||
|
||||
<span class="cm"># Esempio — tutte le licenze ecommerce attive:</span>
|
||||
GET /api/invites/list?channel=ecommerce&status=pending
|
||||
<span class="hd">X-API-Key</span>: nis2_LA_TUA_CHIAVE</pre>
|
||||
|
||||
<p style="font-size:.84rem;color:var(--text-secondary);margin-top:.5rem">
|
||||
Risposta: <code>{ invites: [...], total: N, limit: 50, offset: 0 }</code><br>
|
||||
Ogni licenza include tutti i campi tranne il token in chiaro (non recuperabile dopo creazione).
|
||||
</p>
|
||||
|
||||
<div class="endpoint-line" style="margin-top:1rem">
|
||||
<span class="method m-get">GET</span>
|
||||
<span class="endpoint-url">https://nis2.agile.software/api/invites/<strong>{id}</strong></span>
|
||||
</div>
|
||||
<p style="font-size:.84rem;color:var(--text-secondary)">Dettaglio singola licenza. Se è stata usata, include <code>used_by_org</code> con nome azienda e settore.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Q5 ── -->
|
||||
<h2><span class="num">5</span> Revoca / modifica</h2>
|
||||
<div class="answer-box">
|
||||
<div class="endpoint-line">
|
||||
<span class="method m-del">DELETE</span>
|
||||
<span class="endpoint-url">https://nis2.agile.software/api/invites/<strong>{id}</strong></span>
|
||||
</div>
|
||||
<p style="font-size:.84rem;color:var(--text-secondary);margin-bottom:.75rem">
|
||||
Revoca la licenza (<code>status → revoked</code>). Il record rimane in DB per audit.
|
||||
</p>
|
||||
|
||||
<div class="endpoint-line">
|
||||
<span class="method m-post">POST</span>
|
||||
<span class="endpoint-url">https://nis2.agile.software/api/invites/<strong>{id}</strong>/regenerate</span>
|
||||
</div>
|
||||
<p style="font-size:.84rem;color:var(--text-secondary);margin-bottom:.75rem">
|
||||
Genera un <strong>nuovo token</strong> invalidando il precedente. Stessa configurazione (piano, durata, scadenza). Usare se il token è stato inviato per errore o compromesso prima dell'uso.
|
||||
</p>
|
||||
|
||||
<table class="resp">
|
||||
<thead><tr><th>Stato licenza</th><th>Revocabile</th><th>Rigenerabile</th><th>Note</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>pending</code></td><td class="ok">✓ Sì</td><td class="ok">✓ Sì</td><td>Normale</td></tr>
|
||||
<tr><td><code>used</code></td><td class="err">✗ No</td><td class="err">✗ No</td><td>Già attivata — azienda rimane attiva</td></tr>
|
||||
<tr><td><code>expired</code></td><td class="ok">✓ Sì</td><td class="ok">✓ Sì</td><td>Scaduta ma non usata</td></tr>
|
||||
<tr><td><code>revoked</code></td><td>—</td><td class="ok">✓ Sì</td><td>Già revocata</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Importante:</strong> revocare una licenza blocca solo future attivazioni.
|
||||
Le aziende già provisionate con quella licenza rimangono operative fino a <code>license_expires_at</code>.
|
||||
La revoca non disattiva retroattivamente nessun cliente.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Q6 ── -->
|
||||
<h2><span class="num">6</span> La seconda pagina (licenseExt.html)</h2>
|
||||
<div class="answer-box">
|
||||
<p style="font-size:.88rem;color:var(--text-secondary);margin-bottom:1rem">
|
||||
È il <strong>pannello admin lato marketing</strong> — non il cliente.
|
||||
</p>
|
||||
|
||||
<table class="resp">
|
||||
<thead><tr><th>Pagina</th><th>Audience</th><th>Funzione</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="/licenseExt.html" style="color:var(--primary)">licenseExt.html</a></td>
|
||||
<td><strong>Marketing / Admin NIS2</strong></td>
|
||||
<td>Genera licenze, vede stats (aziende provisionate, utenti coinvolti), revoca, esporta CSV</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>onboarding.html?invite=inv_xxx</code></td>
|
||||
<td><strong>Cliente finale</strong></td>
|
||||
<td>Usa il token ricevuto per attivare la propria azienda in NIS2 (wizard 5 step)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="/integrazioniext.html" style="color:var(--primary)">integrazioniext.html</a></td>
|
||||
<td><strong>Partner tecnici (lg231, e-commerce)</strong></td>
|
||||
<td>Documentazione flow completo, provisioning automatico B2B, webhook</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="callout">
|
||||
<strong>licenseExt.html</strong> richiede login con credenziali <code>super_admin</code>.
|
||||
Non è pubblica — accedervi comunicando l'URL ai soli membri del team autorizzati.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── RIEPILOGO RAPIDO ── -->
|
||||
<h2><span class="num" style="background:#a855f7">→</span> Riepilogo rapido</h2>
|
||||
<div class="answer-box">
|
||||
<pre><span class="cm">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
<span class="cm"> BASE URL</span> https://nis2.agile.software/api
|
||||
<span class="cm"> AUTH</span> X-API-Key: nis2_xxxx (scope: admin:licenses)
|
||||
<span class="cm">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
<span class="mt">POST</span> <span class="ep">/invites/create</span> Crea licenza/e
|
||||
<span class="mt">GET</span> <span class="ep">/invites/list</span> Lista licenze (filtri: status, channel)
|
||||
<span class="mt">GET</span> <span class="ep">/invites/{id}</span> Dettaglio singola
|
||||
<span class="mt">DELETE</span> <span class="ep">/invites/{id}</span> Revoca
|
||||
<span class="mt">POST</span> <span class="ep">/invites/{id}/regenerate</span> Nuovo token (stesso config)
|
||||
<span class="cm">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
<span class="cm"> PUBBLICO (no auth):</span>
|
||||
<span class="mt">GET</span> <span class="ep">/invites/validate?token=inv_</span> Anteprima invito (piano, scadenza)
|
||||
<span class="cm">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
<span class="cm"> COSA CONTROLLA NIS2 (non il mktg):</span>
|
||||
✓ N. aziende attivabili → max_uses
|
||||
✓ Tempo validità invito → invite_expires_days → expires_at
|
||||
✓ Durata licenza → duration_months → license_expires_at
|
||||
✓ N. utenti per azienda → max_users_per_org → license_max_users
|
||||
<span class="cm">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
<span class="cm"> PROVA IN 30 SECONDI:</span>
|
||||
curl https://nis2.agile.software/api/services/status</pre>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border);font-size:.78rem;color:var(--text-secondary)">
|
||||
NIS2 Agile v1.0 · <a href="/licenseExt.html" style="color:var(--primary)">Pannello Licenze</a> ·
|
||||
<a href="/integrazioniext.html" style="color:var(--primary)">Guida Partner</a> ·
|
||||
<a href="/docs/integration/nis2-license-api.postman.json" download style="color:var(--primary)">Postman Collection</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="js/common.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user