commit d84803efc940726be70d6fc4109ce4b67e8ad3f4 Author: Tiago Santos Date: Fri Nov 28 07:49:17 2025 -0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c60337d --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +POSTGRES_DB=telseg +POSTGRES_USER=telseg +POSTGRES_PASSWORD=troque_esta_senha +JWT_SECRET=troque_este_segredo +ADMIN_USER=admin +ADMIN_PASSWORD=admin123 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b9c4897 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM nginx:alpine + +# Copia configuração do Nginx (cache leve para assets, SPA fallback) +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copia arquivos estáticos do dashboard +COPY index.html /usr/share/nginx/html/index.html +COPY assets /usr/share/nginx/html/assets + +# Porta padrão já exposta na imagem base (80) + +# Dica: para rodar +# docker build -t dashboard_telseg . +# docker run --name dashboard_telseg -p 8080:80 -d dashboard_telseg + diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..341740e --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine +WORKDIR /app + +# Instala dependências +COPY package.json package-lock.json* ./ +RUN npm install --production || npm install --production --no-audit --no-fund + +# Copia código +COPY src ./src + +ENV PORT=3000 +EXPOSE 3000 +CMD ["npm", "start"] + diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..01140da --- /dev/null +++ b/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "telseg-dashboard-api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "pg": "^8.11.3", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/api/src/server.js b/api/src/server.js new file mode 100644 index 0000000..013b5f3 --- /dev/null +++ b/api/src/server.js @@ -0,0 +1,412 @@ +import express from 'express'; +import cors from 'cors'; +import pkg from 'pg'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; + +const { Pool } = pkg; + +const PORT = process.env.PORT || 3000; +const DATABASE_URL = process.env.DATABASE_URL || `postgres://${process.env.POSTGRES_USER || 'telseg'}:${process.env.POSTGRES_PASSWORD || 'telseg'}@${process.env.DB_HOST || 'db'}:${process.env.DB_PORT || 5432}/${process.env.POSTGRES_DB || 'telseg'}`; +const JWT_SECRET = process.env.JWT_SECRET || 'devsecret'; +const ADMIN_USER = process.env.ADMIN_USER || 'admin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; + +const pool = new Pool({ connectionString: DATABASE_URL }); + +async function initDb() { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('master','user')), + created_at TIMESTAMPTZ DEFAULT now() + );`); + await client.query(`CREATE TABLE IF NOT EXISTS permissions ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + units_read BOOLEAN DEFAULT false, + units_write BOOLEAN DEFAULT false, + creds_read BOOLEAN DEFAULT false, + creds_write BOOLEAN DEFAULT false, + notes_read BOOLEAN DEFAULT false, + notes_write BOOLEAN DEFAULT false + );`); + await client.query(`CREATE TABLE IF NOT EXISTS units ( + id TEXT PRIMARY KEY, + nome TEXT NOT NULL, + data_criacao DATE, + ocs TEXT[], + link TEXT, + ip TEXT, + login TEXT, + senha TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + );`); + await client.query(`CREATE TABLE IF NOT EXISTS creds ( + id TEXT PRIMARY KEY, + nome TEXT NOT NULL, + usuario TEXT, + url TEXT, + notas TEXT, + password_enc JSONB NOT NULL, + updated_at BIGINT, + created_at TIMESTAMPTZ DEFAULT now() + );`); + await client.query(`CREATE TABLE IF NOT EXISTS vault ( + id INTEGER PRIMARY KEY, + salt TEXT NOT NULL, + verification JSONB NOT NULL, + iterations INTEGER NOT NULL, + v INTEGER DEFAULT 1 + );`); + await client.query(`CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + title TEXT, + body TEXT, + data JSONB, + updated_at BIGINT, + created_at TIMESTAMPTZ DEFAULT now() + );`); + await client.query(`ALTER TABLE notes ADD COLUMN IF NOT EXISTS data JSONB;`); + await client.query(`ALTER TABLE units ADD COLUMN IF NOT EXISTS login TEXT;`); + await client.query(`ALTER TABLE units ADD COLUMN IF NOT EXISTS senha TEXT;`); + await client.query('COMMIT'); + // Seed admin user if none exists + const { rows: cnt } = await pool.query('SELECT COUNT(*)::int AS c FROM users'); + if (cnt[0].c === 0) { + const id = genId(); + const hash = await bcrypt.hash(ADMIN_PASSWORD, 10); + await pool.query('INSERT INTO users (id, username, password_hash, role) VALUES ($1,$2,$3,$4)', [id, ADMIN_USER, hash, 'master']); + await pool.query('INSERT INTO permissions (user_id, units_read, units_write, creds_read, creds_write, notes_read, notes_write) VALUES ($1,true,true,true,true,true,true)', [id]); + console.log('Admin user created:', ADMIN_USER); + } + } catch (e) { + await client.query('ROLLBACK'); + console.error('DB init error:', e); + throw e; + } finally { + client.release(); + } +} + +function genId() { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`; +} + +function normalizePerms(p = {}) { + return { + units_read: !!p.units_read, + units_write: !!p.units_write, + creds_read: !!p.creds_read, + creds_write: !!p.creds_write, + notes_read: !!p.notes_read, + notes_write: !!p.notes_write, + }; +} + +async function loadUserById(id) { + const { rows } = await pool.query('SELECT id, username, role FROM users WHERE id=$1', [id]); + if (!rows.length) return null; + const u = rows[0]; + const { rows: pr } = await pool.query('SELECT * FROM permissions WHERE user_id=$1', [id]); + return { id: u.id, username: u.username, role: u.role, permissions: normalizePerms(pr[0]) }; +} + +function signToken(user) { + return jwt.sign({ sub: user.id, role: user.role }, JWT_SECRET, { expiresIn: '7d' }); +} + +function mapUnit(row) { + return { + id: row.id, + nome: row.nome, + dataCriacao: row.data_criacao ? row.data_criacao.toISOString().slice(0,10) : null, + ocs: row.ocs || [], + link: row.link || '', + ip: row.ip || '', + login: row.login || '', + senha: row.senha || '' + }; +} + +function mapCred(row) { + return { + id: row.id, + nome: row.nome, + usuario: row.usuario || '', + url: row.url || '', + notas: row.notas || '', + passwordEnc: row.password_enc, + updatedAt: Number(row.updated_at || 0) + }; +} + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); + +app.get('/api/health', (_req, res) => { + res.json({ ok: true }); +}); + +// Auth helpers +async function authRequired(req, res, next) { + const auth = req.headers.authorization || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : null; + if (!token) return res.status(401).json({ error: 'unauthorized' }); + try { + const payload = jwt.verify(token, JWT_SECRET); + const user = await loadUserById(payload.sub); + if (!user) return res.status(401).json({ error: 'unauthorized' }); + req.user = user; next(); + } catch { + return res.status(401).json({ error: 'unauthorized' }); + } +} +function requireMaster(req, res, next) { + if (req.user?.role === 'master') return next(); + return res.status(403).json({ error: 'forbidden' }); +} +function requirePermFlag(flag) { + return (req, res, next) => { + if (req.user?.role === 'master') return next(); + if (req.user?.permissions?.[flag]) return next(); + return res.status(403).json({ error: 'forbidden' }); + }; +} + +// Auth endpoints +app.post('/api/auth/login', async (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'missing' }); + const { rows } = await pool.query('SELECT * FROM users WHERE username=$1', [username]); + if (!rows.length) return res.status(401).json({ error: 'invalid' }); + const u = rows[0]; + const ok = await bcrypt.compare(password, u.password_hash); + if (!ok) return res.status(401).json({ error: 'invalid' }); + const token = signToken(u); + const user = await loadUserById(u.id); + res.json({ token, user }); +}); +app.get('/api/auth/me', authRequired, (req, res) => { + res.json({ user: req.user }); +}); + +// Users management (master only) +app.get('/api/users', authRequired, requireMaster, async (_req, res) => { + const { rows } = await pool.query('SELECT id, username, role, created_at FROM users ORDER BY created_at DESC'); + const list = []; + for (const r of rows) { + const { rows: pr } = await pool.query('SELECT * FROM permissions WHERE user_id=$1', [r.id]); + list.push({ id: r.id, username: r.username, role: r.role, permissions: normalizePerms(pr[0] || {}) }); + } + res.json(list); +}); +app.post('/api/users', authRequired, requireMaster, async (req, res) => { + const { username, password, role, permissions } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'missing' }); + const id = genId(); + const hash = await bcrypt.hash(password, 10); + await pool.query('INSERT INTO users (id, username, password_hash, role) VALUES ($1,$2,$3,$4)', [id, username, hash, role === 'master' ? 'master' : 'user']); + const p = normalizePerms(permissions || {}); + await pool.query('INSERT INTO permissions (user_id, units_read, units_write, creds_read, creds_write, notes_read, notes_write) VALUES ($1,$2,$3,$4,$5,$6,$7)', [id, p.units_read, p.units_write, p.creds_read, p.creds_write, p.notes_read, p.notes_write]); + res.status(201).json({ ok: true, id }); +}); +app.put('/api/users/:id', authRequired, requireMaster, async (req, res) => { + const id = req.params.id; const { password, role } = req.body || {}; + if (password) { + const hash = await bcrypt.hash(password, 10); + await pool.query('UPDATE users SET password_hash=$2 WHERE id=$1', [id, hash]); + } + if (role) await pool.query('UPDATE users SET role=$2 WHERE id=$1', [id, role === 'master' ? 'master' : 'user']); + res.json({ ok: true }); +}); +app.put('/api/users/:id/permissions', authRequired, requireMaster, async (req, res) => { + const id = req.params.id; const p = normalizePerms(req.body || {}); + await pool.query('INSERT INTO permissions (user_id, units_read, units_write, creds_read, creds_write, notes_read, notes_write) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (user_id) DO UPDATE SET units_read=EXCLUDED.units_read, units_write=EXCLUDED.units_write, creds_read=EXCLUDED.creds_read, creds_write=EXCLUDED.creds_write, notes_read=EXCLUDED.notes_read, notes_write=EXCLUDED.notes_write', [id, p.units_read, p.units_write, p.creds_read, p.creds_write, p.notes_read, p.notes_write]); + res.json({ ok: true }); +}); +app.delete('/api/users/:id', authRequired, requireMaster, async (req, res) => { + await pool.query('DELETE FROM users WHERE id=$1', [req.params.id]); + res.json({ ok: true }); +}); + +// Units +app.get('/api/units', authRequired, requirePermFlag('units_read'), async (_req, res) => { + // Números primeiro, depois letras; em seguida ordem alfabética + const { rows } = await pool.query( + `SELECT * FROM units + ORDER BY (nome ~ '^[0-9]') DESC, nome ASC` + ); + res.json(rows.map(mapUnit)); +}); + +app.post('/api/units', authRequired, requirePermFlag('units_write'), async (req, res) => { + const u = req.body; + if (!u || !u.id || !u.nome) return res.status(400).json({ error: 'id e nome são obrigatórios' }); + await pool.query( + `INSERT INTO units (id, nome, data_criacao, ocs, link, ip, login, senha) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (id) DO NOTHING`, + [u.id, u.nome, u.dataCriacao || null, u.ocs || [], u.link || '', u.ip || '', u.login || '', u.senha || ''] + ); + res.status(201).json({ ok: true }); +}); + +app.put('/api/units/:id', authRequired, requirePermFlag('units_write'), async (req, res) => { + const id = req.params.id; + const u = req.body || {}; + await pool.query( + `UPDATE units SET nome=$2, data_criacao=$3, ocs=$4, link=$5, ip=$6, login=$7, senha=$8, updated_at=now() WHERE id=$1`, + [id, u.nome || '', u.dataCriacao || null, u.ocs || [], u.link || '', u.ip || '', u.login || '', u.senha || ''] + ); + res.json({ ok: true }); +}); + +app.delete('/api/units/:id', authRequired, requirePermFlag('units_write'), async (req, res) => { + await pool.query('DELETE FROM units WHERE id=$1', [req.params.id]); + res.json({ ok: true }); +}); + +// Creds +app.get('/api/creds', authRequired, requirePermFlag('creds_read'), async (_req, res) => { + const { rows } = await pool.query('SELECT * FROM creds ORDER BY updated_at DESC NULLS LAST, created_at DESC'); + res.json(rows.map(mapCred)); +}); + +app.post('/api/creds', authRequired, requirePermFlag('creds_write'), async (req, res) => { + const c = req.body; + if (!c || !c.id || !c.nome || !c.passwordEnc) return res.status(400).json({ error: 'id, nome e passwordEnc são obrigatórios' }); + await pool.query( + `INSERT INTO creds (id, nome, usuario, url, notas, password_enc, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (id) DO NOTHING`, + [c.id, c.nome, c.usuario || '', c.url || '', c.notas || '', c.passwordEnc, c.updatedAt || Date.now()] + ); + res.status(201).json({ ok: true }); +}); + +app.put('/api/creds/:id', authRequired, requirePermFlag('creds_write'), async (req, res) => { + const id = req.params.id; + const c = req.body || {}; + await pool.query( + `UPDATE creds SET nome=$2, usuario=$3, url=$4, notas=$5, password_enc=$6, updated_at=$7 WHERE id=$1`, + [id, c.nome || '', c.usuario || '', c.url || '', c.notas || '', c.passwordEnc, c.updatedAt || Date.now()] + ); + res.json({ ok: true }); +}); + +app.delete('/api/creds/:id', authRequired, requirePermFlag('creds_write'), async (req, res) => { + await pool.query('DELETE FROM creds WHERE id=$1', [req.params.id]); + res.json({ ok: true }); +}); + +// Notes +app.get('/api/notes', authRequired, requirePermFlag('notes_read'), async (_req, res) => { + const { rows } = await pool.query('SELECT * FROM notes ORDER BY updated_at DESC NULLS LAST, created_at DESC'); + res.json(rows.map(r => ({ id: r.id, title: r.title || '', body: r.body || '', data: r.data || null, updatedAt: Number(r.updated_at || 0) }))); +}); + +app.post('/api/notes', authRequired, requirePermFlag('notes_write'), async (req, res) => { + const n = req.body || {}; + if (!n.id) return res.status(400).json({ error: 'id é obrigatório' }); + await pool.query('INSERT INTO notes (id, title, body, data, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (id) DO NOTHING', [n.id, n.title || '', n.body || '', n.data || null, n.updatedAt || Date.now()]); + res.status(201).json({ ok: true }); +}); + +app.put('/api/notes/:id', authRequired, requirePermFlag('notes_write'), async (req, res) => { + const id = req.params.id; const n = req.body || {}; + await pool.query('UPDATE notes SET title=$2, body=$3, data=$4, updated_at=$5 WHERE id=$1', [id, n.title || '', n.body || '', n.data || null, n.updatedAt || Date.now()]); + res.json({ ok: true }); +}); + +app.delete('/api/notes/:id', authRequired, requirePermFlag('notes_write'), async (req, res) => { + await pool.query('DELETE FROM notes WHERE id=$1', [req.params.id]); + res.json({ ok: true }); +}); + +// Vault meta +app.get('/api/vault', authRequired, requirePermFlag('creds_read'), async (_req, res) => { + const { rows } = await pool.query('SELECT * FROM vault WHERE id=1'); + if (!rows.length) return res.json(null); + const v = rows[0]; + res.json({ salt: v.salt, verification: v.verification, iterations: v.iterations, v: v.v || 1 }); +}); + +app.put('/api/vault', authRequired, requireMaster, async (req, res) => { + const v = req.body || {}; + if (!v.salt || !v.verification) return res.status(400).json({ error: 'salt e verification são obrigatórios' }); + await pool.query( + `INSERT INTO vault (id, salt, verification, iterations, v) + VALUES (1, $1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET salt=EXCLUDED.salt, verification=EXCLUDED.verification, iterations=EXCLUDED.iterations, v=EXCLUDED.v`, + [v.salt, v.verification, v.iterations || 200000, v.v || 1] + ); + res.json({ ok: true }); +}); + +// Export/Import utilitários +app.get('/api/export', authRequired, requireMaster, async (_req, res) => { + const [units, creds, vault, notes] = await Promise.all([ + pool.query('SELECT * FROM units'), + pool.query('SELECT * FROM creds'), + pool.query('SELECT * FROM vault WHERE id=1'), + pool.query('SELECT * FROM notes') + ]); + res.json({ + version: 1, + exportedAt: new Date().toISOString(), + units: units.rows.map(mapUnit), + creds: creds.rows.map(mapCred), + vault: vault.rows[0] ? { salt: vault.rows[0].salt, verification: vault.rows[0].verification, iterations: vault.rows[0].iterations, v: vault.rows[0].v || 1 } : null, + notes: notes.rows.map(r => ({ id: r.id, title: r.title || '', body: r.body || '', data: r.data || null, updatedAt: Number(r.updated_at || 0) })) + }); +}); + +app.post('/api/import', authRequired, requireMaster, async (req, res) => { + const data = req.body || {}; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM units'); + await client.query('DELETE FROM creds'); + await client.query('DELETE FROM notes'); + if (data.vault) { + await client.query('DELETE FROM vault WHERE id=1'); + await client.query('INSERT INTO vault (id, salt, verification, iterations, v) VALUES (1,$1,$2,$3,$4)', [data.vault.salt, data.vault.verification, data.vault.iterations || 200000, data.vault.v || 1]); + } + if (Array.isArray(data.units)) { + for (const u of data.units) { + await client.query('INSERT INTO units (id, nome, data_criacao, ocs, link, ip, login, senha) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)', [u.id, u.nome, u.dataCriacao || null, u.ocs || [], u.link || '', u.ip || '', u.login || '', u.senha || '']); + } + } + if (Array.isArray(data.creds)) { + for (const c of data.creds) { + await client.query('INSERT INTO creds (id, nome, usuario, url, notas, password_enc, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', [c.id, c.nome, c.usuario || '', c.url || '', c.notas || '', c.passwordEnc, c.updatedAt || Date.now()]); + } + } + if (Array.isArray(data.notes)) { + for (const n of data.notes) { + await client.query('INSERT INTO notes (id, title, body, data, updated_at) VALUES ($1,$2,$3,$4,$5)', [n.id, n.title || '', n.body || '', n.data || null, n.updatedAt || Date.now()]); + } + } + await client.query('COMMIT'); + res.json({ ok: true }); + } catch (e) { + await client.query('ROLLBACK'); + console.error('Import error', e); + res.status(500).json({ error: 'import failed' }); + } finally { + client.release(); + } +}); + +initDb().then(() => { + app.listen(PORT, () => console.log(`API listening on :${PORT}`)); +}).catch((e) => { + console.error('Failed to init DB', e); + process.exit(1); +}); diff --git a/assets/Logo tema Claro.png b/assets/Logo tema Claro.png new file mode 100644 index 0000000..7507a3a Binary files /dev/null and b/assets/Logo tema Claro.png differ diff --git a/assets/Logo tema Escuro.png b/assets/Logo tema Escuro.png new file mode 100644 index 0000000..8b35ed0 Binary files /dev/null and b/assets/Logo tema Escuro.png differ diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..136ea53 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,1021 @@ +"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 => `OC ${escapeHTML(x)}`).join(" "); + return ` +
+

