[FEAT] Ruolo Consulente + Wizard Registrazione v2

- register.html: step 0 scelta profilo (Azienda / Consulente)
- onboarding.html: wizard 4-step con P.IVA obbligatoria (auto-fetch CertiSource)
- companies.html: nuova dashboard consulente con cards aziende e compliance score
- common.js: org-switcher sidebar + role labels corretti per consulente
- login.html: routing post-login (consulente → companies.html)
- api.js: isConsultant(), setUserRole(), register con user_type
- AuthController: user_type=consultant → role=consultant in users table
- OnboardingController: multi-org per consulente, duplicate VAT check
- 005_consultant_support.sql: aggiunge 'consultant' a user_organizations.role ENUM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-02-20 08:53:30 +01:00
co-authored by Claude Sonnet 4.6
parent ba21534e6a
commit 7080695d06
9 changed files with 976 additions and 323 deletions
+18 -2
View File
@@ -72,6 +72,7 @@ class NIS2API {
const result = await this.post('/auth/login', { email, password });
if (result.success) {
this.setTokens(result.data.access_token, result.data.refresh_token);
this.setUserRole(result.data.user.role);
if (result.data.organizations && result.data.organizations.length > 0) {
const primary = result.data.organizations.find(o => o.is_primary) || result.data.organizations[0];
this.setOrganization(primary.organization_id);
@@ -80,10 +81,11 @@ class NIS2API {
return result;
}
async register(email, password, fullName) {
const result = await this.post('/auth/register', { email, password, full_name: fullName });
async register(email, password, fullName, userType = 'azienda') {
const result = await this.post('/auth/register', { email, password, full_name: fullName, user_type: userType });
if (result.success) {
this.setTokens(result.data.access_token, result.data.refresh_token);
localStorage.setItem('nis2_user_role', result.data.user.role);
}
return result;
}
@@ -122,6 +124,20 @@ class NIS2API {
localStorage.removeItem('nis2_access_token');
localStorage.removeItem('nis2_refresh_token');
localStorage.removeItem('nis2_org_id');
localStorage.removeItem('nis2_user_role');
}
// Salva ruolo utente al login
setUserRole(role) {
localStorage.setItem('nis2_user_role', role);
}
getUserRole() {
return localStorage.getItem('nis2_user_role');
}
isConsultant() {
return this.getUserRole() === 'consultant';
}
setOrganization(orgId) {
+102 -2
View File
@@ -230,6 +230,9 @@ function loadSidebar() {
</div>
`;
// Org-switcher area (populated async for consultants)
navHTML += `<div id="sidebar-org-switcher" style="display:none;padding:8px 12px;border-bottom:1px solid var(--gray-100);"></div>`;
// Nav sections
navHTML += '<nav class="sidebar-nav">';
for (const section of navItems) {
@@ -263,13 +266,23 @@ function loadSidebar() {
container.innerHTML = navHTML;
// Carica info utente
// Carica info utente (e org-switcher se consulente)
_loadUserInfo();
// Mobile toggle
_setupMobileToggle();
}
const _roleLabels = {
super_admin: 'Super Admin',
org_admin: 'Admin Org',
compliance_manager: 'Compliance Manager',
board_member: 'Membro CDA',
auditor: 'Auditor',
employee: 'Utente',
consultant: 'Consulente',
};
async function _loadUserInfo() {
try {
const result = await api.getMe();
@@ -288,13 +301,100 @@ async function _loadUserInfo() {
.toUpperCase();
avatarEl.textContent = initials;
}
if (roleEl) roleEl.textContent = user.role === 'admin' ? 'Amministratore' : 'Utente';
if (roleEl) roleEl.textContent = _roleLabels[user.role] || user.role || 'Utente';
// Save role to localStorage (ensures isConsultant() works across pages)
if (user.role) api.setUserRole(user.role);
// For consultants: render org-switcher
if (user.role === 'consultant') {
_loadConsultantOrgSwitcher();
}
}
} catch (e) {
// Silenzioso
}
}
async function _loadConsultantOrgSwitcher() {
try {
const result = await api.listOrganizations();
if (!result.success || !result.data || result.data.length === 0) return;
const orgs = result.data;
const currentOrgId = parseInt(api.orgId);
const currentOrg = orgs.find(o => (o.id || o.organization_id) === currentOrgId);
const switcher = document.getElementById('sidebar-org-switcher');
if (!switcher) return;
const dropdownId = 'org-dropdown-' + Date.now();
switcher.style.display = 'block';
switcher.innerHTML = `
<div style="position:relative">
<button onclick="document.getElementById('${dropdownId}').classList.toggle('open')"
style="width:100%;display:flex;align-items:center;gap:8px;padding:6px 10px;
background:var(--gray-50);border:1px solid var(--gray-200);border-radius:6px;
cursor:pointer;font-size:.8rem;font-weight:600;color:var(--gray-700);">
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink:0;color:var(--primary)">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4zm3 1h2v2H7V5zm2 4H7v2h2V9zm2-4h2v2h-2V5zm2 4h-2v2h2V9z" clip-rule="evenodd"/>
</svg>
<span style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${currentOrg ? currentOrg.name : 'Seleziona azienda'}
</span>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink:0">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
<div id="${dropdownId}" class="org-switcher-dropdown"
style="display:none;position:absolute;left:0;right:0;top:calc(100% + 4px);
background:var(--card-bg);border:1px solid var(--gray-200);border-radius:6px;
box-shadow:var(--card-shadow);z-index:200;overflow:hidden;">
${orgs.map(org => {
const orgId = org.id || org.organization_id;
const isActive = orgId === currentOrgId;
return `<button onclick="_switchOrg(${orgId})"
style="width:100%;text-align:left;padding:8px 12px;
background:${isActive ? 'var(--primary-bg)' : 'transparent'};
border:none;cursor:pointer;font-size:.8rem;
color:${isActive ? 'var(--primary)' : 'var(--gray-700)'};
font-weight:${isActive ? '600' : '400'};">
${isActive ? '✓ ' : ''}${org.name}
</button>`;
}).join('')}
<div style="border-top:1px solid var(--gray-100)">
<a href="companies.html"
style="display:block;padding:8px 12px;font-size:.8rem;
color:var(--primary);text-decoration:none;">
← Tutte le aziende
</a>
</div>
</div>
</div>`;
// Close on outside click
document.addEventListener('click', (e) => {
const dd = document.getElementById(dropdownId);
if (dd && !switcher.contains(e.target)) dd.style.display = 'none';
});
// Override toggle to handle display
const ddEl = document.getElementById(dropdownId);
if (ddEl) {
const btn = switcher.querySelector('button');
if (btn) btn.onclick = () => {
ddEl.style.display = ddEl.style.display === 'none' ? 'block' : 'none';
};
}
} catch (e) {
// Silenzioso
}
}
function _switchOrg(orgId) {
api.setOrganization(orgId);
window.location.reload();
}
function _setupMobileToggle() {
// Crea pulsante toggle se non esiste
if (!document.querySelector('.sidebar-toggle')) {