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>
939 lines
42 KiB
JavaScript
939 lines
42 KiB
JavaScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
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 };
|
||
})();
|