${escapeHTML(u.nome)}

+
Criado em ${formatDate(u.dataCriacao)}${u.ip ? ` • IP ${escapeHTML(u.ip)}` : ""}
+ ${ocs ? `
${ocs}
` : ""} + ${u.link ? `` : ""} +
+ ${u.link ? `` : ""} + ${u.ip ? `` : ""} + ${canWrite ? `` : ''} + ${canWrite ? `` : ''} +
+
`; + }).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 ` +
+

${escapeHTML(n.title || 'Sem título')}

+
${n.data?.type === 'passos' ? 'Passo a passo' : ''} ${when ? `• Atualizado: ${when}` : ''}
+ ${n.data?.type === 'passos' ? `
${(n.data.steps||[]).slice(0,3).map((s,i)=>`${i+1}. ${escapeHTML(s.text||'')}`).join('
')}
` : (preview ? `
${preview}
` : '')} +
`; + }; + + 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) => ` +
+
${escapeHTML(s.text)}
+ +
+ `).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' ? 'Passo a passo' : 'Texto'; + const sections = []; + if (n.data?.type === 'passos' && (n.data.steps || []).length) { + const items = (n.data.steps || []).map(s => `
  • ${escapeHTML(s.text || '')}
  • `).join(''); + sections.push(`
    Passos
      ${items}
    `); + } + if ((n.body || '').trim()) { + sections.push(`
    Conteúdo
    ${escapeHTML(n.body)}
    `); + } + if (!sections.length) { + sections.push('
    Sem conteúdo
    '); + } + body.innerHTML = `
    ${chip} ${when ? `• Atualizado: ${when}` : ''}
    ${sections.join('')}
    `; + // 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 { + 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 ` +
    +

    ${escapeHTML(c.nome)}

    +
    ${c.usuario ? `Usuário ${escapeHTML(c.usuario)} • `: ''}${c.url ? `${escapeHTML(c.url)}` : ''} ${when ? `• Atualizado: ${when}` : ''}
    + ${preview ? `
    ${preview}
    ` : ''} +
    `; + }).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(`
    Usuário: ${escapeHTML(c.usuario)}
    `); + if (c.url) access.push(`
    URL: ${escapeHTML(c.url)}
    `); + if (access.length) sections.push(`
    Acesso
    ${access.join('')}
    `); + if ((c.notas || '').trim()) sections.push(`
    Notas
    ${escapeHTML(c.notas)}
    `); + if (!sections.length) sections.push('
    Sem detalhes
    '); + body.innerHTML = `
    ${when ? `Atualizado: ${when}` : ''}
    ${sections.join('')}
    `; + + // 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 ` +
    +

    ${escapeHTML(u.username)} ${u.role === 'master' ? 'Master' : ''}

    +
    + + + + + + +
    +
    + + + +
    +
    `; + }).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'); } + }); +}); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..8ba8751 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,159 @@ +/* Dashboard Telseg - estilos base */ +:root { + /* Tema escuro neutro (preto) */ + --bg: #000000; /* preto puro */ + --panel: #0a0a0a; /* preto levemente mais claro que o bg */ + --card: #121212; /* cards um tom mais claro (igual à barra) */ + --muted: #cbd5e1; /* texto secundário claro */ + --text: #f1f5f9; /* texto principal muito claro */ + --primary: #22c55e; /* green-500 */ + --primary-600: #16a34a; + --border: #262626; /* cinza neutro para contornos sutis */ + --danger: #ef4444; /* red-500 */ + --warning: #f59e0b; /* amber-500 */ + --link: #93c5fd; /* blue-300 */ +} + +[data-theme="light"] { + --bg: #f8fafc; /* slate-50 */ + --panel: #ffffff; + --muted: #475569; /* slate-600 */ + --text: #0f172a; /* slate-900 */ + --primary: #16a34a; + --primary-600: #15803d; + --border: #e2e8f0; + --link: #2563eb; /* blue-600 */ +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + color: var(--text); + background: radial-gradient(1200px 800px at 20% 0%, rgba(34,197,94,0.06), transparent 45%), var(--bg); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; +} +.brand { display: flex; align-items: center; gap: 12px; } +.brand h1 { font-size: 20px; margin: 0; font-weight: 700; letter-spacing: 0.3px; } +.logo { display: none; } +.logo-img { height: 64px; width: auto; display: inline-block; margin-right: 8px; } +.logo-img.dark { display: none; } +[data-theme="dark"] .logo-img.dark { display: inline-block; } +[data-theme="dark"] .logo-img.light { display: none; } +.top-actions { display: flex; gap: 8px; align-items: center; } + +/* Links legíveis no escuro */ +a { color: var(--link); text-decoration: underline; } +a:hover { filter: brightness(1.1); } + +.tabs { display: flex; gap: 8px; padding: 0 12px 8px; border-bottom: 1px solid var(--border); } +.tab { background: transparent; color: var(--muted); border: none; padding: 10px 14px; border-radius: 8px; cursor: pointer; } +.tab.active { background: var(--panel); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); } + +.views { padding: 16px; max-width: 1680px; margin: 0 auto; } +.view { display: none; } +.view.active { display: block; } +.view-bar { display: flex; justify-content: space-between; gap: 12px; margin: 12px 0 16px; } +.view-bar .right, .view-bar .left { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + +.input { width: 100%; padding: 10px 12px; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 10px; outline: none; } +.input::placeholder { color: var(--muted); } + +.btn { padding: 10px 14px; border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 10px; cursor: pointer; } +.btn:hover { filter: brightness(1.05); } +.btn.primary { background: linear-gradient(180deg, var(--primary), var(--primary-600)); border: none; color: white; } +.btn.danger { background: linear-gradient(180deg, #f87171, #ef4444); border: none; color: white; } +.btn.small { padding: 6px 10px; font-size: 13px; } +.icon-btn { padding: 8px 10px; border-radius: 8px; background: var(--panel); color: var(--text); border: 1px solid var(--border); cursor: pointer; } + +.cards { display: grid; grid-template-columns: 1fr; gap: 16px; } +/* Unidades em duas colunas em telas largas */ +@media (min-width: 980px) { + /* Em telas largas, usar colunas responsivas com largura mínima + suficiente para manter os botões na mesma linha. */ + #unidadesList.cards, + #credsList.cards { grid-template-columns: repeat(auto-fit, minmax(720px, 1fr)); } + /* notas agora usam contêiner de colunas */ + /* Manter as ações numa única linha; se apertar muito, permite rolagem. */ + .card .actions { flex-wrap: nowrap; overflow-x: auto; } +} +.card { background: var(--card, var(--panel)); border: 1px solid var(--border); border-radius: 14px; padding: 20px; display: grid; gap: 10px; font-size: 1.12rem; line-height: 1.45; } +.card.clickable { cursor: pointer; } +.card.clickable:hover { box-shadow: 0 0 0 1px var(--border); } +.card h4 { margin: 0; font-size: 22px; } +.meta { color: var(--muted); font-size: 15px; } +.chips { display: flex; gap: 6px; flex-wrap: wrap; } +.chip { font-size: 14px; padding: 4px 10px; border-radius: 999px; color: var(--text); background: rgba(148,163,184,0.25); border: 1px solid var(--border); } +.actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; } +/* botões mais legíveis dentro dos cards */ +.card .btn { font-size: 14px; padding: 10px 14px; } +.card .btn.small { font-size: 14px; padding: 8px 12px; } + +/* Evitar quebra de layout com textos longos (ex.: notas, URLs, tokens) */ +/* Mantém quebras de linha digitadas e permite quebrar palavras muito grandes */ +.card .k { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } +.card .meta { overflow-wrap: anywhere; word-break: break-word; } +.card a { overflow-wrap: anywhere; word-break: break-word; } + +/* Passos (notas) */ +.step-card { background: var(--panel); border: 1px dashed var(--border); border-radius: 10px; padding: 10px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.step-card .text { flex: 1; } +.step-card .text[contenteditable="true"] { outline: 1px dashed var(--border); border-radius: 6px; padding: 3px 6px; } + +/* Visualização de nota */ +.note-view .title { font-size: 22px; margin: 0; } +.note-view .meta { margin-top: 4px; } +.note-view .content { white-space: pre-wrap; line-height: 1.5; font-size: 1.05rem; overflow-wrap: anywhere; word-break: break-word; } +.note-view ol { margin: 6px 0 0 22px; } + +/* Visualização de credencial */ +.cred-view .content { white-space: pre-wrap; line-height: 1.5; font-size: 1.05rem; overflow-wrap: anywhere; word-break: break-word; } +.note-view .modal-body, .cred-view .modal-body { max-height: 70vh; overflow: auto; } +.note-view .modal-body a, .cred-view .modal-body a { overflow-wrap: anywhere; word-break: break-word; } + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } +label span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; } + +.modal { position: fixed; inset: 0; background: rgba(2,6,23,0.6); display: grid; place-items: center; padding: 16px; z-index: 20; } +.modal.hidden { display: none; } +.modal-card { width: min(720px, 100%); background: var(--card, var(--panel)); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,.35); overflow-wrap: anywhere; } +.modal .modal-card { background: var(--card, var(--panel)); } +.modal-card.wide { width: min(960px, 100%); } +.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); } +.modal-head h3 { margin: 0; font-size: 16px; } +.modal-body { padding: 14px; display: grid; gap: 12px; } +.modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } + +.vault-lock { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; display: grid; gap: 10px; max-width: 720px; margin: 18px auto; } +.vault-form { display: grid; gap: 12px; } +.hidden { display: none !important; } + +.app-footer { padding: 18px; text-align: center; color: var(--muted); } + +/* Helpers */ +.k { color: var(--muted); font-size: 13px; } +.row a { display: inline-block; max-width: 100%; } +.row { display: flex; gap: 8px; align-items: center; } + +/* Notas: duas colunas (Notas | Passo a Passo) */ +.notes-columns { display: grid; grid-template-columns: 1fr; gap: 16px; } +.notes-col { display: grid; gap: 8px; align-content: start; } +.column-title { margin: 2px 4px 4px; font-size: 16px; color: var(--muted); font-weight: 600; } +@media (min-width: 980px) { + .notes-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 520px) { + .view-bar { flex-direction: column; align-items: stretch; } + .top-actions { flex-wrap: wrap; } + /* Logo um pouco menor em telas muito pequenas */ + .logo-img { height: 40px; } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd07079 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +version: "3.8" +services: + dashboard: + build: . + container_name: telseg_dashboard + ports: + - "4242:80" + depends_on: + - api + restart: unless-stopped + + db: + image: postgres:15-alpine + container_name: telseg_db + environment: + - POSTGRES_DB=${POSTGRES_DB:-telseg} + - POSTGRES_USER=${POSTGRES_USER:-telseg} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-telseg} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" # opcional: expõe acesso ao host + healthcheck: + test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB:-telseg} -U ${POSTGRES_USER:-telseg}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + adminer: + image: adminer:latest + container_name: telseg_adminer + depends_on: + - db + environment: + - ADMINER_DEFAULT_SERVER=db + ports: + - "8081:8080" + restart: unless-stopped + + api: + build: ./api + container_name: telseg_api + environment: + - DATABASE_URL=postgres://${POSTGRES_USER:-telseg}:${POSTGRES_PASSWORD:-telseg}@db:5432/${POSTGRES_DB:-telseg} + - PORT=3000 + - JWT_SECRET=${JWT_SECRET:-change_this_secret} + - ADMIN_USER=${ADMIN_USER:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + depends_on: + - db + restart: unless-stopped + +volumes: + pgdata: diff --git a/index.html b/index.html new file mode 100644 index 0000000..125d365 --- /dev/null +++ b/index.html @@ -0,0 +1,347 @@ + + + + + + Dashboard Telseg + + + + + +
    +
    + Logo + +

    Dashboard Telseg

    +
    + +
    + +
    + + + + +
    + +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + + +
    +
    +

    Cofre de Senhas

    +

    Proteja suas senhas com uma senha mestra.

    + + + +
    + + +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +

    Notas

    +
    +
    +
    +

    Passo a Passo

    +
    +
    +
    +
    + + + +
    +
    +
    +

    Gerenciar Usuários

    +
    +
    +
    +
    +

    Novo Usuário

    +
    + + + +
    +
    Permissões
    +
    + + + + + + +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    + Detectando modo de armazenamento… +
    + + + + + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..0675ef5 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Proxy para API (mesma origem) + location /api/ { + proxy_pass http://api:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + } + + # SPA fallback: serve index.html para rotas desconhecidas + location / { + try_files $uri $uri/ /index.html; + } + + # Cache de assets por 7 dias (HTML não é cacheado) + location /assets/ { + expires 7d; + add_header Cache-Control "public, max-age=604800, must-revalidate"; + } +}