1022 lines
39 KiB
JavaScript
1022 lines
39 KiB
JavaScript
"use strict";
|
|
|
|
// Armazenamento e chaves
|
|
const STORAGE = {
|
|
UNITS: "telseg_units",
|
|
CREDS: "telseg_passwords",
|
|
VAULT: "telseg_vault",
|
|
THEME: "telseg_theme",
|
|
NOTES: "telseg_notes",
|
|
AUTH: "telseg_auth",
|
|
};
|
|
|
|
const $ = (sel, el = document) => el.querySelector(sel);
|
|
const $$ = (sel, el = document) => Array.from(el.querySelectorAll(sel));
|
|
|
|
// Detecção de API
|
|
const state = { useApi: false, units: [], creds: [], vault: null, notes: [], auth: null };
|
|
|
|
async function probeApi() {
|
|
try {
|
|
const r = await fetch('/api/health', { cache: 'no-store' });
|
|
state.useApi = r.ok; return state.useApi;
|
|
} catch { state.useApi = false; return false; }
|
|
}
|
|
|
|
function updateStorageInfo() {
|
|
const el = document.getElementById('storageInfo');
|
|
if (!el) return;
|
|
el.textContent = state.useApi
|
|
? 'Servidor • Dados persistem no banco (Postgres) do container.'
|
|
: 'Local • Seus dados ficam somente no seu navegador.';
|
|
}
|
|
|
|
// ---- Auth (API) ----
|
|
function getToken() { try { return JSON.parse(localStorage.getItem(STORAGE.AUTH) || 'null')?.token || null; } catch { return null; } }
|
|
function setToken(token) { localStorage.setItem(STORAGE.AUTH, JSON.stringify({ token })); }
|
|
async function apiFetch(path, opts = {}) {
|
|
const o = { ...opts, headers: { ...(opts.headers||{}), 'Content-Type': 'application/json' } };
|
|
const token = getToken();
|
|
if (token) o.headers['Authorization'] = `Bearer ${token}`;
|
|
const r = await fetch(path, o);
|
|
if (r.status === 401) { showLogin(); throw new Error('unauthorized'); }
|
|
return r;
|
|
}
|
|
function canMaster() { return !!state.auth?.user && state.auth.user.role === 'master'; }
|
|
function can(flag) { if (canMaster()) return true; return !!state.auth?.user && !!state.auth.user.permissions?.[flag]; }
|
|
function applyPermissionsUI() {
|
|
if (!state.useApi || !state.auth?.user) return;
|
|
// tabs
|
|
const tabs = [
|
|
{ id: 'unidades', read: 'units_read' },
|
|
{ id: 'senhas', read: 'creds_read' },
|
|
{ id: 'notas', read: 'notes_read' },
|
|
];
|
|
tabs.forEach(t => {
|
|
const btn = document.querySelector(`.tab[data-tab="${t.id}"]`);
|
|
const view = document.getElementById(`view-${t.id}`);
|
|
const visible = can(t.read);
|
|
if (btn) btn.style.display = visible ? '' : 'none';
|
|
if (view) view.style.display = visible ? '' : 'none';
|
|
});
|
|
// users tab only master
|
|
const userTab = document.querySelector('#tab-users');
|
|
const userView = document.getElementById('view-usuarios');
|
|
if (userTab) userTab.style.display = canMaster() ? '' : 'none';
|
|
if (userView) userView.style.display = canMaster() ? '' : 'none';
|
|
// action buttons
|
|
document.getElementById('addUnidadeBtn').style.display = can('units_write') ? '' : 'none';
|
|
document.getElementById('addCredBtn').style.display = can('creds_write') ? '' : 'none';
|
|
document.getElementById('addNoteBtn').style.display = can('notes_write') ? '' : 'none';
|
|
}
|
|
function showLogin() { document.getElementById('authGate')?.classList.remove('hidden'); }
|
|
function hideLogin() { document.getElementById('authGate')?.classList.add('hidden'); }
|
|
|
|
// Tema (claro/escuro)
|
|
function loadTheme() {
|
|
const t = localStorage.getItem(STORAGE.THEME) || "dark";
|
|
document.documentElement.setAttribute("data-theme", t);
|
|
}
|
|
function toggleTheme() {
|
|
const cur = document.documentElement.getAttribute("data-theme") || "dark";
|
|
const next = cur === "dark" ? "light" : "dark";
|
|
document.documentElement.setAttribute("data-theme", next);
|
|
localStorage.setItem(STORAGE.THEME, next);
|
|
}
|
|
|
|
// Utilidades
|
|
function uid() {
|
|
// ID simples e estável suficiente para uso local
|
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function saveJSON(key, value) { localStorage.setItem(key, JSON.stringify(value)); }
|
|
function loadJSON(key, fallback) {
|
|
try { const v = JSON.parse(localStorage.getItem(key) || ""); return v ?? fallback; }
|
|
catch { return fallback; }
|
|
}
|
|
|
|
function todayStr() { return new Date().toISOString().slice(0, 10); }
|
|
|
|
function copyText(text) {
|
|
navigator.clipboard?.writeText(text).catch(() => {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text; document.body.appendChild(ta); ta.select();
|
|
document.execCommand("copy"); document.body.removeChild(ta);
|
|
});
|
|
}
|
|
|
|
// Navegação por abas
|
|
function initTabs() {
|
|
$$(".tab").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
$$(".tab").forEach(b => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
const tab = btn.dataset.tab;
|
|
$$(".view").forEach(v => v.classList.remove("active"));
|
|
$(`#view-${tab}`).classList.add("active");
|
|
if (tab === 'usuarios') renderUsers();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ------- Unidades -------
|
|
async function refreshUnits() {
|
|
if (state.useApi) {
|
|
const r = await apiFetch('/api/units');
|
|
state.units = r.ok ? await r.json() : [];
|
|
} else {
|
|
state.units = loadJSON(STORAGE.UNITS, []);
|
|
}
|
|
return state.units;
|
|
}
|
|
function loadUnits() { return state.units || []; }
|
|
function saveUnits(list) { saveJSON(STORAGE.UNITS, list); state.units = list; }
|
|
|
|
function parseOCs(str) {
|
|
return (str || "")
|
|
.split(/[\n,]/g)
|
|
.map(s => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function renderUnits(filter = "") {
|
|
if (state.useApi) await refreshUnits();
|
|
const listEl = $("#unidadesList");
|
|
const items = loadUnits();
|
|
const f = filter.trim().toLowerCase();
|
|
const filtered = !f ? items : items.filter(u => {
|
|
return (
|
|
u.nome.toLowerCase().includes(f) ||
|
|
(u.ip || "").toLowerCase().includes(f) ||
|
|
(u.ocs || []).some(x => x.toLowerCase().includes(f))
|
|
);
|
|
});
|
|
// Ordena por nome: números primeiro, depois letras (ordem alfabética/natural)
|
|
filtered.sort((a, b) => {
|
|
const an = (a.nome || '').trim();
|
|
const bn = (b.nome || '').trim();
|
|
const aNum = /^[0-9]/.test(an);
|
|
const bNum = /^[0-9]/.test(bn);
|
|
if (aNum !== bNum) return aNum ? -1 : 1; // números primeiro
|
|
return an.localeCompare(bn, 'pt-BR', { numeric: true, sensitivity: 'base' });
|
|
});
|
|
|
|
const canWrite = !state.useApi || can('units_write');
|
|
listEl.innerHTML = filtered.map(u => {
|
|
const ocs = (u.ocs || []).map(x => `<span class="chip">OC ${escapeHTML(x)}</span>`).join(" ");
|
|
return `
|
|
<article class="card" data-id="${u.id}">
|
|
<h4>${escapeHTML(u.nome)}</h4>
|
|
<div class="meta">Criado em ${formatDate(u.dataCriacao)}${u.ip ? ` • IP ${escapeHTML(u.ip)}` : ""}</div>
|
|
${ocs ? `<div class="chips">${ocs}</div>` : ""}
|
|
${u.link ? `<div class="row k"><span>Link:</span> <a href="${escapeAttr(u.link)}" target="_blank" rel="noopener">${escapeHTML(u.link)}</a></div>` : ""}
|
|
<div class="actions">
|
|
${u.link ? `<button class="btn small" data-act="open">Abrir Discadora</button>` : ""}
|
|
${u.ip ? `<button class="btn small" data-act="copyip">Copiar IP</button>` : ""}
|
|
${canWrite ? `<button class=\"btn small\" data-act=\"edit\">Editar</button>` : ''}
|
|
${canWrite ? `<button class=\"btn small danger\" data-act=\"del\">Excluir</button>` : ''}
|
|
</div>
|
|
</article>`;
|
|
}).join("");
|
|
|
|
// Complementa UI com login/senha
|
|
filtered.forEach(u => {
|
|
const card = listEl.querySelector(`.card[data-id="${u.id}"]`);
|
|
if (!card) return;
|
|
const meta = card.querySelector('.meta');
|
|
if (u.login && meta && !meta.textContent.includes('Login')) {
|
|
meta.innerHTML = `${meta.innerHTML} • Login ${escapeHTML(u.login)}`;
|
|
}
|
|
const actions = card.querySelector('.actions');
|
|
if (actions) {
|
|
if (u.login && !actions.querySelector('[data-act="copylogin"]')) {
|
|
const b = document.createElement('button');
|
|
b.className = 'btn small';
|
|
b.setAttribute('data-act', 'copylogin');
|
|
b.textContent = 'Copiar Login';
|
|
actions.insertBefore(b, actions.querySelector('[data-act="edit"]') || null);
|
|
}
|
|
if (u.senha && !actions.querySelector('[data-act="copypass"]')) {
|
|
const b2 = document.createElement('button');
|
|
b2.className = 'btn small';
|
|
b2.setAttribute('data-act', 'copypass');
|
|
b2.textContent = 'Copiar Senha';
|
|
actions.insertBefore(b2, actions.querySelector('[data-act="edit"]') || null);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Delegação de eventos nos cartões
|
|
listEl.onclick = async (ev) => {
|
|
const btn = ev.target.closest("[data-act]");
|
|
if (!btn) return;
|
|
const card = ev.target.closest(".card");
|
|
const id = card?.dataset.id;
|
|
if (!id) return;
|
|
const items2 = loadUnits();
|
|
const idx = items2.findIndex(x => x.id === id);
|
|
const u = items2[idx];
|
|
if (!u) return;
|
|
const act = btn.dataset.act;
|
|
if (act === "open" && u.link) {
|
|
window.open(u.link, "_blank", "noopener");
|
|
} else if (act === "copyip" && u.ip) {
|
|
copyText(u.ip);
|
|
} else if (act === "copylogin" && u.login) {
|
|
copyText(u.login);
|
|
} else if (act === "copypass" && u.senha) {
|
|
copyText(u.senha);
|
|
} else if (act === "edit") {
|
|
openUnidadeModal(u);
|
|
} else if (act === "del") {
|
|
if (confirm(`Excluir unidade "${u.nome}"?`)) {
|
|
if (state.useApi) {
|
|
await apiFetch(`/api/units/${encodeURIComponent(u.id)}`, { method: 'DELETE' });
|
|
await refreshUnits();
|
|
} else {
|
|
items2.splice(idx, 1); saveUnits(items2);
|
|
}
|
|
renderUnits($("#searchUnidades").value);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function openUnidadeModal(u) {
|
|
$("#unidadeId").value = u?.id || "";
|
|
$("#unidadeNome").value = u?.nome || "";
|
|
$("#unidadeData").value = u?.dataCriacao || todayStr();
|
|
$("#unidadeOCs").value = (u?.ocs || []).join(", ");
|
|
$("#unidadeLink").value = u?.link || "";
|
|
$("#unidadeIP").value = u?.ip || "";
|
|
$("#unidadeLogin").value = u?.login || "";
|
|
$("#unidadeSenha").value = ""; // nunca preencher senha existente
|
|
showModal("unidadeModal");
|
|
}
|
|
|
|
// ------- Modal Helpers -------
|
|
function showModal(id) { const el = document.getElementById(id); el?.classList.remove("hidden"); }
|
|
function hideModal(id) { const el = document.getElementById(id); el?.classList.add("hidden"); }
|
|
|
|
// Escapar HTML básico
|
|
function escapeHTML(s) { return (s ?? "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">","\"":"""}[c])); }
|
|
function escapeAttr(s) { return escapeHTML(s).replace(/'/g, "'"); }
|
|
function formatDate(s) { try { const [y,m,d] = (s||"").split("-"); return d && m && y ? `${d}/${m}/${y}` : s; } catch { return s; } }
|
|
// ------- Cofre de Senhas (WebCrypto) -------
|
|
async function refreshCreds() {
|
|
if (state.useApi) {
|
|
const r = await apiFetch('/api/creds');
|
|
state.creds = r.ok ? await r.json() : [];
|
|
} else {
|
|
state.creds = loadJSON(STORAGE.CREDS, []);
|
|
}
|
|
return state.creds;
|
|
}
|
|
function loadCreds() { return state.creds || []; }
|
|
function saveCreds(list) { saveJSON(STORAGE.CREDS, list); state.creds = list; }
|
|
|
|
async function refreshVault() {
|
|
if (state.useApi) {
|
|
const r = await apiFetch('/api/vault');
|
|
state.vault = r.ok ? await r.json() : null;
|
|
} else {
|
|
state.vault = loadJSON(STORAGE.VAULT, null);
|
|
}
|
|
return state.vault;
|
|
}
|
|
function getVault() { return state.vault; }
|
|
function setVault(v) { saveJSON(STORAGE.VAULT, v); state.vault = v; }
|
|
|
|
// ------- Notas -------
|
|
async function refreshNotes() {
|
|
if (state.useApi) {
|
|
const r = await apiFetch('/api/notes');
|
|
state.notes = r.ok ? await r.json() : [];
|
|
} else {
|
|
state.notes = loadJSON(STORAGE.NOTES, []);
|
|
}
|
|
return state.notes;
|
|
}
|
|
function loadNotes() { return state.notes || []; }
|
|
function saveNotes(list) { saveJSON(STORAGE.NOTES, list); state.notes = list; }
|
|
|
|
function openNoteModal(n) {
|
|
$("#noteId").value = n?.id || "";
|
|
$("#noteTitle").value = n?.title || "";
|
|
$("#noteBody").value = n?.body || "";
|
|
$("#noteType").value = n?.data?.type || 'texto';
|
|
editingSteps = (n?.data?.steps || []).map(s => ({ id: s.id || uid(), text: s.text || '' }));
|
|
toggleStepsBlock();
|
|
renderSteps();
|
|
showModal("noteModal");
|
|
}
|
|
|
|
async function renderNotes(filter = "") {
|
|
if (state.useApi) await refreshNotes();
|
|
const listText = document.getElementById('notesTextList');
|
|
const listSteps = document.getElementById('notesStepsList');
|
|
const items = loadNotes();
|
|
const f = filter.trim().toLowerCase();
|
|
const filtered = !f ? items : items.filter(n => (
|
|
(n.title || "").toLowerCase().includes(f) || (n.body || "").toLowerCase().includes(f)
|
|
));
|
|
filtered.sort((a,b) => (b.updatedAt||0)-(a.updatedAt||0));
|
|
|
|
const renderCard = (n) => {
|
|
const preview = escapeHTML((n.body || '').split('\n').slice(0,3).join(' '));
|
|
const when = n.updatedAt ? new Date(n.updatedAt).toLocaleString() : '';
|
|
return `
|
|
<article class="card clickable" data-id="${n.id}">
|
|
<h4>${escapeHTML(n.title || 'Sem título')}</h4>
|
|
<div class="meta">${n.data?.type === 'passos' ? '<span class="chip">Passo a passo</span>' : ''} ${when ? `• Atualizado: ${when}` : ''}</div>
|
|
${n.data?.type === 'passos' ? `<div class="k">${(n.data.steps||[]).slice(0,3).map((s,i)=>`${i+1}. ${escapeHTML(s.text||'')}`).join('<br/>')}</div>` : (preview ? `<div class=\"k\">${preview}</div>` : '')}
|
|
</article>`;
|
|
};
|
|
|
|
const texts = filtered.filter(n => (n.data?.type || 'texto') !== 'passos');
|
|
const steps = filtered.filter(n => n.data?.type === 'passos');
|
|
|
|
if (listText) listText.innerHTML = texts.map(renderCard).join("");
|
|
if (listSteps) listSteps.innerHTML = steps.map(renderCard).join("");
|
|
|
|
const attach = (el) => {
|
|
if (!el) return;
|
|
el.onclick = (ev) => {
|
|
const card = ev.target.closest('.card');
|
|
if (!card) return;
|
|
const id = card.dataset.id;
|
|
const n = loadNotes().find(x => x.id === id);
|
|
if (n) openNoteView(n);
|
|
};
|
|
};
|
|
attach(listText);
|
|
attach(listSteps);
|
|
}
|
|
|
|
// --- Editor de passos (modal Nota)
|
|
let editingSteps = [];
|
|
function toggleStepsBlock() {
|
|
const type = $("#noteType").value;
|
|
const block = $("#stepsBlock");
|
|
if (type === 'passos') block.classList.remove('hidden'); else block.classList.add('hidden');
|
|
}
|
|
function renderSteps() {
|
|
const el = $("#stepsList");
|
|
el.innerHTML = editingSteps.map((s, idx) => `
|
|
<div class="step-card" data-idx="${idx}">
|
|
<div class="text" contenteditable="true">${escapeHTML(s.text)}</div>
|
|
<button class="btn small" data-del>Remover</button>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
// Visualização de nota (popup)
|
|
function openNoteView(n) {
|
|
const body = document.getElementById('noteViewBody');
|
|
const title = document.getElementById('noteViewTitle');
|
|
title.textContent = n.title || 'Sem título';
|
|
const when = n.updatedAt ? new Date(n.updatedAt).toLocaleString() : '';
|
|
const chip = n.data?.type === 'passos' ? '<span class="chip">Passo a passo</span>' : '<span class="chip">Texto</span>';
|
|
const sections = [];
|
|
if (n.data?.type === 'passos' && (n.data.steps || []).length) {
|
|
const items = (n.data.steps || []).map(s => `<li>${escapeHTML(s.text || '')}</li>`).join('');
|
|
sections.push(`<div class="k">Passos</div><ol>${items}</ol>`);
|
|
}
|
|
if ((n.body || '').trim()) {
|
|
sections.push(`<div class="k" style="margin-top:8px;">Conteúdo</div><div class="content">${escapeHTML(n.body)}</div>`);
|
|
}
|
|
if (!sections.length) {
|
|
sections.push('<div class="k">Sem conteúdo</div>');
|
|
}
|
|
body.innerHTML = `<div><div class="meta">${chip} ${when ? `• Atualizado: ${when}` : ''}</div>${sections.join('')}</div>`;
|
|
// Ações
|
|
document.getElementById('noteViewEdit').onclick = () => { hideModal('noteViewModal'); openNoteModal(n); };
|
|
document.getElementById('noteViewDelete').onclick = async () => {
|
|
if (!confirm('Excluir esta nota?')) return;
|
|
if (state.useApi) {
|
|
await apiFetch(`/api/notes/${encodeURIComponent(n.id)}`, { method: 'DELETE' });
|
|
await refreshNotes();
|
|
} else {
|
|
const list = loadNotes().filter(x => x.id !== n.id); saveNotes(list);
|
|
}
|
|
hideModal('noteViewModal');
|
|
renderNotes($("#searchNotes").value);
|
|
};
|
|
showModal('noteViewModal');
|
|
}
|
|
|
|
const textEnc = new TextEncoder();
|
|
const textDec = new TextDecoder();
|
|
|
|
function ab2b64(ab) {
|
|
const bytes = new Uint8Array(ab);
|
|
let bin = ""; for (let i=0; i<bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
return btoa(bin);
|
|
}
|
|
function b642ab(b64) {
|
|
const bin = atob(b64); const len = bin.length; const bytes = new Uint8Array(len);
|
|
for (let i=0; i<len; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes.buffer;
|
|
}
|
|
|
|
async function deriveKey(password, saltB64, iterations) {
|
|
const salt = b642ab(saltB64);
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
"raw", textEnc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]
|
|
);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
|
keyMaterial,
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"]
|
|
);
|
|
}
|
|
|
|
async function encryptString(key, plain) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, textEnc.encode(plain));
|
|
return { iv: ab2b64(iv), data: ab2b64(ct) };
|
|
}
|
|
async function decryptString(key, enc) {
|
|
const iv = new Uint8Array(b642ab(enc.iv));
|
|
const data = b642ab(enc.data);
|
|
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
|
|
return textDec.decode(pt);
|
|
}
|
|
|
|
let vaultKey = null;
|
|
const VAULT_ITERS = 200_000;
|
|
|
|
function renderVaultGate() {
|
|
const meta = getVault();
|
|
const setForm = $("#setVaultForm");
|
|
const unlockForm = $("#unlockVaultForm");
|
|
const status = $("#vaultStatus");
|
|
$("#vaultLocked").classList.remove("hidden");
|
|
$("#vaultUnlocked").classList.add("hidden");
|
|
if (!meta) {
|
|
status.textContent = "Proteja suas senhas com uma senha mestra.";
|
|
setForm.classList.remove("hidden");
|
|
unlockForm.classList.add("hidden");
|
|
} else {
|
|
status.textContent = "Informe sua senha mestra para desbloquear.";
|
|
setForm.classList.add("hidden");
|
|
unlockForm.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
async function unlockVault(password) {
|
|
const meta = getVault();
|
|
if (!meta) throw new Error("Cofre não configurado");
|
|
const key = await deriveKey(password, meta.salt, meta.iterations || VAULT_ITERS);
|
|
try {
|
|
const check = await decryptString(key, meta.verification);
|
|
if (check !== "ok") throw new Error("Senha inválida");
|
|
vaultKey = key; // mantido em memória
|
|
$("#vaultLocked").classList.add("hidden");
|
|
$("#vaultUnlocked").classList.remove("hidden");
|
|
renderCreds($("#searchCreds").value);
|
|
} catch {
|
|
alert("Senha mestra incorreta.");
|
|
}
|
|
}
|
|
|
|
function lockVault() {
|
|
vaultKey = null;
|
|
$("#vaultUnlockPass").value = "";
|
|
$("#credsList").innerHTML = "";
|
|
renderVaultGate();
|
|
}
|
|
|
|
async function renderCreds(filter = "") {
|
|
if (state.useApi) await refreshCreds();
|
|
const listEl = $("#credsList");
|
|
const items = loadCreds();
|
|
const f = filter.trim().toLowerCase();
|
|
const filtered = !f ? items : items.filter(c => {
|
|
return (
|
|
c.nome.toLowerCase().includes(f) ||
|
|
(c.usuario || "").toLowerCase().includes(f) ||
|
|
(c.url || "").toLowerCase().includes(f)
|
|
);
|
|
});
|
|
filtered.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
|
|
const canWrite = !state.useApi || can('creds_write');
|
|
listEl.innerHTML = filtered.map(c => {
|
|
const preview = escapeHTML((c.notas || '').split('\n').slice(0,2).join(' '));
|
|
const when = c.updatedAt ? new Date(c.updatedAt).toLocaleString() : '';
|
|
return `
|
|
<article class="card clickable" data-id="${c.id}">
|
|
<h4>${escapeHTML(c.nome)}</h4>
|
|
<div class="meta">${c.usuario ? `Usuário ${escapeHTML(c.usuario)} • `: ''}${c.url ? `<a href="${escapeAttr(c.url)}" target="_blank" rel="noopener">${escapeHTML(c.url)}</a>` : ''} ${when ? `• Atualizado: ${when}` : ''}</div>
|
|
${preview ? `<div class=\"k\">${preview}</div>` : ''}
|
|
</article>`;
|
|
}).join("");
|
|
|
|
listEl.onclick = async (ev) => {
|
|
const btn = ev.target.closest("[data-act]");
|
|
const card = ev.target.closest('.card');
|
|
if (!card) return;
|
|
const id = card.dataset.id;
|
|
const items2 = loadCreds();
|
|
const idx = items2.findIndex(x => x.id === id);
|
|
const c = items2[idx];
|
|
if (!c) return;
|
|
if (btn) {
|
|
// Mantido por compatibilidade caso algum botão exista
|
|
if (!vaultKey) { alert("Cofre bloqueado."); return; }
|
|
const act = btn.dataset.act;
|
|
if (act === "open" && c.url) {
|
|
window.open(c.url, "_blank", "noopener");
|
|
} else if (act === "copyuser" && c.usuario) {
|
|
copyText(c.usuario);
|
|
} else if (act === "copypass") {
|
|
try { const pass = await decryptString(vaultKey, c.passwordEnc); copyText(pass); } catch { alert("Falha ao descriptografar."); }
|
|
} else if (act === "reveal") {
|
|
try { const pass = await decryptString(vaultKey, c.passwordEnc); alert(`Senha: ${pass}`); } catch { alert("Falha ao descriptografar."); }
|
|
} else if (act === "edit") {
|
|
openCredModal(c);
|
|
} else if (act === "del") {
|
|
if (confirm(`Excluir credencial "${c.nome}"?`)) {
|
|
if (state.useApi) {
|
|
await apiFetch(`/api/creds/${encodeURIComponent(c.id)}`, { method: 'DELETE' });
|
|
await refreshCreds();
|
|
} else {
|
|
items2.splice(idx,1); saveCreds(items2);
|
|
}
|
|
renderCreds($("#searchCreds").value);
|
|
}
|
|
}
|
|
} else {
|
|
// Clique no card abre visualização
|
|
if (!vaultKey) { alert("Cofre bloqueado."); return; }
|
|
openCredView(c, canWrite);
|
|
}
|
|
};
|
|
}
|
|
|
|
function openCredModal(c) {
|
|
$("#credId").value = c?.id || "";
|
|
$("#credNome").value = c?.nome || "";
|
|
$("#credUsuario").value = c?.usuario || "";
|
|
$("#credSenha").value = ""; // nunca preencher
|
|
$("#credUrl").value = c?.url || "";
|
|
$("#credNotas").value = c?.notas || "";
|
|
showModal("credModal");
|
|
}
|
|
|
|
// Visualização de credencial (popup)
|
|
function openCredView(c, canWrite) {
|
|
const body = document.getElementById('credViewBody');
|
|
const title = document.getElementById('credViewTitle');
|
|
title.textContent = c.nome || 'Credencial';
|
|
const when = c.updatedAt ? new Date(c.updatedAt).toLocaleString() : '';
|
|
const sections = [];
|
|
const access = [];
|
|
if (c.usuario) access.push(`<div>Usuário: <strong>${escapeHTML(c.usuario)}</strong></div>`);
|
|
if (c.url) access.push(`<div>URL: <a href="${escapeAttr(c.url)}" target="_blank" rel="noopener">${escapeHTML(c.url)}</a></div>`);
|
|
if (access.length) sections.push(`<div class="k">Acesso</div><div class="content">${access.join('')}</div>`);
|
|
if ((c.notas || '').trim()) sections.push(`<div class="k" style="margin-top:8px;">Notas</div><div class="content">${escapeHTML(c.notas)}</div>`);
|
|
if (!sections.length) sections.push('<div class="k">Sem detalhes</div>');
|
|
body.innerHTML = `<div><div class="meta">${when ? `Atualizado: ${when}` : ''}</div>${sections.join('')}</div>`;
|
|
|
|
// Ações
|
|
const btnOpen = document.getElementById('credViewOpen');
|
|
const btnCopyUser = document.getElementById('credViewCopyUser');
|
|
const btnCopyPass = document.getElementById('credViewCopyPass');
|
|
const btnReveal = document.getElementById('credViewReveal');
|
|
const btnEdit = document.getElementById('credViewEdit');
|
|
const btnDelete = document.getElementById('credViewDelete');
|
|
|
|
btnOpen.style.display = c.url ? '' : 'none';
|
|
btnCopyUser.style.display = c.usuario ? '' : 'none';
|
|
btnEdit.style.display = canWrite ? '' : 'none';
|
|
btnDelete.style.display = canWrite ? '' : 'none';
|
|
|
|
btnOpen.onclick = () => { if (c.url) window.open(c.url, '_blank', 'noopener'); };
|
|
btnCopyUser.onclick = () => { if (c.usuario) copyText(c.usuario); };
|
|
btnCopyPass.onclick = async () => {
|
|
try { const pass = await decryptString(vaultKey, c.passwordEnc); copyText(pass); } catch { alert('Falha ao descriptografar.'); }
|
|
};
|
|
btnReveal.onclick = async () => {
|
|
try { const pass = await decryptString(vaultKey, c.passwordEnc); alert(`Senha: ${pass}`); } catch { alert('Falha ao descriptografar.'); }
|
|
};
|
|
btnEdit.onclick = () => { hideModal('credViewModal'); openCredModal(c); };
|
|
btnDelete.onclick = async () => {
|
|
if (!confirm(`Excluir credencial "${c.nome}"?`)) return;
|
|
const items2 = loadCreds();
|
|
const idx = items2.findIndex(x => x.id === c.id);
|
|
if (state.useApi) {
|
|
await apiFetch(`/api/creds/${encodeURIComponent(c.id)}`, { method: 'DELETE' });
|
|
await refreshCreds();
|
|
} else if (idx >= 0) {
|
|
items2.splice(idx,1); saveCreds(items2);
|
|
}
|
|
hideModal('credViewModal');
|
|
renderCreds($("#searchCreds").value);
|
|
};
|
|
|
|
showModal('credViewModal');
|
|
}
|
|
|
|
// ------- Exportar / Importar -------
|
|
function exportAll() {
|
|
const payload = {
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
theme: document.documentElement.getAttribute("data-theme"),
|
|
units: loadUnits(),
|
|
vault: getVault(),
|
|
creds: loadCreds(),
|
|
notes: loadNotes(),
|
|
};
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
|
const a = document.createElement("a");
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth()+1).padStart(2,"0");
|
|
const day = String(d.getDate()).padStart(2,"0");
|
|
a.download = `telseg_dashboard_backup_${y}${m}${day}.json`;
|
|
a.href = URL.createObjectURL(blob);
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
|
|
}
|
|
|
|
function importAll(file) {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const data = JSON.parse(String(reader.result || "{}"));
|
|
if (!confirm("Importar backup e substituir dados atuais?")) return;
|
|
if (data.theme) { localStorage.setItem(STORAGE.THEME, data.theme); document.documentElement.setAttribute("data-theme", data.theme); }
|
|
if (Array.isArray(data.units)) saveUnits(data.units);
|
|
if (Array.isArray(data.creds)) saveCreds(data.creds);
|
|
if (data.vault && data.vault.salt && data.vault.verification) setVault(data.vault);
|
|
if (Array.isArray(data.notes)) saveNotes(data.notes);
|
|
alert("Importação concluída.");
|
|
renderUnits($("#searchUnidades").value);
|
|
renderNotes($("#searchNotes").value);
|
|
lockVault(); // força rechecagem do cofre
|
|
} catch (e) {
|
|
alert("Arquivo inválido.");
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// ------- Inicialização -------
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
loadTheme();
|
|
await probeApi();
|
|
// Auth flow when API is enabled
|
|
if (state.useApi) {
|
|
// try existing token
|
|
const token = getToken();
|
|
if (token) {
|
|
try {
|
|
const r = await apiFetch('/api/auth/me');
|
|
const data = await r.json();
|
|
state.auth = data; hideLogin();
|
|
applyPermissionsUI();
|
|
if (canMaster()) await renderUsers();
|
|
} catch { showLogin(); }
|
|
} else {
|
|
showLogin();
|
|
}
|
|
}
|
|
if (!state.useApi || state.auth?.user) {
|
|
await Promise.all([refreshUnits(), refreshCreds(), refreshVault(), refreshNotes()]);
|
|
}
|
|
updateStorageInfo();
|
|
applyPermissionsUI();
|
|
|
|
$("#themeToggle").addEventListener("click", toggleTheme);
|
|
initTabs();
|
|
|
|
// Unidades
|
|
$("#addUnidadeBtn").addEventListener("click", () => openUnidadeModal());
|
|
$("#searchUnidades").addEventListener("input", ev => renderUnits(ev.target.value));
|
|
$("#unidadeForm").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
const id = $("#unidadeId").value || uid();
|
|
const listBefore = loadUnits();
|
|
const idxBefore = listBefore.findIndex(x => x.id === id);
|
|
const existing = idxBefore >= 0 ? listBefore[idxBefore] : null;
|
|
const item = {
|
|
id,
|
|
nome: $("#unidadeNome").value.trim(),
|
|
dataCriacao: $("#unidadeData").value || todayStr(),
|
|
ocs: parseOCs($("#unidadeOCs").value),
|
|
link: $("#unidadeLink").value.trim(),
|
|
ip: $("#unidadeIP").value.trim(),
|
|
login: $("#unidadeLogin").value.trim(),
|
|
senha: $("#unidadeSenha").value ? $("#unidadeSenha").value : (existing?.senha || ''),
|
|
};
|
|
if (!item.nome) { alert("Informe o nome do cliente."); return; }
|
|
if (state.useApi) {
|
|
const exists = idxBefore >= 0;
|
|
await apiFetch(exists ? `/api/units/${encodeURIComponent(id)}` : '/api/units', {
|
|
method: exists ? 'PUT' : 'POST',
|
|
body: JSON.stringify(item)
|
|
});
|
|
await refreshUnits();
|
|
} else {
|
|
const list = listBefore.slice();
|
|
if (idxBefore >= 0) list[idxBefore] = item; else list.push(item);
|
|
saveUnits(list);
|
|
}
|
|
$("#unidadeSenha").value = "";
|
|
hideModal("unidadeModal");
|
|
renderUnits($("#searchUnidades").value);
|
|
});
|
|
|
|
// Fechar modais
|
|
document.body.addEventListener("click", (ev) => {
|
|
const btn = ev.target.closest("[data-close-modal]");
|
|
if (btn) hideModal(btn.getAttribute("data-close-modal"));
|
|
});
|
|
|
|
// Senhas/Cofre
|
|
renderVaultGate();
|
|
$("#lockVaultBtn").addEventListener("click", lockVault);
|
|
$("#searchCreds").addEventListener("input", ev => renderCreds(ev.target.value));
|
|
$("#addCredBtn").addEventListener("click", () => {
|
|
if (!vaultKey) { alert("Desbloqueie o cofre primeiro."); return; }
|
|
openCredModal();
|
|
});
|
|
|
|
// Notas
|
|
$("#addNoteBtn").addEventListener("click", () => openNoteModal());
|
|
$("#searchNotes").addEventListener("input", ev => renderNotes(ev.target.value));
|
|
$("#noteType").addEventListener("change", toggleStepsBlock);
|
|
$("#addStepBtn").addEventListener("click", () => {
|
|
const input = $("#newStepText");
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
editingSteps.push({ id: uid(), text });
|
|
input.value = "";
|
|
renderSteps();
|
|
});
|
|
$("#stepsList").addEventListener("click", (ev) => {
|
|
const del = ev.target.closest('[data-del]');
|
|
const row = ev.target.closest('.step-card');
|
|
if (del && row) {
|
|
const idx = Number(row.dataset.idx);
|
|
editingSteps.splice(idx,1);
|
|
renderSteps();
|
|
}
|
|
});
|
|
$("#stepsList").addEventListener("blur", (ev) => {
|
|
const row = ev.target.closest('.step-card');
|
|
if (!row) return;
|
|
const idx = Number(row.dataset.idx);
|
|
const textEl = row.querySelector('.text');
|
|
if (textEl) editingSteps[idx].text = textEl.textContent.trim();
|
|
}, true);
|
|
$("#noteForm").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
const id = $("#noteId").value || uid();
|
|
const note = {
|
|
id,
|
|
title: $("#noteTitle").value.trim(),
|
|
body: $("#noteBody").value,
|
|
updatedAt: Date.now(),
|
|
data: $("#noteType").value === 'passos' ? { type: 'passos', steps: editingSteps.slice() } : { type: 'texto' }
|
|
};
|
|
if (state.useApi) {
|
|
const exists = loadNotes().some(n => n.id === id);
|
|
await apiFetch(exists ? `/api/notes/${encodeURIComponent(id)}` : '/api/notes', {
|
|
method: exists ? 'PUT' : 'POST',
|
|
body: JSON.stringify(note)
|
|
});
|
|
await refreshNotes();
|
|
} else {
|
|
const list = loadNotes();
|
|
const idx = list.findIndex(n => n.id === id);
|
|
if (idx>=0) list[idx] = note; else list.push(note);
|
|
saveNotes(list);
|
|
}
|
|
hideModal('noteModal');
|
|
renderNotes($("#searchNotes").value);
|
|
});
|
|
|
|
$("#setVaultForm").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
const p1 = $("#vaultPass1").value;
|
|
const p2 = $("#vaultPass2").value;
|
|
if (!p1 || p1 !== p2) { alert("Senhas não conferem."); return; }
|
|
const salt = ab2b64(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
|
const key = await deriveKey(p1, salt, VAULT_ITERS);
|
|
const verification = await encryptString(key, "ok");
|
|
const meta = { salt, verification, iterations: VAULT_ITERS, v: 1 };
|
|
if (state.useApi) {
|
|
await apiFetch('/api/vault', { method: 'PUT', body: JSON.stringify(meta) });
|
|
await refreshVault();
|
|
} else {
|
|
setVault(meta);
|
|
}
|
|
alert("Cofre configurado. Guarde sua senha mestra!");
|
|
$("#vaultPass1").value = ""; $("#vaultPass2").value = "";
|
|
unlockVault(p1);
|
|
});
|
|
|
|
$("#unlockVaultForm").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
const p = $("#vaultUnlockPass").value;
|
|
await unlockVault(p);
|
|
});
|
|
|
|
$("#credForm").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
if (!vaultKey) { alert("Cofre bloqueado."); return; }
|
|
const id = $("#credId").value || uid();
|
|
const list = loadCreds();
|
|
const idx = list.findIndex(x => x.id === id);
|
|
const existing = idx >= 0 ? list[idx] : null;
|
|
const passwordPlain = $("#credSenha").value; // pode estar vazio ao editar
|
|
let passwordEnc = existing?.passwordEnc;
|
|
if (passwordPlain) passwordEnc = await encryptString(vaultKey, passwordPlain);
|
|
const item = {
|
|
id,
|
|
nome: $("#credNome").value.trim(),
|
|
usuario: $("#credUsuario").value.trim(),
|
|
url: $("#credUrl").value.trim(),
|
|
notas: $("#credNotas").value.trim(),
|
|
passwordEnc,
|
|
updatedAt: Date.now(),
|
|
};
|
|
if (!item.nome) { alert("Informe o nome."); return; }
|
|
if (!item.passwordEnc) { alert("Informe a senha."); return; }
|
|
if (state.useApi) {
|
|
const exists = idx >= 0;
|
|
await apiFetch(exists ? `/api/creds/${encodeURIComponent(id)}` : '/api/creds', { method: exists ? 'PUT' : 'POST', body: JSON.stringify(item) });
|
|
await refreshCreds();
|
|
} else {
|
|
if (idx >= 0) list[idx] = item; else list.push(item);
|
|
saveCreds(list);
|
|
}
|
|
hideModal("credModal");
|
|
renderCreds($("#searchCreds").value);
|
|
});
|
|
|
|
// Exportar / Importar
|
|
$("#exportBtn").addEventListener("click", async () => {
|
|
if (state.useApi) {
|
|
const r = await apiFetch('/api/export');
|
|
const payload = await r.json();
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
|
const a = document.createElement("a");
|
|
a.download = `telseg_dashboard_backup_api_${new Date().toISOString().slice(0,10)}.json`;
|
|
a.href = URL.createObjectURL(blob); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000);
|
|
} else {
|
|
exportAll();
|
|
}
|
|
});
|
|
$("#importFile").addEventListener("change", async (ev) => {
|
|
const f = ev.target.files?.[0]; if (!f) return;
|
|
if (state.useApi) {
|
|
const text = await f.text();
|
|
const data = JSON.parse(text);
|
|
if (!confirm("Importar backup e substituir dados atuais no servidor?")) return;
|
|
await apiFetch('/api/import', { method: 'POST', body: JSON.stringify(data) });
|
|
await Promise.all([refreshUnits(), refreshCreds(), refreshVault(), refreshNotes()]);
|
|
lockVault();
|
|
renderUnits($("#searchUnidades").value); // re-render
|
|
renderNotes($("#searchNotes").value);
|
|
} else {
|
|
importAll(f);
|
|
}
|
|
ev.target.value = ""; // permite importar o mesmo arquivo novamente
|
|
});
|
|
|
|
// Login form
|
|
const loginForm = document.getElementById('loginForm');
|
|
if (loginForm) {
|
|
loginForm.addEventListener('submit', async (ev) => {
|
|
ev.preventDefault();
|
|
const username = document.getElementById('loginUser').value.trim();
|
|
const password = document.getElementById('loginPass').value;
|
|
try {
|
|
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
|
if (!r.ok) throw new Error();
|
|
const data = await r.json();
|
|
setToken(data.token);
|
|
state.auth = { user: data.user };
|
|
hideLogin();
|
|
applyPermissionsUI();
|
|
await Promise.all([refreshUnits(), refreshCreds(), refreshVault(), refreshNotes()]);
|
|
if (canMaster()) await renderUsers();
|
|
renderUnits(document.getElementById('searchUnidades').value);
|
|
renderNotes(document.getElementById('searchNotes').value);
|
|
} catch {
|
|
alert('Login inválido');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Render inicial
|
|
if (!state.useApi || state.auth?.user) {
|
|
renderUnits("");
|
|
renderNotes("");
|
|
}
|
|
});
|
|
|
|
// ---- Usuários (somente Master) ----
|
|
async function renderUsers() {
|
|
if (!state.useApi || !canMaster()) return;
|
|
try {
|
|
const r = await apiFetch('/api/users');
|
|
const users = await r.json();
|
|
const list = document.getElementById('usersList');
|
|
list.innerHTML = users.map(u => {
|
|
const p = u.permissions || {};
|
|
return `
|
|
<article class="card" data-id="${u.id}">
|
|
<h4>${escapeHTML(u.username)} ${u.role === 'master' ? '<span class="chip">Master</span>' : ''}</h4>
|
|
<div class="grid">
|
|
<label><input type="checkbox" data-perm="units_read" ${p.units_read ? 'checked':''}/> OC: ver</label>
|
|
<label><input type="checkbox" data-perm="units_write" ${p.units_write ? 'checked':''}/> OC: editar/excluir</label>
|
|
<label><input type="checkbox" data-perm="creds_read" ${p.creds_read ? 'checked':''}/> Senhas: ver</label>
|
|
<label><input type="checkbox" data-perm="creds_write" ${p.creds_write ? 'checked':''}/> Senhas: editar/excluir</label>
|
|
<label><input type="checkbox" data-perm="notes_read" ${p.notes_read ? 'checked':''}/> Notas: ver</label>
|
|
<label><input type="checkbox" data-perm="notes_write" ${p.notes_write ? 'checked':''}/> Notas: editar/excluir</label>
|
|
</div>
|
|
<div class="actions">
|
|
<label class="row"><span class="k">Perfil:</span>
|
|
<select data-role class="input" style="max-width:160px">
|
|
<option value="user" ${u.role==='user'?'selected':''}>Usuário</option>
|
|
<option value="master" ${u.role==='master'?'selected':''}>Master</option>
|
|
</select>
|
|
</label>
|
|
<button class="btn small" data-act="reset">Redefinir senha</button>
|
|
<button class="btn small danger" data-act="del">Excluir</button>
|
|
</div>
|
|
</article>`;
|
|
}).join('');
|
|
|
|
// Handlers
|
|
list.onchange = async (ev) => {
|
|
const card = ev.target.closest('.card'); if (!card) return;
|
|
const id = card.dataset.id;
|
|
if (ev.target.matches('[data-perm]')) {
|
|
// Collect card perms
|
|
const inputs = Array.from(card.querySelectorAll('[data-perm]'));
|
|
const body = Object.fromEntries(inputs.map(i => [i.getAttribute('data-perm'), i.checked]));
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}/permissions`, { method: 'PUT', body: JSON.stringify(body) });
|
|
} else if (ev.target.matches('[data-role]')) {
|
|
const role = ev.target.value;
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ role }) });
|
|
await renderUsers();
|
|
}
|
|
};
|
|
list.onclick = async (ev) => {
|
|
const card = ev.target.closest('.card'); if (!card) return;
|
|
const id = card.dataset.id;
|
|
const actBtn = ev.target.closest('[data-act]'); if (!actBtn) return;
|
|
if (actBtn.dataset.act === 'reset') {
|
|
const pass = prompt('Nova senha:'); if (!pass) return;
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ password: pass }) });
|
|
alert('Senha atualizada');
|
|
} else if (actBtn.dataset.act === 'del') {
|
|
if (!confirm('Excluir este usuário?')) return;
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
await renderUsers();
|
|
}
|
|
};
|
|
} catch (e) {
|
|
console.error('renderUsers', e);
|
|
}
|
|
}
|
|
|
|
// Create user form
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const form = document.getElementById('userForm');
|
|
if (!form) return;
|
|
form.addEventListener('submit', async (ev) => {
|
|
ev.preventDefault();
|
|
const username = document.getElementById('newUserName').value.trim();
|
|
const password = document.getElementById('newUserPass').value;
|
|
const role = document.getElementById('newUserRole').value;
|
|
const body = {
|
|
username, password, role,
|
|
permissions: {
|
|
units_read: document.getElementById('p_units_read').checked,
|
|
units_write: document.getElementById('p_units_write').checked,
|
|
creds_read: document.getElementById('p_creds_read').checked,
|
|
creds_write: document.getElementById('p_creds_write').checked,
|
|
notes_read: document.getElementById('p_notes_read').checked,
|
|
notes_write: document.getElementById('p_notes_write').checked,
|
|
}
|
|
};
|
|
try {
|
|
await apiFetch('/api/users', { method: 'POST', body: JSON.stringify(body) });
|
|
form.reset();
|
|
await renderUsers();
|
|
alert('Usuário criado');
|
|
} catch { alert('Falha ao criar usuário'); }
|
|
});
|
|
});
|