Implementazione completa del progetto allineamento alla suite Evix (TRPG/lg231),
basato sul doc canonico docs/GAP_TRPG_NIS2_ALIGNMENT.md (5 fasi, 18 gap).
Version 1.0.0 → 1.5.0
Fase 1 — SSO Federation (v1.1.0)
- Migration 015_sso_columns: users.sso_identity_id + password_version
- application/services/SsoHelper.php (client SSO dual-mode, cURL nativo, zero deps)
- AuthController::login() + changePassword() conditional SSO (SSO_MODE=local default)
Fase 2 — Multi-device Sessions (v1.2.0)
- Migration 016_active_sessions: tabella + refresh_tokens.session_jti
- BaseController::requireAuth() verifica jti + last_activity throttle + parseDeviceLabel
- login() genera jti, logout/changePassword revoca selettiva
- GET/DELETE /auth/sessions[/{id}]
- UI settings.html tab Sicurezza con lista device + revoca
Fase 3 — Password Reset + Tenant Switcher (v1.3.0)
- Migration 017_password_reset_tokens (TTL 30min, single-use)
- POST /auth/forgot-password (risposta opaca) + reset-password
- Pagine forgot-password.html + reset-password.html (con strength bar)
- EmailService::sendPasswordReset
- POST /auth/switchContext con rotazione JWT + organization_id claim
- Dropdown tenant in sidebar esposto a tutti gli utenti con ≥2 org
Fase 4 — Impersonate + Preferences + Versioning UI (v1.4.0)
- POST /auth/impersonate (super_admin o consulente stesso firm, TTL 1h, audit)
- Migration 018_user_preferences: users.theme/timezone/notif_email/notif_inapp
- GET/PUT /auth/preferences
- Sidebar footer mostra versione + changelog modal su click
Fase 5 — Branding white-label + Auth-gate (v1.5.0)
- Migration 019_firm_branding (logo/colori/brand_name per consulting firm)
- BrandingController GET /branding/current (auth opzionale) + PUT
- common.js auto-applica CSS variables al boot
- public/js/auth-gate.js (gate password client-side per docs riservati, da TRPG)
Skip motivati:
- G15 demo login: simulator esistenti coprono
- G18 refactor controllers: rinviato (~5gg, valore tecnico solo)
Cron sync SSO: AgileHub Ticket #220 aperto a team AGILEHUB per estendere
sso-password-sync.sh al DB nis2_agile_db. Prerequisito per switch SSO_MODE=dual.
Backup files: tutti i file modificati hanno .bak.pre-{fase}-{ts} sia in DEV
sia in /var/www/nis2-agile/.backups/ su Hetzner (rollback ready).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.0 KiB
PHP
115 lines
4.0 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - BrandingController
|
|
*
|
|
* White-label branding per consulting firms (Fase 5 / G16).
|
|
* - GET /api/branding/current
|
|
* - Se utente autenticato → lookup tramite users.consulting_firm_id
|
|
* - Altrimenti, query param ?firm_id=N (utile in pagine pre-auth come login)
|
|
* - Ritorna logo_url, primary_color, secondary_color, custom_brand_name (o defaults)
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class BrandingController extends BaseController
|
|
{
|
|
private const DEFAULTS = [
|
|
'logo_url' => null,
|
|
'primary_color' => '#1e40af',
|
|
'secondary_color' => '#06b6d4',
|
|
'custom_brand_name' => null,
|
|
];
|
|
|
|
public function getCurrent(): void
|
|
{
|
|
$firmId = null;
|
|
|
|
// Auth opzionale: se token presente, usa firm dell'utente; altrimenti param.
|
|
$token = $this->getBearerToken();
|
|
if ($token) {
|
|
$payload = $this->verifyJWT($token);
|
|
if ($payload && !empty($payload['user_id'])) {
|
|
$u = Database::fetchOne(
|
|
'SELECT consulting_firm_id FROM users WHERE id = ? AND is_active = 1',
|
|
[$payload['user_id']]
|
|
);
|
|
if ($u && !empty($u['consulting_firm_id'])) {
|
|
$firmId = (int) $u['consulting_firm_id'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: query param
|
|
if (!$firmId && isset($_GET['firm_id'])) {
|
|
$firmId = (int) $_GET['firm_id'];
|
|
}
|
|
|
|
$branding = self::DEFAULTS;
|
|
|
|
if ($firmId > 0) {
|
|
$row = Database::fetchOne(
|
|
'SELECT logo_url, primary_color, secondary_color, custom_brand_name
|
|
FROM firm_branding WHERE firm_id = ?',
|
|
[$firmId]
|
|
);
|
|
if ($row) {
|
|
foreach ($branding as $k => $v) {
|
|
if (!empty($row[$k])) $branding[$k] = $row[$k];
|
|
}
|
|
$branding['firm_id'] = $firmId;
|
|
}
|
|
}
|
|
|
|
$this->jsonSuccess($branding);
|
|
}
|
|
|
|
/**
|
|
* PUT /api/branding
|
|
* Body: { logo_url?, primary_color?, secondary_color?, custom_brand_name? }
|
|
* Permesso: super_admin OR consulente del firm
|
|
*/
|
|
public function update(): void
|
|
{
|
|
$this->requireAuth();
|
|
$firmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0);
|
|
|
|
if ($this->currentUser['role'] === 'super_admin' && $this->hasParam('firm_id')) {
|
|
$firmId = (int) $this->getParam('firm_id');
|
|
}
|
|
if (!$firmId) {
|
|
$this->jsonError('Nessun firm associato', 422, 'NO_FIRM');
|
|
}
|
|
if ($this->currentUser['role'] !== 'super_admin' && $this->currentUser['role'] !== 'consultant') {
|
|
$this->jsonError('Solo super_admin o consulente possono modificare il branding', 403, 'BRANDING_FORBIDDEN');
|
|
}
|
|
|
|
$updates = [];
|
|
foreach (['logo_url','primary_color','secondary_color','custom_brand_name'] as $k) {
|
|
if ($this->hasParam($k)) {
|
|
$v = $this->getParam($k);
|
|
if ($v === '' || $v === null) $v = null;
|
|
if (in_array($k, ['primary_color','secondary_color'], true) && $v !== null
|
|
&& !preg_match('/^#[0-9A-Fa-f]{6}$/', $v)) {
|
|
$this->jsonError('Colore non valido (formato atteso #RRGGBB): ' . $k, 400, 'INVALID_COLOR');
|
|
}
|
|
$updates[$k] = $v;
|
|
}
|
|
}
|
|
if (empty($updates)) {
|
|
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
|
}
|
|
|
|
// Upsert
|
|
$existing = Database::fetchOne('SELECT firm_id FROM firm_branding WHERE firm_id = ?', [$firmId]);
|
|
if ($existing) {
|
|
Database::update('firm_branding', $updates, 'firm_id = ?', [$firmId]);
|
|
} else {
|
|
$updates['firm_id'] = $firmId;
|
|
Database::insert('firm_branding', $updates);
|
|
}
|
|
|
|
$this->logAudit('branding_updated', 'firm', $firmId, $updates);
|
|
$this->jsonSuccess($updates, 'Branding aggiornato');
|
|
}
|
|
}
|