ligbox-ops-platform/projects/ops-desk/frontend/assets/user-wizard.js
Ligbox Spec Hub b03bb2c37c feat(desk): UI/API Spec 039-041 + deploy atómico e smoke GREEN
Commita governance, user-wizard, operational-feed e catálogo RBAC;
adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial;
documenta credencial VM112 @betinplace.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 13:32:15 +00:00

939 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* UserWizard — criar utilizador
* Spec 040 · DS-FE-003 · mockup Roger · POST /api/v1/governance/users/wizard
*/
(function () {
'use strict';
const ROLE_META = [
{ value: 'super_admin', label: 'Super Admin', group: 'Ops', code: 'SU' },
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops', code: 'CO' },
{ value: 'technician', label: 'Suporte', group: 'Ops', code: 'TEC' },
{ value: 'noc', label: 'NOC', group: 'Ops', code: 'NOC' },
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial', code: 'SAD' },
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial', code: 'SSU' },
{ value: 'finance', label: 'Financeiro', group: 'Negócio', code: 'FIN' },
{ value: 'marketing', label: 'Marketing', group: 'Negócio', code: 'MKT' },
{ value: 'seo', label: 'SEO', group: 'Negócio', code: 'SEO' },
{ value: 'developer', label: 'Developer', group: 'Plataforma', code: 'DEV' },
{ value: 'devops', label: 'DevOps', group: 'Plataforma', code: 'DVO' },
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma', code: 'SOC' },
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma', code: 'CMS' },
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma', code: 'AIO' },
{ value: 'partner', label: 'Partner', group: 'Externo', code: 'PTR' },
];
const MODULES = [
{ id: 'desk', label: 'Desk' },
{ id: 'openpanel', label: 'OpenPanel' },
{ id: 'billing', label: 'Billing' },
{ id: 'api', label: 'API' },
{ id: 'security', label: 'Security' },
{ id: 'ai_agents', label: 'AI Agents' },
];
const LEVELS = [
{ value: 'full', label: 'Total' },
{ value: 'partial', label: 'Parcial' },
{ value: 'read', label: 'Leitura' },
{ value: 'none', label: 'Sem acesso' },
];
const MODULE_TREE = [
{ id: 'desk', label: 'Desk (Help Desk)', actions: ['Criar / editar / remover utilizadores Desk', 'Gerir tickets e filas', 'Ver relatórios operacionais'] },
{ id: 'openpanel', label: 'OpenPanel (Plataforma)', actions: ['Provisionar sites', 'Gerir DNS e SSL'] },
{ id: 'billing', label: 'Faturação & Billing', actions: ['Ver facturas', 'Validar estado billing'] },
{ id: 'ai_agents', label: 'Agentes IA', actions: ['Executar runbooks', 'Aprovar remediação A7'] },
{ id: 'security', label: 'Segurança / SOC', actions: ['Ver incidentes', 'Reset 2FA utilizadores'] },
{ id: 'api', label: 'API & Integrações', actions: ['Gerir tokens API', 'Webhooks inbound'] },
];
const SECONDARY_GROUP_OPTIONS = ['Financeiro', 'Suporte Nível 2', 'Ops', 'Comercial', 'Negócio', 'Plataforma', 'Externo'];
const GROUP_HINTS = {
Ops: 'Operações internas — NOC, suporte e administradores.',
Comercial: 'Equipa comercial, vendas e pós-venda.',
Negócio: 'Financeiro, marketing e conteúdo.',
Plataforma: 'Dev, DevOps, segurança e agentes IA.',
Externo: 'Parceiros e acessos limitados.',
};
const PERM_TABS = [
{ id: 'modules', label: 'Módulos e funções' },
{ id: 'special', label: 'Recursos especiais' },
{ id: 'restrictions', label: 'Restrições' },
{ id: 'data', label: 'Acesso a dados' },
];
const STEPS = ['Dados do utilizador', 'Perfil e grupo', 'Permissões', 'Revisão'];
const STATUS_OPTIONS = [
{ value: 'active', label: 'Ativo' },
{ value: 'pending', label: 'Inativo' },
{ value: 'frozen', label: 'Congelado' },
{ value: 'invited', label: 'Desactivado' },
];
const SVG = {
userPlus: '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>',
close: '<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
eye: '<svg viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
whatsapp: '<svg viewBox="0 0 24 24"><path d="M21 11.5a8.38 8.38 0 0 1-3.3 6.7 8.5 8.5 0 0 1-12.7-7.1 8.38 8.38 0 0 1 3.3-6.7A8.5 8.5 0 0 1 21 11.5z"/><path d="M8 12h.01M12 12h.01M16 12h.01"/></svg>',
info: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
calendar: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
shield: '<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
bell: '<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>',
key: '<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
arrow: '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>',
chevron: '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>',
};
let open = false;
let step = 0;
let saving = false;
let result = null;
let onDone = null;
let defaultRole = 'technician';
let permTab = 'modules';
let permSearch = '';
let expandedModules = new Set(['desk']);
let confirmOpen = false;
let groupHintOpen = false;
const form = {
display_name: '',
email: '',
phone: '',
document: '',
birthdate: '',
password: '',
password_confirm: '',
account_status: 'active',
force_password_change: true,
mfa_required: true,
notifications_enabled: true,
api_access: false,
role: 'technician',
main_group: 'Ops',
secondary_groups: [],
module_permissions: {},
notes: '',
send_invite_email: true,
activate_account: true,
};
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function roleMeta(id) {
return ROLE_META.find((r) => r.value === id) || { label: id, group: '—', code: '?' };
}
function initials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '—';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function statusLabel(value) {
return STATUS_OPTIONS.find((s) => s.value === value)?.label || value;
}
function statusBadgeClass(value) {
if (value === 'active') return 'lb-badge lb-badge--active';
if (value === 'frozen') return 'lb-badge lb-badge--frozen';
return 'lb-badge lb-badge--warn';
}
function defaultPerms(role) {
const p = {};
MODULES.forEach((m) => { p[m.id] = 'none'; });
if (role === 'super_admin') MODULES.forEach((m) => { p[m.id] = 'full'; });
else if (role === 'ops_lead' || role === 'devops') {
Object.assign(p, { desk: 'full', openpanel: 'partial', security: 'partial', api: 'read' });
} else if (role === 'technician') Object.assign(p, { desk: 'partial', openpanel: 'read' });
else if (role === 'finance') Object.assign(p, { billing: 'full', desk: 'read' });
else if (role === 'agentic_operator') Object.assign(p, { ai_agents: 'full', desk: 'partial' });
return p;
}
function permStats() {
const perms = form.module_permissions || {};
const allowed = MODULES.filter((m) => perms[m.id] && perms[m.id] !== 'none');
const fullCount = allowed.filter((m) => perms[m.id] === 'full').length;
const partialCount = allowed.filter((m) => perms[m.id] === 'partial').length;
const readCount = allowed.filter((m) => perms[m.id] === 'read').length;
const functionsTotal = 32;
const functionsReleased = Math.min(functionsTotal, allowed.length * 4 + fullCount * 2 + partialCount + readCount);
return {
modules: allowed.length,
functions: `${functionsReleased} de ${functionsTotal}`,
special: String(fullCount + partialCount).padStart(2, '0'),
};
}
function resetForm(role) {
form.display_name = '';
form.email = '';
form.phone = '';
form.document = '';
form.birthdate = '';
form.password = '';
form.password_confirm = '';
form.account_status = 'active';
form.force_password_change = true;
form.mfa_required = true;
form.notifications_enabled = true;
form.api_access = false;
form.role = role || defaultRole;
form.main_group = roleMeta(form.role).group;
form.secondary_groups = [];
form.module_permissions = defaultPerms(form.role);
form.notes = '';
form.send_invite_email = true;
form.activate_account = true;
step = 0;
result = null;
permTab = 'modules';
permSearch = '';
expandedModules = new Set(['desk']);
confirmOpen = false;
groupHintOpen = false;
}
function levelLabel(value) {
return LEVELS.find((l) => l.value === value)?.label || value;
}
function profileSummaryHtml() {
const stats = permStats();
return `
<aside class="lw-summary">
<h4 class="lw-summary-title">Permissões por perfil (resumo)</h4>
<div class="lw-summary-stat"><span>Módulos permitidos</span><strong>${stats.modules} módulos</strong></div>
<div class="lw-summary-stat"><span>Funções libertadas</span><strong>${stats.functions}</strong></div>
<div class="lw-summary-stat"><span>Recursos especiais</span><strong>${stats.special} habilitados</strong></div>
<div class="lw-summary-stat"><span>Restrições</span><strong>Nenhuma</strong></div>
<div class="lw-summary-info">
${SVG.info}
<span>Perfil define o que o utilizador pode fazer. Grupo define onde tem acesso.</span>
</div>
</aside>`;
}
function toggleRow(name, title, desc, checked) {
return `
<label class="lw-access-row">
<div class="lw-access-main">
<span class="lw-access-icon">${SVG[name === 'mfa_required' ? 'shield' : name === 'notifications_enabled' ? 'bell' : 'key']}</span>
<span class="lw-toggle-text">
<strong>${esc(title)}</strong>
<span class="lw-toggle-desc">${esc(desc)}</span>
</span>
</div>
<span class="lw-toggle">
<input type="checkbox" name="${name}"${checked ? ' checked' : ''}/>
<span class="lw-toggle-slider"></span>
</span>
</label>`;
}
function summaryHtml() {
const rm = roleMeta(form.role);
const stats = permStats();
const name = form.display_name.trim() || '—';
const email = form.email.trim() || '—';
return `
<aside class="lw-summary">
<h4 class="lw-summary-title">Resumo do utilizador</h4>
<div class="lw-summary-preview">
<div class="lw-summary-avatar">${esc(initials(form.display_name))}</div>
<div>
<div class="lw-summary-name">${esc(name)}</div>
<div class="lw-summary-email">${esc(email)}</div>
<span class="${statusBadgeClass(form.account_status)}">${esc(statusLabel(form.account_status))}</span>
</div>
</div>
<dl class="lw-summary-kv">
<dt>Perfil</dt><dd>${esc(rm.label)}</dd>
<dt>Grupo</dt><dd>${esc(form.main_group)}</dd>
<dt>Estado</dt><dd>${esc(statusLabel(form.account_status))}</dd>
<dt>2FA</dt><dd>${form.mfa_required ? 'Activado' : 'Opcional'}</dd>
<dt>Acesso API</dt><dd>${form.api_access ? 'Permitido' : 'Não permitido'}</dd>
<dt>Notificações</dt><dd>${form.notifications_enabled ? 'Activas' : 'Desactivadas'}</dd>
</dl>
<div class="lw-summary-perms">
<h5>Permissões iniciais (resumo)</h5>
<div class="lw-summary-stat"><span>Módulos permitidos</span><strong>${stats.modules}</strong></div>
<div class="lw-summary-stat"><span>Funções libertadas</span><strong>${stats.functions}</strong></div>
<div class="lw-summary-stat"><span>Recursos especiais</span><strong>${stats.special} habilitados</strong></div>
</div>
<div class="lw-summary-info">
${SVG.info}
<span>Após a criação, o perfil e as permissões podem ser ajustados na gestão de utilizadores.</span>
</div>
</aside>`;
}
function stepBasic() {
const statusChips = STATUS_OPTIONS.map((s) =>
`<button type="button" class="lw-status-chip${form.account_status === s.value ? ' active' : ''}" data-status="${s.value}">${esc(s.label)}</button>`
).join('');
return `
<div class="lb-wizard-form">
<section class="lw-section">
<h3 class="lw-section-title">Dados pessoais</h3>
<div class="lw-field-grid lw-field-grid--2">
<div class="lw-field lw-field--full">
<label class="lw-field__label">Nome completo <span class="lw-req">*</span></label>
<input name="display_name" value="${esc(form.display_name)}" placeholder="Digite o nome completo" required/>
</div>
<div class="lw-field">
<label class="lw-field__label">E-mail (login) <span class="lw-req">*</span></label>
<input type="email" name="email" value="${esc(form.email)}" placeholder="ex.: nome@empresa.com" required autocomplete="off"/>
</div>
<div class="lw-field">
<label class="lw-field__label">Telefone <span style="font-weight:400;color:var(--lb-text-muted)">(opcional)</span></label>
<div class="lw-input-icon">
<input name="phone" value="${esc(form.phone)}" placeholder="(11) 99999-9999"/>
<span class="lw-input-trail">${SVG.whatsapp}</span>
</div>
</div>
<div class="lw-field">
<label class="lw-field__label">Documento <span style="font-weight:400;color:var(--lb-text-muted)">(opcional)</span></label>
<div class="lw-input-icon">
<input name="document" value="${esc(form.document)}" placeholder="CPF ou documento"/>
<span class="lw-input-trail">${SVG.info}</span>
</div>
</div>
<div class="lw-field">
<label class="lw-field__label">Data de nascimento <span style="font-weight:400;color:var(--lb-text-muted)">(opcional)</span></label>
<div class="lw-input-icon">
<input name="birthdate" value="${esc(form.birthdate)}" placeholder="dd/mm/aaaa"/>
<span class="lw-input-trail">${SVG.calendar}</span>
</div>
</div>
</div>
</section>
<section class="lw-section">
<h3 class="lw-section-title">Segurança e acesso</h3>
<div class="lw-field-grid lw-field-grid--2">
<div class="lw-field">
<label class="lw-field__label">Senha inicial <span class="lw-req">*</span></label>
<div class="lw-input-icon">
<input type="password" name="password" minlength="6" required autocomplete="new-password"/>
<button type="button" class="lw-input-action" data-toggle-pw="password" aria-label="Mostrar senha">${SVG.eye}</button>
</div>
</div>
<div class="lw-field">
<label class="lw-field__label">Confirmar senha <span class="lw-req">*</span></label>
<div class="lw-input-icon">
<input type="password" name="password_confirm" minlength="6" required autocomplete="new-password"/>
<button type="button" class="lw-input-action" data-toggle-pw="password_confirm" aria-label="Mostrar senha">${SVG.eye}</button>
</div>
</div>
</div>
<label class="lw-toggle-row" style="margin-top:8px">
<span class="lw-toggle-text">
<strong>Forçar alteração no primeiro acesso</strong>
<span class="lw-toggle-desc">O utilizador terá de definir uma nova senha no primeiro login</span>
</span>
<span class="lw-toggle">
<input type="checkbox" name="force_password_change"${form.force_password_change ? ' checked' : ''}/>
<span class="lw-toggle-slider"></span>
</span>
</label>
<div class="lw-field" style="margin-top:14px">
<span class="lw-field__label">Estado da conta</span>
<div class="lw-status-chips" role="radiogroup" aria-label="Estado da conta">
${statusChips}
<input type="hidden" name="account_status" value="${esc(form.account_status)}"/>
</div>
</div>
</section>
<section class="lw-section">
<h3 class="lw-section-title">Acesso adicional</h3>
${toggleRow('mfa_required', 'Activar autenticação de dois factores (2FA)', 'Recomendado para perfis administrativos', form.mfa_required)}
${toggleRow('notifications_enabled', 'Receber notificações do sistema', 'Alertas operacionais e avisos de segurança', form.notifications_enabled)}
${toggleRow('api_access', 'Permitir acesso via API', 'Gera token de API para integrações externas', form.api_access)}
</section>
</div>
${summaryHtml()}`;
}
function stepProfile() {
const groups = [...new Set(ROLE_META.map((r) => r.group))];
const opts = ROLE_META.filter((r) => r.value !== 'super_admin' || form.role === 'super_admin')
.map((r) => `<option value="${r.value}"${r.value === form.role ? ' selected' : ''}>${esc(r.label)} (${r.code})</option>`).join('');
const addable = SECONDARY_GROUP_OPTIONS.filter((g) => !form.secondary_groups.includes(g));
const tags = form.secondary_groups.map((g) =>
`<span class="lw-tag">${esc(g)}<button type="button" data-remove-group="${esc(g)}" aria-label="Remover">×</button></span>`
).join('') || '<span class="lb-stat-sub">Nenhum grupo adicional</span>';
const groupHint = GROUP_HINTS[form.main_group] || 'Grupo organizacional do utilizador.';
return `
<div class="lb-wizard-form">
<section class="lw-section">
<h3 class="lw-section-title">Perfil e grupo</h3>
<div class="lw-field-grid lw-field-grid--2">
<div class="lw-field">
<label class="lw-field__label">Perfil principal</label>
<select name="role">${opts}</select>
<p class="lw-field-hint">Perfil sugerido com base nas permissões seleccionadas.</p>
</div>
<div class="lw-field">
<label class="lw-field__label">Grupo principal</label>
<select name="main_group">
${groups.map((g) => `<option value="${esc(g)}"${form.main_group === g ? ' selected' : ''}>${esc(g)}</option>`).join('')}
</select>
<button type="button" class="lw-link-btn" data-toggle-group-hint>${groupHintOpen ? 'Ocultar' : 'Ver'} descrição do grupo</button>
${groupHintOpen ? `<p class="lw-field-hint">${esc(groupHint)}</p>` : ''}
</div>
</div>
<div class="lw-field" style="margin-top:12px">
<label class="lw-field__label">Grupos adicionais</label>
<select data-add-group ${addable.length ? '' : 'disabled'}>
<option value="">Seleccionar grupo…</option>
${addable.map((g) => `<option value="${esc(g)}">${esc(g)}</option>`).join('')}
</select>
<div class="lw-tags" data-group-tags>${tags}</div>
</div>
<div class="lw-summary-info" style="margin-top:16px">
${SVG.info}
<span><strong>Perfil</strong> define o que o utilizador pode fazer. <strong>Grupo</strong> define onde tem acesso.</span>
</div>
</section>
</div>
${profileSummaryHtml()}`;
}
function filteredModules() {
const q = permSearch.trim().toLowerCase();
if (!q) return MODULE_TREE;
return MODULE_TREE.filter((m) =>
m.label.toLowerCase().includes(q) || m.actions.some((a) => a.toLowerCase().includes(q))
);
}
function moduleLevelSelect(moduleId) {
const lv = form.module_permissions[moduleId] || 'none';
const opts = LEVELS.map((l) =>
`<option value="${l.value}"${lv === l.value ? ' selected' : ''}>${l.label}</option>`).join('');
return `<select name="perm_${moduleId}" class="lw-module-level" data-module="${moduleId}">${opts}</select>`;
}
function stepPermissions() {
const tabs = PERM_TABS.map((t) =>
`<button type="button" class="lw-perm-tab${permTab === t.id ? ' active' : ''}" data-perm-tab="${t.id}">${esc(t.label)}</button>`
).join('');
let tabBody = '';
if (permTab === 'modules') {
const modules = filteredModules();
tabBody = `
<div class="lw-perm-toolbar">
<input type="search" placeholder="Pesquisar módulos ou funções…" value="${esc(permSearch)}" data-perm-search/>
<button type="button" class="lb-btn-ghost" data-expand-all>Expandir todos</button>
</div>
<div class="lw-module-list">
${modules.map((m) => {
const isOpen = expandedModules.has(m.id);
const actions = m.actions.map((label, i) => `
<div class="lw-action-row">
<span>${esc(label)}</span>
${i === 0 ? moduleLevelSelect(m.id) : `<span class="lb-stat-sub">${esc(levelLabel(form.module_permissions[m.id] || 'none'))}</span>`}
</div>`).join('');
return `
<div class="lw-module${isOpen ? ' is-open' : ''}" data-module-id="${m.id}">
<button type="button" class="lw-module-head" data-toggle-module="${m.id}">
<span class="lw-module-chevron">${SVG.chevron}</span>
<span class="lw-module-title">${esc(m.label)}</span>
<span class="lw-module-count">${m.actions.length} funções</span>
</button>
<div class="lw-module-body">${actions}</div>
</div>`;
}).join('')}
</div>`;
} else if (permTab === 'special') {
tabBody = `<div class="lw-tab-placeholder">Recursos especiais — ${permStats().special} habilitados pelo perfil <strong>${esc(roleMeta(form.role).label)}</strong>.</div>`;
} else if (permTab === 'restrictions') {
tabBody = `<div class="lw-tab-placeholder">Nenhuma restrição adicional configurada para este utilizador.</div>`;
} else {
tabBody = `<div class="lw-tab-placeholder">Acesso a dados sensíveis herda do perfil e grupo principal.</div>`;
}
return `
<div class="lb-wizard-form">
<section class="lw-section">
<h3 class="lw-section-title">Permissões</h3>
<p class="lb-stat-sub" style="margin:0 0 12px">Ajuste fino das permissões antes de criar a conta.</p>
<div class="lw-perm-tabs">${tabs}</div>
${tabBody}
</section>
</div>
${profileSummaryHtml()}`;
}
function stepReview() {
const secGroups = form.secondary_groups.length ? form.secondary_groups.join(', ') : '—';
const stats = permStats();
return `
<div class="lb-wizard-form lb-wizard-form--wide">
<section class="lw-section">
<h3 class="lw-section-title">Revisão</h3>
<p class="lb-stat-sub" style="margin:0 0 14px">Confirme todos os dados antes de criar o utilizador.</p>
<div class="lw-review-grid">
<div class="lw-review-col">
<h4>Dados do utilizador</h4>
<dl class="lw-review-kv">
<dt>Nome</dt><dd>${esc(form.display_name)}</dd>
<dt>E-mail</dt><dd>${esc(form.email)}</dd>
<dt>Telefone</dt><dd>${esc(form.phone || '—')}</dd>
<dt>Estado da conta</dt><dd>${esc(statusLabel(form.account_status))}</dd>
<dt>2FA</dt><dd>${form.mfa_required ? 'Activado' : 'Opcional'}</dd>
</dl>
</div>
<div class="lw-review-col">
<h4>Perfil e grupo</h4>
<dl class="lw-review-kv">
<dt>Perfil principal</dt><dd>${esc(roleMeta(form.role).label)}</dd>
<dt>Grupo principal</dt><dd>${esc(form.main_group)}</dd>
<dt>Grupos adicionais</dt><dd>${esc(secGroups)}</dd>
</dl>
<h4 style="margin-top:14px">Permissões</h4>
<dl class="lw-review-kv">
<dt>Módulos</dt><dd>${stats.modules} módulos</dd>
<dt>Funções</dt><dd>${stats.functions}</dd>
</dl>
</div>
<div class="lw-review-col">
<h4>Acessos adicionais</h4>
<dl class="lw-review-kv">
<dt>2FA</dt><dd>${form.mfa_required ? 'Activado' : 'Não'}</dd>
<dt>Acesso API</dt><dd>${form.api_access ? 'Permitido' : 'Não permitido'}</dd>
<dt>Notificações</dt><dd>${form.notifications_enabled ? 'Activas' : 'Desactivadas'}</dd>
</dl>
<div class="lw-field" style="margin-top:12px">
<label class="lw-field__label">Observações (opcional)</label>
<textarea name="notes" maxlength="200" rows="3" placeholder="Notas internas sobre este utilizador…">${esc(form.notes)}</textarea>
<span class="lw-field-hint">${form.notes.length}/200 caracteres</span>
</div>
<label style="flex-direction:row;gap:8px;align-items:center;display:flex;font-size:0.78rem;margin-top:10px">
<input type="checkbox" name="send_invite_email"${form.send_invite_email ? ' checked' : ''}/> Enviar e-mail convite
</label>
</div>
</div>
</section>
</div>`;
}
function stepSuccess() {
const email = result?.user?.email || form.email;
const invite = result?.invite_link || '';
return `
<div class="lb-wizard-success">
<div class="lw-success-icon">✓</div>
<h2>Utilizador criado com sucesso!</h2>
<p class="lb-stat-sub">${form.send_invite_email ? `E-mail de convite enviado para ${esc(email)}` : `Conta criada: ${esc(email)}`}</p>
<p class="lb-stat-sub">ID interno · ${esc(result?.internal_id || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:20px">
<button type="button" class="lb-btn-primary" data-wz-view-user>Ver detalhes do utilizador</button>
<button type="button" class="lb-btn-ghost" data-wz-another>Criar outro utilizador</button>
<button type="button" class="lb-btn-ghost" data-wz-goto-mgmt>Voltar para a lista</button>
${invite ? `<button type="button" class="lb-btn-ghost" data-wz-copy-invite>Copiar link convite</button>` : ''}
</div>
${form.send_invite_email ? `
<div class="lw-email-preview">
<div class="lw-email-preview__head">ligbox</div>
<div class="lw-email-preview__body">
<p>Olá <strong>${esc(form.display_name)}</strong>,</p>
<p>Foi criada a sua conta no Ligbox Ops Desk. Clique abaixo para definir a sua senha no primeiro acesso.</p>
<p style="margin-top:14px"><span class="lb-btn-primary" style="display:inline-block;text-decoration:none">Definir minha senha</span></p>
<p class="lb-stat-sub" style="margin-top:12px">Link válido por 24 horas · ${esc(email)}</p>
</div>
</div>` : ''}
</div>`;
}
function confirmModalHtml() {
return `
<div class="lw-confirm-overlay" data-wz-confirm-overlay>
<div class="lw-confirm-card" role="alertdialog">
<h3>Confirmar criação do utilizador</h3>
<p class="lb-stat-sub" style="margin:0 0 10px">O que acontece ao clicar «Criar utilizador»:</p>
<ul>
<li>Conta criada na base Desk</li>
<li>${form.send_invite_email ? 'E-mail de convite enviado' : 'Convite por e-mail não enviado'}</li>
<li>Utilizador define senha no 1.º acesso${form.force_password_change ? ' (obrigatório)' : ''}</li>
<li>Permissões RBAC aplicadas conforme revisão</li>
<li>Registo em audit log</li>
</ul>
<div class="lw-confirm-actions">
<button type="button" class="lb-btn-ghost" data-wz-confirm-cancel>Cancelar</button>
<button type="button" class="lb-btn-primary" data-wz-confirm-ok ${saving ? 'disabled' : ''}>${saving ? 'A criar…' : 'Criar utilizador'}</button>
</div>
</div>
</div>`;
}
function renderSteps() {
if (step >= 4) return '';
return STEPS.map((label, i) => {
const cls = i === step ? 'active' : i < step ? 'done' : '';
return `<div class="lb-wizard-step ${cls}"><span class="lb-wizard-step-num">${i + 1}</span>${esc(label)}</div>`;
}).join('');
}
function renderBody() {
if (step === 0) return stepBasic();
if (step === 1) return stepProfile();
if (step === 2) return stepPermissions();
if (step === 3) return stepReview();
return stepSuccess();
}
function syncDom() {
const root = document.getElementById('lb-user-wizard-root');
if (!open) {
if (root) root.innerHTML = '';
document.body.classList.remove('um-scroll-lock');
return;
}
document.body.classList.add('um-scroll-lock');
const el = root || (() => {
const d = document.createElement('div');
d.id = 'lb-user-wizard-root';
document.body.appendChild(d);
return d;
})();
const isSuccess = step >= 4;
const isWide = step === 3 || isSuccess;
const nextLabel = step === 3 ? 'Criar utilizador' : 'Continuar';
el.innerHTML = `
<div class="lb-wizard-root" role="dialog" aria-modal="true" aria-labelledby="lw-title">
<div class="lb-wizard-card">
<header class="lb-wizard-head">
<div class="lb-wizard-head__main">
<div class="lb-wizard-head__icon">${SVG.userPlus}</div>
<div>
<h2 id="lw-title" class="lb-wizard-head__title">Criar utilizador</h2>
<p class="lb-wizard-head__desc">Crie uma nova conta de utilizador e defina perfil, grupo e permissões iniciais.</p>
</div>
</div>
<div class="lb-wizard-head__right">
${!isSuccess ? `<nav class="lb-wizard-steps" aria-label="Passos">${renderSteps()}</nav>` : ''}
<button type="button" class="lb-wizard-close" data-wz-close aria-label="Fechar">${SVG.close}</button>
</div>
</header>
<div class="lb-wizard-body${isSuccess ? ' lb-wizard-body--success' : ''}${isWide && !isSuccess ? ' lb-wizard-body--wide' : ''}">${renderBody()}</div>
${!isSuccess ? `
<footer class="lb-wizard-foot">
<button type="button" class="lb-btn-ghost" data-wz-cancel>Cancelar</button>
<div style="display:flex;gap:8px">
${step > 0 ? `<button type="button" class="lb-btn-ghost" data-wz-back>Voltar</button>` : ''}
<button type="button" class="lb-btn-primary" data-wz-next ${saving ? 'disabled' : ''}>
${step === 3 && saving ? 'A criar…' : nextLabel}${step < 3 ? SVG.arrow : ''}
</button>
</div>
</footer>` : ''}
</div>
${confirmOpen ? confirmModalHtml() : ''}
</div>`;
bindWizard(el);
}
function readForm(container) {
const g = (n) => container.querySelector(`[name="${n}"]`);
if (step === 0) {
form.display_name = g('display_name')?.value?.trim() || '';
form.email = g('email')?.value?.trim().toLowerCase() || '';
form.phone = g('phone')?.value?.trim() || '';
form.document = g('document')?.value?.trim() || '';
form.birthdate = g('birthdate')?.value?.trim() || '';
form.password = g('password')?.value || '';
form.password_confirm = g('password_confirm')?.value || '';
form.account_status = g('account_status')?.value || 'active';
form.force_password_change = !!container.querySelector('[name="force_password_change"]')?.checked;
form.mfa_required = !!container.querySelector('[name="mfa_required"]')?.checked;
form.notifications_enabled = !!container.querySelector('[name="notifications_enabled"]')?.checked;
form.api_access = !!container.querySelector('[name="api_access"]')?.checked;
if (!form.display_name) throw new Error('Nome completo é obrigatório');
if (!form.email) throw new Error('E-mail é obrigatório');
if (form.password !== form.password_confirm) throw new Error('Senhas não coincidem');
} else if (step === 1) {
form.role = g('role')?.value || form.role;
form.main_group = g('main_group')?.value || roleMeta(form.role).group;
form.module_permissions = defaultPerms(form.role);
} else if (step === 2) {
MODULES.forEach((m) => {
form.module_permissions[m.id] = g(`perm_${m.id}`)?.value || 'none';
});
} else if (step === 3) {
form.notes = g('notes')?.value?.trim() || '';
form.send_invite_email = !!container.querySelector('[name="send_invite_email"]')?.checked;
}
}
function parseApiError(text) {
try {
const data = JSON.parse(text);
if (Array.isArray(data.detail)) {
return data.detail.map((d) => d.msg || d.message || JSON.stringify(d)).join('\n');
}
if (typeof data.detail === 'string') return data.detail;
return data.message || text;
} catch (_) {
return text;
}
}
async function submitWizard() {
saving = true;
syncDom();
try {
const perms = {};
MODULES.forEach((m) => { perms[m.id] = form.module_permissions[m.id] || 'none'; });
const extraNotes = [form.document && `Doc: ${form.document}`, form.birthdate && `Nasc.: ${form.birthdate}`].filter(Boolean).join(' · ');
const notesRaw = [form.notes, extraNotes].filter(Boolean).join('\n') || null;
const notes = notesRaw ? notesRaw.slice(0, 200) : null;
if (form.password.length < 6) throw new Error('Senha inicial: mínimo 6 caracteres');
const r = await fetchWithTimeout('/api/v1/governance/users/wizard', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
display_name: form.display_name,
email: form.email,
phone: form.phone || null,
password: form.password,
account_status: form.account_status,
force_password_change: form.force_password_change,
mfa_required: form.mfa_required,
notifications_enabled: form.notifications_enabled,
api_access: form.api_access,
role: form.role,
main_group: form.main_group,
secondary_groups: form.secondary_groups,
module_permissions: perms,
notes,
send_invite_email: form.send_invite_email,
activate_account: form.account_status === 'active',
}),
});
if (!r.ok) throw new Error(parseApiError(await r.text()));
result = await r.json();
step = 4;
if (typeof onDone === 'function') onDone(result);
} catch (e) {
window.alert(e.message);
} finally {
saving = false;
syncDom();
}
}
function bindWizard(root) {
root.querySelector('[data-wz-close]')?.addEventListener('click', close);
root.querySelector('[data-wz-cancel]')?.addEventListener('click', close);
root.querySelector('.lb-wizard-root')?.addEventListener('click', (e) => {
if (e.target.classList.contains('lb-wizard-root')) close();
});
root.querySelectorAll('[data-toggle-pw]').forEach((btn) => {
btn.addEventListener('click', () => {
const field = root.querySelector(`[name="${btn.dataset.togglePw}"]`);
if (!field) return;
field.type = field.type === 'password' ? 'text' : 'password';
});
});
root.querySelectorAll('[data-status]').forEach((chip) => {
chip.addEventListener('click', () => {
form.account_status = chip.dataset.status;
syncDom();
});
});
root.querySelector('[data-wz-back]')?.addEventListener('click', () => {
if (step > 0) { step -= 1; syncDom(); }
});
root.querySelector('[data-wz-next]')?.addEventListener('click', async () => {
const body = root.querySelector('.lb-wizard-body');
try {
readForm(body);
} catch (e) {
window.alert(e.message);
return;
}
if (step === 3) {
confirmOpen = true;
syncDom();
return;
}
step += 1;
syncDom();
});
root.querySelector('[data-wz-confirm-cancel]')?.addEventListener('click', () => {
confirmOpen = false;
syncDom();
});
root.querySelector('[data-wz-confirm-overlay]')?.addEventListener('click', (e) => {
if (e.target.classList.contains('lw-confirm-overlay')) {
confirmOpen = false;
syncDom();
}
});
root.querySelector('[data-wz-confirm-ok]')?.addEventListener('click', async () => {
confirmOpen = false;
await submitWizard();
});
root.querySelector('[data-toggle-group-hint]')?.addEventListener('click', () => {
groupHintOpen = !groupHintOpen;
syncDom();
});
root.querySelector('[data-add-group]')?.addEventListener('change', (e) => {
const val = e.target.value;
if (val && !form.secondary_groups.includes(val)) {
form.secondary_groups.push(val);
syncDom();
}
});
root.querySelectorAll('[data-remove-group]').forEach((btn) => {
btn.addEventListener('click', () => {
form.secondary_groups = form.secondary_groups.filter((g) => g !== btn.dataset.removeGroup);
syncDom();
});
});
root.querySelectorAll('[data-perm-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
permTab = btn.dataset.permTab;
syncDom();
});
});
root.querySelector('[data-perm-search]')?.addEventListener('input', (e) => {
permSearch = e.target.value;
clearTimeout(root._permTimer);
root._permTimer = setTimeout(() => syncDom(), 180);
});
root.querySelector('[data-expand-all]')?.addEventListener('click', () => {
MODULE_TREE.forEach((m) => expandedModules.add(m.id));
syncDom();
});
root.querySelectorAll('[data-toggle-module]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.toggleModule;
if (expandedModules.has(id)) expandedModules.delete(id);
else expandedModules.add(id);
syncDom();
});
});
root.querySelectorAll('.lw-module-level').forEach((sel) => {
sel.addEventListener('change', (e) => {
form.module_permissions[e.target.dataset.module] = e.target.value;
syncDom();
});
});
root.querySelector('[name="notes"]')?.addEventListener('input', (e) => {
form.notes = e.target.value.slice(0, 200);
const hint = root.querySelector('.lw-field-hint');
if (hint) hint.textContent = `${form.notes.length}/200 caracteres`;
});
root.querySelector('[name="role"]')?.addEventListener('change', (e) => {
form.role = e.target.value;
form.main_group = roleMeta(form.role).group;
form.module_permissions = defaultPerms(form.role);
syncDom();
});
['display_name', 'email', 'phone'].forEach((field) => {
root.querySelector(`[name="${field}"]`)?.addEventListener('input', (e) => {
form[field] = e.target.value;
const summary = root.querySelector('.lw-summary');
if (summary && step === 0) {
const nameEl = summary.querySelector('.lw-summary-name');
const emailEl = summary.querySelector('.lw-summary-email');
const avatarEl = summary.querySelector('.lw-summary-avatar');
if (nameEl) nameEl.textContent = form.display_name.trim() || '—';
if (emailEl) emailEl.textContent = form.email.trim() || '—';
if (avatarEl) avatarEl.textContent = initials(form.display_name);
}
});
});
root.querySelectorAll('.lw-toggle input[type="checkbox"]').forEach((el) => {
el.addEventListener('change', () => {
if (step !== 0) return;
const body = root.querySelector('.lb-wizard-body');
form.force_password_change = !!body.querySelector('[name="force_password_change"]')?.checked;
form.mfa_required = !!body.querySelector('[name="mfa_required"]')?.checked;
form.notifications_enabled = !!body.querySelector('[name="notifications_enabled"]')?.checked;
form.api_access = !!body.querySelector('[name="api_access"]')?.checked;
const old = root.querySelector('.lw-summary');
if (old) old.outerHTML = summaryHtml();
});
});
root.querySelector('[data-wz-another]')?.addEventListener('click', () => {
resetForm(defaultRole);
syncDom();
});
root.querySelector('[data-wz-goto-mgmt]')?.addEventListener('click', () => {
close();
if (typeof setView === 'function') {
const ds = window.getDeskState?.();
if (ds) ds.matrixTab = 'access-control';
setView('access-matrix');
}
});
root.querySelector('[data-wz-view-user]')?.addEventListener('click', () => {
const email = result?.user?.username || result?.user?.email;
close();
if (email && window.DeskAccessControlHub?.selectUser) {
window.DeskAccessControlHub.selectUser(email);
if (typeof onDone === 'function') onDone(result);
}
});
root.querySelector('[data-wz-copy-invite]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(result?.invite_link || '');
window.alert('Link copiado');
} catch (_) {
window.prompt('Copiar link:', result?.invite_link);
}
});
}
function openWizard(opts = {}) {
defaultRole = opts.defaultRole || 'technician';
onDone = opts.onDone || null;
resetForm(defaultRole);
open = true;
syncDom();
}
function close() {
open = false;
syncDom();
}
window.DeskUserWizard = { open: openWizard, close };
})();